Многоступенчатые сборки

Многоэтапные сборки полезны всем, кто пытался оптимизировать Docker-файлы, сохраняя их легкость чтения и сопровождения.

Примечание

Признательность

Особая благодарность Алекс Эллис за разрешение использовать его сообщение в блоге Шаблон Builder против многоэтапных сборок в Docker в качестве основы для примеры далее.

Перед многоступенчатым построением

Один из самых сложных моментов при создании образов — это уменьшение размера образа. Каждая оператор RUN, COPY и ADD в Dockerfile добавляет слой к образу, и вам нужно не забыть очистить все ненужные артефакты перед переходом к следующему слою. Чтобы написать действительно эффективный Dockerfile, традиционно необходимо использовать трюки оболочки и другую логику, чтобы слои были как можно меньше и чтобы каждый слой имел только те артефакты, которые ему нужны от предыдущего слоя, и ничего больше.

На самом деле было очень распространено использование одного Dockerfile для разработки (который содержал все необходимое для создания вашего приложения) и уменьшенного Dockerfile для производства, который содержал только ваше приложение и то, что было необходимо для его запуска. Такой подход называют «паттерном сборщика». Ведение двух Docker-файлов не является идеальным.

Вот пример build.Dockerfile и Dockerfile, которые соответствуют приведенному выше шаблону строителя:

build.Dockerfile:

# syntax=docker/dockerfile:1
FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
COPY app.go ./
RUN go get -d -v golang.org/x/net/html \
  && CGO_ENABLED=0 go build -a -installsuffix cgo -o app .

Обратите внимание, что данный пример также искусственно сжимает две команды RUN вместе с помощью оператора Bash &&, чтобы избежать создания дополнительного слоя в образы. Это чревато сбоями и трудно поддерживать. Легко вставляет другую команду и забыть продолжить строку, используя, например, символ \.

Dockerfile:

# syntax=docker/dockerfile:1
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY app ./
CMD ["./app"]

build.sh:

#!/bin/sh
echo Building alexellis2/href-counter:build
docker build -t alexellis2/href-counter:build . -f build.Dockerfile

docker container create --name extract alexellis2/href-counter:build
docker container cp extract:/go/src/github.com/alexellis/href-counter/app ./app
docker container rm -f extract

echo Building alexellis2/href-counter:latest
docker build --no-cache -t alexellis2/href-counter:latest .
rm ./app

Когда вы запускаете сценарий build.sh, ему нужно сгенерировать первый образ, создать из него контейнер для копирования артефакта, а затем создает второй образ. Оба образа занимают место в системе, а артефакт app все ещё находится на локальном диске.

Многоступенчатые сборки значительно упрощают эту ситуацию!

Используйте многоступенчатые сборки

При многоэтапной сборке вы используете несколько инструкций FROM в своём Dockerfile. Каждая оператор FROM может использовать разную базу, и каждая из них начинает новый этап сборки. Вы можете выборочно копировать артефакты с одного этапа на другой, оставляя в конечном образе все, что вам не нужно. Чтобы показывает, как это работает, давайте адаптируем инструкцию Dockerfile из предыдущего раздела для использования многоэтапной сборки.

# syntax=docker/dockerfile:1

FROM golang:1.16
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go ./
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/alexellis/href-counter/app ./
CMD ["./app"]

Вам нужен только один Dockerfile. Вам также не нужен отдельный сценарий сборки. Просто выполните docker build.

$ docker build -t alexellis2/href-counter:latest .

Конечным результатом является то же самое крошечное производственное образ, что и раньше, со значительным снижением сложности. Вам не нужно создавать никаких промежуточных образов, и вам вообще не нужно извлекать какие-либо артефакты в локальную систему.

Как это работает? Вторая оператор FROM запускает новый этап сборки, основой которого является образ alpine:latest. Строка COPY --from=0 копирует только собранный артефакт с предыдущего этапа в данный новый этап. Go SDK и любые промежуточные артефакты остаются позади и не сохраняются в конечном образе.

Назовите этапы сборки

По умолчанию этапы не имеют имён, и вы обращаетесь к ним по их целочисленному номеру, начиная с 0 для первой инструкции FROM. Однако вы можете присвоить имя своим этапам, добавив AS <NAME> к инструкции FROM. Данный пример улучшает предыдущий, называя этапы и используя имя в инструкции COPY. Это означает, что даже если инструкции в вашем Dockerfile впоследствии будут переупорядочены, оператор COPY не сломается.

# syntax=docker/dockerfile:1

