reproducible docker containers
Многие сегодня используют docker для доставки своих решений в продакшен. Обычно образы собираются с помощью т.н. Dockerfile
, который может иметь следующий вид:
FROM golang:latest as builder WORKDIR /go/src COPY . . RUN go build -o app FROM alpine:latest COPY --from=builder /go/src/app /bin/app CMD /bin/app
Можно ли сказать что образ, полученный в результате сборки с указанным Dockerfile
, будет воспроизводимым?
Нет. Эта заметка объясняет:
- почему
- как писать
Dockerfile
чтобы приблизиться к воспроизводимости - чем заменить
Dockerfile
чтобы сделать сборки воспроизводимыми
про образы
Для начала давайте попробуем собрать предыдущий пример:
$ mkdir tmp $ cd tmp $ touch Dockerfile # самое время наполнить Dockerfile содержимым из предыдущего сниппета
Напишем простейшую программу на go:
package main import "fmt" func main() { fmt.Println("hello world!") }
Теперь можно всё это собрать командой docker build --rm -t corpix.github.io/test1 .
, после чего мы должны увидеть что-то похожее:
$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE corpix.github.io/test1 latest fda1a374151c 2 minutes ago 7.43MB ...
Проверим:
$ docker run --rm corpix.github.io/test1 hello world!
Работает.
Теперь предположим что мы захотели проделать тоже самое через неделю, возможно на другом компьютере.
- получим ли мы тот же самый компилятор
go
что и на прошлой неделе? возможно, но гарантий нет - будет ли наш код выполняться в той же версии образа
alpine
? может быть да, но зависит от обстоятельств
Мы не можем с уверенностью говорить о том что сборка воспроизводима в данном случае потому что используется тэг latest
, который может меняться.
Что с этим можно сделать? Очевидным решением является использование тэгов, которые указывают на конкретную версию своим названием. Например:
- вместо
golang:latest
использоватьgolang:1.11
- вместо
alpine:latest
использоватьalpine:3.9
Решает ли этот подход изначальную проблему? Ничего подобного:
- для
golang:1.11
иногда выпускаются и минорные версии, такие как1.11.5
, они могут двигать тэг1.11
- когда проекты будут обновлять версию
go
/alpine
придётся перелопатить великое множествоDockerfile
’ов
И вообще, тэги можно двигать, т.е. они by design не надежны и не дают гарантий воспроизводимости сборки. Если тэг подвинут и у вас на localhost закэширована старая версия образа то вас это спасёт, но часто сборка происходит в CI, кэш которого иногда дропается или со временем “вымывается”. Так что возможны различные странности в виде “месяц назад собиралось, а сегодня уже нет”.
Как поступают с обновлением версий базовых образов мейнтейнеры, у которых много Dockerfile
’ов? Пишут скрипты, которые генерируют Dockerfile
’ы из темплейтов. Такой подход хорош для небольших вещей, но разве его можно назвать системным решением? [нет]
про зависимости
До этого момента мы говорили лишь про образы, теперь поговорим о зависимостях… и раз уж я приводил примеры с базовым образом golang
то продолжу.
Ещё один источник невоспроизводимости кроется в использовании системного пакетного менеджера для установки зависимостей, например apk
в alpine
:
apk add bash \ gcc \ musl-dev \ openssl \ go \ ...
- откуда происходит установка инструментов?
- какие версии инструментов получим в результате?
- как часто они обновляются?
Всё это определяется менеджером пакетов и мейнтейнерами alpine, где последние способны выкатывать обновления, что значительно увеличивает вероятность получить новую версию gcc
или другого пакета в какой-то момент.
Является ли выходом указание конкретных версий пакетов при установке? например:
apk add packagename=1.2.3
Да, если опустить тот факт что политика версионирования зависит от людей, так что:
- конкретная версия пакета может быть подменена мейнтейнером пакетного репозитория, вы скорее всего ничего не заметите
- указывать версии при установке каждого инструмента это просто не удобно
- обновлять версии пакетов будет крайне не удобно
Вобщем, с таким подходом гарантий конечно больше, но решение снова не системное. Постепенно переходя к иному способу исправления описанных проблем не могу не заметить один очень грустный “паттерн”, который повторяется от одного Dockerfile
’a к другому когда люди хотят проверить контрольную сумму скаченного софта/исходников:
wget -O go.tgz "https://golang.org/dl/go$GOLANG_VERSION.src.tar.gz"; \ echo 'bc1ef02bb1668835db1390a2e478dcbccb5dd16911691af9d75184bbe5aa943e *go.tgz' | sha256sum -c -; \ ...
Здорово конечно что скриптинг даёт возможность решать огромное множество задач и наследие юникса настолько богато, но разве это не задача инструмента для сборки?
решение
Легко заметить что многие проблемы исходили от использования Dockerfile
как формата императивного описания сборки образа. Формат образа открыт и специфицирован, так что почему бы не поискать инструменты, которые способны хорошо решать задачи сборки ПО и поддерживают данную спецификацию?
Одним из таких инструментов является nix, вернее он сам не поддерживает сборку образов, но существует набор инструментов, созданный на базе nix, который умеет собирать образы и по счастливому стечению обстоятельств является одним из самых крупных репозиториев открытого ПО, да, я говорю о nixpkgs.
Вот пример конфигурации, в которой описана сборка приложения на go и сборка образа:
with (import <nixpkgs> {}); let app = pkgs.buildGoPackage rec { name = "app-${version}"; version = "unstable-2019-02-03"; goPackagePath = "github.com/corpix/corpix.github.io/test2"; src = ./.; buildPhase = '' go build -o app $src/main.go ''; installPhase = '' mkdir -p $bin/bin mv app $bin/bin ''; }; in pkgs.dockerTools.buildImage { name = "corpix.github.io/test2"; tag = "latest"; contents = app; runAsRoot = '' #!${stdenv.shell} ${dockerTools.shadowSetup} ''; config = { Cmd = [ "/bin/app" ]; }; }
Чтобы опробовать данный пример создайте рядом с имеющимся Dockerfile
дополнительно файл с именем container.nix
и поместите туда содержимое из сниппета выше.
Не обязательно иметь установленный nix
чтобы выполнить сборку образа данным способом, данная сборка может быть запущена внутри контейнера, поскольку не использует никаких привилегированных системных функций. Так можно запустить контейнер, в окружении которого будет доступен nix
:
$ docker run --rm -v $(pwd):/build -w /build -it nixos/nix:latest /bin/sh
И для запуска сборки:
$ nix-build container.nix these derivations will be built: /nix/store/05sc2xkxhml9y2ghqk305lwh557hvhgv-run-as-root.sh.drv /nix/store/kfx56w0bf4xbkxzn2wkf78dnvwfgl7rj-remove-references-to.drv /nix/store/8q23dq7238cf5dxciw8gggcp063j1px4-app-unstable-2019-02-03.drv /nix/store/f75qxsrvraxry2hxwdhb6hs7xivvd27y-vm-run-stage2.drv /nix/store/da2gn6bsp9l1qfhml44gfqwy1mpav0cr-vm-run.drv /nix/store/rl0q819byn3cfpbf196yy4knrf7qiaq6-test2-config.json.drv /nix/store/vfpvbnhjhb10mnaaslh1ck93pdbvhnlp-extra-commands.sh.drv /nix/store/w7prk8xslwj209xbw60p6c44ifp94zlm-docker-layer-test2.drv /nix/store/gb7hpl9c5wk9p4ywcayaybdywkk7c0i6-runtime-deps.drv /nix/store/5vvr6h8j57bl146vrb3kjxliy6qmp9gs-docker-image-test2.tar.gz.drv these paths will be fetched (326.31 MiB download, 1361.59 MiB unpacked): ... /nix/store/3vnmdfmifff9ig8vr78yz4l09ikjl5ii-docker-image-test2.tar.gz
Хочу обратить внимание на то что в этом примере собирается образ без использования базового, т.е. как если бы мы использовали
FROM scratch
вDockerfile
.
После сборки в поток stdout
будет выведен путь к собранному артефакту, который можно загрузить в docker с помощью docker load < archive.tar.gz
. Также рядом с container.nix
будет создан symlink result
, ведущий к tar.gz образа.
Собрать и загрузить образ в docker можно и одной командой:
$ nix-build container.nix | xargs -n1 docker load -i ...
Что конечно не заработает, если вы собираете образ с помощью
nix
в контейнере, потому что доступа к сокету docker скорее всего не будет.
Давайте скопируем его в нашу рабочую директорию, чтобы “вытащить” архив из контейнера и загрузить его в docker уже за пределами контейнера:
$ cp $(readlink -f result) ./ $ ls -lah total 10896 drwxr-xr-x 1 1000 users 188 Feb 4 00:38 . drwxr-xr-x 1 root root 144 Feb 4 00:28 .. -r--r--r-- 1 root root 10.6M Feb 4 00:38 3vnmdfmifff9ig8vr78yz4l09ikjl5ii-docker-image-test2.tar.gz -rw-r--r-- 1 1000 users 155 Feb 3 21:57 Dockerfile -rw-r--r-- 1 1000 users 652 Feb 4 00:16 container.nix -rw-r--r-- 1 1000 users 73 Feb 3 21:58 main.go lrwxrwxrwx 1 root root 69 Feb 4 00:35 result -> /nix/store/3vnmdfmifff9ig8vr78yz4l09ikjl5ii-docker-image-test2.tar.gz
Теперь загрузим образ:
$ docker load < 3vnmdfmifff9ig8vr78yz4l09ikjl5ii-docker-image-test2.tar.gz eb1d81974318: Loading layer [==================================================>] 31.16MB/31.16MB Loaded image: corpix.github.io/test2:latest $ docker images REPOSITORY TAG IMAGE ID CREATED SIZE corpix.github.io/test1 latest fda1a374151c 3 hours ago 7.43MB corpix.github.io/test2 latest 1132699ed641 49 years ago 29.3MB
В первом примере имя образа было corpix.github.io/test1
.
Во втором соответственно corpix.github.io/test2
.
Попробуем его запустить:
$ docker run --rm -it corpix.github.io/test2 hello world!
Как мы видим всё работает и теперь с этим образом можно производить любые операции, которые свойствены обычному docker образу.
Я уверен, у вас накопилось некоторое количество вопросов, так что пройдусь по самым вероятным:
Что это за язык такой для описания сборки?
Это
nix
(имя совпадает с названием инструмента) и о языке можно почитать вот здесь, к сожалению пост получился и так достаточно ёмким, так что я не очень хочу вдаваться в подробности языка. Могу посоветовать открыть мануал, запуститьnix repl '<nixpkgs>'
в контейнере и поисследовать язык самостоятельно.
Почему образ занимает немного больше места?
Потому что используется glibc и упаковывает bash(кстати я пока не знаю зачем именно он там).
Почему docker говорит что образ создан 49 лет назад?
Nix обнуляет unix таймстемпы до “unix epoch”(
00:00:01 1 Jan 1970
). Это важная часть обеспечения бинарной воспроизводимости, поскольку нам негде сохранять “стейт”.
Я вижу что в runAsRoot
формируется какой-то скрипт, зачем он и как узнать его содержимое?
Это скрипт, который может что-то сделать от имени супер-пользователя при сборке контейнера(сборка происходит в qemu). Нам он нужен чтобы подготовить базовое окружение внутри контейнера, поскольку в этом примере мы не используем базовый контейнер. Чтобы узнать значение выражения в nix есть два способа. Первый интерактивный, у
nix
есть REPL, его можно запустить командойnix repl '<nixpkgs>'
, которая предоставит REPL вместе с загруженным из текущего окружения nixpkgs. Второй предназначается для скриптов и я покажу его с выводом:
$ nix-instantiate --eval --expr 'with import <nixpkgs> {}; stdenv.shell' "/nix/store/can00lfiynqkbsdkkmgp6qg8p8w92cxa-bash-4.4-p23/bin/bash" $ nix-instantiate --eval --expr 'with import <nixpkgs> {}; dockerTools.shadowSetup' | jq -r . export PATH=/nix/store/1hx9psidid8b1ps72dhlj47745n4p7yi-shadow-4.6/bin:$PATH mkdir -p /etc/pam.d if [[ ! -f /etc/passwd ]]; then echo "root:x:0:0::/root:/nix/store/can00lfiynqkbsdkkmgp6qg8p8w92cxa-bash-4.4-p23/bin/bash" > /etc/passwd echo "root:!x:::::::" > /etc/shadow fi if [[ ! -f /etc/group ]]; then echo "root:x:0:" > /etc/group echo "root:x::" > /etc/gshadow fi if [[ ! -f /etc/pam.d/other ]]; then cat > /etc/pam.d/other <<EOF account sufficient pam_unix.so auth sufficient pam_rootok.so password requisite pam_unix.so nullok sha512 session required pam_unix.so EOF fi if [[ ! -f /etc/login.defs ]]; then touch /etc/login.defs fi
(я в последнем примере jq использую чтобы сделать вывод строки с переносами более опрятным)
Теперь перейдём к главному: почему мы можем утверждать что данная сборка будет воспроизводимой?
На самом деле она не будет воспроизводимой из-за двух причин:
- мы используем системный
nixpkgs
(первая строкаcontainer.nix
его импортирует) - мы указываем в качестве хранилища исходного кода текущую директорию(
src = ./.
)
Объясню подробнее.
Внутри nixpkgs
содержатся похожие на container.nix
конфигурации сборки, которые ссылаются на исходные коды ПО, зафиксированные в самой конфигурации по SHA256 сумме. От того, используем ли мы одинаковую ревизию nixpkgs
на машинах, которым хотим обеспечить воспроизводимость сборки, во многом зависит воспроизводимость результата, который мы получим.
В моём примере я не зафиксировал исходный код на конкретную SHA256 сумму, вместо этого я указал текущую директорию в качестве источника исходных кодов. Что произойдёт с этой директорией после сборки? В ней появится симлинк, что вызовет изменение mtime директории, что скажет nix
о том что наш код должен быть пересобран и конечный “артефакт” получит новый хэш в “хранилище артефактов”, которое использует nix
- /nix/store
.
Так что самое простое что мы можем сделать это положить main.go
в отдельную директорию:
$ mkdir app $ mv main.go app
Рядом создадим файл с декларацией для сборки приложения:
$ touch app/default.nix
И наполним его следующим содержимым:
with (import <nixpkgs> {}); pkgs.buildGoPackage rec { name = "app-${version}"; version = "unstable-2019-02-03"; goPackagePath = "github.com/corpix/corpix.github.io/test2"; src = ./.; buildPhase = '' go build -o app $src/main.go ''; installPhase = '' mkdir -p $bin/bin mv app $bin/bin ''; }
И приведём container.nix
к следующему виду:
with (import <nixpkgs> {}); pkgs.dockerTools.buildImage { name = "corpix.github.io/test2"; tag = "latest"; contents = import ./app; runAsRoot = '' #!${stdenv.shell} ${dockerTools.shadowSetup} ''; config = { Cmd = [ "/bin/app" ]; }; }
Теперь mtime директории с исходником нашего приложения не меняется и образ всегда будет собираться одинаковый. Теперь давайте обеспечим воспроизводимость сборки между несколькими машинами. Для этого нам нужно будет синхронизировать используемую ревизию nixpkgs
, возьмём для примера свежак 614b29a и распакуем его рядом с container.nix
:
$ wget https://github.com/NixOS/nixpkgs/archive/614b29a.tar.gz ... $ tar xf 614b29a.tar.gz $ ls -la -rw-r--r-- 1 user users 16042311 2019-02-04 01:25 614b29a.tar.gz drwxr-xr-x 1 user users 210 2019-02-03 14:12 nixpkgs-614b29a93b51e3438f6dc4c121229fcf2dcc2fbd ...
Теперь запустим docker контейнер с nix
, добавив переменную окружения NIX_PATH
:
$ docker run \ --rm -v $(pwd):/build -w /build \ -e NIX_PATH=nixpkgs=/build/nixpkgs-614b29a93b51e3438f6dc4c121229fcf2dcc2fbd \ -it nixos/nix:latest /bin/sh
Данная переменная скажет nix
: при каждом import <nixpkgs>
нужно резолвить <nixpkgs>
в /build/nixpkgs-614b29a93b51e3438f6dc4c121229fcf2dcc2fbd
, таким образом загружаться будет файл /build/nixpkgs-614b29a93b51e3438f6dc4c121229fcf2dcc2fbd/default.nix
.
Теперь для эксперимента над воспроизводимостью сборки запустим два контейнера командой, которая указана выше и в обоих выполним nix-build container.nix
. В каждом получим образ с одинаковой SHA256 суммой, например:
$ ls -la result lrwxrwxrwx 1 root root 69 Feb 4 01:40 result -> /nix/store/abk6lm0cxq5fimyxfmlfzpdca4kwdc8s-docker-image-test2.tar.gz $ sha256sum result 86bccceff84e1f0537b24accc34d7768a1cb3a4d44921f918247c74252f24016 result
Стоит заметить, что вам вряд ли удастся получить такую же SHA256 сумму, поскольку у нас с вами разные mtime на директории с исходниками приложения, которое я здесь контейнеризировал для примера. Пожалуй в будущем я поправлю статью чтобы приложение выкачивалось откуда-нибудь, либо заменю его на что-то что уже есть в
nixpkgs
чтобы сама статья стала воспроизводимой на все 100% :)
Теперь:
- не нужно явно управлять версиями инструментов, достаточно управлять ревизией
nixpkgs
- нет необходимости использовать кучу shell команд в
Dockerfile
, поскольку есть декларативный язык описания конфигурации - в получившемся контейнере будет 1 слой
Мы не пользовались базовыми образами(как если бы мы указали FROM scratch
в Dockerfile
), у нас нет зависимости, которая может внезапно поменяться, сборка полностью воспроизводима. Мы можем получить воспроизводимые сборки с базовыми образами, но я пока не описал их в данной статье, так что советую заглянуть в блог к Luca Bruno, он описывает на примерах сборку образов с помощью nix
c бòльшим количеством деталей, так что его статья может очень хорошо дополнить полученные здесь знания.