How to use Docker Multistage to make tiny images (TS and Golang)

ยท

4 min read

Have you ever wondered how you can remove redundant build dependencies from your docker image?

Did you know your image can be easily optimized?

We're not much language-related in this article, and reading all examples can be beneficial.

TS example

For example, maybe you've always been using this kind of Dockerfile to build and run your Typescript project:

FROM node:18-alpine3.15
WORKDIR /usr/app
COPY package.json .
RUN npm install --include=dev
COPY tsconfig.json .
COPY src src
RUN ["npm", "run", "build"]
CMD ["npm", "run", "start"]

Simple, but it can be smaller without losing anything important, Because, on the line

RUN ["npm", "run", "build"]

We are converting some TS codes to JS codes with some Tools (dev dependencies) What happens if we remove those tools after conversion? Nothing!

Have a look at the picture below, we've built our image in three stages, instead of one, and we saved 35% of space!

optimizing docker builds with multistage builds

With the code below:

FROM node:18-alpine3.15 as ts-compiler
WORKDIR /usr/app
COPY package*.json ./
COPY tsconfig.json ./
RUN npm install --include=dev
COPY src src
# Build the project (TS to JS conversion)
RUN ["npm", "run", "build"]

FROM node:18-alpine3.15 as ts-remover
WORKDIR /usr/app
# We need package.json again
COPY --from=ts-compiler /usr/app/package*.json ./
# Move built codes from last stage here
COPY --from=ts-compiler /usr/app/build ./
# We don't need dev dependencies anymore
RUN npm install --omit=dev

# Using google optimized containers can make it even smaller
FROM gcr.io/distroless/nodejs:18
WORKDIR /usr/app
COPY --from=ts-remover /usr/app ./
USER 1000
CMD ["index.js"]

Be careful about environment variables

Imagine you are using a special package, for example, Puppeteer, Puppeteer will download a chrome browser when you're installing it, but you can disable it if you want, by setting an ENV. Have a look at the change we made on stage one of the last code:

FROM node:18-alpine3.15 as ts-compiler
WORKDIR /usr/app
COPY package.json .

# This line is added ๐Ÿ‘‡๐Ÿป
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true

RUN npm install --include=dev
COPY tsconfig.json .
COPY src src
RUN ["npm", "run", "build"]

So if you run the multistage build again, you'll see a huge increment ๐Ÿ˜ณ

Increment after multistage

What is happening? The fact is, you should use ENV and ARG commands per Stage, we are installing dependencies twice in two stages, so we should skip chrome installation twice! Nothing talks more than a piece of code ๐Ÿ˜„:

FROM node:18-alpine3.15 as ts-compiler
WORKDIR /usr/app
COPY package.json .

# Define ENV once here
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
RUN npm install --include=dev
COPY tsconfig.json .
COPY src src
RUN ["npm", "run", "build"]

FROM node:18-alpine3.15 as ts-remover
WORKDIR /usr/app
COPY --from=ts-compiler /usr/app/package.json .
COPY --from=ts-compiler /usr/app/build .

# Define ENV once again!
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
RUN npm install --omit=dev

# We don't need that ENV in this stage, 
# because we're not installing anything more.
FROM gcr.io/distroless/nodejs:18
WORKDIR /usr/app
COPY --from=ts-remover /usr/app ./
USER 1000
CMD ["index.js"]

Go Example

Go containers (and any other compile language) can be optimized even more. (much more ๐Ÿ˜„) Because their runtime is tied to them, you don't need a runtime like NodeJS applications do, so, you can drop the compiler itself in the second stage and just use a binary executable.

FROM golang:1.18-alpine3.15 AS builder
WORKDIR /app
# Copy go.mod and go.sum first, because of caching reasons.
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
# Compile project
RUN CGO_ENABLED=0 GOOS=linux go build -a -o main .

# Use another clean image without Golang compiler.
FROM alpine:3.15 AS production
COPY --from=builder /app/main .
CMD ["./main"]

Optimizing Golang Container Image Size

It's right! we saved about 96% of our space! The first result is without the multistage technique, Second is using alpine-3.15 as production. Third is using gcr.io/distroless/static-debian11 which is the smallest image out there.

Including UI

You can even add a UI to your Dockerfile in another stage! In this case, I have a Svelte SPA project beside my project (in ui folder) and I'm serving it from Go Backend.

FROM golang:1.18-alpine3.15 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -a -o main .

# This stage convert svelte project to vanilla HTML, CSS, JS.
FROM node:18-alpine3.15 AS frontend
WORKDIR /ui
COPY ui .
RUN npm install
RUN npm run build

FROM alpine:3.15 AS production
COPY --from=builder /app/main .
COPY --from=frontend /ui/public /ui
CMD ["./main"]

This code is a part of the Blogo project. https://github.com/arshamalh/blogo

I hope you learned something from me here. ๐Ÿ˜ƒโœŒ๐Ÿป Any suggestion or correction is welcome.

ย