Всем привет, на связи Вадим Макеров, бэкенд-разработчик iSpring. Успешная воспроизводимая сборка проекта является критическим фактором в поддержке и развитии проекта. При большом количестве проектов и технологических стеков гарантировать воспроизводимость сборки — «собралось однажды, соберется всегда» — сложнее.
О том, как реализовать идемпотентность сборки, я рассказывал в рамках митапа в офисе iSpring в 2023 году. Эта статья — текстовая версия моего доклада.
Моделируем
Предположим, что имеем систему, состоящую из множества проектов. Все проекты используют такой набор инструментов для сборки:
Этих инструментов достаточно для того, чтобы собрать любой проект. Количество проектов в системе увеличивается и благодаря изолированности команд проекты разрабатываются параллельно.
Здесь возникает первая проблема
Проблема №1 — Доработка старых проектов
По требованию отдела ИБ нужно доработать несколько проектов, которые уже давно не трогали.
Алгоритм внесения изменений:
Клонировать репозиторий проекта
Собрать проект локально (необходимо для выгрузки библиотек проекта)
Внести изменения
Скомпилировать, прогнать тесты, запустить линтер
Закоммитить изменения
После начала работ над первым проектом пошли первые неприятности:
Отсутствие специфических инструментов
Некоторые проекты, помимо общего набора инструментов, могут требовать специфические, которые не установлены у большинства разработчиков.
Новый линтер — старый проект
После внесения доработок запуск линтера выдал следующее
Линтер обнаружил замечания, которые раньше не замечал. Мы уверены, что раньше их не было, т.к. линтер запускается в CI/CD перед релизом изменений в проекта.
Такое поведение вызвано различием версий линтеров при разработке проекта и обновлении проекта у разработчика локально.
Замечания корректные и по-хорошему их нужно править, но без обновления инструментария — мы хотим, чтобы сборка всегда выдавала один и тот же результат, без фантомных замечаний линтера.
В итоге
Имеем сложности сборки проекта и замечания линтера, которые нужно править.
Проблема №2 — Обновление инструмента
Если быть точным — обратно несовместимое обновление инструмента в проекте.
Приведу пример реальной ситуации с обновлением инструментов:
Обновляется кодогенератор с версии v1 на v2, версии между собой несовместимы.
Команда A и B имеют локально установленные версии v1 инструмента.
В рамках доработок проекта команда A решает использовать инструмент v2.
Команда A ставит себе инструмент локально и дорабатывает проект под использование версии v2.
В виду производственной необходимости команде B необходимо доработать проект вместо команды A.
Команда B не способна собрать проект без указаний команды A об обновлении инструмента.
В итоге
Команда B не способна собрать проект без обращения к команде A или самостоятельных поисков и изучении нужной версии инструмента.
Ситуация осложняется при большом количестве команд, дорабатывающих разные проекты.
Проблема №3 — Отсутствие инструмента локально
Этого тезиса я касался в секции доработки старых проектов, хочу отдельно разобрать эту проблему.
Отсутствие заранее подготовленного инструмента локально вынуждает разработчика идти по шагам:
Искать в документации к проекту версию инструмента и как её установить. Но вряд ли это будет там описано
Отвлекать других разработчиков
Искать в интернете нужный инструмент и подбирать версию
В итоге
Разработчик дольше вливается в разработку
Нет гарантий, что разработчик установит нужную версию инструмента
Проблематика
Вышеописанные примеры являются следствием системных недоработок
Версии инструментов не зафиксированы
-
Неизвестно какая версия используется в проекте
Усложняется первичная настройка
При обновлении инструментов нужно как-то уведомить другие команды/отделы
Локальное влияние
Локальный сетап непредсказуемо влияет на сборку
Появляются сайд-эффекты
Решение
Одним из решений может быть контейнеризация сборки
Под контейнеризацией я подразумеваю использование Docker и Docker-образов с необходимыми инструментами
Контейнеризация сборки - не единственный способ решения описанных проблем.
Решений существует множество, таких как Nix-shell, к примеру. Нам хотелось идти в контейнеризацию и изолированность инструментов, поэтому мы выбрали контейнеры и Docker.
Основные плюсы, которые привносит докеризация сборки:
Портативность
Docker-образы легко переносятся между машинами разработчиков, распространяются через registry docker.hub или другие registry.
К тому же, образы легко переносятся в локальное registry организации при необходимости изоляции CI/CD от внешних факторов
Консистентное окружение
Инструмент запускается в настроенном под него окружении, не требуя настройки локального окружения(переменные окружения, пути к исполняемым утилитам) и не конфликтуя с локальными утилитами разработчика.
Изоляция
Использование утилит изолированно в контейнере, что дает дополнительную безопасность локального окружения разработчика и CI/CD.
Запускаемые инструменты изолированы и не могут влиять на хост‑машину разработчика.
(Заметка на полях: у зловредных инструментов есть множество способов выбраться, но инструменту запущенному в докер контейнера навредить хост‑машине сложнее — чем будучи запущенным на хосте напрямую)
Версионирование
Docker‑образы идеально версионируются, в качестве тэга позволяя выставить любую строку. Можно использовать semver, день выпуска версии инструмента или просто хэш коммита из GIT.
Выбор инструмента
В CI/CD мы уже используем сборку в контейнерах посредством макро-образа со всеми утилитами.
Данное решение не подходит для локальной сборки и усложняет независимое обновление инструментов.
Таким образом, имеем следующее требование:
Сборка проекта должна проходить одинаково — локально и в CI/CD.
Makefile + Docker
Можно описать сборку в Makefile, где использовать контейнеры определенных образов для сборки
Пример:
all: build test check
.PHONY: build
build:
@docker run --rm -it \
-w ${PWD} \
-v ${PWD}:${PWD} \
-e GOCACHE=/app/cache/go-build \
-v /app/cache/go-build \
golang:1.22 \
build -v ./cmd/app -o ./bin/app
.PHONY: test
test:
@docker run --rm -it \
-w ${PWD} \
-v ${PWD}:${PWD} \
-e GOCACHE=/app/cache/go-build \
-v /app/cache/go-build \
golang:1.22 \
test ./...
.PHONY: check
check:
@docker run --rm -it \
-w ${PWD} \
-v ${PWD}:${PWD} \
-e GOCACHE=/app/cache/go-build \
-v /app/cache/go-build \
golangci/golangci-lint:v1.56 \
golangci-lint run
Плюсами я бы выделил:
Простота — такой Makefile написать достаточно просто
Интуитивность — в таком Makefile понятно что каждая команда делает
Минусы же:
Нет возможности переиспользовать слои от других этапов сборки, как в классическом Dockerfile
Не очень удобно оперировать
docker run
, когда нужно писать более сложные команды
Dev containers
Dev containers расширение для VS Code (JetBrains также поддержали в своих продуктах) позволяющее запустить IDE внутри контейнера с заранее подготовленным окружением для разработки.
Минусом такого подхода является монолитный образ для IDE и невозможность оперировать такими контейнерами на CI/CD.
DevConainer больше похож на описание окружения, а не утилиту сборки. Из-за чего с ним возникают неудобство в оперировании путями кэша, экспорта кэша в CI/CD и так далее.
Earthly.dev
Earthly позволяет описывать сборку в формате Eaethfile похожему на Makefile + Dockerfile
(пример с README.md проекта)
# Earthfile
VERSION 0.8
FROM golang:1.15-alpine3.13
RUN apk --update --no-cache add git
WORKDIR /go-example
all:
BUILD +lint
BUILD +docker
build:
COPY main.go .
RUN go build -o build/go-example main.go
SAVE ARTIFACT build/go-example AS LOCAL build/go-example
lint:
RUN go get golang.org/x/lint/golint
COPY main.go .
RUN golint -set_exit_status ./...
docker:
COPY +build/go-example .
ENTRYPOINT ["/go-example/go-example"]
SAVE IMAGE go-example:latest
Плюсами Earthly я бы выделил:
Все команды выполняются в контейнерах
Использует Buildkit
Минусы же для нас оказались более значительными
-
Форсирование своей инфраструктуры
Используются кастомные версии Buildkit, что является еще одной точкой отказа в долговременной поддержке
-
Возможны Breaking-changes в инструменте
Версия v0.8.14 на момент написания статьи
Собственный инструмент
BrewKit
Решили сделать собственный инструмент: BrewKit
(да-да, написали свой велосипед)
Выделяющими качествами BrewKit являются:
-
Сборка в контейнерах
Команды не выполняются локально
-
Исходники копируются в стадию сборки и результаты стадии явно экспортируются или используются дальше
Неправильное использование утилит не позволит удалить или испортить локальные файлы
Если зависимые файлы для стадии не изменились — стадия будет пропущена
В качестве движка сборки используется BuildKit
Описание сборки в Jsonnet — мощное расширение над классическим JSON
Архитектура
BrewKit обращается к docker-демону с конкретными командами сборки в рамках определенных образов.
Демо работы инструмента
https://asciinema.org/a/q09d6OZyAiGNz1QEFyrPLxPTi
Выводы
С контейнеризацией сборки теперь:
Проще контроль за используемыми инструментами и их версиями
Упрощенное обновление инструментов
-
Улучшение Developer Experience
Разработчик быстрее вливается в разработку проекта
Дополнение к выводам спустя 1 год
С данной темой я выступал на митапе год назад и хочу поделится тем, что изменилось в проекте за это время:
Brewkit → OpenSource
BrewKit выложен в opensource под лицензией MIT. Как и обещалось на митапе.
Теперь BrewKit можно опробовать у себя. В REDME.md лежит пример быстрого старта, а в docs/
лежит больше деталей о его внутренней реализации.
Обновления упростились
Недавно вышел go 1.22 и наше обновление на него было простым и быстрым.
Раньше у нас обновление проекта на новую версию Go, линтера и кодогенераторов занимал часа 4 на проект. С введением контейнеризации сборки — обновление каждого проекта занимает полчаса(в реальности даже меньше).
Подготовка отдельных образов инструментов, вместо одного макрообраза со всеми интрументами — сократило время введения новых инструментов до пары минут, вместо часа и сложной поддержки макрообраза как раньше.
Комментарии (6)
gohrytt
22.07.2024 14:20Ну то есть мало CI на 20 минут на серваке, тащим CI на 20 минут на локалку
VadimMakerov Автор
22.07.2024 14:20В целом, контейнеризация сборки приносит два заметных торможения в сборку:
Запуск контейнера на каждый шаг - дополнительные
+-500m
в зависимости от системыКак будто, процессы местами медленнее работают внутри докер-конейтнеров, которые запускает Buildkit(там он немного лайфхачит с монтированием файловой системы) - примерно на 10% работает медленнее
Это не такие драматичные тормоза, которые сильно затормозят локальную сборку.
Как будто, в случае 20 мин CI - возможно стоит оптимизировать этот процесс или локально не гонять все этапы, требуются в CI/CD, т.к. это не всегда необходимо
Gorthauer87
22.07.2024 14:20+2Идея разбивается о скалы, когда у тебя Мак на arm, а собирать надо под Интел и у тебя есть сишные зависимости.
В то время, как nix shell, все ещё работает в таком случае.
VadimMakerov Автор
22.07.2024 14:20Brewkit без проблем собирает на маке на arm под интел: под капотом у Docker Buidlkit используется qemu, который запустит x86 образы докера с встроеной эмуляцией.
Будет медленно, да.
Но только в случае отсуствия нативного образа под ARM.
Если есть или пересобрать образ под arm, указать его platform-у, докер будет автоматом скачивать образ под нужную платформу и обходится без той же виртуализации. Даже в Dockerfile или brewit.jsonnet ничего указывать не нужно.
Так что Brewkit/Docker Buidlkit прекрасно собирают проекты на разных системах (из ARM под x86, из x86 под ARM) - просто есть свои нюансы и особенности :)
noRoman
«Жаль, что нам так и не удалось послушать начальника транспортного цеха...»
Демо работы инструмента - удален
VadimMakerov Автор
Записал демку будучи незалогиненным на https://asciinema.org/ и через 7 дней она пропала :)
Перезалил на залогиненный акк, так что больше не пропадёт