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!
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 ๐ณ
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"]
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.