Structure Testing for Docker Containers
Tags: docker, security, cicd, tutorial
Published: 2021-11-10 17:13:42

So you’ve set up [continuous integration](https://semaphoreci.com/continuous-integration) for your project. Everything looks good and now all you need is a container. Just build and run it, right? Not so fast!

Whether using containers to support development or for packaging an application, it’s easy to take them for granted. But many things can go wrong with them: moved files, incorrect permissions, a user is missing, the Dockerfile is incomplete, the list goes on…

Containers are as crucial as the code they support. In this tutorial, we’ll introduce a different way of testing them before deployment.

## One of the many ways Google tests containers

[Container Structure Tests](https://github.com/GoogleContainerTools/container-structure-test) (CST) is a container-testing tool developed by Google and open-sourced with the Apache 2.0 license. CST comes with a predefined set of tests for looking at what’s actually inside a container image.

For instance, CST can check if a file exists, run a command and validate its output, or check if the container exposes the correct ports. Almost every declarative keyword in the Dockerfile has a corresponding test.

One important note is that the project is not officially supported by Google, so it doesn’t show a lot of activity. But it’s popular enough to keep it active and it’s still accepting contributions.

## More tests, less uncertainty

Any old container should do in order to try out CST. Though it will be easier if we build one from a known Dockerfile. So, if you want to follow along with me as I explore this tool, fork and clone our Ruby Kubernetes demo project:

{% github semaphoreci-demos/semaphore-demo-ruby-kubernetes %}

It’s a “Hello, World” application written on Ruby. It comes with a Dockerfile and a [complete CI/CD pipeline](https://semaphoreci.com/blog/cicd-pipeline).

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3jt5uy1uudvz3nmg4kl5.png)

You’ll also need a:

-   A Docker installation.
-   A CST config file.
-   The CST executable.

Once you’ve cloned the demo, build the container image with:

``` bash
$ docker build -t test-image .
```

Install the CST tool using the [installation instructions](https://github.com/GoogleContainerTools/container-structure-test#installation). For instance, on Linux:

``` bash
$ 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
```

If you’re running on a non-Intel architecture, you can build the project by yourself or try the [CST image](https://gcr.io/gcp-runtimes/container-structure-test) instead.

The command to run the test follows works like this:

``` bash
$ container-structure-test test –config config-cst.yaml –image test-image:latest
```

But of course, that won’t work until we configure the tests. We’ll do that next.

## Setting up CST tests

CST supports three categories of tests:

-   **Commands**: starts the container, runs the command, and validates its result.
-   **Filesystem**: checks for file existence, owner, permissions, and contents.
-   **Metadata**: this category contains things such as environment variables, exposed ports, labels, among other image metadata.

Create a new file called `config-cst.yaml` (JSON also works) and add the following mandatory line:

``` yaml
schemaVersion: 2.0.0
```

We’ll start with the command tests.

### Command tests

Let’s try some command tests. We can use something like this to check that Ruby is installed. Of course, I cheated and looked where it’s actually located before trying.

``` yaml
commandTests:
  - name: "Ruby is installed"
    command: "which"
    args: ["ruby"]
    expectedOutput: ["/usr/local/bin/ruby"]
```

Now we’ll check that `ruby --version` outputs the correct number:

``` yaml
  - name: "Ruby version is correct"
    command: "/usr/local/bin/ruby"
    args: ["--version"]
    expectedOutput: ["ruby 2.7.*"]
```

Should we be in a really security-conscious mindset, we can checksum the Ruby binary for extra safety. We can run preparation commands before the test with `setup`.

``` yaml
  - name: "Ruby binary checksum"
    setup: [["apt-get", "update"], ["apt-get","install","-y","shatag"]]
    command: "sha512sum"
    args: ["/usr/local/bin/ruby"]
    expectedOutput: ["df2fb393261ab88e5f991b96d958363f5e5185b51f1af319375be0d8b9ed6c27097ac8bfab399798497909c3e9e2bcc6d715a1e514f13fe5b7365344af555c7e  /usr/local/bin/rub"]
```

Now that we have some initial tests, we can actually run the tool for the first time:

``` bash
$ container-structure-test test --config config.yaml --image test-image:latest

=== RUN: Command Test: Ruby is installed
--- PASS
duration: 391.76725ms
stdout: /usr/local/bin/ruby

=== RUN: Command Test: Ruby version is correct
--- PASS
duration: 319.6335ms
stdout: ruby 2.7.4p191 (2021-07-07 revision a21a3b7d23) [aarch64-linux]

=== RUN: Command Test: Ruby binary checksum
--- PASS
duration: 302.199834ms
stdout: df2fb393261ab88e5f991b96d958363f5e5185b51f1af319375be0d8b9ed6c27097ac8bfab399798497909c3e9e2bcc6d715a1e514f13fe5b7365344af555c7e  /usr/local/bin/ruby


===============================
=========== RESULTS ===========
===============================
Passes:      3
Failures:    0
Duration:    1.013600584s
Total tests: 3
```

All command tests require a working Docker installation, as the tool must start a temporary container to run them. However, filesystem and metadata tests can be run without Docker appending the `--driver tar` option.

### Filesystem tests

Filesystem tests inspect the contents of the image; they check if files exist, their permissions, their contents, owner, and group.

Here’s how we can test that the code was correctly copied in the image. If we look at our Dockerfile, it should live in the `/app/` folder. So, we define a `fileExistenceTests` like this:

``` yaml
fileExistenceTests:
  - name: 'app.rb exists and has correct permissions'
    path: '/app/app.rb'
    shouldExist: true
    permissions: '-rw-rw-r--'
    uid: 0
    gid: 0
```

We can also check the reverse: that a file is *not* present in the image. Setting `shouldExist` to `false` is a great way to avoid shipping sensitive files by mistake. For example, we don’t need the unit tests contained in `spec` for the final build.

``` yaml
  - name: 'spec/ directory should not exist'
    path: '/app/spec'
    shouldExist: false
```

This time, CST should fail because the Docker image *has* the `spec` folder.

``` bash
$ container-structure-test test --config config.yaml --image test-image:latest

=== RUN: Command Test: Ruby is installed
--- PASS
duration: 308.371875ms
stdout: /usr/local/bin/ruby

=== RUN: Command Test: Ruby version is correct
--- PASS
duration: 312.744792ms
stdout: ruby 2.7.4p191 (2021-07-07 revision a21a3b7d23) [aarch64-linux]

=== RUN: Command Test: Ruby binary checksum
--- PASS
duration: 286.408167ms
stdout: df2fb393261ab88e5f991b96d958363f5e5185b51f1af319375be0d8b9ed6c27097ac8bfab399798497909c3e9e2bcc6d715a1e514f13fe5b7365344af555c7e  /usr/local/bin/ruby

=== RUN: File Existence Test: app.rb exists and has correct permissions
--- PASS
duration: 0s
=== RUN: File Existence Test: spec/ directory should not exist
--- FAIL
duration: 0s
Error: File /app/spec should not exist but does

===============================
=========== RESULTS ===========
===============================
Passes:      4
Failures:    1
Duration:    907.524834ms
Total tests: 5
```

We can fix the `spec` failure by adding the following line into the `.dockerignore` (you may also experience a permission error in `/app/app.rb`, which can be quickly fixed with `chmod`).

``` yaml
spec/
```

After rebuilding the image, the test should pass.

We have tested that a file exists. But, what about its contents? For that, we should use `fileContentTests`. The following example shows how to test if the Ruby Gems have been installed from a safe repository.

``` yaml
fileContentTests:
  - name: 'Gemfile remote is rubygems.org'
    path: '/app/Gemfile.lock'
    expectedContents: ['remote: https://rubygems.org/']
```

Both types of tests support regular expressions in the `expected*` fields for more flexibility.

### Metadata tests

Unlike the others, you can only have one metadata test. But it may check several things simultaneously, as metadata tests include a whole range of standard Docker variables.

Let’s say we want to test environment variables. In that case, we use `env`.

``` yaml
metadataTest:
  env:
    - key: APP_HOME
      value: /app
    - key: RUBY_VERSION
      value: 2.7.4
```

You can also check Docker declarations like `WORKDIR`, `EXPOSE`, `VOLUME`, or `USER`.

``` yaml
  exposedPorts: ["4567"]
  workdir: "/app"
  volumes: []
```

And the always important `CMD` and `ENTRYPOINT`.

``` yaml
  cmd: ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"]
  entrypoint: []
```

Once satisfied with the tests, commit the config file into the repository. This is the final version I got after experimenting with some extra tests follows.

``` yaml
schemaVersion: 2.0.0
commandTests:
  - name: "Ruby is installed"
    command: "which"
    args: ["ruby"]
    expectedOutput: ["/usr/local/bin/ruby"]
  - name: "Ruby version is correct"
    command: "/usr/local/bin/ruby"
    args: ["--version"]
    expectedOutput: ["ruby 2.7.*"]
  - name: "Ruby binary checksum"
    setup: [["apt-get", "update"], ["apt-get","install","-y","shatag"]]
    command: "sha512sum"
    args: ["/usr/local/bin/ruby"]
    expectedOutput: ["df2fb393261ab88e5f991b96d958363f5e5185b51f1af319375be0d8b9ed6c27097ac8bfab399798497909c3e9e2bcc6d715a1e514f13fe5b7365344af555c7e  /usr/local/bin/rub"]
  - name: "Bundle is installed"
    command: "which"
    args: ["bundle"]
    expectedOutput: ["/usr/local/bin/bundle"]
  - name: "Bundler version is correct"
    command: "/usr/local/bin/bundle"
    args: ["--version"]
    expectedOutput: ["Bundler version 2.1.*"]
fileExistenceTests:
  - name: 'app.rb exists and has correct permissions'
    path: '/app/app.rb'
    shouldExist: true
    permissions: '-rw-rw-r--'
    uid: 0
    gid: 0
  - name: 'spec/ directory should not exist'
    path: '/app/spec'
    shouldExist: false
fileContentTests:
  - name: 'Gemfile remote is rubygems.org'
    path: '/app/Gemfile.lock'
    expectedContents: ['remote: https://rubygems.org/']
metadataTest:
  env:
    - key: APP_HOME
      value: /app
    - key: RUBY_VERSION
      value: 2.7.4
  exposedPorts: ["4567"]
  cmd: ["bundle", "exec", "rackup", "--host", "0.0.0.0", "-p", "4567"]
  workdir: "/app"
  entrypoint: []
  volumes: []
```

## Testing container structure in CI/CD

Of course, CST wouldn’t be a lot of help to us unless it’s part of a CI/CD pipeline. The logical place for structure tests is between container build and deployment.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zuxwxdi07nito31w5ek7.png)

Adding CST in your CI/CD pipeline is straightforward. To keep things simple, we’ll extend the one already included in the demo, but the steps should work with any pipeline.

First, ensure Semaphore has access to your repository. Follow the [getting started guide](https://docs.semaphoreci.com/guided-tour/getting-started/) to learn how to do this.

The demo pipeline builds and tests the Ruby app, then [dockerizes it](https://semaphoreci.com/blog/guide-continuous-deployment-kubernetes) and finally deploys it to Kubernetes. We’ll add a structure test right between Docker build and Kubernetes deploy.

First, ensure you have stored your Docker Hub credentials as a Semaphore [secret](https://docs.semaphoreci.com/essentials/environment-variables/).

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/raj50ieyvd0axzsjzjlu.png)

Next, open the workflow editor using the **Edit Workflow** button.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pwup7j2las8xqkv567jl.png)

Expand the [continuous delivery](https://semaphoreci.com/cicd) pipeline and add a block immediately after the Docker build step.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tuluc84v4tj1c4706sg5.png)

The CST block will have one job with five commands:

1.  Sign in with Docker Hub, where the container image is stored.
2.  Pull the image into the CI environment.
3.  Install the CST Linux binary.
4.  Clone the repository so we can access the CST config file.
5.  Run the tests. If the test fails, the process stops with an error.

The complete command sequence is:

``` yaml
echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin
docker pull "${DOCKER_USERNAME}"/semaphore-demo-ruby-kubernetes:$SEMAPHORE_WORKFLOW_ID
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
checkout
container-structure-test test --config config-cst.yaml --image "${DOCKER_USERNAME}"/semaphore-demo-ruby-kubernetes:$SEMAPHORE_WORKFLOW_ID
```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xjz5ou5ueilqibkpgswx.png)

To finalize, enable the `dockerhub` secret on the block and give it a try by clicking on **Run the workflow**.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fbajnskwydluv51i8hj3.png)

Once the CI pipeline is complete, an [auto-promotion](https://docs.semaphoreci.com/essentials/deploying-with-promotions/) should kick off the continuous delivery pipeline.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fyzz7llhhxw6mrv2bamv.png)

The image is ready and tested for the next stage. Now you can deliver containers with more confidence.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gfouj4lyeebbu1ltno0r.png)

## Conclusion

The more you know about your container, the less surprises you’ll get. Container Structure Test may not be the most flexible tool, but it certainly is a quick and easy way of adding some confidence to the release process. So, it should be on the radar of anyone using containers for serious work.

Read next:

- [Kubernetes vs Docker: Understanding Containers in 2021](https://semaphoreci.com/blog/kubernetes-vs-docker)

- [Beyond Docker with Earthly](https://semaphoreci.com/blog/beyond-docker-with-earthly)

- [Docker Image Size – Does It Matter?](https://semaphoreci.com/blog/2018/03/14/docker-image-size.html)