Ошибочные шаблоны при построении образов контейнеров

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

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

Однако, только ситхи все возводят в абсолют, поэтому помните, что такие ошибочные шаблоны не всегда будут ошибкой.

Многие из них безвредны, если использовать их по отдельности. Но, как вы увидите, если применить сразу несколько, они легко могут поставить под угрозу вашу продуктивность и заставить вас напрасно тратить свое время и ресурсы.

Большие образы

Лучше, когда образ небольшого размера. Как правило, такие образы требуют меньше времени на сборку, сжатие или извлечение и занимают меньше дискового пространства и сети.

Но какой размер для образа считать большим?

Для микросервисов с относительно небольшим количеством зависимостей я бы не стал переживать за образы размером менее 100 МБ. Для более сложных рабочих нагрузок (монолиты или, скажем, приложения для дата-сайенс) можно использовать образы размером до 1 ГБ. При большем размере я бы уже задумался.

Я опубликовал в блоге несколько постов об оптимизации размера образов (часть 1, часть 2, часть 3). Не буду повторять их содержание здесь, вместо этого давайте сосредоточимся на некоторых исключениях из правил.

Гигантские образы «все в одном»

Иногда в образ нужно собрать Node, PHP, Python, Ruby и несколько движков баз данных, а также сотни библиотек, потому что этот образ планируется сделать основой для платформы PAAS или CI. Это характерный пример для платформ, у которых есть только один доступный образ для запуска всех приложений и задач. Действительно, в таком случае в образе должно быть собрано все необходимое.

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

Наборы данных

Иногда коду (особенно в области дата-сайенс) для работы требуется набор данных: эталонный геном, модель машинного обучения, огромный график, на котором будем производить вычисления…

Так заманчиво поместить набор данных в образ, чтобы контейнер мог «просто работать» независимо от того, где и как его запустят. И это нормально, если набор данных небольшой.

В противном случае, скажем, если он превышает 1 ГБ, возникают проблемы. Конечно, если Dockerfile у вас хорошо организован, модель будет добавлена перед кодом. Кошмар случится, если добавить модель после кода. Сборки будут тормозить, занимать много места на диске, а если код нужно тестировать на удаленных машинах (а не на локальных), модель нужно будет каждый раз сжимать и извлекать, что требует много дискового пространства на удаленных машинах. И это очень плохо.

Вместо этого, подумайте о возможности подключения набора данных из тома. Допустим, код может получить доступ к нужным данным в /data.

Когда вы запускаете его локально с помощью такого инструмента, как Compose, можно использовать монтирование привязки из локального каталога (он выступает здесь в роли кеша) и отдельный контейнер для загрузки данных. Файл Compose будет выглядеть так:

services:

 data-loader:

  image: nixery.dev/shell/curl

  volumes:

  - ./data:/data

  command: |

   if ! [ -f /data/dataset ]; then

curl ... -o /data/dataset

touch /data/ready

   fi

 data-worker:

  build: worker

  volumes:

  - ./data:/data

  command: |

   while ! [ -f /data/ready ]; do sleep 1; done

   exec worker   

Сервис data-worker ждет, когда данные будут доступны, перед запуском, data-loader загружает их в локальную директорию data. Данные скачиваются только один раз. Если их нужно загрузить снова, просто удалите эту директорию и запустите процесс еще раз.

В таком случае, при запуске, например, на Kubernetes, можно использовать для загрузки данных initContainer с примерно похожим Pod’ом:

spec:

 volumes:

 - name: data

 initContainers:

 - name: data-loader

  image: nixery.dev/curl

  volumeMounts:

  - name: data

   mountPath: /data

  command:

  - curl

  - ...

  - -o

  - /data/dataset

 containers:

 - name: data-worker

  image: .../worker

  volumeMounts:

  - name: data

   mountPath: /data

Обратите внимание, что рабочему контейнеру не нужно ждать загрузки данных. Kubernetes запускает его только после завершения initContainer .

Если запустить несколько рабочих контейнеров на один узел, можно также использовать том hostPath (вместо эфемерного тома emptyDir ) – нам важно, чтобы данные загружались только один раз.

Другой вариант - использовать DaemonSet, чтобы заранее автоматически заполнять эту директорию данных на каждом узле кластера.

Здесь нет единственного лучшего варианта – все зависит от того, с чем именно вы работаете. Единый большой набор данных? Несколько? Как часто они меняются?

