Старайтесь всегда использовать многоэтапную сборку, для создания более компактных 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)



  1. aml
    23.01.2022 18:23
    +1

    Можно ещё убрать отладочные символы (стектрейсы правда испортятся), да и сертификаты можно монтировать с хост-системы, но это уже мелочи конечно.


  1. ertaquo
    23.01.2022 18:23

    У подобного способа есть один минус: отсутствует кеширование при построении контейнера. Это довольно критично, если у вас много зависимостей, которые долго скачиваются либо собираются.

    Как вариант, на одном из шагов можно удалять лишние данные из контейнера, чтобы уменьшить его размер.


    1. sergeykons
      23.01.2022 18:49
      +1

      Насколько я помню это зависит от сборщика. Например kaniko точно умеет в кеширование слоев в multi-stage


    1. Deissh
      24.01.2022 01:43

      Докер не будет пересобирать слой если в нем не увидел изменений.


      1. ertaquo
        24.01.2022 07:31

        В Docker кешируются слои только для последнего stage. По крайней мере так было до 2021, далее с multistage building не работал.

        Как справедливо отметил @sergeykons, у других сборщиков поведение может отличаться.


        1. 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 . .


  1. sergeykons
    23.01.2022 18:39
    +2

    Можно накинуть несколько уточнений
    1 go.mod и go.sum нужно все таки копировать в одной инструкции COPY чтобы не создавать лишний слой

    2 Если использовать scratch то очень легко поймать проблемы с сертификатами (Которые нужно копировать из alpine) или таймзонами или любыми другими зависимостями. Можно использовать distroless
    3 RUN mkdir вначале не нужен. WORKDIR сам создает директорию


  1. manyakRus
    24.01.2022 15:11

    Зачем вам компилировать внутри докера go build... ? Если можно компилировать на своём компьютере без докера :-)


    1. monomoto Автор
      24.01.2022 15:36
      +2

      Такой подход не всегда удобен, например если сборка происходит в пайплайне CI/CD, тогда придется устанавливать зависимости и golang.