FROM golang:1.16 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go ./
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app ./
CMD ["./app"]

Остановка на определенном этапе сборки

При сборке образа не обязательно собирать весь Dockerfile, включая все этапы. Вы можете указывает целевой этап сборки. Следующая команда предполагает, что вы используете предыдущий Dockerfile, но останавливаетесь на этапе с именем builder:

$ docker build --target builder -t alexellis2/href-counter:latest .

Несколько сценариев, в которых это может быть очень полезно:

  • Отладка определённого этапа сборки

  • Использование каскада debug со всеми включенными отладочными символами или инструментами и обедненного каскада production

  • Использование этапа testing, на котором ваше приложение заполняется тестовыми данными, а для производства используется другой этап, использующий реальные данные.

Используйте внешнее образ в качестве «сцены»

При использовании многоэтапных сборок вы не ограничены копированием из этапов, созданных ранее в вашем Dockerfile. Вы можете использовать инструкцию COPY --from для копирования из отдельного образа, используя имя локального образа, тег, доступный локально или в реестре Docker, или идентификатор тега. При необходимости клиент Docker извлекает образ и копирует артефакт оттуда. Синтаксис следующий:

COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

Используйте предыдущий этап в качестве нового этапа

Вы можете продолжить предыдущий этап, сославшись на него при использовании директивы FROM. Например:

# syntax=docker/dockerfile:1

FROM alpine:latest AS builder
RUN apk --no-cache add build-base

FROM builder AS build1
COPY source1.cpp source.cpp
RUN g++ -o /binary source.cpp

FROM builder AS build2
COPY source2.cpp source.cpp
RUN g++ -o /binary source.cpp

Совместимость версий

Синтаксис многоэтапной сборки был введен в Docker Engine 17.05.

Различия между унаследованным конструктором и BuildKit

Старый сборщик Docker Engine обрабатывает все этапы Dockerfile, ведущие к выбранному --target. Он будет собирать этап, даже если выбранная цель не зависит от этого этапа.

BuildKit строит только те этапы, от которых зависит целевой этап.

Например, если дать следующий Dockerfile:

# syntax=docker/dockerfile:1
FROM ubuntu AS base
RUN echo "base"

FROM base AS stage1
RUN echo "stage1"

FROM base AS stage2
RUN echo "stage2"

В BuildKit включён сборка цели stage2 в этом Dockerfile означает, что обрабатываются только base и stage2. Зависимость от stage1 отсутствует, поэтому она пропускается.

$ DOCKER_BUILDKIT=1 docker build --no-cache -f Dockerfile --target stage2 .
[+] Building 0.4s (7/7) FINISHED
 => [internal] load build definition from Dockerfile                                            0.0s
 => => transferring dockerfile: 36B                                                             0.0s
 => [internal] load .dockerignore                                                               0.0s
 => => transferring context: 2B                                                                 0.0s
 => [internal] load metadata for docker.io/library/ubuntu:latest                                0.0s
 => CACHED [base 1/2] FROM docker.io/library/ubuntu                                             0.0s
 => [base 2/2] RUN echo "base"                                                                  0.1s
 => [stage2 1/1] RUN echo "stage2"                                                              0.2s
 => exporting to image                                                                          0.0s
 => => exporting layers                                                                         0.0s
 => => writing image sha256:f55003b607cef37614f607f0728e6fd4d113a4bf7ef12210da338c716f2cfd15    0.0s

С другой стороны, сборка той же цели без BuildKit приводит к тому, что все этапы обрабатываются:

$ DOCKER_BUILDKIT=0 docker build --no-cache -f Dockerfile --target stage2 .
Sending build context to Docker daemon  219.1kB
Step 1/6 : FROM ubuntu AS base
 ---> a7870fd478f4
Step 2/6 : RUN echo "base"
 ---> Running in e850d0e42eca
base
Removing intermediate container e850d0e42eca
 ---> d9f69f23cac8
Step 3/6 : FROM base AS stage1
 ---> d9f69f23cac8
Step 4/6 : RUN echo "stage1"
 ---> Running in 758ba6c1a9a3
stage1
Removing intermediate container 758ba6c1a9a3
 ---> 396baa55b8c3
Step 5/6 : FROM base AS stage2
 ---> d9f69f23cac8
Step 6/6 : RUN echo "stage2"
 ---> Running in bbc025b93175
stage2
Removing intermediate container bbc025b93175
 ---> 09fc3770a9c4
Successfully built 09fc3770a9c4

stage1 выполняется, когда BuildKit отключён, даже если stage2 не зависит от него.