Большой плюс заключается в том, что образы будут намного меньше, и они по-прежнему будут работать одинаково как в локальных средах так и в удаленных кластерах, не требуя специального кода для загрузки или управления моделью в логике вашего приложения. А это победа!

Маленькие образы

Есть вероятность, что образ будет слишком маленьким. Стоп, а что не так с образом размером всего 5 МБ?

С самим размером проблем нет, но если образ такой маленький, в нем может не быть некоторых полезных инструментов, а это будет стоить вам и вашим коллегам много времени при устранении неполадок с ним.

Образы, созданные с помощью distroless или из FROM scratch , могут быть небольшими. Но есть ли в этом смысл, если команда регулярно страдает, потому что они не могут даже получить оболочку в образе, чтобы, например, проверить, какая сейчас версия у конкретного файла, увидеть запущенные процессы с помощью ps или сетевые соединения с помощью netstat или ss?

Все очень зависит от контекста. У кого-то совсем нет потребности добавлять оболочку в образ. Если вы используете Docker, можно проверить, что происходит, скопировав в запущенный контейнер статические инструменты (например, busybox) через docker cp . Если вы работаете с локальными образами, вы можете легко перестроить образ и добавить необходимые инструменты. В Kubernetes вы можете включить альфа-функцию эфемерных контейнеров. Но в большинстве рабочих кластеров Kubernetes у вас не будет доступа к базовому движку контейнера, и вы не сможете включить альфа-функции, поэтому…

...Вот один из способов добавить очень простой набор инструментов к существующему образу. В этом примере показан distroless-образ, но он подходит и для других образов:

FROM gcr.io/distroless/static-debian11

COPY --from=busybox /bin/busybox /busybox

SHELL ["/busybox", "sh", "-c"]

RUN /busybox --install

Если вам нужно больше инструментов, есть очень элегантный способ через Nixery – установите нужные инструменты, не стирая полезные данные в существующем. Если код развернут в Kubernetes, можно даже добавить инструменты в том, так что вам не придется перестраивать и повторно деплоить новый образ. Дайте знать, если вам интересно, и я напишу об этом более подробно.

В целом, лично я предпочитаю делать сборки на образах Alpine, потому что они крошечные (Alpine - 5 МБ). В Alpine вы можете использовать apk add для всего, что захотите, и когда вам это нужно. Проблемы с сетевым трафиком? Воспользуйтесь tcpdump и ngrep. Нужно загружать и удалять файлы JSON? Вам помогут curl и jq!

Итог: маленькие образы, как правило, хороши, а образы distroless – это вообще бомба, но при определенных обстоятельствах. В случаях, когда «У меня нет доступа в контейнер, и мне придется добавить несколько операторов print() в код и извлекать его через CI до переноса данных, потому что я не могу использовать «kubectl exec ls», возможно, это решение не для вас. Но решать, конечно, вам самим!

Zip, tar, и прочие архивы

(Добавлено 15 декабря 2021 года)

Как правило, добавлять архив (zip, tar.gz или другие) к образу контейнера – так себе идея. Это определенно плохая идея, если контейнер распаковывает этот архив при запуске, бессмысленно тратя на этот процесс время и дисковое пространство!

Оказывается, образы Docker уже сжаты, когда хранятся в реестре, помещаются в реестр или извлекаются из него. Это означает две вещи:

  • хранение сжатых файлов в образе контейнера не позволяет сэкономить дисковое пространство,

  • если хранить несжатые файлы в контейнере, он не будет занимать больше места.

Если мы включим в образ архив (например, архив tar) и распакуем его при запуске контейнера:

  • мы тратим время и циклы ЦП по сравнению с образом контейнера с уже распакованными и готовыми к использованию данными;

  • мы тратим впустую дисковое пространство, потому что в конечном итоге сохраняем как сжатые, так и несжатые данные в файловой системе контейнера;

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

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

Повторная сборка общих базовых образов

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

Если создание образа занимает много времени (больше, чем просто пару минут), я рекомендую вам сохранить этот базовый образ в реестре и брать его оттуда вместо локальной сборки.

Вы спросите, почему?

Причина №1: брать образ из реестра почти всегда быстрее, чем собирать его. (Да, есть исключения, но поверьте, они довольно редки.)

