CI/CD с использованием GitHub Actions для Rails и Docker

Я слишком долго делал то, что технически мне было не нужно, поэтому теперь я собираюсь потратить больше времени на написание статьи об этом 😉. Когда-то давно я проводил тесты на CodeShip. Это было не очень. Он не был предназначен для пареллизации (в то время), он был медленным, как черт, я получал временные сбои, когда Elasticsearch выходил из себя, и результаты просто выплескивались в один огромный текстовый блок. Я перешел на CircleCI.

Переход с Codeship на CircleCI был относительно простым. Интерфейс CircleCI был более отзывчивым, он лучше разбирал результаты тестов RSpec, а распараллеливание было легко реализовать. Но мне все равно удалось найти вещи, которые мне не понравились: У параллельных бегунов было однобокое время, которое отказывалось обновляться, и, что хуже всего, не было способа уведомить его о том, что все параллельные работники либо преуспели, либо потерпели неудачу. Я мог получить только уведомление о прохождении или провале для каждого рабочего, что означало, что каждый push на GitHub приводил к 10 уведомлениям в Slack 😱. Я потратил несколько часов, пытаясь это исправить, но нашел только множество сообщений о том, что это невозможно. Я решил сменить сервис, когда найду время.

Когда был анонсирован GitHub Actions, он показался мне более привлекательным вариантом, поскольку я мог иметь и git-репо, и CI/CD инфру в одном месте. К тому же он был бесплатным. Но стоил ли этот переход того? Смогу ли я распараллелить GitHub Actions, насколько быстро это будет, сколько времени займет настройка, смогу ли я наконец перестать получать по 1 уведомлению из Slack на каждого параллельного работника? Так много вопросов и только один способ выяснить это.

Старая установка

Моя предыдущая настройка включала в себя много сборки образов и много ожидания.

  1. [Вручную] Создать образ локально, достичь некоторого рабочего состояния.
  2. [Необязательно] Отправка образа в реестр контейнеров Google (GCR), если произошли изменения в ОС или пакетах apk/apt
  3. [Manual] Push to GitHub
  4. [Автоматически] CircleCI извлекает последний образ из GCR, извлекает ветку или последний мастер-коммит из GitHub, повторно запускает bundle и yarn, предварительно компилирует активы, затем запускает RSpec.
  5. [Вручную] Если все 10 рабочих сообщили о прохождении тестов (глаз дергается), то я запускаю локальное развертывание, выполнив git push hostname:master, где hostname — git remote моего производственного сервера, на котором настроен Dokku. Это позволит создать образ с нуля, а затем развернуть этот образ.

Если это звучит расточительно, то так оно и есть. В CircleCI была польза в извлечении из GCR и повторном выполнении шагов — мне нужно было вводить/выводить только тогда, когда происходили изменения под слоем пакета, а это были только ОС и зависимости библиотек APT/APK. Тем не менее, необходимость помнить, что для того, чтобы тесты CircleCI были действительны, нужно было проталкивать их в GCR, что несколько раз ставило меня в тупик.

Мечта

Я предварительно настроил GitHub Action для сборки образа и больше ничего с ним не делал. Эта простая проверка на вменяемость на самом деле очень полезна. В конце концов, можно отправить файл Docker, который не может собраться, и это помогло мне устранить проблему, когда сборка работала на моем Mac, на Windows с Ubuntu WSL, но не на Ubuntu (спасибо Docker), что требовалось для развертывания с помощью Dokku. Заставить это действие сборки работать было невероятно просто, но теперь было время посмотреть, смогу ли я достичь святой земли: один сервис репозитория и CI/CD, чтобы управлять ими всеми.

Не буду врать, запустить тесты на GH Actions было непросто. Потребовалось много догадок, проверок и ожиданий, поэтому я написал статью, чтобы избавить вас от моих мучений! Пожалуйста. «Build once use everywhere» — это мечта, но поскольку могут быть различия в зависимости от того, где мы собираем (кашель, спасибо еще раз Docker), я собираюсь изменить «мечту» на build once use everywhere, когда мы выкладываем на GitHub.

