Привет, Хабр.
Последнее время DevOps и CI/CD де-факто стали повсеместным стандартом независимо от размера команды, в интернетах хватает статей, почему это важно, в чём собственно идея, полезных практик и других материалов. Я же решил подойти с несколько другой стороны и максимально доступным языком на практических примерах рассмотреть отдельные задачи и их решения в рамках концепции.
Одной из таких задач, которую я хотел бы рассмотреть первой, является задача создания сборочного цеха базовых докер-образов, используемых в репозиториях и процессах проекта.
Кому, собственно, это может пригодиться? Я бы сказал, что почти кому угодно, кто хочет организовать ту компоненту CI/CD, которая отвечает за построение и релизный цикл базовых образов внутри инфраструктуры вашей компании или даже персональных проектов (зародился он именно в процессе приведения в порядок моих пет-проектов).
Какой практический пример может быть без публичного репозитория с MIT-лицензией? Если вам не интересно читать статью, то можно прямо из превью перейти по ссылке и напитаться кодом. Репозиторий же можно копировать как целиком, так и кусками, буду только рад, если он кому-нибудь пригодится. На любые вопросы — готов ответить в комментариях или issues.
Проблематика
Зачем нужны базовые образы, спросите вы меня. Не хотите описывать одни и те же общие зависимости в каждом сервисе? Нужен базовый образ. Не хотите бегать по репозиториям и подкладывать новый корневой сертификат после переезда на другую инфраструктуру? Нужен базовый образ. Хотите запускать ваш пайплайн CI/CD в единой среде в разных репозиториях? Вы угадали, нужен базовый образ.
Идеологически базовые образы позволяют разделить ответственность и облегчить всем жизнь. Каждая конкретная проблема, если она может быть решена в базовом образе, решается именно там, это позволит сделать задачу лишь единожды, а не в каждом конкретном месте, где она будет возникать. Такой себе депопсерский DRY, если вы мне позволите.
Вопрос: почему именно монорепозиторий? Почему не хранить код и релизить образы в отдельных репозиториях?
Ответ: можно и так, но я сторонник монорепозитория для данной задачи по нескольким причинам:
Общий тулинг. Все образы строятся/проверяются/релизятся на одном и том же наборе инструментов, выносить их в какой-то пакет или копировать из репозитория в репозиторий не очень удобно, на мой взгляд.
Зависимости. Часто образы строятся один поверх другого, иногда образуя достаточно длинные цепочки, бегать из репозитория в репозиторий, чтобы по очереди поднять в каждом версию и донести изменения из корневого, не всегда удобно (bump version hell).
Есть и минусы у этого подхода:
Мы будем использовать единую версию для всех образов, и порой у нас будет подниматься версия у образа даже тогда, когда никаких изменений в нём не происходило. Этого можно избежать, если использовать раздельное версионирование, а не github release. Это в целом не минус монорепы, а именно выбранного мной релизного механизма. Не считаю это минусом, потому что актуализировать незакреплённые зависимости всё равно нужно, даже для образов, в которых, кажется, ничего не меняется.
Из-за того, что сборка происходит сразу для всех образов, время релиза значительно увеличивается, хоть мы и будем использовать технологии, которые позволяют эффективно распараллелить сборку и практически нивелировать данный эффект.
Вопрос: а как будет решена проблема построения дерева зависимостей для определения порядка сборки и как будет устроен параллелизм? Да ещё и заявленная мультиплатформенность? Очередная статья о том, «как мы накрутили 100 500 строк кода на баше»?
Ответ: внимательный читатель мог уже догадаться, куда я клоню, но если нет, то, знакомьтесь:
buildx bake
Сначала я относился к нему с некоторым скептицизмом. Но попробовав на одном из проектов, быстро понял: если мне нужно собрать что-то сложнее одного образа из банального докерфайла, то без него никуда.
В статье я коротко расскажу о том, что такое бэйк и как я его использовал в своих целях, но если вам понадобятся какие-то более глубокие знания, за этим всё же советую сходить в документацию, благо она относительно адекватно написана:
Документация buildx bake
Референс по наполнению docker-bake.hcl
Всё на самом деле достаточно просто: вы описываете bake-файл, в нём описываете цели (target) или группы (group), а затем специальной командой (docker buildx bake) можете вызывать сборку/пуш целей или групп целей. Синтаксис поддерживает как переменные, так и функции, позволяет играючи менять те или иные аспекты сборки, а также параметризовать контекст сборки. Но обо всём подробнее на примерах.
Для начала разберём остальной тулинг, используемый в проекте.
Taskfile
Прекрасная замена Makefile, идеально подходит для организации и хранения команд и сценариев использования. Да, у него есть свои минусы, но проект достаточно быстро развивается, плюс у него гораздо менее зубодробительный синтаксис, когда дело касается циклов и условий. Подробно не буду на инструменте останавливаться, но буду использовать примеры на его основе, подробнее с синтаксисом можно ознакомиться тут.
Github Actions
Использую исключительно в рамках размещения на Github'е, да и в разумных пределах / на открытых репозиториях мощности бесплатны. Максимально стараюсь не привязываться к конкретной CI, потому практически все действия делаются через простой вызов той или иной таски/скрипта.
Disclaimer: да, я знаю, что есть уже готовые action'ы под те или иные действия, их неиспользование осмысленно и умышленно. Причин две. Во-первых, стараюсь не вендорлочить себя наглухо к технологии CI. Ну и во-вторых — это повторяемость исполнения между локальным и CI-окружением.
Linting
Линтовка и тестирование в проекте — неотъемлемая часть процесса CI. Вскользь пробежимся по используемым инструментам:
husky
— управление git-хукамиcommitlint
— линтовка сообщений коммитовlint-staged
— линтовка изменённых файлов, а не всего репозитория на pre-commit хукахprettier
— линтовка .js, .json, .md, .yamldockerfilelint
— линтовка Dockerfile
Перечень не исчерпывающий, при желании можно добавить что-то ещё, но, кажется, минимально — этого достаточно.
Наполнение
Репозиторий наполнен четырьмя образами. Сами образы ни в коем случае не являются эталонными, а лишь даны в качестве примера, чтобы нам было о чём говорить. Наполнение:
Ubuntu — базовый образ, который используется для всех последующих, строится на основе ванильного образа убунты из docker hub. Донасыщен минимальным набором общего инструментария.
Ubuntu CI — образ, который используется в CI организации. Достаточно толстый, совершенно не стесняемся ставить всё, что нам может понадобиться в CI, чтобы не тратить время на установку окружения в CI-time.
Python — минималистичный runtime-образ для Python-сервиса.
Python Dev — толстый образ, включающий сборочный инструментарий, необходимый для build-time Python-сервиса, тоже не стесняемся раздувать, поскольку он используется исключительно как сборочный.
Организация репозитория
Если упростить, то папка с исходниками организована примерно по следующей структуре:
src/
ubuntu/ - Папка образа
jammy/ - Папка тэг-префикса
...
ubuntu-ci/
...
Taskfile.yaml - Общий файл сценариев работы с исходниками
docker-bake.hcl - Общий bake-файл для переиспользуемых аргументов/функций
По сути, в выбранной архитектуре мы разбиваем папку с исходниками на подпапки с образами, которые в свою очередь разбиваются на подпапки с группами тегов (например, focus/jammy для ubuntu или 3.9/3.10 для Python-образов).
Вопрос: зачем нам поддерживать образы с несколькими версиями (тег-префиксов), а не использовать везде %выбери свою версию%?
Ответ: смена версии может занимать значительные усилия в зависимых проектах, да и не всегда мы это хотим делать, если проект на LTS. При этом мы хотим иметь возможность дальше добавлять в наши базовые образы требуемые изменения даже для старых версий (часто это бывают требования безопасности или инфраструктуры). Рано или поздно можно будет пометить версию deprecated и перестать её выпускать, но сама возможность поддержки должна быть.
Общие файлы — Taskfile.yaml
К сожалению, bake не очень приспособлен к тому, чтобы использовать иерархические структуры папок и файлов для построения единой системы построения образов. Но тут нам и пригодится магия taskfile, с помощью него мы создадим команду, которая нам позволит передавать в качестве аргументов -f
все файлы в папках тегов:
# src/Taskfile.yaml
...
tasks:
bake:
vars:
BAKE_FILES:
sh: find */*/docker-bake.hcl
cmds:
- docker buildx bake
--file docker-bake.hcl
{{range $i, $file := .BAKE_FILES | splitLines }} --file {{$file}}{{end}}
{{.CLI_ARGS}}
...
Теперь мы сможем использовать эту команду как из консоли через task
bake -- ...
, так и в других командах. Помимо команд build
и release
, которые просто собирают и собирают+push'ат соответственно, интересна ещё и команда configure-builder
, которая настраивает наш buildx на использование docker-container драйвера (необходимо для мультиплатформенных образов).
configure-builder:
desc: Configure buildx for multi-arch builds
cmds:
- echo 'Configuring buildx...'
- docker buildx create
--driver docker-container
--use
Общие файлы - docker-bake.hcl
В общем файле будем описывать общие переменные (variable) и функции (function), а также дефолтную группу.
Пример переменных — список платформ, при желании его можно перегрузить, передав в качестве аргумента при вызове команды.
# src/docker-bake.hcl
...
variable "PLATFORMS" {
default = [
"linux/amd64",
"linux/arm64",
]
}
...
В качестве примера функции — метод, формирующий тег из имени образа, префикса тега и дефолтных/передаваемых значений.
function "tags" {
params = [name, tag_prefix]
result = ["${REGISTRY}${name}:${tag_prefix}-${TAG_POSTFIX}"]
}
В группу по умолчанию складываем все цели, которые будут собираться, если цель не указана явным образом.
group "default" {
targets = [
"ubuntu__jammy",
"ubuntu-ci__jammy",
"python__3_10-jammy",
"python-dev__3_10-jammy",
]
}
Файлы образа
Исходный код каждой группы тегов выглядит примерно следующим образом:
.../
scripts/
001_script_name.sh
...
999_script_name.sh
docker-bake.hcl
Dockerfile
Файлы образа - Dockerfile
Давайте начнём с привычного всем Dockerfile
, на примере src/ubuntu-ci/jammy
(линк).
# src/ubuntu-ci/jammy/Dockerfile
FROM ubuntu
...
Эта строчка может смутить неподготовленного читателя, поскольку может показаться, что здесь по сути будет использован latest тег образа ubuntu с докерхаба, но нет, это не так. При использовании bake контексты могут и должны быть определены в bake-файле, но про это подробнее позже.
COPY ./scripts /tmp/scripts
RUN chmod a+x /tmp/scripts/*.sh && \
run-parts --regex '.*sh$' \
--exit-on-error \
/tmp/scripts && \
rm -rf /tmp/scripts
Эти страшные строки, по сути, не делают ничего, кроме того, что копируют все файлы из папки scripts/
, выполняют их в алфавитном порядке, а затем удаляют. Весь исполняемый код, запускаемый во время сборки образа, я стараюсь организовывать именно так.
Вопрос: зачем сделано именно так, а не кодом напрямую в докерфайл?
Ответ: это решает ряд проблем:
кэш для apt-get
декомпозиция шагов на отдельные файлы
возможность переиспользования шагов между образами
Что ещё попадает в Dockerfile? Честно говоря, за всю практику для базовых образов мне не нужно было ничего в них добавлять, кроме переменных окружения и секретов сборки. Переменные окружения устанавливаются привычной инструкцией ENV
, например:
ENV PATH /root/.nvm/versions/node/v16.15.1/bin:${PATH}
ENV NODE_PATH /root/.nvm/versions/node/v16.15.1/lib/node_modules
С секретами всё чуть посложнее, но тоже достаточно просто, достаточно добавить пару строк в инструкцию RUN
, а так же пробросить их из bake-файла.
RUN --mount=type=secret,id=SECRET_KEY \
SECRET_KEY=$(cat /run/secrets/SECRET_KEY) \
...
Вопрос: почему не пробрасывать секреты через аргументы (--build-args
)?
Ответ: аргументы сборки сохраняются в логах и внутри собранного образа, если вы не хотите, чтоб ваши секреты утекли любому, у кого есть доступ к логам или самому образу (а это может случиться рано или поздно, даже если образы приватные), то не делайте так никогда.
Важно: как бы вам ни хотелось, хранить секреты в самом образе нельзя ни при каких условиях. Любые секреты должны монтироваться либо в build-time, либо в run-time.
Файлы образа - docker-bake.hcl
Рассмотрим устройство docker-bake.hcl
на примере src/ubuntu-ci/jammy
(линк).
# src/ubuntu-ci/jammy/docker-bake.hcl
target "ubuntu-ci__jammy" {
...
}
По сути мы просто описываем цель(target) для сборки, наполняя её соответствующими атрибутами.
context = "ubuntu-ci/jammy/"
Блок target.context отвечает за контекст сборки Dockerfile
. Обратите внимание, что он указывается относительно папки из которой вызывается команда docker buildx bake
.
contexts = {
ubuntu = "target:ubuntu__jammy"
}
Блок target.contexts, не путать с предыдущим, отвечает за назначение контекстов при использовании в инструкциях FROM
и COPY --from
. Именно этот магический блок и позволяет нам не привязываться в Dockerfile
к конкретному образу, и, что более важно, в качестве контекста одного target'а можно (но не обязательно) использовать другой. Это позволит нам строить целые цепочки зависимых образов без особых усилий.
dockerfile = "Dockerfile"
Всё достаточно прозаично, target.dockerfile — это лишь путь к нашему Dockerfile
, часто можно встретить использование target.dockerfile-inline , но, честно сказать, я не большой фанат, вероятно, в первую очередь из эстетических соображений.
platforms = PLATFORMS
Если вы сталкивались с проблемой построения мультиплатформенных образов во времена, когда buildx ещё не появился на свет, то вы знаете всю боль. В современном же мире это делается одной строчкой с использованием target.platfroms. Здесь в качестве списка платформ мы используем общий аргумент из корневого src/docker-bake.hcl
.
labels = LABELS
Точно такой же приём используем и для списка лейблов.
tags = tags("python", "3.10-jammy")
А для формирования тэгов используем уже функцию.
CI/CD pipeline
test+build делаем во время ПРов: линтуем наш проект, запускаем тестовую сборку, артефакты никуда не пушим, разве что можно сохранить кэши, но, на мой взгляд, с этим стоит заморачиваться, когда у вас действительно очень большой поток версий. В любом случае вы скорее всего раньше уйдёте на раздельное версионирование, а возможно, и на раздельную сборку. Соответствующий workflow.
release делаем в рамках релизного процесса гитхаба, при желании можно делать на каждом вмёрдженном ПРе, автоматически выпуская новую версию. Это уже на любителя, я бы сказал. Workflow.
Курица или яйцо
Есть две проблемы, ну или скорее нюанса, о которых я не упомянул, так что поговорим о них отдельно. Первое, что может броситься в глаза, если внимательно ознакомиться с репозиторием, — это то, что в CI образ строится в контейнере, запущенном на этом самом образе. И становится неясно, как это вообще возможно.
Кто-то скажет, что так нельзя строить пайплайн. И возможно, я в чём-то соглашусь, но мне проще локально собрать первую версию образа и запушить её, тем самым решив проблему, чем собирать отдельный CI-образ, на котором я буду делать сборку именно в этом репозитории. В общем, проблему я раскрыл, оставлю её на ваш суд.
GitРub PAT (Personal Access Token)
Можно использовать токены, которые генерятся в рамках воркфлоу, но у них есть определённые ограничения, плюс получение доступа к образам, собранным в других репозиториях, раньше в Github было достаточно сильно забаговано, так что с тех самых пор для некоторых задач я привык использовать роботные учётки и их персональные токены с узкими правами, которые я храню непосредственно в секретах.
Б — Безопасность
Пока верстался номер, один мой коллега напомнил, что немаловажной частью любого сборочного пайплайна является безопасность. Обычно я использую инструменты на стороне registry, но тут решил посмотреть, что же нам предлагает дивный мир опенсорса. А оказалось, что он много что предлагает, но я решил остановиться — trivy. Его достаточно легко и приятно использовать в консольном режиме, но при этом он позволяет генерить удобные отчёты (пример). Потому я решил дополнить репозиторий соответствующими командами и воркфлоу, которые обеспечивают проверки.
ghcr.io/ovsds-example-organizaton/python-dev:3.10-jammy-0.3.0 (ubuntu 22.04)
============================================================================
Total: 0 (HIGH: 0, CRITICAL: 0)
ghcr.io/ovsds-example-organizaton/python:3.10-jammy-0.3.0 (ubuntu 22.04)
========================================================================
Total: 0 (HIGH: 0, CRITICAL: 0)
ghcr.io/ovsds-example-organizaton/ubuntu-ci:jammy-0.3.0 (ubuntu 22.04)
======================================================================
Total: 0 (HIGH: 0, CRITICAL: 0)
Node.js (node-pkg)
==================
Total: 1 (HIGH: 1, CRITICAL: 0)
┌─────────────────────────────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬────────────────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├─────────────────────────────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼────────────────────────────────────────────────────────────┤
│ http-cache-semantics (package.json) │ CVE-2022-25881 │ HIGH │ fixed │ 4.1.0 │ 4.1.1 │ http-cache-semantics: Regular Expression Denial of Service │
│ │ │ │ │ │ │ (ReDoS) vulnerability │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2022-25881 │
└─────────────────────────────────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴────────────────────────────────────────────────────────────┘
Заключение
Честно говоря, эта статья — попытка прощупать почву, насколько вообще интересна тема такого себе девопса на пальцах для начинающих, где я стараюсь максимально просто, но при этом на реальном примере решать конкретные задачи. Если будет интерес, то, возможно, это перейдёт в некоторую серию.
Надеюсь, было полезно, буду рад любой обратной связи, особенно критике о том, что можно было бы улучшить, и вашим вопросам.