How To Secure Docker Images With Encryption Through Containerd

Docker image encryption with containerd
Table of Contents

Docker has exploded into popularity over the past decade for DevOps. Its massive adoption rates make it the first choice for container-based orchestration. It is used by small implementations and large-scale enterprises to launch and deploy applications using Linux containers — which in a nutshell is a form of OS-level virtualization.

In contrast to a VM, a container just contains the required files. Docker is open-sourced and is a container engine that uses Linux Kernel features to create containers on top of an operating system. This means that it is easy for a developer to efficiently shift an application over from a laptop to a test environment.

Docker by design is small, lightweight, portable, fast to launch, highly scalable, and great for continuous integration (CI) and continuous deployment (CD). But how secure is it?

By default, Docker container images are unencrypted. These container images often contain code and sensitive data such as private and API keys that are used by the application. This means that if a malicious user gains access to the Docker container, they also gain access to your sensitive data.

How do we prevent this? The easiest solution is to encrypt your Docker containers.

How to encrypt a Docker container image

For this tutorial, we will be using containerd to encrypt your Docker image. What is containerd?  containerd is an industry-standard for container runtimes that is available as a daemon for Linux and Windows and is designed to be embedded into a larger system. It manages the complete container lifecycle of its host system, from image transfer and storage, container execution and supervision, to low-level storage and network attachments.

To start using containerd, you will need Go 1.9.x or above on your Linux host. To install containerd, you can do so using the wget command or go directly to the download page. The current latest version is 1.5.2 and here is the command for installing the binaries for containerd.

wget https://github.com/containerd/containerd/releases/download/v1.5.2/containerd-1.5.2-linux-amd64.tar.gz
 tar xvf containerd-1.5.2-linux-amd64.tar.gz
Once the installation process is completed, a new containerd-1.5.2 directory will be created. The daemon uses the configuration file located at /etc/containerd/config.toml and looks something like this:
 subreaper = true
 oom_score = -999
 ​
 [debug]
        level = "debug"
 ​
 [metrics]
        address = "127.0.0.1:1338"
 ​
 [plugins.linux]

If a configuration file doesn’t exist, you can generate a default one using the following command:

containerd config default > /etc/containerd/config.toml

To connect to containerd, create a new main.go file and import containerd as a root package that contains the client. Here is the sample code:

package main
​
import (
  "log"
​
  "github.com/containerd/containerd"
)
​
func main() {
  if err := redisExample(); err != nil {
  log.Fatal(err)
  }
}
​
func redisExample() error {
  client, err := containerd.New("/run/containerd/containerd.sock")
  if err != nil {
  return err
  }
  defer client.Close()
  return nil
}

The above will create a new client with a default containerd socket path. To change this, create a context for calls to client methods.

ctx := namespaces.WithNamespace(context.Background(), "example")

Now it’s time to pull in the redis image from DockerHub.

image, err := client.Pull(ctx, "docker.io/library/redis:alpine", containerd.WithPullUnpack)
  if err != nil {
  return err
  }

Here is the entire main.go code you need in one space:

package main
​
import (
        "context"
        "log"
​
        "github.com/containerd/containerd"
        "github.com/containerd/containerd/namespaces"
)
​
func main() {
        if err := redisExample(); err != nil {
                log.Fatal(err)
        }
}
​
func redisExample() error {
        client, err := containerd.New("/run/containerd/containerd.sock")
        if err != nil {
                return err
        }
        defer client.Close()
​
        ctx := namespaces.WithNamespace(context.Background(), "example")
        image, err := client.Pull(ctx, "docker.io/library/redis:alpine", containerd.WithPullUnpack)
        if err != nil {
                return err
        }
        log.Printf("Successfully pulled %s image\n", image.Name())
​
        return nil
}

Now you can build your main.go

go build main.go

If you run sudo ./main, you will get the following returned result (or something similar):

2021/06/02 17:43:21 Successfully pulled docker.io/library/redis:alpine image

Now that we have containerd working, how exactly do we encrypt a Docker image?

First, we need to generate some keys. Here are the commands for generating RSA keys with openssl.

$ openssl genrsa --out mykey.pem
Generating RSA private key, 2048 bit long modulus (2 primes)
...............................................++++
............................+++++
e is 65537 (0x010001)
$ openssl rsa -in mykey.pem -pubout -out mypubkey.pem
writing RSA key