Новая установка

Новый рабочий процесс должен выглядеть следующим образом:

  1. [Manual] Собираем образ локально, достигаем некоторого рабочего состояния.
  2. [Manual] Push to GitHub
  3. [Автоматически] Запускается рабочий процесс GitHub Actions. Он создает образ, используя кэш докерного слоя GHA, и запускает rspec на этом образе с помощью docker-compose.
  4. [Автоматически] Если все прошло успешно, отправьте уже созданный образ на ghcr.io.
  5. [Автоматически] Условное развертывание в продакшн, дав команду Dokku извлечь образ с ghcr.io.

Это гораздо более автоматизированный способ, и в зависимости от того, насколько нагреты различные кэши, он может быть быстрее. В этой статье я не буду рассказывать о своей попытке распараллелить RSpec в GitHub Actions. Важной частью является то, что после отправки на GH мы можем автоматически перейти на продакшн, если все прошло хорошо.

Проблем, с которыми пришлось столкнуться, было много

1 — Отсутствие обучающих ресурсов для данного конкретного случая использования.

Существует множество ресурсов по GHA, но я не смог найти ни одного, который бы собирал образ, а затем запускал тесты на выходе сборки. Похоже, что все они довольствуются тестами на ubuntu-latest, затем собирают свой собственный Dockerfile и считают, что различия несущественны. Мое приложение требует многокомпонентной сборки Dockerfile для прохождения тестов.
Решение: Статья, которую вы сейчас читаете!

2 — Отсутствие возможности настройки с помощью оператора container.

С помощью оператора container можно запустить шаги на выбранном вами образе Docker, но удачи вам в запуске на динамическом теге, таком как pr-123. Вывод тегов, который обычно доступен с помощью ${{ steps.meta.outputs.tags }}, недоступен в операторе container. Также нет доступа к ${{ github.ref }} или даже к env. Это означает, что я не могу использовать оператор контейнера 🤷♂️.
Решение: Вместо того чтобы загружать образ в реестр контейнеров только для того, чтобы вытащить его и запустить на нем тесты, лучше загружать в реестр контейнеров только правильные образы и устранить узкое место в сети. Это означает использование load:true на этапе сборки, а затем просто использование docker-compose для запуска на локально доступном образе.

3 — Предоставление окружения docker без дублирования

Предоставление окружения для слоя ubuntu-latest было простым, благодаря использованию env в yaml-файле рабочего процесса и ${{ secrets.EXAMPLE }}, но предоставление env команде docker run без дублирования оказалось сложной задачей. У нее есть очень простое решение, но оно сопровождается неприятностью (см. 4).
Решение: Просто сбросьте env в .env файл, который ожидает docker compose. Например:

-
  name: Run tests
  run: |
    env > .env
    docker-compose run web rails db:setup 
    docker-compose run web bundle exec rspec
Войти в полноэкранный режим Выйти из полноэкранного режима

4 — Команды просто не работали. Например, любая из них.

bundle, rails, rake, все они говорили /usr/bin/env: 'ruby': No such file or directory.
Решение: Эта задача была сложной, и казалось, что GHA хочет меня подловить, но на самом деле я сделал это сам с помощью шага 3. Переменная $PATH в базовом окружении ubuntu-latest не подходит для вашего окружения Dockerfile, и она была просто переопределена. Проверьте ваш $PATH в локальном окружении с помощью чего-то вроде docker-compose run web echo $PATH и убедитесь, что эти пути есть в вашем GH workflow yaml под $PATH.

5 — Elasticsearch долго загружается

Неполная инициализация Elasticsearch перед запуском шага посева базы данных вызывала эту ошибку:

Faraday::Error::ConnectionFailed: Couldn't connect to server
Войдите в полноэкранный режим Выход из полноэкранного режима

Это никогда не было проблемой локально, но локально я обычно запускал docker-compose exec web rspec после запуска docker-compose up, что давало Elasticsearch достаточно времени для загрузки.
Решение: Наконец-то пришло время настроить правильную проверку работоспособности в docker-compose.yml. Вот мой сервис elasticsearch:

elasticsearch:
  container_name: elasticsearch
  image: docker.elastic.co/elasticsearch/elasticsearch:7.4.2
  environment:
    - discovery.type=single-node
    - cluster.name=docker-cluster
    - bootstrap.memory_lock=true
    - "ES_JAVA_OPTS=-Xms1024m -Xmx1024m"
    - "logger.org.elasticsearch=error"
  healthcheck:
    test: curl --fail elasticsearch:9200/_cat/health >/dev/null || exit 1
    interval: 30s
    timeout: 10s
    retries: 5
  ulimits:
    memlock:
      soft: -1
      hard: -1
  volumes:
    - esdata:/usr/share/elasticsearch/data
  ports:
    - 9200:9200
Войти в полноэкранный режим Выход из полноэкранного режима

Вам может потребоваться изменить тест healthcheck в зависимости от вашей версии ES. Для этого потребуется соответствующий оператор depends_on в веб-контейнере:

depends_on:
  elasticsearch:
    condition: service_healthy
  postgres:
    condition: service_started
  redis:
    condition: service_started
Вход в полноэкранный режим Выход из полноэкранного режима

Мне кажется странным, что вы должны явно указывать это после определения утверждения healthcheck. Возможно, docker compose 4 будет делать это автоматически. Я столкнулся с той же проблемой с Redis, и решение этой проблемы находится в docker-ci.yml ниже.

6 — Сбои в тестировании из-за очевидной проблемы с компиляцией активов

На этом этапе у меня были запущены тесты, и некоторые из них прошли, что было огромным облегчением. Но все тесты запросов завершались следующим образом:

ActionView::Template::Error:
Webpacker can't find application.js in /app/public/packs-test/manifest.json. Possible causes:
1. You want to set webpacker.yml value of compile to true for your environment
  unless you are using the `webpack -w` or the webpack-dev-server.
2. webpack has not yet re-run to reflect updates.
3. You have misconfigured Webpacker's config/webpacker.yml file.
4. Your webpack configuration is not creating a manifest.
Your manifest contains:
{
}
Войти в полноэкранный режим Выход из полноэкранного режима

Это удивительно, поскольку мы только что запекли активы в изображение на этапе сборки.

Решение: Совет Даниэле Барон, здесь есть настоящий спасительный инструмент под названием tmate.

Вставьте его, и вы, по сути, получите отладочный отчет в рабочем процессе GH:

- # Creates a SSH tunnel!
  name: Setup tmate session
  uses: mxschmitt/action-tmate@v3
Войти в полноэкранный режим Выйти из полноэкранного режима

Просто дождитесь этого шага и скопируйте заявление ssh в свой терминал.

Это была чертовски сложная проблема для устранения неполадок. Я всегда запекал производственные активы в изображение. У меня никогда не было ошибки отсутствия активов, если только я не нарушал конфигурацию webpack. Эта настройка всегда работала во всех окружениях, которые когда-либо полагались на нее — Codeship, CircleCI и 2 совершенно разных производственных окружения.

Каким-то образом, запуск этого показывает заполненный манифест:

docker run web cat public/packs/manifest.json
Войти в полноэкранный режим Выйти из полноэкранного режима

но это говорит об отсутствии такого файла:

docker-compose run web cat public/packs/manifest.json
Enter fullscreen mode Выйти из полноэкранного режима

… что? 🤔. Как могли активы явно присутствовать в изображении, а затем исчезнуть при использовании docker-compose?

Как и во всем, что касается компьютеров, это был еще один случай, когда мы сами себя подставили под удар. Docker Compose монтирует тома, которые вы ему укажете с помощью такого заявления:

volumes:
  - ".:/app"
Войти в полноэкранный режим Выйти из полноэкранного режима