Причина №2: поскольку это база, на которой строится все остальное, вероятно, вам захочется быть уверенным, что у вас есть определенный набор версий образа. В противном случае мы возвращаемся к проблемам типа «ну а у меня-то работает» - именно этого мы пытались избежать, используя контейнеры! Если каждый проводит сборку локально, нужно быть особенно осторожными, чтобы этот процесс был детерминированным и воспроизводимым: закрепить все версии; проверить хеш всех загрузок; используя && или set -e, где требуется, чтобы немедленно прервать выполнение, если что-то в списке команд в процессе сборки пойдет не так. Либо мы можем просто сохранить базовый образ в реестре и быть уверены, что все используют один и тот же образ. Готово.

А если нужно скорректировать этот базовый образ? Есть ли простой способ сделать это, не отправляя новую версию базового образа (это и не нужно, если образ используется только локально), или не редактируя файлы Docker?

Если вы используете Compose, вот пример шаблона основного образа. Это очень простой шаблон (не думаю, что он станет для вас каким-то открытием), но я часто вижу, как его внедряют с помощью скриптов оболочки, файлов Makefile и других инструментов, , поэтому я подумал, что будет полезным показать, что Compose вполне хватит для этой задачи. При сборке приложения он откроет базовый образ. Но если вам нужен настраиваемый базовый образ, можно собрать его отдельно docker-compose build.

Сборка единого репозитория из корневой директории

Что касается единых репозиториев, я не могу однозначно сказать, «за» я или «против». Но если вы храните код в едином репозитории, вероятно, там есть разные подкаталоги для разных сервисов и контейнеров.

Например:

monorepo

├── app1

│  └── source...

└── app2

  └── source...

Вы можете разместить файлы Docker в корне репозитория (или в персональном подкаталоге), например, следующим образом:

monorepo

├── app1

│  └── source...

├── app2

│  └── source...

├── Dockerfile.app1

└── Dockerfile.app2

Далее можно собрать сервисы с помощью docker build . -f Dockerfile.app1. Проблема с этим подходом заключается в том, что если мы используем «старый» конструктор Docker (а не BuildKit), первое, что он делает - это загружает весь репозиторий в Docker Engine. А если репозиторий у вас на 5 ГБ, то Docker будет копировать 5 ГБ перед каждой сборкой, даже в остальном ваш файл Docker прекрасно спроектирован и отлично использует кеширование.

Я предпочитаю иметь файлы Docker в каждом подкаталоге, чтобы их сборка была независимой, в небольшом и изолированном контексте:

monorepo

├── app1

│  ├── Dockerfile

│  └── source...

└── app2

  ├── Dockerfile

  └── source...

Затем мы можем перейти в директории app1 или app2 и запустить docker build. Ему понадобится только содержимое этого подкаталога.

Однако иногда для процесса сборки требуются зависимости, которые находятся вне директории для приложения; например, общий код в подкаталоге lib ниже:

monorepo

├── app1

│  └── source...

├── app2

│  └── source...

└── lib

  └── source...

Что же делать в этом случае?

Решение №1: запаковать зависимости в отдельные образы. При создании образов для app1 и app2 вместо копирования директории lib из репозитория скопируйте его из образа lib или общего базового образа. Возможно, это про вас, а может, это вас не касается: одним из основных преимуществ единого репозитория является то, что конкретный коммит может точно описать, какую версию кода и его зависимости мы используем; а такое решение может это поломать.

Решение №2: использовать BuildKit. Для BuildKit не надо копировать весь контекст сборки, и в таком скрипте это будет гораздо более эффективным решением.

Давайте взглянем на BuildKit в этом контексте!

Если не использовать BuildKit

BuildKit - это новый бэкэнд для docker build. Это полная переработка с множеством новых функций, включая параллельные сборки, кросс-арочные сборки (например, создание образов ARM на Intel и наоборот), создание образов в Kubernetes Pods и многое другое. При этом, он все еще полностью совместим с существующим синтаксисом файлов Docker. Это похоже на переход на электромобиль: у нас все еще есть руль и две педали, но начинка совершенно другая.

Если вы работаете в последней версии Docker Desktop, вероятно, вы уже используете BuildKit, так что это здорово. В противном случае (в частности, если вы работаете в Linux) установите переменную среды DOCKER_BUILDKIT=1 и запустите команду docker build или docker-compose; например:

DOCKER_BUILDKIT=1 docker build . --tag test

Если вам понравится результат (а я уверен, что он вам понравится), вы можете установить эту переменную в профиле оболочки.

«Как мне понять, что я использую BuildKit?»

Выходные данные сборки без BuildKit:

Sending build context to Docker daemon 529.9kB

Step 1/92 : FROM golang:alpine AS builder

 ---> cfd0f4793b46

...

