[Перевод] Слабо поднять такой крошечный контейнер? Создаем контейнеризованный HTTP-сервер на 6kB
scratch
и крошечного http-сервера на основе этой сборки, я смог ужать результат до 6.32kB! Если предпочитаете видео, вот ролик по статье, выложенный на YouTube!
Раздутые контейнеры
Контейнеры часто превозносятся как панацея, позволяющая справиться с любыми вызовами, связанными с эксплуатацией ПО. Притом, как мне нравятся контейнеры, на практике мне часто попадаются контейнерные образы, отягощенные разнообразными проблемами. Распространенная беда — размер контейнера; у некоторых образов он достигает многих гигабайт!
Поэтому я решил устроить челлендж себе и всем желающим и попытаться создать настолько компактный образ, насколько возможно.
Задача
Правила довольно просты:
- Контейнер должен выдавать содержимое файла по http на выбранный вами порт
- Монтирование томов не допускается (так называемое «Правило Марека»)
Упрощенное решение
Чтобы узнать размер базового образа, можно воспользоваться node.js и создать простой сервер
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/`);
});
и сделать из него образ, запуская официальный базовый образ node:
FROM node:14
COPY . .
CMD ["node", "index.js"]
Этот завесил на
943MB
! Уменьшенный базовый образ
Один из простейших и наиболее очевидных тактических подходов к уменьшению размера образа — выбрать более компактный базовый образ. Официальный базовый образ node существует в варианте
slim
(по-прежнему на основе debian, но с меньшим количеством предустановленных зависимостей) и вариант alpine
на основе Alpine Linux.С применением node:14-slim
и node:14-alpine
в качестве базового удается уменьшить размер образа до 167MB
и 116MB
соответственно.
Поскольку образы docker аддитивны, где каждый уровень надстраивается над следующим, здесь уже практически нечего сделать, чтобы еще сильнее уменьшить решение с node.js.
Скомпилированные языки
Чтобы вывести ситуацию на новый уровень, можно перейти к компилируемому языку, где гораздо меньше зависимостей времени исполнения. Есть ряд вариантов, но для создания веб-сервисов часто применяется golang.
Я создал простейший файловый сервер 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)
}
}
И встроил его в контейнерный образ, воспользовавшись официальным базовым образом golang:
FROM golang:1.14
COPY . .
RUN go build -o server .
CMD ["./server"]
Который завесил на…
818MB
. Здесь есть проблема: в базовом образе golang установлено много зависимостей, которые полезны при создании программ на go, но не нужны для запуска программ.
Многоступенчатые сборки
В Docker есть возможность под названием многоступенчатые сборки, с которыми просто собирать код в среде, содержащей все необходимые зависимости, а потом скопировать полученный исполняемый файл в иной образ.
Это полезно по нескольким причинам, но одна из наиболее очевидных — размер образа! Выполнив рефакторинг dockerfile вот так:
### этап сборки ###
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"]
Размер полученного образа — всего
13.2MB
! Статическая компиляция + образ Scratch
13 MB — совсем неплохо, но у нас в запасе осталась еще пара трюков, позволяющих еще сильнее ужать этот образ.
Есть базовый образ под названием scratch, который однозначно пуст, его размер равен нулю. Поскольку внутри scratch
ничего нет, любой образ, построенный на его основе, должен нести в себе все необходимые зависимости.
Чтобы это было возможно на основе нашего сервера на go, нужно добавить несколько флагов на этапе компиляции, чтобы обеспечить статическую линковку всех необходимых библиотек в исполняемый файл:
### этап сборки ###
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"]
В частности, мы задаем
external
в качестве режима линковки и передаем флаг -static
внешнему линковщику.Благодаря двум этим изменениям удается довести размер образа до 8.65MB
ASM как залог победы!
Образ размером менее 10MB, написанный на языке вроде Go, отчетливо миниатюрен почти для любых обстоятельств…, но можно сделать еще меньше! Пользователь nemasu выложил на Github полноценный http-сервер, написанный на ассемблере. Он называется assmttpd.
Все, что потребовалось для его контейнеризации — установить несколько зависимостей сборки в базовый образ Ubuntu, прежде, чем запустить предоставленный рецепт 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"]
Затем полученный в результате исполняемый файл
asmttpd
копируется в scratch-образ и вызывается через командную строку. Размер полученного образа — всего 6,34kB!