Это берет текущий каталог (.) и монтирует его в каталог /app внутри образа. Это приводит к тому, что папка без скомпилированных активов записывается в папку с уже скомпилированными активами. Это не было заметно на Codeship или CircleCI, потому что у них был явный шаг предварительной компиляции активов. Я придумал 3 способа решения этой проблемы:

  1. Использовать addnab/docker-run-action и вообще отказаться от Docker Compose 😅.
  2. Просто перекомпилировать активы, несмотря на то, что они только что были скомпилированы. Легко, но расточительно.
  3. Написать docker-compose.yml специально для CI, который не монтирует локальные файлы.

Я попробовал 1, у меня возникли трудности с передачей env и проблемы с подключением к services. Затем я попробовал 2, и это сработало, но было очень медленно. 3 кажется правильным подходом, и он сократил время выполнения с 31 м до 12 м!

Без лишних слов

Вот yamls, который вы ждали.

docker-ci.yml

version: "3.7"
services:
  postgres:
    image: "postgres:14-alpine"
    environment:
      POSTGRES_USER: "example"
      POSTGRES_PASSWORD: "example"
    ports:
      - "5432:5432"
    volumes:
      - "postgres:/var/lib/postgresql/data"
  redis:
    image: "redis:5-alpine"
    command: ["redis-server", "--requirepass", "yourpassword", "--appendonly", "yes"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "6379:6379"
    volumes:
      - redis:/data
    sysctls:
      # https://github.com/docker-library/redis/issues/35
      net.core.somaxconn: "511"
  sidekiq:
    depends_on:
      - "postgres"
      - "redis"
      - "elasticsearch"
    build:
      context: .
      args:
        environment: development
    image: you/yourapp
    command: bundle exec sidekiq -C config/sidekiq.yml.erb
    volumes:
      - ".:/app"
      # don"t mount tmp directory
      - /app/tmp
    env_file:
      - ".env"
  web:
    build:
      context: .
      args:
        environment: development
    image: you/yourapp
    command: bundle exec rspec
    depends_on:
      elasticsearch:
        condition: service_healthy
      postgres:
        condition: service_started
      redis:
        condition: service_healthy
    tty: true
    stdin_open: true
    ports:
      - "3000:3000"
    env_file:
      - ".env"
  elasticsearch:
    container_name: elasticsearch
    image: docker.elastic.co/elasticsearch/elasticsearch:7.4.2
    environment:
      - discovery.type=single-node
      - cluster.name=docker-cluster
      - bootstrap.memory_lock=true
      - "ES_JAVA_OPTS=-Xms1024m -Xmx1024m"
      - "logger.org.elasticsearch=error"
    healthcheck:
      test: curl --fail elasticsearch:9200/_cat/health >/dev/null || exit 1
      interval: 30s
      timeout: 10s
      retries: 5
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - esdata:/usr/share/elasticsearch/data
    ports:
      - 9200:9200
volumes:
  redis:
  postgres:
  esdata:
Вход в полноэкранный режим Выход из полноэкранного режима

main.yml

name: Main

on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]

jobs:
  build:
    name: Build, Test, Push
    runs-on: ubuntu-20.04
    env:
      REGISTRY: ghcr.io
      IMAGE_NAME: ${{ github.repository }}
      POSTGRES_USER: example
      POSTGRES_PASSWORD: example
      POSTGRES_HOST: postgres
      # Humour me here, this needs to be production for the sake of baking assets into the image
      RAILS_ENV: production
      NODE_ENVIRONMENT: production
      ACTION_MAILER_HOST: localhost:3000
      REDIS_URL: redis://redis:yourpassword@redis:6379
      DATABASE_URL: postgresql://example:example@postgres:5432/dbname?encoding=utf8&pool=5&timeout=5000
      ELASTICSEARCH_URL: elasticsearch:9200
      # Actually secret secrets
      EXAMPLE_KEY: ${{ secrets.EXAMPLE_KEY}}
      # Append our own PATHs because env > .env nukes it!
      PATH: /usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/local/bundle/bin:/usr/local/bundle/gems/bin:/usr/lib/fullstaq-ruby/versions/3.0.4-jemalloc/bin:/app/bin
    steps:
      -
        name: Checkout repo
        uses: actions/checkout@v3
        with:
          fetch-depth: 1

      - # Not required but recommend to be able to build multi-platform images, export cache, etc.
        name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      -
        name: Log in to the Container registry
        uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      -
        name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v3
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: ${{ steps.extract_branch.outputs.branch }}

      -
        name: Build with Docker
        uses: docker/build-push-action@v3
        with:
          context: .
          load: true
          cache-from: type=gha
          cache-to: type=gha,mode=max
          tags: |
            ${{ steps.meta.outputs.tags }}
            ${{ github.repository }}:latest

      # - # Creates a SSH tunnel!
      #   name: Setup tmate session
      #   uses: mxschmitt/action-tmate@v3

      -
        name: Run tests
        run: |
          env > .env
          ./run-tests.sh

      - # NOTE Building in this step will use cache
        name: Build and push Docker image
        uses: docker/build-push-action@v3
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
Войти в полноэкранный режим Выход из полноэкранного режима

