GrabDuck

Как разделить окружение для сборки и запуска сервиса в Docker сегодня и как это ...

:

image

Большинство из нас уже давно научилось готовить Docker и используют его на локальных машинах, на тестовых стендах и на боевых серверах. Docker, который недавно превратился в Moby, прочно вошел в процессы доставки кода до пользователя. Но best practice работы с контейнерной виртуализацией и, в частности, с Docker вырабатываются до сих пор.


Как это было

В начале становления Docker как основного инструмента изоляции процессов, многие использовали его аналогично использованию виртуальных машин. Подход был максимально прост: устанавливаем все необходимые зависимости в образ (Docker Image), там же билдим всё, что должно билдиться а что не должно двигаем и билдим, получаем артефакт сборки и запекаем всё это в итоговый образ.

Такой подход имеет явные недостатки: софт, который нужен для сборки, не всегда нужен для работы, например, для сборки программы на C++ или Go понадобится компилятор, но полученный бинарник можно запускать уже без компилятора. При этом софт, необходимый для сборки, может весить намного больше, чем полученный артефакт.
Второй недостаток вытекает из первого: больше софта в итоговом образе — больше уязвимостей, а значит, мы теряем в безопасности наших сервисов.


Актуально сегодня

Сегодня устоявшейся практикой является отделение образа для сборки от образа для запуска.

Выглядит и используется это примерно так:


  1. Все необходимые для сборки сервиса зависимости мы ставим внутри build.Dockerfile и собираем, так называемый, buildbox-image из этого файла.
    # Флаги:
    #
    # -f — название Dockerfile, который будет использоваться для сборки образа (в нашем случае "build.Dockerfile")
    # -t — название образа, который будет получен после билда (в нашем случае "buildbox-image")
    #
    docker build -f build.Dockerfile -t buildbox-image .
  2. Теперь используем buildbox-image для сборки сервиса. Для этого при запуске прокидываем внутрь контейнера исходники и запускаем сборку. (В примере запуск сборки происходит командой make build)
    # Флаги:
    #
    # --rm — удалить контейнер после завершения операции
    # -v — прокидывает текущую директорию в директорию "/app" внутри контейнера
    #
    docker run --rm -v $(pwd):/app -w /app buildbox-image make build
  3. Получив артефакт сборки, например в $(pwd)/bin/myapp, мы можем просто запечь его внутрь образа с минимальным количеством софта. Для этого рядом с build.Dockerfile кладем Dockerfile, который и будет использоваться для запуска сервиса на бою. Выглядеть этот Dockerfile может так:
    FROM alpine:3.5
    COPY bin/myapp /myapp
    EXPOSE 65122
    CMD ["/myapp"]

Подход с разделением Dockerfile хорошо себя зарекомендовал, но само разделение — это довольно рутинная и не всегда приятная задача, поэтому идеи над упрощением процесса появились довольно давно.


Что станет стандартом завтра?

Об идее build-stages внутри Dockerfile я услышал от ребят из Grammarly. Они давно реализовали стадии сборки в фасаде над Docker'ом и назвали его Rocker. Но в самом Docker Engine такой функциональности не было.

И вот, в Docker наконец смержили пулл-реквест, который реализует стадии сборки (https://github.com/moby/moby/pull/32063), сейчас они доступны в версии v17.05.0-ce-rc2 https://github.com/moby/moby/releases/tag/v17.05.0-ce-rc2

Теперь отдельные Dockerfile'ы для билда перестали быть нужны, так как появилась возможность разделять стадии сборки в одном Dockerfile.

В build stage возможно произвести все операции, связанные со сборкой, а уже только артефакт отправлять в следующую стадию, из которой на выходе получим image только с необходимыми для работы сервиса обвесами.

Как пример возьмем сервис на Golang. Dockerfile такого сервиса с разделением стадий в общем случае может выглядеть так:

# Стадия сборки "build-env"
FROM golang:1.8.1-alpine AS build-env
# Устанавливаем зависимости, необходимые для сборки
RUN apk add --no-cache \
    git \
    make
ADD . /go/src/github.com/username/project
WORKDIR /go/src/github.com/username/project
# Запускаем сборку
RUN make build

# --------

# Стадия подготовки image к бою
FROM alpine:3.5
# Копируем артефакт сборки из стадии "build-env" в указанный файл
COPY --from=build-env /go/src/github.com/username/project/bin/service /usr/local/bin/service
EXPOSE 65122
CMD ["service"]

Результаты сборки:

REPOSITORY                                   TAG                   IMAGE ID               CREATED              SIZE
registry.my/username/project                 master              ce784fb88659        2 seconds ago       16.5MB
<none>                                       <none>              9cc9ed2befc5        6 seconds ago       330MB

330MB на стадии билда, 16.5MB после билда и готовое к запуску. Всё в одном Dockerfile с минимальной конфигурацией.

В системе build-стадия сохраняется на диск как <none>:<none>.

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

Вот пример того, как можно использовать несколько стадий сборки:

# Стадия сборки "build-env"
FROM golang:1.8.1-alpine AS build-env
ADD . /go/src/github.com/username/project
WORKDIR /go/src/github.com/username/project
# Запускаем сборку
RUN make build

# --------
# Вторая стадия сборки "build-second"
FROM build-env AS build-second
RUN touch /newfile
RUN echo "123" > /newfile

# --------
# Стадия сборки frontend "build-front"
FROM node:alpine AS build-front
ENV PROJECT_PATH /app
ADD . $PROJECT_PATH
WORKDIR $PROJECT_PATH
RUN npm run build

# --------
# Стадия подготовки image к бою
FROM alpine:3.5
# Копируем артефакт сборки из стадии "build-env" в указанный файл
COPY --from=build-env /go/src/github.com/username/project/bin/service /usr/local/bin/service
# Копируем артефакт сборки из стадии "build-front" в указанную директорию
COPY --from=build-front /app/public /app/static
EXPOSE 65122
CMD ["service"]

Для выбора стадии сборки предлагается использовать флаг --target. С этим флагом сборка осуществляется до указанной стадии. (Включая все предыдущие) На диск в этом случае сохранится и отметится тегом именно эта стадия.


Когда можно пользоваться?

Релиз 17.05.0 запланирован на 2017-05-03. И насколько можно судить, это действительно полезный функционал, особенно для компилируемых языков.

Спасибо за внимание.