Step 90/92 : RUN (   ab -V ...

 ---> Running in 645af9563c4d

Removing intermediate container 645af9563c4d

 ---> 0972a40bd5bb

Step 91/92 : CMD  if tty >/dev/null; then ...

 ---> Running in 50226973af9f

Removing intermediate container 50226973af9f

 ---> 2e963346566b

Step 92/92 : EXPOSE 22/tcp

 ---> Running in e06a628465b3

Removing intermediate container e06a628465b3

 ---> 37d860630477

Successfully built 37d860630477

  • начинается с «Sending build context…» (в данном случае более 500 КБ)

  • нужно передавать весь контекст сборки при каждой сборке

  • текстовый вывод в основном черно-белый, за исключением стандартного вывода ошибок этапов сборки, который отображается красным

  • каждая строка файла Docker соответствует «шагу»

  • каждая строка файла Docker создает промежуточный образ (тот самый ---> xxx на выходе)

  • линейное исполнение (92 шага для этого образа и всех его этапов)

  • время сборки для этого образа: 3 минуты 40 секунд

Выходные данные сборки для того же файла Docker, но с использованием BuildKit:

 => [internal] load build definition from Dockerfile                 0.0s

 => => transferring dockerfile: 8.91kB                             0.0s

 => [internal] load .dockerignore                               0.0s

 => => transferring context: 2B                            0.0s

 => [internal] load metadata for docker.io/library/golang:alpine           0.0s

...

 => [stage-19 27/28] COPY setup-tailhist.sh /usr/local/bin              0.0s

 => [stage-19 28/28] RUN (   ab -V | head -n1 ;  bash --version | head -n1 ;  curl --ve 0.7s

 => exporting to image                                2.0s

 => => exporting layers                                 2.0s

 => => writing image sha256:9bd0149e04b9828f9e0ab2b09222376464ee3ca00a2de0564f973e2f90e0cfdb  0.0s

  • начинается с нескольких строчек [internal] и переносит только необходимое из контекста сборки

  • может кэшировать части контекста между различными сборками

  • вывод текста в основном темно-синий

  • команды файла Docker, например, RUN и COPY создают новые шаги, тогда как другие команды (например, EXPOSE и CMD в конце) нет

  • каждый шаг создает слой, но без промежуточных образов

  • по возможности, параллельное исполнение с использованием диаграммы зависимостей (итоговый образ – на 28 шаге 19 стадии файла Docker)

  • время сборки для этого образа: 1 минута, 30 секунд

Поэтому постарайтесь использовать BuildKit: я просто не могу придумать для него недостатков. Он никогда не замедлит работу и во многих случаях значительно ускорит сборки.

Необходимость повторной сборки для каждого изменения

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

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

В большей части рабочих процессов разработки, которые я видел, правильно использовали тома или обновление в режиме реального времени с помощью таких инструментов, как Tilt; но мне, бывало, встречались разработчики, которые, например, генерировали код Python или полностью перезапускали веб-пакет после каждого изменения (вместо использования сервера разработки веб-пакета).

(Кстати, если вы попробуете очень быстро задеплоить изменения в кластере разработки Kubernetes, обязательно посмотрите статью Эллен Кёрбс «В поисках самого быстрого деплоя» (видео и презентация). Спойлер: мне хватит пальцев одной руки, чтобы посчитать секунды между «Сохранить мой код Go в редакторе» и «теперь этот код запущен на удаленных кластерах Kubernetes».

Источник: https://habr.com/ru/company/nixys/blog/664660/


Интересные статьи

Интересные статьи

Сегодня взял в руки книжку Эндрю Таненбаума "Архитектура компьютера" (последнее издание на русском языке вышло в 2018 году). Я ее пролистывал лет 10 назад, но сегодня решил пролистать снова, чтобы быт...
Доступное американское высшее образованиеНекоторое время назад, разбирая предложения различных онлайн курсов, обнаружил довольно интересный университет. На первой страниц...
Всем привет. Если вы когда-либо работали с универсальными списками в Битрикс24, то, наверное, в курсе, что страница детального просмотра элемента полностью идентична странице редак...
Как-то у нас исторически сложилось, что Менеджеры сидят в Битрикс КП, а Разработчики в Jira. Менеджеры привыкли ставить и решать задачи через КП, Разработчики — через Джиру.
Ученые НИТУ «МИСиС» совместно с коллегами из Технологического университета Лулело (Швеция) и Йенского университета имени Фридриха Шиллера (Германия) разработали первый в мире термоэлектрический м...