Docker 17.06 CE: multi-stage builds в stable - пересобираем production-образы
Docker 17.06 CE выходит со stable multi-stage builds. Образ Go-сервиса с 800 МБ builder-слоя превращается в 12 МБ финального - пересобираем все production-образы по новой схеме.
Docker 17.06 CE - multi-stage builds переведены в stable, secrets в Docker Swarm, улучшен stack deploy с поддержкой compose v3.3
Docker 17.06 CE вышел на этой неделе. Главное что нас интересовало - multi-stage builds переведены из experimental в stable. Ждали именно этого: когда фича была experimental, ставить её в производственный пайплайн было рискованно. Теперь ограничений нет, и мы взялись переделывать образы.
Проблема с размером образов которую все знали но терпели
До multi-stage builds типичный Dockerfile для Go-сервиса выглядел примерно так: либо собираешь прямо в образе с полным golang-окружением и получаешь финальный образ на 700-800 МБ, либо городишь два отдельных Dockerfile - один для сборки, второй для запуска - и пишешь shell-скрипт который копирует бинарник между ними.
Второй вариант работал, но это был костыль. Два файла которые нужно держать в синхронизации, build-скрипт который делает docker cp или монтирует том, CI-пайплайн который про это знает. При малейшем изменении в процессе сборки нужно было обновлять все три места. Мы в итоге делали именно так для нескольких сервисов, и каждый раз при онбординге нового разработчика уходило время на объяснение «а почему тут два Dockerfile».
Как теперь выглядит multi-stage build
Механика простая: в одном Dockerfile можно описать несколько этапов через FROM, каждый с именем (AS builder), и копировать артефакты между ними через COPY --from=builder.
FROM golang:1.8 AS builder
WORKDIR /go/src/app
COPY . .
RUN go build -o /go/bin/app
FROM debian:jessie-slim
COPY --from=builder /go/bin/app /usr/local/bin/app
CMD ["app"]
Финальный образ содержит только бинарник и минимальный runtime. Golang-тулчейн, все зависимости которые тянулись при сборке, исходники - всё это остаётся в builder-слое и в финальный образ не попадает.
Для одного из наших Go-сервисов результат: образ c golang:1.8 as builder весит в районе 800 МБ, финальный на debian:jessie-slim - 12 МБ. Разница не в процентах, а в порядке величины. То же самое работает для Java (убрать JDK, оставить JRE), Node.js (убрать devDependencies и build-тулы), C++ (убрать заголовки и компилятор).
Что пересобирали и в каком порядке
Прошлись по всем production-образам которые мы собираем для клиентов на managed. Примерно треть оказалась кандидатами на переход - те где в образе был toolchain от которого в runtime ничего не нужно.
Go-сервисы переписали в первую очередь - там выигрыш самый очевидный, и нет runtime-зависимостей от языка, бинарник статически слинкован. Образы с Node.js - следом: npm install с devDependencies, webpack, потом копируем только dist/ и node_modules без dev-пакетов в финальный образ.
Один момент который нужно держать в голове. При копировании бинарника Go в debian:slim или alpine встречается проблема с динамической линковкой: если сборка шла в golang-образе с CGO включённым, бинарник ссылается на системные библиотеки которых в alpine нет. Решение - или CGO_ENABLED=0 при сборке, или финальный образ на debian:slim вместо alpine. Мы выбрали по ситуации: для сервисов без cgo-зависимостей переходим на scratch или alpine, для остальных - slim.
Secrets в Swarm и stack deploy
Помимо multi-stage builds в 17.06 пришло ещё несколько вещей.
Docker secrets для Swarm стали значительно удобнее - теперь можно создавать и ротировать секреты через docker secret create, сервисы получают их как файлы в /run/secrets/. Это лучше чем тащить пароли через environment variables, которые видны в docker inspect и утекают в логи при ошибке конфигурации. Мы уже используем это на нескольких Swarm-кластерах.
stack deploy с compose v3.3 - поддержка configs и secrets в compose-файле, что позволяет описывать production-окружение полностью декларативно. Раньше часть вещей приходилось делать через отдельные команды после деплоя.
Параллельно: Kubernetes или Swarm
Мы смотрели на этот вопрос в апреле и не дали однозначного ответа - потому что его нет. С multi-stage builds интереснее другое: сам формат Dockerfile никуда не денется независимо от оркестратора. Переход с Swarm на Kubernetes или обратно не меняет то как собирается образ - это отдельный слой.
Состояние после переключения
Пересборка заняла несколько дней - больше не из-за технических сложностей, а из-за тестирования и согласования. Результат который видим сейчас:
- Pull-время образов при деплое сократилось заметно - меньше данных гонять по сети
- Registry занимает меньше места - не радикально, но приятно
- Dockerfile стал читаемым: всё в одном файле, два FROM, понятная структура
Образы для разработки оставили как были - там размер менее критичен, зато удобно иметь все инструменты под рукой внутри контейнера. Multi-stage builds хорошо решают именно production-проблему.
Экспериментальный флаг снят, фича stable - можно спокойно встраивать в CI и не объяснять коллегам почему это работает только с особым демоном.