Is it weak to lift such a tiny container? Building a 6kB containerized HTTP server

TL; DR   I decided to create the smallest container image with which you can still do something useful. Taking advantage of the multi-stage builds, base image,  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!



All Articles