Пайплайн 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 был обусловлен быстрой компиляцией и, как мне кажется, это окупается.

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


  1. ekini
    17.11.2024 20:19

    А можно было просто использовать

    - uses: actions/setup-go@v5
      with:      go-version: '^1.13.1' # The Go version to download (if necessary) and use.

    чтобы использовать определённую версию Go, а не ту, что установлена в последней версии hosted runner Github. И кэширование там уже включено.