CI/CD в каждый дом: сборочный цех базовых docker-образов

от автора

Привет, Хабр.

Последнее время DevOps и CI/CD де-факто стали повсеместным стандартом независимо от размера команды, в интернетах хватает статей, почему это важно, в чём собственно идея, полезных практик и других материалов. Я же решил подойти с несколько другой стороны и максимально доступным языком на практических примерах рассмотреть отдельные задачи и их решения в рамках концепции.

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

Кому, собственно, это может пригодиться? Я бы сказал, что почти кому угодно, кто хочет организовать ту компоненту CI/CD, которая отвечает за построение и релизный цикл базовых образов внутри инфраструктуры вашей компании или даже персональных проектов (зародился он именно в процессе приведения в порядок моих пет-проектов).

Какой практический пример может быть без публичного репозитория с MIT-лицензией? Если вам не интересно читать статью, то можно прямо из превью перейти по ссылке и напитаться кодом. Репозиторий же можно копировать как целиком, так и кусками, буду только рад, если он кому-нибудь пригодится. На любые вопросы — готов ответить в комментариях или issues.

Проблематика

Зачем нужны базовые образы, спросите вы меня. Не хотите описывать одни и те же общие зависимости в каждом сервисе? Нужен базовый образ. Не хотите бегать по репозиториям и подкладывать новый корневой сертификат после переезда на другую инфраструктуру? Нужен базовый образ. Хотите запускать ваш пайплайн CI/CD в единой среде в разных репозиториях? Вы угадали, нужен базовый образ. 

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

Вопрос: почему именно монорепозиторий? Почему не хранить код и релизить образы в отдельных репозиториях?

Ответ: можно и так, но я сторонник монорепозитория для данной задачи по нескольким причинам:

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

  • Зависимости. Часто образы строятся один поверх другого, иногда образуя достаточно длинные цепочки, бегать из репозитория в репозиторий, чтобы по очереди поднять в каждом версию и донести изменения из корневого, не всегда удобно (bump version hell).

Есть и минусы у этого подхода:

  • Мы будем использовать единую версию для всех образов, и порой у нас будет подниматься версия у образа даже тогда, когда никаких изменений в нём не происходило. Этого можно избежать, если использовать раздельное версионирование, а не github release. Это в целом не минус монорепы, а именно выбранного мной релизного механизма. Не считаю это минусом, потому что актуализировать незакреплённые зависимости всё равно нужно, даже для образов, в которых, кажется, ничего не меняется.

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

Вопрос: а как будет решена проблема построения дерева зависимостей для определения порядка сборки и как будет устроен параллелизм? Да ещё и заявленная мультиплатформенность? Очередная статья о том, «как мы накрутили 100 500 строк кода на баше»?

Ответ: внимательный читатель мог уже догадаться, куда я клоню, но если нет, то, знакомьтесь:

buildx bake

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

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

Всё на самом деле достаточно просто: вы описываете bake-файл, в нём описываете цели (target) или группы (group), а затем специальной командой (docker buildx bake) можете вызывать сборку/пуш целей или групп целей. Синтаксис поддерживает как переменные, так и функции, позволяет играючи менять те или иные аспекты сборки, а также параметризовать контекст сборки. Но обо всём подробнее на примерах.

Для начала разберём остальной тулинг, используемый в проекте.

Taskfile

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

Github Actions

Использую исключительно в рамках размещения на Github’е, да и в разумных пределах / на открытых репозиториях мощности бесплатны. Максимально стараюсь не привязываться к конкретной CI, потому практически все действия делаются через простой вызов той или иной таски/скрипта.

Disclaimer: да, я знаю, что есть уже готовые action’ы под те или иные действия, их неиспользование осмысленно и умышленно. Причин две. Во-первых, стараюсь не вендорлочить себя наглухо к технологии CI. Ну и во-вторых  — это повторяемость исполнения между локальным и CI-окружением.

Linting

Линтовка и тестирование в проекте — неотъемлемая часть процесса CI. Вскользь пробежимся по используемым инструментам:

  1. husky — управление git-хуками

  2. commitlint — линтовка сообщений коммитов

  3. lint-staged — линтовка изменённых файлов, а не всего репозитория на pre-commit хуках

  4. prettier — линтовка .js, .json, .md, .yaml

  5. dockerfilelint — линтовка Dockerfile

Перечень не исчерпывающий, при желании можно добавить что-то ещё, но, кажется, минимально — этого достаточно.

Наполнение

Репозиторий наполнен четырьмя образами. Сами образы ни в коем случае не являются эталонными, а лишь даны в качестве примера, чтобы нам было о чём говорить. Наполнение:

  1. Ubuntu — базовый образ, который используется для всех последующих, строится на основе ванильного образа убунты из docker hub. Донасыщен минимальным набором общего инструментария.

  2. Ubuntu CI — образ, который используется в CI организации. Достаточно толстый, совершенно не стесняемся ставить всё, что нам может понадобиться в CI, чтобы не тратить время на установку окружения в CI-time.

  3. Python — минималистичный runtime-образ для Python-сервиса.

  4. 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-organization/python-dev:3.10-jammy-0.3.0 (ubuntu 22.04) ============================================================================ Total: 0 (HIGH: 0, CRITICAL: 0)   ghcr.io/ovsds-example-organization/python:3.10-jammy-0.3.0 (ubuntu 22.04) ======================================================================== Total: 0 (HIGH: 0, CRITICAL: 0)   ghcr.io/ovsds-example-organization/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                 │ └─────────────────────────────────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴────────────────────────────────────────────────────────────┘

Заключение

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

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

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

Про что рассказать в следующий раз?

41.18% Репозиторий зеркало docker-образов14
23.53% IaC репозиторий для управления github-организацией8
73.53% Как доставить секреты на машину разработчика и не пролить их25

Проголосовали 34 пользователя. Воздержались 4 пользователя.

ссылка на оригинал статьи https://habr.com/ru/articles/782620/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *