Docker image tests
Published on:
Today we’re going to set up a CI/CD GitHub Action with a Container Structure Test step which will help us to enforce the certain quality policies for the images which we build and ship.
It’s a good idea to review your docker images. First of all, it can save time, disk space, and money.
When our images are lean the build time is reduced as well as the pull and startup time. Local Docker cache takes less space. And as a cherry on the cake, we pay less for our cloud storage and egress/cross-region/cross-az traffic.
A win-win-win situation.
We also should take care of the content of our images because we don’t need to provide a broader attack surface. Fewer tools installed means fewer opportunities for those zero-days exploits.
And last, but not least everyone who can pull our can inspect them (tools like dive can make this process easy as pie).
So it makes sense to reject images with sensitive data/information baked in the layer.
Container Structure Test
There are several tools that are available to help us enforce policies on the images that we build and deploy. One of them is a framework called Container Structure Tests
The Container Structure Tests provide a framework to validate the structure of a container image. These tests can be used to check the output of commands in an image, as well as verify metadata and contents of the filesystem.
Tests can be run either through a standalone binary or through a Docker image.
container-structure-test test --image gcr.io/registry/image:latest \
--config config.yaml
Let’s check a few examples.
Bad Docker image
I’m going to use this repository as an example. Feel free to fork it and play with it yourself.
Let’s review the docker file first:
Everything is wrong about it:
- All the files are transferred to the docker daemon
- .NET Core SDK is shipped to production
- Unit tests are shipped as well
- Many useless layers
- Large image size
$ docker images
REPOSITORY TAG SIZE
asp-net-container latest 850MB
850 MB for a super basic echo application. Let’s get those things fixed.
Local setup
At first, I’m going to install container-structure-tests locally.
Linux
curl -LO \
https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 \
&& chmod +x container-structure-test-linux-amd64 && \
sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test
MacOS
curl -LO \
https://storage.googleapis.com/container-structure-test/latest/container-structure-test-darwin-amd64 && \
chmod +x container-structure-test-darwin-amd64 && \
sudo mv container-structure-test-darwin-amd64 /usr/local/bin/container-structure-test
And let’s create container-structure-tests.yaml
file with the following content:
This test verifies if we have .git
directory copied over to the /app
working directory.
And now we can run this (I assume that we already have an image built from our Dockerfile)
container-structure-test test \
--image asp-net-container:latest \
--config container-structure-tests.yaml
This is something that can be fixed with a good .dockerignore
file:
When I measure the difference on the CI (GitHub Actions) I can see that the transferred context size is reduced from 140.3kB down to 23.55kB. The local difference is even more remarkable because I have packages, DLLs, and other obj content.
Image size and SDKs
Container Structure Tests support so-called commandTests
. They are the tests that can execute any CLI tool of your choice against your container and verify the produced output.
The syntax is pretty basic and somewhat limited. Tho we can still express our desires.
We want our image to satisfy the following policies:
- No .NET Core SDK installed
- .NET Core runtime should be installed and registered
- No unit tests .dlls should be shipped
Let’s turn these requirements into tests:
commandTests:
- name: ".NET Core runtime installed"
command: "which"
args: [dotnet]
expectedOutput: ["/usr/bin/dotnet"]
- name: "no SDK installed"
command: "dotnet"
args: ["--list-sdks"]
excludedOutput: [".*/sdk]*"]
- name: "no test dlls present"
command: "find"
args: ["/app", "-name", "*.Tests.dll"]
excludedOutput: [".*.Tests.dll*"]
The first test executes which
command and checks the standard output content with the provided string (this string is treated as a RegEx btw). This test will pass on the first run, so I can’t call that a TDD approach, heh.
The second test runs the dotnet --list-sdks
command and verifies that there are no matches in the output.
And the last one runs the find
command which looks up for *.Tests.dll
in the /app
directory.
To fix the second test, we should use a multi-stage build approach.
That will allow us to build and test our code in one container and then ship and run in another.
And the test dlls could be excluded by this little fix to the .csproj
file.
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPublishable>false</IsPublishable>
<IsPackable>false</IsPackable>
</PropertyGroup>
Metadata tests
metadataTests
can help us to ensure that the necessary environment variables are set, that our image has labels, the needed ports are exposed and the working directory is set correctly.
metadataTest:
env:
- key: 'ASPNETCORE_URLS'
value: 'http://+:80'
- key: 'DOTNET_RUNNING_IN_CONTAINER'
value: 'true'
labels:
- key: 'org.opencontainers.image.authors'
value: 'https://github.com/asizikov'
- key: 'org.opencontainers.image.source'
workdir: "/app"
exposedPorts: ["80"]
(I’m using label naming schema by Open Container Initiative)
Automate all the things!
As soon as we have a successful tests run locally
it’s time to put things together and update our GitHub Actions workflow file.
I’m going to use the Container Structure Test Action here.
which is extremely easy to use: provide it with a name of your image or a tar
file with the image export results.
- name: run structure tests
uses: plexsystems/[email protected]
with:
image: my-image:latest
config: tests.yaml
The final action file would look like that:
and the fixed Dockerfile is here.
What could be better than a green CI pipeline?
PS: while fixing our tests we also reduced the size of our image. Now its size is just 208Mb. A nice side benefit, eh?