This article demonstrates how to build a minimal container image (which does something useful, of course). With multistage builds, Scratch based mirrors and an assembly based HTTP server program, we were able to reduce the size to 6.32KB!

Expanding vessel

Containers are often touted as a panacea for all the problems associated with software operations and maintenance. As much as I love containers, I often run into wild mirrors with all sorts of problems. A common problem is the image size, sometimes a single image can be several gigabytes!

So I decided to challenge myself to build the smallest container image possible.

challenge

The rules are simple:

  • The container should provide access to the file content over HTTP on a specific port
  • Volume installation is not allowed

The simplest solution

For comparison, let’s create a simple server named index.js with Node.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/);
});
Copy the code

And put it in an image (this container uses the nodeJS official image) :

FROM node:14
COPY.
CMD ["node"."index.js"]
Copy the code

This image is built to a size of 943MB!

Smaller base images

To reduce the size of the target image, the simplest strategy is to use a smaller base image. The official nodeJS image has a variant called Slim (still based on Debian, but with fewer pre-installed dependencies) and a variant based on Alpine Linux called Alpine.

Using Node: 14-Slim and Node: 14-Alpine as base images reduces the final image size to 167MB and 116MB, respectively.

Due to the layered overlay nature of Docker images, each new layer must be built on top of the original base image, so there is no better way to reduce the size of nodeJS images.

Compiled languages

To take this one step further, we can use compiled languages with fewer runtime dependencies. There are several options, of course, but Golang is a “popular” choice for building Web services.

To do this, I created a basic file server, server.go, as follows:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main(a) {
	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)
	}
}
Copy the code

And build with golang’s official base image:

FROM golang:1.14
COPY.
RUN go build -o server .
CMD ["./server"]
Copy the code

The final size was 818MB. 😡

The problem here is that Golang’s base image has a number of dependencies installed that are useful in building Golang programs, but are generally not needed at runtime.

Many section of the building

Docker has a feature called multi-stage Builds, which build code in an environment with all the necessary dependencies installed and then copy the resulting executable files to other images.

This feature is often useful, and one of the main reasons is that the image size can be drastically reduced!

Reconstruct the Dockerfile as follows:

### build stage ###
FROM golang:1.14-alpine AS builder
COPY.
RUN go build -o server .

### run stage ###
FROM alpine:3.12
COPY --from=builder /go/server ./server
COPY index.html index.html
CMD ["./server"]
Copy the code

The image size is suddenly 13.2MB! 🙂

Static compilation + Scratch mirroring

13MB is good, but there are still ways to make it smaller.

There is a basic image called Scratch that contains nothing and has a size of zero. So any image built with it must come with all the necessary dependencies.

In order for our GO server to do this, we need to add some flags during compilation to ensure that the necessary libraries can be statically linked into the executable:

### build stage ###
FROM golang:1.14 as builder
COPY.
RUN go build \
  -ldflags "-linkmode external -extldflags -static" \
  -a server.go

### run stage ###
FROM scratch
COPY --from=builder /go/server ./server
COPY index.html index.html
CMD ["./server"]
Copy the code

Specifically, we set the link mode to external and pass the -static flag to the external linker.

These two changes reduce the image size to 8.65MB! 😀

ASM to win!

Images smaller than 10MB written in languages like Go are almost at their limit… But we can go on shrinking!

Github user Nemasu wrote a fully functional HTTP server assmTTpd in assembly form on Github.

To container it, all you need to do is install some of the build dependencies into the Ubuntu base image before making release:

### build stage ###
FROM ubuntu:18.04 as builder
RUN apt update
RUN apt install -y make yasm as31 nasm binutils 
COPY.
RUN make release

### run stage ###
FROM scratch
COPY --from=builder /asmttpd /asmttpd
COPY /web_root/index.html /web_root/index.html
CMD ["/asmttpd"."/web_root"."8080"] 
Copy the code

Copy the generated executable file asmttpd into the Scratch image and invoke it through CMD.

This results in a mirror size of just 6.34kB! 🥳


Below is a list of container images for your reference.


From the original 943MB NodeJS image to this tiny 6.34KB assembly image, I hope you enjoyed the process.

By understanding this process, hopefully you can also optimize your own container images to make them smaller.


The original link: devopsdirective.com/posts/2021/…