Старайтесь всегда использовать многоэтапную сборку, для создания более компактных Docker образов. Давайте, рассмотрим на примере, как многоэтапная сборка позволяет значительно уменьшить размер Docker образа. В качестве примера, мы будем использовать простое веб-приложение на Golang:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", HelloServer)
fmt.Printf("Starting server at port 8080\n")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
func HelloServer(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello world")
}
Сначала, соберем Docker образ в один этап:
FROM golang:1.16-alpine
WORKDIR /build
COPY go.mod . # go.sum
RUN go mod download
COPY . .
RUN go build -o /main main.go
ENTRYPOINT ["/main"]
На выходе мы получили Docker образ, размер которого 308 MB. Давайте, теперь мы пересоберем тоже самое приложение, но с использованием многоэтапной сборки:
# Этап, на котором выполняется сборка приложения
FROM golang:1.16-alpine as builder
WORKDIR /build
COPY go.mod . # go.sum
RUN go mod download
COPY . .
RUN go build -o /main main.go
# Финальный этап, копируем собранное приложение
FROM alpine:3
COPY --from=builder main /bin/main
ENTRYPOINT ["/bin/main"]
Мы получили Docker образ, размер которого всего 11.8 MB. Неплохо, мы уменьшили образ в более, чем 25 раз. Но, за счет чего нам удалось добиться такого результата?
В первом случае, мы используем сборку в один этап, а следовательно размер финального Docker образа состоит:
из размера golang:1.16-alpine - 302 MB
размер исходников - 0.5 MB
размер скомпилированного приложения - 6.2 MB
Во, втором случае, мы выполнили компиляцию и сборку приложения, а затем в финальный этап, перенесли уже скомпилированный результат. Таким образом, для создания Docker образа используется лишь один, финальный этап, который состоит из:
размера alpine:3 - 5.59 MB
размер скомпилированного приложения - 6.2 MB
А можно ли еще уменьшить размер Docker образа?
Можно, но для этого в качестве финального образа, мы должны использовать docker scratch - это пустой образ в докере, размер которого - 0 MB.
FROM golang:1.16-alpine as builder
WORKDIR /build
COPY go.mod . # go.sum
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /main main.go
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder main /bin/main
ENTRYPOINT ["/bin/main"]
В итоге наш контейнер занимает всего лишь 6.34 MB (размер скомпилированного приложения). На что стоит обратить внимание?
Первое, нам пришлось изменить команду сборки, а именно добавить дополнительные флаги:
CGO_ENABLED - мы отключаем CGO, таким образом мы получаем скомпилированное Go-приложение вместе с статически связанными C-библиотеками, поэтому наш бинарник будет работать без каких-либо внешних зависимостей. Более подробно, можно почитать тут.
GOOS - мы указываем Linux в качестве ОС.
Второе, на что стоит обратить внимание - это SSL. Т.к. scratch пуст, то там просто нету рутовых SSL сертификатов, а значит их нужно добавить вручную.
Комментарии (10)
aml
23.01.2022 18:23+1Можно ещё убрать отладочные символы (стектрейсы правда испортятся), да и сертификаты можно монтировать с хост-системы, но это уже мелочи конечно.
ertaquo
23.01.2022 18:23У подобного способа есть один минус: отсутствует кеширование при построении контейнера. Это довольно критично, если у вас много зависимостей, которые долго скачиваются либо собираются.
Как вариант, на одном из шагов можно удалять лишние данные из контейнера, чтобы уменьшить его размер.
sergeykons
23.01.2022 18:49+1Насколько я помню это зависит от сборщика. Например kaniko точно умеет в кеширование слоев в multi-stage
Deissh
24.01.2022 01:43Докер не будет пересобирать слой если в нем не увидел изменений.
ertaquo
24.01.2022 07:31В Docker кешируются слои только для последнего stage. По крайней мере так было до 2021, далее с multistage building не работал.
Как справедливо отметил @sergeykons, у других сборщиков поведение может отличаться.
Sky-Fox
24.01.2022 09:54В Docker кешируются слои только для последнего stage. По крайней мере так было до 2021, далее с multistage building не работал.
не буду спорить, может так и было. Сейчат @Deissh прав. Но важно сначала копировать и скачивать файлы зависимостей а только потом сам проект. Зависимости не так часто меняются и этот слой не будет пересобираться.
COPY go.mod go.sum ./ RUN go mod download # copy all sources COPY . .
sergeykons
23.01.2022 18:39+2Можно накинуть несколько уточнений
1 go.mod и go.sum нужно все таки копировать в одной инструкции COPY чтобы не создавать лишний слой2 Если использовать scratch то очень легко поймать проблемы с сертификатами (Которые нужно копировать из alpine) или таймзонами или любыми другими зависимостями. Можно использовать distroless
3 RUN mkdir вначале не нужен. WORKDIR сам создает директорию
manyakRus
24.01.2022 15:11Зачем вам компилировать внутри докера go build... ? Если можно компилировать на своём компьютере без докера :-)
monomoto Автор
24.01.2022 15:36+2Такой подход не всегда удобен, например если сборка происходит в пайплайне CI/CD, тогда придется устанавливать зависимости и golang.
neenik
https://github.com/google/ko