Downsizing Docker Images (~20X Improvement)

Boat Ride in the Canals. Amsterdam, Netherlands. @photosbysaurav on Instagram
Boat Ride in the Canals. Amsterdam, Netherlands. @photosbysaurav on Instagram

Note: This blog was originally published on my personal website: Downsizing Docker Images (~20X Improvement)

Docker is great, isn’t it? It has solved so many problems and helps developers easily deploy applications without complex configuration. But it’s also our responsibility to use it well. Docker images are basically templates that will create containers will which run our application — so it is important that we package our application which creates the most optimized and compact results. This is to ensure that our docker images can be used more effectively — deploying your image as a microservice? It’s best to have light-weight services. Writing these docker images to a container registry? Save space and store the most optimized docker images.

Note: Even though we’re going to take a look at a sample nestjs application, this applies to any sort of application you’re building.

You can find the entire repository here and you can experiment with this. So, to give you a brief about the app, it’s a basic hello API which has one route that returns some information about itself. If you run the app locally, with a you can see the response by sending a request to

Here’s a sample response:

{
"data": {
"app": "hello",
"version": "v0.0.1"
}
}

The first version of the application is ready, which will evolve overtime, let’s say. So now we can move over to this application. I'll write each version of the app against the operation we do.

To build the docker image, I’ll be using this command

$ docker build -t hello:v0.0.1 -f dockerfile . # <-- replace the version for each build

Once you have created an image, you can run it with this docker run command

$ docker run -p 8080:80 hello:v0.0.1 # <-- you can now hit <http://localhost:8080/>

v0.0.1 — Simple and Straightforward

So, first, let’s write a very simple and straight forward dockerfile to create our image. You can see, we’re just using the node’s LTS tagged image to install our packages, building our project and running it.

FROM node:ltsWORKDIR /appCOPY package* . RUN npm ciCOPY . .RUN npm run buildENTRYPOINT node dist/src/main.js

I’ve also symlinked the to as we don't need those in our context.

# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json

So, right now, we end-up with an image whose size is 1.23GB, which is bad news for just a hello sort of app. By the way, you can check the size of your images with in the command.

v0.0.2 — Muliti Stage Builds

Here, we’ll build and package our application in separate environments. This will fix a few things for us -

  • the docker image will not container any
  • we can also choose to remove the test code or the source code entirely from the docker image

For the final image, we’re using alpine tags. alpine is a lightweight linux distro and great for docker images.

FROM node:14.17.1 as buildEnvWORKDIR /appCOPY package* .RUN npm ciCOPY . .RUN npm run buildFROM node:14.17.1-alpine # <-- real small imagesWORKDIR /appCOPY --from=buildEnv /app/package* .RUN npm i --prodCOPY --from=buildEnv /app/dist distENTRYPOINT node . # <-- this works because of the 'main' property in package.json

That was great! We have an image with a size of 130MB. That’s great progress. But we can do better. How?

v0.0.3 — Install Node in an Alpine Image

Rather than using node’s alpine tagged docker image, we can create our own from alpine images. So what will be trimming off? Well, node’s alpine tags still contain things like , which we really don't need at runtime. (Maybe your app does, so this might not work for you.)

FROM node:lts as buildEnvWORKDIR /appCOPY package* .RUN npm ciCOPY . .RUN npm run buildFROM node:lts-alpine as finalCodeEnvWORKDIR /appCOPY --from=buildEnv /app/package* .RUN npm i --prodCOPY --from=buildEnv /app/dist distFROM alpine WORKDIR /appRUN apk add nodejsCOPY --from=finalCodeEnv /app .ENTRYPOINT node .

We’ve already reduced the image size from 1.23GB to 58.5MB — which is almost a twenty fold () improvement, but can we go further? Since we've installed some packages with apk, maybe we can remove the cache to save some space. Let's add that to our final stage in our to clear the apk cache. Let's give it a go!

v0.0.4 — Clear APK Cache

FROM node:lts as buildEnvWORKDIR /appCOPY package* .RUN npm ciCOPY . .RUN npm run buildFROM node:lts-alpine as finalCodeEnvWORKDIR /appCOPY --from=buildEnv /app/package* .RUN npm i --prodCOPY --from=buildEnv /app/dist distFROM alpine WORKDIR /appRUN apk add nodejs && rm -rf /var/cache/apk/* ## <-- Clear APK CacheCOPY --from=finalCodeEnv /app .ENTRYPOINT node .

If you see the final image sizes, there’s not much of a difference. After checking folder sizes inside the container — our app is around 29MB. The next largest file is the interpreter itself, around 30MB, but can't really do much about that. But, at the end of it all, it was a great exercise and now you can see how light-weight you can make nestjs, and nodejs images in general.

$ docker images --format "{{.ID}}\\t{{.Size}}\\t{{.Repository}}\\t{{.Tag}}" | sort -k 4 | grep helloe3f7f026264d	1.23GB	hello	v0.0.1
167438686d8f 130MB hello v0.0.2
7f83291130fd 58.6MB hello v0.0.3
ab3fc836943c 56.5MB hello v0.0.4

So this was a guide as to how to downsize images when writing dockerfiles for your applications. Try this out with different sort of apps and languages. It’s always fun to see how to creatively create optimized images. Enjoy! Happy coding!

Saurav

Opinions are my own. Full Stack Engineer. CEO of “it was just working 🤷‍♂️”