Skip to main content

Command Palette

Search for a command to run...

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

Published
β€’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.

A

But it is already install in the first stage, so it takes storage as well, I did not understand why big thing is not big

A

Previous stages are ephemeral, they are just there to help us achieve final stage.

Final stage can import (copy) things (e.g. our built binary) from previous stages, and then, previous stages with all their contents will be gone. (actually they are also cached for next builds, but it will not be a part of final stage)

1
A

Arsham Arya aha I catch you now. It is very interesting to learn

1
A

Where we drop the go compiler in the second example? Can you describe this?

1
A

Thanks for your attention.

When we write this line:

FROM golang:1.18-alpine3.15 AS builder

We are actually using an alpine image with go compiler version 1.18 pre-installed. (as our builder stage)


At the lines:

FROM alpine:3.15 AS production
COPY --from=builder /app/main .

We are importing the compiled (binary) of our app from the builder stage (previous stage) into a new alpine image that doesn't have the golang compiler preinstalled.

So we didn't directly drop it, we just don't import or install it in next stages because we don't need it anymore.