basnotes

 

Docker image best practices

tip

When building your own docker images, there are several best practices you can follow to optimize on security and size of your image. I came across a video on YouTube that mentions 8, and I think they make sense.

The lady handles 8 best practices and discusses each of them quite nicely.
Obviously you can just view the video, but if you prefer a written reference (I do), just read along.

Best practice 1: Use an official and verified image as base

For most images you’ll use some other image as base. That base image will have an OS installed and possibly some utilities. But, it may also contain malware. So, for unknown and non-verified publishers, try to review the Dockerfile to see its content. Conversely, using official and verified images will allow you to trust its content (if you trust the site that
verifies and determines what is official, of course…).

Best practice 2: Use specific docker image versions

A (software) configuration management best practice is to make everything specific. So, make specific which versions of which dependencies are needed to make your application work.

For docker images, it means that you shouldn’t use images tagged :LATEST. You never know which version you may get and a new version may introduce a change breaking for your application. So, moving to a new version of the base image must be a conscious choice in a process that also includes testing your application with the updated base image.

Best practice 3: Use a base image that is most specific to your need

When choosing a base image, choose one that is most specific to your need. E.g. when you need an image that supports NodeJS, you could take an Ubuntu image as base and install NodeJS. But, picking a dedicated NodeJS image has a few advantages:

  1. Dedicated images are usually optimized and use best-practices for their purpose.
  2. Dedicated images are usually smaller.
  3. Dedicated images have less moving parts and therefor have a smaller attack surface.

Additionally, for dedicated containers, there are multiple options for the underlying OS-es. They may use Ubuntu, Debian or Alpine. The choice depends on required available functionality versus size and attack surface. Mostly, Alpine images are smallest in both.

Best practice 4: Optimize caching of image layers

Docker images consist of a layered filesystem. By stacking them, you can build on other layers, even on layers made by other authors. A good example of this, is the base image you use with the FROM keyword. Your image builds on top of that image by adding your layers.

Another advantage of using layers, is that you can reuse layers if they are not changed. They are cached. This also speeds up building the image.

Each command in a Dockerfile creates a layer. Use command docker history to see the layers and the commands that created them:

➜  docker history mysql:5.7
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
c20987f18b13   3 weeks ago   /bin/sh -c #(nop)  CMD ["mysqld"]               0B        
<missing>      3 weeks ago   /bin/sh -c #(nop)  EXPOSE 3306 33060            0B        
<missing>      3 weeks ago   /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B        
<missing>      3 weeks ago   /bin/sh -c ln -s usr/local/bin/docker-entryp…   34B       
<missing>      3 weeks ago   /bin/sh -c #(nop) COPY file:345a22fe55d3e678…   14.5kB    
<missing>      3 weeks ago   /bin/sh -c #(nop)  VOLUME [/var/lib/mysql]      0B        
<missing>      3 weeks ago   /bin/sh -c {   echo mysql-community-server m…   313MB     
<missing>      3 weeks ago   /bin/sh -c echo 'deb http://repo.mysql.com/a…   55B       
<missing>      3 weeks ago   /bin/sh -c #(nop)  ENV MYSQL_VERSION=5.7.36-…   0B        
<missing>      3 weeks ago   /bin/sh -c #(nop)  ENV MYSQL_MAJOR=5.7          0B        
<missing>      3 weeks ago   /bin/sh -c set -ex;  key='A4A9406876FCBD3C45…   1.84kB    
<missing>      3 weeks ago   /bin/sh -c apt-get update && apt-get install…   52.2MB    
<missing>      3 weeks ago   /bin/sh -c mkdir /docker-entrypoint-initdb.d    0B        
<missing>      3 weeks ago   /bin/sh -c set -eux;  savedAptMark="$(apt-ma…   4.17MB    
<missing>      3 weeks ago   /bin/sh -c #(nop)  ENV GOSU_VERSION=1.12        0B        
<missing>      3 weeks ago   /bin/sh -c apt-get update && apt-get install…   9.34MB    
<missing>      3 weeks ago   /bin/sh -c groupadd -r mysql && useradd -r -…   329kB     
<missing>      3 weeks ago   /bin/sh -c #(nop)  CMD ["bash"]                 0B        
<missing>      3 weeks ago   /bin/sh -c #(nop) ADD file:bd5c9e0e0145fe33b…   69.3MB

As mentioned, layers are stacked. This means the order of stacking is important. Say you have 5 layers and the 2nd one is changed, then layers 2 to 5 need to be rebuilt. While if the 2nd layer was stacked as the 5th layer only that last one would have to be rebuilt when changed. So, when designing your image, make sure that layers that change most often are stacked last.

Best practice 5: Use a .dockerignore file

When building your application and the docker image containing it, quite often intermediate build artifacts are created. Additionally, files like documentation and README.md are usually not needed in the image.

You can exclude these file by copying only the desired files to the image. An alternative and mostly easier way is to exclude those files from being copied. Docker, like git, uses an ignore file to specify these exclusions. The .dockerignore file must be added next to the Dockerfile.

Best practice 6: Use multi-stage builds

Sometimes you need tools to build your app and image which you don’t need when running the image as container. A good example is a Java application. To build the application a JDK is needed often combined with Maven or Gradle. When the jar/war is built, those are no longer needed and a JRE suffices.

A solution is to use multi-stage builds. One image to do the actual building and then copy the result into the final image that will be deployed. This can be combined in single Dockerfile:

## build stage
FROM maven:3.8-openjdk-11 AS build
COPY src /home/app/src
COPY pom.xml /home/app
RUN mvn -B -f /home/app/pom.xml clean package

## run stage
FROM openjdk:11.0.11-jre-slim
COPY --from=build /home/app/target/app-1.0-SNAPSHOT.jar /usr/local/lib/app.jar
COPY .config.yaml /.config.yaml
EXPOSE 8080
ENTRYPOINT ["java","-jar","/usr/local/lib/app.jar"]

Best practice 7: Use the least privileged user

By default, docker containers run as user root. Often this is not needed for the application. It poses a security risk, because the user in the container could potentially have root permissions on the docker host. That would allow an attacker to have root privileges on the host if he were to break out of the container onto the host. This is called privilege escalation.

So, use the lease privileged user possible for your application. Create a dedicated group and user for your application. Set the required (file) permissions and change to the user by using the USER directive:

RUN groupadd -r app_user && useradd -g app_user app_user && chown -R app_user:app_user /app
USER app_user
CMD node index.js

Base images may already have a least privileged user that you can use:

FROM node:10-alpine
RUN chown -R node:node /app
USER node
CMD node index.js

Best practice 8: Scan your image for security vulnerabilities

Docker hub automatically scans your image for vulnerabilities when pushing your container. This makes it easy to check for known issues.

Conclusion

With the best practices mentioned above, the docker images you create will be smaller, better to be cached, less vulnerable and reliable.

January 9, 2022