Let’s pull in an image so that we can encrypt it.

$ sudo ctr-enc images pull --all-platforms docker.io/library/bash:latest
[... truncated ...]

To view your encryption information on the image, you can use the ctr-enc image layerinfo command. As we haven’t encrypted our image yet, here is what it can look like:

$ sudo ctr-enc images layerinfo --platform linux/amd64 docker.io/library/bash:latest
    #                                                                   DIGEST     PLATFORM     SIZE   ENCRYPTION   RECIPIENTS
    0   sha256:9e57c4ce12a330de1631e554b498a125e564ced155ebdd1c7764eb871cbd9609   linux/amd64   2789544                         
    1   sha256:5ee01fd661d4ec8478c5096b983326b44e4fc8bd7f98209b9e840291be9b15c0   linux/amd64   3174231                         
    2   sha256:735cfbca546415867c7b55f357dc15e45973e7d285c2b3b783bd2b2b8ea52def3   linux/amd64       125

Now it’s time to encrypt our Docker image. To do this by using the ctr-enc images encrypt command. This will encrypt the existing image to a new tag. ctr-enc images encrypt takes five arguments.

The first argument is –recipient jwe:mypubkey.pem. This portion of the command tells containerd that we want to encrypt the image using the public key mypubkey.pem. It is prefixed with jwe: to indicate that the encryption scheme is JSON web encryption scheme.

The second argument is –platform linux/amd64. This flag tells containerd to only encrypt the linux/amd64 image.

The third argument is docker.io/library/bash:latest, which points to the image we want to encrypt.

The fourth argument is bash.enc:latest, which is the tag of the encrypted image to be created.

And finally, you can also decide which layer you want to encrypt using the –layer tag. This argument is optional and can be omitted if you want to encrypt the entire image and not just parts of the image.

Here is an example of how to use it with our public key.

$ sudo ctr-enc images encrypt --recipient jwe:mypubkey.pem --platform linux/amd64 docker.io/library/bash:latest bash.enc:latest
Encrypting docker.io/library/bash:latest to bash.enc:latest

To push your encrypted image to the registry, you can just use sudo docker run. It’s good to note that only the Docker registry version 2.7.1 and above supports encrypted OCI images.

Here is the full command for it:

$ sudo docker run -d -p 5000:5000 --restart=always --name registry registry:2.7.1

You can now tag and push the image, and then delete the local copy using the following command:

$ sudo ctr-enc images tag bash.enc:latest localhost:5000/bash.enc:latest
$ sudo ctr-enc images push localhost:5000/bash.enc:latest
$ sudo ctr-enc images rm localhost:5000/bash.enc:latest bash.enc:latest
$ sudo ctr-enc images pull localhost:5000/bash.enc:latest

Now if we attempt to run the encrypted container, the image will fail if the keys for the encrypted image are not provided. You can pass in the keys using the –key flag. Here is an example of how to do so:

$ sudo ctr-enc run --rm --key mykey.pem localhost:5000/bash.enc:latest test echo 'It works!'
It works!

That is basically it for encrypting a Docker image, pushing it to a registry, and running the decrypted image.

Where to from here?

When it comes to security, using the default settings is one of the biggest risks that any production-level application can experience. Encryption is one methodology for securing your Docker. Other methods include setting resource limits for your container, and implementing Docker bench security to check host, docker daemon configuration, and configuration files, in addition to container images, build files, and container runtimes.

Another standard security protocol for Docker is to never run a container as a root user. If you do not specify a user when starting a container, it defaults the user set in the image — which is often the root user.

Always scan and rebuild images to include security patches, so that your deployments are always up to date. You can also enable Docker Content Trust (DCT), which uses digital signatures to validate the integrity of images pulled from remote Docker registries.

Recent resources

What is LDAP Injection? Types, Examples and How to Prevent It

Learn what LDAP Injection is, its types, examples, and how to prevent it. Secure your applications against LDAP attacks.

Read more

How to Use Dependency Injection in Java: Tutorial with Examples

Learn how to use Dependency Injection in Java with this comprehensive tutorial. Discover its benefits, types, and practical examples.

Read more

Idempotency: The Microservices Architect’s Shield Against Chaos

Discover the power of idempotency in microservices architecture. Learn how to maintain data consistency and predictability.

Read more