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


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

Поделиться с друзьями
-->

Комментарии (30)


  1. samizdam
    01.05.2017 23:23
    +1

    Спасибо за пост.
    Интересная фича. Сейчас как альтернативу образам сборки использую докеризированные команды, что в моём случае (интерпретируемые языки) вполне удобно.


    1. SbWereWolf
      02.05.2017 02:47

      +1 ( была бы карма, поставил бы полноценный плюсик )


    1. renskiy
      02.05.2017 09:13

      использую докеризированные команды

      Вы могли бы привести пример?


      1. samizdam
        02.05.2017 21:36

        Под контейнеризированными командами я подразумеваю когда некий скрипт (или бинарный файл) выполняется внутри контейнера. Например в случае задач по сборке — артефакты пакетного менеджера или компилятора могут располагаться в примантированной директории. Контейнер в этом случае живёт только время выполнения задачи сборки и никак не связан с образом времени исполнения и не нуждается в Dockerfile.


    1. Fr05t1k
      02.05.2017 12:10

      А сама команда не является образом?


      1. samizdam
        02.05.2017 21:38

        Команда может выполняться в контейнере на базе специализированного образа. Для большинства распространённых сборщиков есть официальные образы, https://hub.docker.com/r/library/maven/ например, https://hub.docker.com/r/library/composer/ и подобные.


  1. f9k56
    02.05.2017 00:28

    Docker всё больше напоминает мне Нео из Матрицы. С каждым днем он может всё больше и лучше.


  1. Resert
    02.05.2017 07:41

    Парни из flant не стали ждать заморочились сами dapp, ссылка на доклад Собираем Docker-образы быстро и удобно.


  1. 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
    


    1. samizdam
      02.05.2017 08:45

      Если зависимостей сборки уйма, весь этот ком не забыть почистить… Не очень изящно, имхо.


      1. renskiy
        02.05.2017 09:10
        -2

        Согласен, изящность немного хромает из-за жирного RUN. Но такой способ мне кажется предпочтительнее использования нескольких Dockerfile и сложной процедуры сборки образа.


        1. aatarasoff
          02.05.2017 10:10
          +1

          Не соглашусь.

          Как минимум:
          1. Не всё можно поставить в образ через менеджер пакетов.
          2. Теряется кэширование слоёв. В итоге каждый раз ставим всё заново, что увеличивает общее время сборки.


  1. vaniaPooh
    02.05.2017 11:11
    +5

    Если вы используете Go для написания вашего кода, то можно писать FROM scratch и тогда размер контейнера будет равен размеру вашего бинарника. Это позволяет получать контейнеры размером в 1-2 мегабайта. Пример: https://github.com/aerokube/selenoid-ui/blob/master/Dockerfile#L1 Недостаток такого подхода — в контейнере вообще ничего нет кроме бинарника, т.е. зайти внутри и поотлаживаться не получится.


    1. Flaker
      02.05.2017 11:45

      Большое спасибо за дополнение! Это самый минималистичный вариант из возможных!


  1. Borz
    02.05.2017 11:37

    т.е. помимо того, что, как написали выше, не получится сделать отладку, ещё и чистить сборочные образы придётся вручную? да ещё и промежуточных сборок не оставить.
    В чём profit?


    и да, отдельные докеры для сборки и запуска это возможность поддерживать их более централизованно. А так получается, на N-цать похожих сервисов придётся вместо одного докера-сборщика и одного докера-запускатора делать поддержку N-цати докеров


    1. Flaker
      02.05.2017 20:38

      При использовании стадий сборки промежуточные слои остаются полностью функциональны, так что кеширование есть.


      1. Borz
        03.05.2017 11:35

        а когда более двух стадий, то каждая стадия выглядит как none:none, отличаясь только imageId? а когда несколько сборок идут на сервере для разных Dockerfile, то как понять какая стадия от какой сборки легла?


        1. Flaker
          03.05.2017 11:40

          К сожалению, понять не получится.


  1. 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
    Плюс — собираю и запускаю юнит тесты фактически в той среде, в которой и запускаю готовое приложение


    1. Flaker
      02.05.2017 20:43

      Вполне рабочий вариант! Многие так делают. При таком подходе обычно уменьшают размер финального образа вызывая `apt-get clean` после установки зависимостей.

      Best practice и при использовании новой фичи остаются актуальны: https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/


  1. huksley
    02.05.2017 23:05

    Еще не хватает такой фишки как чисто сборочный контейнер.
    Т.е. когда нужно собрать, забрать артефакты и уничтожить.


    1. VolCh
      03.05.2017 09:10
      +2

      Опций --rm и --force-rm у docker build не хватает?


      1. huksley
        03.05.2017 09:54

        Декларативный стиль vs процедурный. Как забрать потом артефакты без volume mount?


  1. funca
    03.05.2017 00:01
    +2

    А потом появятся зависимости между стадиями? Как-то сам собой уже напрашивается синтаксис Makefile :)


    1. VolCh
      03.05.2017 09:08
      +1

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


  1. VolCh
    03.05.2017 09:24

    Опередили с постом :)


    А с docker-compose уже пробовал кто проверять как дружит?


    особенно для компилируемых языков.

    Очень хороши когда нужно разложить артефакты сборки по разным контейнерам, например, для веб-приложения статику положить в контейнер nginx, а собственно приложение упаковать отдельно. До сих пор приходилось мудрить либо сборкой контейнеров отдельно с удалением ненужного на последней стадии (зачастую без уменьшения образа), либо использовать тома для доступа из nginx к контейнеру с артефактами.


    1. Flaker
      03.05.2017 09:32

      С docker-compose всё по старому, ведь по сути процесс билда не изменился, так что можно смело пользовать!


      1. VolCh
        03.05.2017 15:25

        Как в нём указать разные стейджи одного докерфвайла для разных сервисов? По идее должна быть поддержка --target, но ничего не нашёл по этому поводу, ни доках, ни в ишью/ПР.


        1. Flaker
          03.05.2017 15:30

          Думаю, что сейчас никаких поддержек для новой фичи в docker-compose нет, просто потому что еще даже в самом Docker не зарелизили.

          Правда, есть практика использовать по одному Dockerfile на сервис. Поэтому, если есть общая часть какая-то, то может стоит вынести её в родительский image?


          1. VolCh
            03.05.2017 15:59

            Некоторые фичи у них в параллели вводились, не чужие люди, как говорится.


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


            • его избыточность (решается в принципе с помощью стейджинга, то есть делается родительский образ не для конечных образов, а для билдера, из которого нужный артефакт копируется в конечный)
            • необходимость либо пересобирать вручную при изменении исходников/зависимостей, либо включать и его в список сервисов с фейковым запуском

            Тогда уж проще иметь два почти одинаковых докерфайла, синкая их ручками.


            Правда, есть практика использовать по одному Dockerfile на сервис.

            Собственно раньше особо возможности не было использовать несколько сервисов на один докер-файл.