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

Последнее время 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-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                 │
└─────────────────────────────────────┴────────────────┴──────────┴────────┴───────────────────┴───────────────┴────────────────────────────────────────────────────────────┘

Заключение

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

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

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