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 бòльшим количеством деталей, так что его статья может очень хорошо дополнить полученные здесь знания.