Как стать автором
Обновить
164.39
Beget
Beget — международный облачный провайдер

Как я оптимизировал свой конвейер CI/CD до выполнения за 60 секунд

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров5.9K
Автор оригинала: Nicholas Rees

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

Теги:
Хабы:
Всего голосов 14: ↑14 и ↓0+17
Комментарии1

Публикации

Информация

Сайт
beget.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия