scratch
and tiny http server based on this build, I was able to squeeze the result down to 6.32kB!
If you prefer video, here's a YouTube video for the article!
Bloated containers
Containers are often touted as a panacea for handling any software challenge. Moreover, as I like containers, in practice I often come across container images, burdened with various problems. A common problem is the size of the container; for some images it reaches many gigabytes!
So I decided to challenge myself and everyone else and try to create as compact an image as possible.
A task
The rules are pretty simple:
- The container should serve the contents of the file via http to the port of your choice
- Mounting volumes is not allowed (so called "Marek's Rule")
Simplified solution
To find out the size of the base image, you can use node.js and create a simple server
index.js
:
const fs = require("fs"); const http = require('http'); const server = http.createServer((req, res) => { res.writeHead(200, { 'content-type': 'text/html' }) fs.createReadStream('index.html').pipe(res) }) server.listen(port, hostname, () => { console.log(`Server: http://0.0.0.0:8080/`); });
and make an image out of it by running the official base image of node:
FROM node:14 COPY . . CMD ["node", "index.js"]
This one hung on
943MB
!
Reduced base image
One of the simplest and most obvious tactical approaches to reducing skin size is to opt for a leaner base skin. The official base image of node exists in a variant
slim
(still based on debian, but with fewer pre-installed dependencies) and a variant
alpine
based on Alpine Linux .
Using
node:14-slim
and
node:14-alpine
as a base, it is possible to reduce the size of the image to
167MB
and,
116MB
respectively.
Since docker images are additive, with each layer building on top of the next, there is almost nothing to do here to further reduce the node.js solution.
Compiled languages
To take things to the next level, you can move to a compiled language that has much fewer run-time dependencies. There are a number of options, but golang is often used to create web services .
I created the simplest file server
server.go
:
package main import ( "fmt" "log" "net/http" ) func main() { fileServer := http.FileServer(http.Dir("./")) http.Handle("/", fileServer) fmt.Printf("Starting server at port 8080\n") if err := http.ListenAndServe(":8080", nil); err != nil { log.Fatal(err) } }
And I built it into the container image using the official golang base image:
FROM golang:1.14 COPY . . RUN go build -o server . CMD ["./server"]
Which hung onβ¦
818MB
.
There is a problem here: there are many dependencies installed in the base golang image, which are useful when building go programs, but not needed to run programs.
Multi-stage builds
Docker has a feature called multistage builds , with which it is easy to build code in an environment containing all the necessary dependencies, and then copy the resulting executable file into another image.
This is useful for several reasons, but one of the most obvious is the size of the image! By refactoring the dockerfile like this:
### ### FROM golang:1.14-alpine AS builder COPY . . RUN go build -o server . ### ### FROM alpine:3.12 COPY --from=builder /go/server ./server COPY index.html index.html CMD ["./server"]
The size of the resulting image is everything
13.2MB
!
Static compilation + Scratch image
13 MB is not bad at all, but we still have a couple of tricks left to make this look even tighter.
There is a base image called scratch , which is unambiguously empty, its size is zero. Since
scratch
there is nothing inside , any image built on its basis must carry all the necessary dependencies.
To make this possible based on our go server, we need to add a few flags at compile time to ensure that all necessary libraries are statically linked into the executable:
### ### FROM golang:1.14 as builder COPY . . RUN go build \ -ldflags "-linkmode external -extldflags -static" \ -a server.go ### ### FROM scratch COPY --from=builder /go/server ./server COPY index.html index.html CMD ["./server"]
In particular, we set
external
the linking mode and pass the flag to the
-static
external linker.
Thanks to these two changes, it is possible to bring the image size up to
8.65MB
ASM as a guarantee of victory!
An image of less than 10MB, written in a language like Go, is distinctly miniaturized for almost any circumstance ... but you can make it even smaller! User nemasu has posted a full-fledged http server written in assembler on Github. It's called assmttpd .
All it took to containerize it was to install a few build dependencies into the base Ubuntu image, before running the provided recipe
make release
:
### ### FROM ubuntu:18.04 as builder RUN apt update RUN apt install -y make yasm as31 nasm binutils COPY . . RUN make release ### ### FROM scratch COPY --from=builder /asmttpd /asmttpd COPY /web_root/index.html /web_root/index.html CMD ["/asmttpd", "/web_root", "8080"]
The resulting executable is then
asmttpd
copied to the scratch image and invoked through the command line. The size of the resulting image is only 6.34kB!