Большинство из нас уже давно научилось готовить Docker и используют его на локальных машинах, на тестовых стендах и на боевых серверах. Docker, который недавно превратился в Moby, прочно вошел в процессы доставки кода до пользователя. Но best practice работы с контейнерной виртуализацией и, в частности, с Docker вырабатываются до сих пор.
Как это было
В начале становления Docker как основного инструмента изоляции процессов, многие использовали его аналогично использованию виртуальных машин. Подход был максимально прост: устанавливаем все необходимые зависимости в образ (Docker Image), там же билдим всё, что должно билдиться а что не должно двигаем и билдим, получаем артефакт сборки и запекаем всё это в итоговый образ.
Такой подход имеет явные недостатки: софт, который нужен для сборки, не всегда нужен для работы, например, для сборки программы на C++ или Go понадобится компилятор, но полученный бинарник можно запускать уже без компилятора. При этом софт, необходимый для сборки, может весить намного больше, чем полученный артефакт.
Второй недостаток вытекает из первого: больше софта в итоговом образе — больше уязвимостей, а значит, мы теряем в безопасности наших сервисов.
Актуально сегодня
Сегодня устоявшейся практикой является отделение образа для сборки от образа для запуска.
Выглядит и используется это примерно так:
- Все необходимые для сборки сервиса зависимости мы ставим внутри
build.Dockerfile
и собираем, так называемый,buildbox-image
из этого файла.
# Флаги: # # -f — название Dockerfile, который будет использоваться для сборки образа (в нашем случае "build.Dockerfile") # -t — название образа, который будет получен после билда (в нашем случае "buildbox-image") # docker build -f build.Dockerfile -t buildbox-image .
- Теперь используем
buildbox-image
для сборки сервиса. Для этого при запуске прокидываем внутрь контейнера исходники и запускаем сборку. (В примере запуск сборки происходит командойmake build
)
# Флаги: # # --rm — удалить контейнер после завершения операции # -v — прокидывает текущую директорию в директорию "/app" внутри контейнера # docker run --rm -v $(pwd):/app -w /app buildbox-image make build
- Получив артефакт сборки, например в
$(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. И насколько можно судить, это действительно полезный функционал, особенно для компилируемых языков.
Спасибо за внимание.
Комментарии (30)
f9k56
02.05.2017 00:28Docker всё больше напоминает мне Нео из Матрицы. С каждым днем он может всё больше и лучше.
Resert
02.05.2017 07:41Парни из flant не стали ждать заморочились сами dapp, ссылка на доклад Собираем Docker-образы быстро и удобно.
renskiy
02.05.2017 08:40На практике сейчас также можно обходиться одним Dockerfile и при этом не держать внутри образа зависимостей, необходимых только в момент сборки. Ваш пример можно было бы переписать так:
FROM golang:1.8.1-alpine WORKDIR /go/src/github.com/username/project ADD . ./ # Устанавливаем зависимости, необходимые для сборки RUN apk add --no-cache --virtual .build-requirements git make # Запускаем сборку && make build # удаляем установленные выше пакеты && apk del .build-requirements
samizdam
02.05.2017 08:45Если зависимостей сборки уйма, весь этот ком не забыть почистить… Не очень изящно, имхо.
renskiy
02.05.2017 09:10-2Согласен, изящность немного хромает из-за жирного RUN. Но такой способ мне кажется предпочтительнее использования нескольких Dockerfile и сложной процедуры сборки образа.
aatarasoff
02.05.2017 10:10+1Не соглашусь.
Как минимум:
1. Не всё можно поставить в образ через менеджер пакетов.
2. Теряется кэширование слоёв. В итоге каждый раз ставим всё заново, что увеличивает общее время сборки.
vaniaPooh
02.05.2017 11:11+5Если вы используете Go для написания вашего кода, то можно писать FROM scratch и тогда размер контейнера будет равен размеру вашего бинарника. Это позволяет получать контейнеры размером в 1-2 мегабайта. Пример: https://github.com/aerokube/selenoid-ui/blob/master/Dockerfile#L1 Недостаток такого подхода — в контейнере вообще ничего нет кроме бинарника, т.е. зайти внутри и поотлаживаться не получится.
Flaker
02.05.2017 11:45Большое спасибо за дополнение! Это самый минималистичный вариант из возможных!
Borz
02.05.2017 11:37т.е. помимо того, что, как написали выше, не получится сделать отладку, ещё и чистить сборочные образы придётся вручную? да ещё и промежуточных сборок не оставить.
В чём profit?
и да, отдельные докеры для сборки и запуска это возможность поддерживать их более централизованно. А так получается, на N-цать похожих сервисов придётся вместо одного докера-сборщика и одного докера-запускатора делать поддержку N-цати докеров
Flaker
02.05.2017 20:38При использовании стадий сборки промежуточные слои остаются полностью функциональны, так что кеширование есть.
debsaw
02.05.2017 20:12+1Решаю в данный момент похожую задачу, т.к. обновиться на новый докер возможности нет, реализую следующую идею:
FROM ... ARG APPLICATION=app=1.2.3 RUN apt-get install <dependencies> $APPLICATION ...
получается параметризованный образ, который по дефолту используется для запуска приложения, а когда нужно сделать сборку из командной строки в ARG передаю «devscripts debhelper ..» и все, что нужно для сборки.
Конечно во время сборки CMD тоже нужно оверрайднуть, docker run это позволяет.
Минус тот же, что уже обсуждали — нет кэширования, время сборки возрастает. После успешной сборки нужно еще раз запустить docker build
Плюс — собираю и запускаю юнит тесты фактически в той среде, в которой и запускаю готовое приложениеFlaker
02.05.2017 20:43Вполне рабочий вариант! Многие так делают. При таком подходе обычно уменьшают размер финального образа вызывая `apt-get clean` после установки зависимостей.
Best practice и при использовании новой фичи остаются актуальны: https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/
huksley
02.05.2017 23:05Еще не хватает такой фишки как чисто сборочный контейнер.
Т.е. когда нужно собрать, забрать артефакты и уничтожить.
VolCh
03.05.2017 09:24Опередили с постом :)
А с docker-compose уже пробовал кто проверять как дружит?
особенно для компилируемых языков.
Очень хороши когда нужно разложить артефакты сборки по разным контейнерам, например, для веб-приложения статику положить в контейнер nginx, а собственно приложение упаковать отдельно. До сих пор приходилось мудрить либо сборкой контейнеров отдельно с удалением ненужного на последней стадии (зачастую без уменьшения образа), либо использовать тома для доступа из nginx к контейнеру с артефактами.
Flaker
03.05.2017 09:32С docker-compose всё по старому, ведь по сути процесс билда не изменился, так что можно смело пользовать!
VolCh
03.05.2017 15:25Как в нём указать разные стейджи одного докерфвайла для разных сервисов? По идее должна быть поддержка --target, но ничего не нашёл по этому поводу, ни доках, ни в ишью/ПР.
Flaker
03.05.2017 15:30Думаю, что сейчас никаких поддержек для новой фичи в docker-compose нет, просто потому что еще даже в самом Docker не зарелизили.
Правда, есть практика использовать по одному Dockerfile на сервис. Поэтому, если есть общая часть какая-то, то может стоит вынести её в родительский image?VolCh
03.05.2017 15:59Некоторые фичи у них в параллели вводились, не чужие люди, как говорится.
Если создавать родительский образ, то возникает две проблемы:
- его избыточность (решается в принципе с помощью стейджинга, то есть делается родительский образ не для конечных образов, а для билдера, из которого нужный артефакт копируется в конечный)
- необходимость либо пересобирать вручную при изменении исходников/зависимостей, либо включать и его в список сервисов с фейковым запуском
Тогда уж проще иметь два почти одинаковых докерфайла, синкая их ручками.
Правда, есть практика использовать по одному Dockerfile на сервис.
Собственно раньше особо возможности не было использовать несколько сервисов на один докер-файл.
samizdam
Спасибо за пост.
Интересная фича. Сейчас как альтернативу образам сборки использую докеризированные команды, что в моём случае (интерпретируемые языки) вполне удобно.
SbWereWolf
+1 ( была бы карма, поставил бы полноценный плюсик )
renskiy
Вы могли бы привести пример?
samizdam
Под контейнеризированными командами я подразумеваю когда некий скрипт (или бинарный файл) выполняется внутри контейнера. Например в случае задач по сборке — артефакты пакетного менеджера или компилятора могут располагаться в примантированной директории. Контейнер в этом случае живёт только время выполнения задачи сборки и никак не связан с образом времени исполнения и не нуждается в Dockerfile.
Fr05t1k
А сама команда не является образом?
samizdam
Команда может выполняться в контейнере на базе специализированного образа. Для большинства распространённых сборщиков есть официальные образы, https://hub.docker.com/r/library/maven/ например, https://hub.docker.com/r/library/composer/ и подобные.