How to Secure a Docker Container with Distroless Images

How can we increase security within our containers? This is the question we all ask when we begin considering our production build.

This is where distroless images come in.

Best Practices

The premise behind this discussion is this: we only want to install the things that we need.

With fewer packages installed, we’ll have:

  • A smaller security footprint
  • A smaller image size
  • Lower CPU/memory utilization
  • Simpler dependency management

If our current container is based on Debian, Alpine, or some other Linux distribution, it will likely have a shell installed. If an attacker gains access to the container, they may be able to use that shell to execute an attack.

We can get around this by using a base image that contains only our application in its runtime dependencies.

The good thing is that containers require far fewer things can we’d expect. For starters, there’s no need for a package manager or even a shell.

We generally won’t ssh into the shell of a container and install a new package, and we generally shouldn’t.

With distroless images, we are essentially following the best practice of only installing what we need to run our application, which happens to add a layer of security within our containers.

A Simple Example

We can use a multi-stage build to run our production containers with a distroless image.

Multi-stage builds in our Dockerfile allow for as many stages of the build process as we’d like. We can daisy-chain the outputs of one stage as inputs to the next stage. The only expectation is that the last stage will be our production stage with the distroless image.

We’ll only have two stages: one to build the image and another to run our application.

### Stage 1 ###
FROM node:14-alpine3.13 as builder
WORKDIR /usr/app
COPY package*.json .
RUN npm install
COPY . .

### Stage 2 ###
WORKDIR /usr/app
COPY --from=builder /usr/app .
USER 1000
CMD ["index.js"]

Here are the changes to note with this multi-stage, distroless setup.

Provide a name for the build image. We add builder as a name for the first stage. We can then reference the files in this stage using the COPY --from directive in the next stage.

Pull a distroless image. We use the nodejs distroless image provided by Google.

Execute as a non-root user. By default, many containers are configured to execute as root, which is needed to install packages and make configuration settings. But after all that is done, we can change to a non-root user. We can specify a numerical ID 1000, which corresponds to a default non-root, user ID provided by node. Usually, we can just use USER node, but node isn’t defined in the distroless image.

Use the CMD exec form. The standard ENTRYPOINT won’t be available to us, but luckily, we can use the CMD exec form to execute a command.

Now, our application is running in a container with nothing but the bare requirements to run our application.

But why is it called “distroless”? We can see in Google’s official distroless repository that these “distroless” images are based off of debian10. So, they are a distribution… but distroless? I like it.