Пайплайн CI/CD (Continuous Integration (CI) и Continuous Delivery (CD)) — один из ключевых инструментов разработчиков, позволяющий обеспечивать качество софта. Идея в том, чтобы вместо внесения большого количества изменений за раз и тестирования всего вместе в конце постоянно тестировать и релизить софт для ускорения нахождения багов.
Как и многие, я храню свой код на GitHub. Пару лет назад я сделал простой пайплайн для сборки, анализа и тестирования моих веб‑приложений и сервисов. Он выполнял свою задачу, и так как это был мой первый опыт по настройке пайплайна CI/CD на GitHub, он сводился к одному шагу.
build (and deploy)
Со временем я стал замечать, что я стараюсь избегать вносить изменения в код. Будучи счастливым обладателем ADHD, я часто замечаю за собой сложность в решении задач с большим количеством препятствий и одним из них стало то, что выполнение пайплайна занимало больше 5 минут. Я коммитил изменения и шел делать кофе, пока пайплайн тестировал и деплоил код. И не всегда возвращался, отвлекаясь на другие вещи.
Для понимания, за эти 5.5 минут выполнялись следующие вещи:
сборка
purgecss
stylelint (css)
html‑validate
yamllint
Сканирование уязвимостей SCA (go vuln)
2 линтера go (staticcheck и golangci‑lint)
упаковка проекта в zip‑архив, включая конфиги nginx для деплоя
выполнение 200 различных юнит и интеграционных тестов
Я решил для себя, что максимальное количество времени, которое я готов ждать — 1 минута.
Вот что я сделал для оптимизации:
разделил задание на несколько параллельных
использование кэширования github
оптимизировал линтеры
настроил задания так, чтобы они лучше согласовывались друг с другом
Несмотря на то, что мое приложение написано на Golang, данный подход должен сработать для любого языка.
Параллельные задачи
Моя первая попытка параллелизации немного вышла из под контроля. Я решил вынести каждый этап в Makefile в отдельное задание. Линт github yaml? В свое задание. Линт CSS для сайта? Ага, в свое задание. И так далее.
В целом это сработало, но сильно раздуло использование минут на GitHub. Некоторые из задач выполнялись всего за 9 секунд, но оплата списывалась как за минуту. Хотелось чего‑то чуть более адекватного, так что я объединил большую часть мелких задач. Так как GitHub предоставляет двухъядерные VM, первой идеей стало объединение задач и параллельный запуск с помощью make -j2
.
Это было сносно, но усложняло дебаг в случае неудачного выполнения, так как логи превращались в кашу. Также было сложно отследить, как долго выполнялась каждая из частей.
В итоге я пришел к 5 задачам, если все 5 выполнялись успешно — запускался пайплайн деплоя на dev‑сервер:
Это был неплохой баланс с точки зрения цена/производительность. Но чтобы прийти к этим 5 задачам, потребовались некоторые телодвижения.
Кеширование GitHub
Самый большой эффект дало включение кэширования. При каждом запуске сборки:
GitHub запускал сборку в docker‑контейнере, загружая все пакеты go. Так как это происходит последовательно, только на загрузку зависимостей уходило больше минуты.
Кеширование зависимостей
К счастью, у GitHub есть хорошо задокументированная фича кэширования зависимостей.
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run make build
run: |
make build
После добавления шага actions/cache@v4
в билд GitHub автоматически кэшировал зависимости на основе файла go.sum
. Пока зависимости не меняются, они автоматически берутся из кэша, а в случае изменения первый запуск просто будет медленнее, пока кэш не будет пересобран.
Кэш работал очень быстро! 419 МБ зависимостей загружались за 6 секунд:
Загвоздка в том, что для go также запускаются линтеры и сканеры уязвимостей и им нужны немного другие зависимости. Так что я немного поигрался с ключами кэша, сделав их немного другими при запуске, например, golangci‑lint
:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-golint-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-golint-
- name: Run make ci-lint
run: |
make ci-lint
Вот так выглядит мой Makefile, который устанавливает зависимости и запускает задачу. Я часто использую Makefile, так как их легко перемещать между машинами и они позволяют легко настроить как окружение для разработки, так и CI‑пайплайн.
(Для разработки я использую и mac, и WSL на Windows)
install-golang-ci: install-golang
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
ci-lint: install-golang-ci
golangci-lint run
Дополнительный плюс в том, что я могу запускать большую часть CI локально, что значительно быстрее, после чего добавить одну строчку в файл yaml, избегая затрат времени на дебаг yaml.
(Я вернусь к этому, но по опыту могу сказать, что локальная разработка раза в 4 быстрее тестирования изменений на раннерах GitHub.)
Кеширование данных
Другим проблемным местом были тесты - им требовалась подготовка в виде загрузки нескольких гигабайт данных с YouTube. Я решил подготавливать базу данных и кэш заранее, создав отдельную задачу, которая запускается в полночь и подготавливает кэш для следующего дня.
Я смогу срезать несколько минут с выполнения тестов, загружая файлы в кэш github посредством cache/save@v4
:
- name: Run make test
run: |
make test
- name: Run make cache-archive
run: |
make cache-archive
- uses: actions/cache/save@v4
with:
path: |
cache.tar.gz
key: ${{ runner.os }}-cache-${{ hashFiles('cache.tar.gz') }}
- uses: actions/cache/save@v4
with:
path: |
bleve.tar.gz
key: ${{ runner.os }}-bleve-${{ hashFiles('bleve.tar.gz') }}
И восстанавливая с помощью cache/restore@v4
:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache/restore@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- uses: actions/cache/restore@v4
with:
path: |
cache.tar.gz
key: ${{ runner.os }}-cache-${{ hashFiles('cache.tar.gz') }}
restore-keys: |
${{ runner.os }}-cache-
- uses: actions/cache/restore@v4
with:
path: |
bleve.tar.gz
key: ${{ runner.os }}-bleve-${{ hashFiles('bleve.tar.gz') }}
restore-keys: |
${{ runner.os }}-bleve-
- name: Run make cache-install
run: |
make cache-install
- name: Run make test
run: |
make test
Теперь сборка и юнит‑тесты занимают меньше минуты.
Линтинг
Также некоторое время ушло на оптимизацию производительности линтеров. Опять же, это было значительно проще благодаря тому, что я использую Makefile и могу быстро вносить правки в задачи локально на своем ноутбуке.
Линтинг разметки
Была создана отдельная задача под линтеры разметки, в которую вошли похожие по смыслу задания:
yamllint
html‑validate
csspurge
stylelint
Технически я мог бы использовать кэширование пакетов NPM для html и css на GitHub. Но… мне не хотелось лезть в пучину под названием NPM. Да, кто‑то скажет «но ведь это просто». И да, скорее всего и правда просто. Но эти 4 задания выполняются где‑то за 20 секунд, что меня вполне устраивает.
Не все стоит максимальной оптимизации. Если текущий результат уже устраивает, иногда стоит просто остановиться. Хотя одно изменение в установку пакетов я все же внес: добавил несколько флагов производительности, а также одновременную установку, что позволило срезать 20 секунд по сравнению с их установкой по очереди:
install-weblint: install-npm
npm --no-audit --progress=false i -g html-validate@latest purgecss@latest stylelint@latest stylelint-config-standard@latest
Линтинг Golang
В случае с golang я уже давно использую golangci-lint
, но никогда особо не задумывался, что именно он делает, так что заодно использовал staticcheck
— я заметил, что при запуске staticcheck отдельно значительно более строгие правила по сравнению с включенными в golangci-lint
. Так что первые впечатления от golangci-lint
были смешанные.
Но у golangci-lint
есть одно большое преимущество: его можно заставить запускать почти все, что нужно для ускорения процесса.
Так что я убрал отдельные линтеры и создал следующий .golangci.yml
:
---
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- bodyclose
- exhaustive
- gocheckcompilerdirectives
- godox
- gofmt
- goimports
- gosec
- whitespace
- usestdlibvars
linters-settings:
staticcheck:
checks: ["all"]
Результат меня вполне устраивает. По итогу запускаются все нужные мне линтеры (гораздо больший список по сравнению со стандартными настройками golangci-lint
), а также все они кэшируемы и выполняются меньше, чем за минуту после создания кэша.
Небольшая ремарка: я абсолютно не понимаю, почему golangci-lint
не запускает gosec по умолчанию.
markup-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run make markup-lint
run: |
make -j2 markup-lint
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-golint-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-golint-
- name: Run make ci-lint
run: |
make ci-lint
Еще раз напомню: одна из причин, почему я очень люблю Makefile — мой M2 Macbook гораздо быстрее раннеров GitHub.
Выполнение golangci-lint
на ноутбуке занимает чуть меньше 13 секунд.
time make ci-lint
scripts/checkGoVersion.sh
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run
make ci-lint 2.11s user 4.72s system 53% cpu 12.738 total
Запуск на GitHub в 4 раза медленнее (51 секунда):
Если, прочитав это, вы все еще помещаете всю логику пайплайна в файл yaml github, вам придется ждать в 4 раза дольше в ожидании завершения пайплайна после каждого внесенного изменения.
Я крайне рекомендую превращать каждое задание GitHub yaml в одну локально протестированную команду.
Tweak The Jobs
Посмотрев на время выполнения сканирования и markup‑lint
, я понял, что могу обратно объединить их в задание сборки.
Создаем отдельное задание в Makefile для упаковки и архивирования моей сборки, назваем это «пакетом», добавляем markup‑lint
и vuln
в качестве требований, после чего вызываем с помощью make ‑j2
, чтобы использовать оба ядра раннера…
package: install-golang arm64 package-nginx vuln tidy markup-lint
scripts/build_and_package.sh
И получаем простой GitHub action, состоящий из сборки/линта/тестирования, каждый из которых не занимает дольше минуты (пусть и за счет некоторого усложнения в виде запуска части линтинга в задаче сборки).
Примечание: Я решил назвать задание на GitHub build, при этом таргет в Makefile называется «package». Это исключительно из личных эстетических предпочтений. Вы вольны следовать своим предпочтениям в вашем пайплайне.
Заключение
Теперь у меня есть пайплайн CI с задачами для сборки, тестирования и линтинга.
Каждая из них стоит мне одну расчетную минуту.
Деплой также стоит мне одну минуту, несмотря на то, что выполняется всего 2 секунды, плюс за кулисами в другом репозитории происходит задача деплоя, которая, соответственно, деплоит проект:
deploy:
runs-on: ubuntu-latest
needs: [
lint,
test,
build,
]
if: github.event_name == 'push' && github.ref_name == 'main'
steps:
- name: deploy
run: |
export GH_TOKEN=${{secrets.GH_DEPLOY_TOKEN}}
gh workflow run github-actions-deploy.yml -f \
env=DEV -f version=${{github.sha}} \
--repo myrepo/deploy
Так что в итоге каждая сборка и деплой стоят мне 5 расчетных минут.
При наличии 2000 расчетных минут в месяц в бесплатном плане GitHub, этого достаточно для 400 деплоев в месяц или 13.3 в день.
Однако, из‑за кэширующей задачи, которая упаковывает базу данных и файлы кэша по ночам, съедается по 4 минуты в день, или 120 минут в месяц.
Что вносит свои коррективы и вместо этого дает (2000 — 120) / 5 = 360 деплоев в месяц — по 12 в день.
Более чем достаточно для одиночной разработки.
Так или иначе, это было весело. Локальная оптимизация заданий, а также использование кэширования GitHub может помочь ускорить большую часть пайплайнов CI/CD, приближая их по времени к локальной разработке.
В итоге мне удалось впихнуть в минуту времени следующее:
сборку
purgecss
stylelint (css)
html‑validate
yamllint
сканирование уязвимостей SCA (go vuln)
сканирование уязвимостей SAST (gosec) НОВОЕ
14 других линтеров golang 7 НОВЫХ
упаковка проекта в zip‑архив, включая конфиги nginx для деплоя
выполнение 200 различных юнит и интеграционных тестов
К сожалению, если ваше приложение долго компилируется (привет Java‑приложениям из 2005), вам скорее всего не удастся значительно ускорить процесс. Выбор golang был обусловлен быстрой компиляцией и, как мне кажется, это окупается.
ekini
А можно было просто использовать
чтобы использовать определённую версию Go, а не ту, что установлена в последней версии hosted runner Github. И кэширование там уже включено.