run-tests.sh

# Uses docker-compose to run tests on GitHub Actions

# Exit if a step fails
set -e

echo "============== DB SETUP"
docker-compose -f docker-ci.yml run web rails db:reset RAILS_ENV=test

echo "============== RSPEC"
docker-compose -f docker-ci.yml up --abort-on-container-exit
Вход в полноэкранный режим Выход из полноэкранного режима

Выводы (на данный момент)

Как вы можете видеть, GitHub Actions более свободен, чем большинство сервисов CI. Они в основном говорят: «Вот Ubuntu, развлекайтесь». Запуск созданного вами образа возможен, но они, конечно, не держат вас за руку. Если вы знаете GitHub Action, который действительно держит вас за руку в отношении запуска тестов на только что созданном образе, напишите об этом в комментариях!

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

Удалось ли мне распараллелить GitHub Actions?

Нет. Просто запуск на собранном образе был настоящим испытанием. Если вы знаете, как распараллелить GHA с помощью docker-compose, дайте мне знать в комментариях!

Сколько времени ушло на настройку этого GHA?

Слишком долго. Стыдно долго. Но я сэкономил вам время, верно?

… верно?

Насколько это быстро?

Это сравнение даже близко не справедливо. Локально и с помощью CircleCI, я использую уже созданный образ, а CircleCI имеет 10 параллельных рабочих.

CircleCI (теплый): 4m20s 🪴
Локально (тепло): 10м44с
GHA (теплый): 12м42с

Я действительно впечатлен временем, которое получает GHA, учитывая, что она собирает образ, собирает пакет (с теплым кэшем), прекомпилирует активы и отправляет образ на ghcr.io.

Стоил ли переход?

На данный момент — нет. Времени, потраченного на настройку, потребуется очень много, чтобы окупиться. 12 м против 4 м — это не улучшение, хотя это большое преимущество — не думать о том, когда я должен отправить свежий образ в наш реестр контейнеров, и это будет огромным преимуществом — возможность автоматического развертывания (без необходимости перестройки).

Смог ли я наконец-то перестать получать по 1 уведомлению Slack на каждого параллельного работника?

Да! Но я также вернулся к последовательному рабочему процессу с одним рабочим. Я мог бы легко указать одного работника в CircleCI и получить тот же результат.

Следите за deploy.yml для части Dokku, когда я доберусь до его написания!

Последние замечания!

  • Возможно, вы не захотите запускать шаги развертывания при каждой отправке на master! Возможно, вы захотите выполнять их только при отправке нового тега.
  • Если вы копируете и вставляете, вам нужно проверить ваш PATH и записать его в main.yml. Тот, который я предоставил, скорее всего, не будет работать для вас.
  • Обратите внимание на отсутствие тома для веб-контейнера в docker-ci.yml. Это сделано специально, потому что мы не хотим перезаписывать активы, которые мы только что предварительно скомпилировали. Отсутствие предварительной компиляции активов во второй раз экономит много времени. Это также означает, что вы не сможете получить доступ к файлам, написанным во время тестирования (например, к файлу results.json), а затем получить к ним доступ на более позднем этапе.

Оцените статью
devanswers.ru
Добавить комментарий