Или CI/CD с помощью Portainer

Это вторая часть из цикла статей, посвященных инфраструктуре для стартапов. Всего их будет две, поделенные, по моему мнению, на логически законченные блоки.

Часть первая. Настройка окружения
Часть вторая. CI/CD и советы

Для кого и для чего описывать не буду, см. ч.1 этой статьи.

Предисловие

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

  • Swarm может “сломаться на двух серверах -- нужен минимум 3 менеджера, иначе можно поймать split-brain

  • Swarm хотя и жив, но сообщество давно ушло в сторону Kubernetes

  • Отсутствие self-healing-кластера. Docker Swarm не имеет полноценного встроенного механизма для автоматического восстановления после сбоев
    > Уже были мысли писать скрипты для автоматического расширения/поднятия дополнительных нод и перезагрузки старых

  • Отказоустойчивость искусственная -- если оба VPS у одного хостера, это не спасёт от падения
    > Больше VPS = больше точек отказа. Не факт, что несколько худых vps могут быть лучше одного хорошего, так у нас периодически падают машинки, особенно неприятно когда с traefik'ом

  • Overlay-сети в сварме на дешевых VPS могут чудить
    > Мы с товарищем до сих пор не смогли найти, кто конкретно стирает часть хедеров у запросов, несколько ночей дебага не помогли. Отложили на потом.

CI/CD через Portainer

После настройки окружения сразу встал вопрос: как автоматизировать деплой, чтобы не лазить каждый раз по SSH и не обновлять сервисы руками.

Самый простой и рабочий способ — использовать Portainer Webhooks + GitHub Actions (или GitLab CI)ля автосборки и доставки.

Идейно наш процесс ci/cd заключается в следующем:

  • Есть репозиторий с шаблонами пайплайнов

  • Для проектов используем моно-репозитории, где внутри сервисы разделены по директориям

  • В репе сервисов лежат пайпланы на каждый сервис

  • Секреты лежат в GH Secrets (теперь в Vault). Как перенести или использовать волт - к другим статьям

  • Пайплайны делят образы сервисов на dev/prod с помощью тега :dev и latest соответственно

  • После билда - дергается ручка portainer и по уникальному хешу сервиса, обновляется образ

1. Структура пайплайнов и организация репозитория

В отдельном репозитории для пайплайнов мы храним общий шаблон:

Целиком можно посмотреть в репозитории
Целиком можно посмотреть в репозитории

В репозитории проекта, мы переиспользуем следующим образом:

name: deploy-server

on: 
  workflow_dispatch:
    inputs:
      environment:
        required: true
        description: Deploy to PROD/DEV
        type: choice
        options: [PROD, DEV]
        default: DEV


jobs:
  deploy:
    uses: .shampsdev/cicd-pipelines/.github/workflows/pipeline.yaml@main
    permissions:
      packages: write
      contents: read
      attestations: write
      id-token: write
    secrets: inherit
    with:
      dockerfile_path: 'server/Dockerfile.server'
      context_path: 'server'
      image_name: 'project-server'
      environment: ${{ github.event.inputs.environment }}
      secret-service-hash: ${{ github.event.inputs.environment == 'PROD' && 'SERVER_SERVICE_HASH' || 'SERVER_SERVICE_HASH_DEV' }}

Можно заметить, для билда используется Dockerfile из директории нужного сервиса в репозитории. Его содержимое вы определите сами.

2. Разделение окружений

Для разделения DEV и PROD мы используем workflow_dispatch с выбором окружения.
Внутри пайплайна окружение влияет на:

  • теги Docker-образов (:dev, :latest);

  • секреты (разные hash'и для secret-service-hash);

В будущем можно автоматизировать запуск от пуша в ветку, но ручной запуск -- хорошая защита от случайных релизов. Мы предпочитаем гнать все по кнопочке.

Каждое окружение в отдельном Stack'e project-dev и project-prod

3. Webhook Portainer и обновление сервиса

В Portainer для каждого сервиса можно включить отдельный webhook — он будет просто перезапускать сервис с обновлённым образом.

Где найти:

  • Зайди в Portainer, в Services

  • Выбери нужный сервис

  • Внизу будет блок Webhooks

  • Нажми "Enable webhook" -- появится уникальный URL

Он выглядит примерно так:

https://portainer.example.com/api/webhooks/abcdef1234567890
Можно заметить, что портейнер не даст знать, обновил он сервис или нет. Остается надеяться :)
Можно заметить, что портейнер не даст знать, обновил он сервис или нет. Остается надеяться :)

Именно abcdef1234567890 необходимо ввести в SERVICE_HASH

4. Деплоим!

После того, как вы подготовили все пайплайны, можно запускать
После того, как вы подготовили все пайплайны, можно запускать

В моем пайплайне следующие особенности:

- Деплой в прод только из ветки main, так мы гарантируем для себя, что в этой ветке только рабочий код

- Деплой в дев из любой ветки. Удобно для гибкой работы в команде

- Для уведомлений используется Shoutrrr, который отправляет сообщения в телеграмм в любом случае.

Все готово, после успешного деплоя вы получите следующее сообщение в телеграмме:

На каждый деплой -- два сообщения: билд успешен, деплой начался
На каждый деплой -- два сообщения: билд успешен, деплой начался

5. Особенности и подводные камни

  • Webhook только перезапускает сервис, он не пересоздаёт его. Если образ не обновился, значит перезапуск будет бесполезным

  • Портейнер возвращает 204, говоря о том, что запустил процесс обновления сервиса. Обновился он или нет -- никто не знает

  • В Stack'e обязательно приходится указывать общий тег, без конкретной версии. Пока не нашел способа обойти это

  • Откаты и rolling update надо настраивать самостоятельно в файле стека.

  • Один webhook = одна команда обновления. Нельзя передавать параметры или обновлять сразу все (сразу Stack можно обновить только в Enterprise портейнере :) )

А что дальше?

Далее мы с командой подняли свой registry, его также можно прямо в portainer'e добавить. Пушим и тянем образы туда.

Как накрутить логи/метрики и все прочие сервисы -- вы сможете разобраться самостоятельно. Этот процесс особо не отличается от настройки в докере в одном docker-compose.

Заключение

Таким образом мы получили удобный способ автоматизировать деплой без лишней головной боли: сервисы обновляются по команде, без SSH и ручного docker service update.

Если статья вам понравилась или заставила задуматься -- обязательно пишите комментарии. Мы открыты к любой конструктивной критике и нам всегда интересно узнать советы коллег с опытом!

Тут ссылка на репозиторий

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


  1. Vaniog
    20.08.2025 09:54

    А как откатывать версию приложения, если задеплоили сломанную?


    1. MrUssy Автор
      20.08.2025 09:54

      На такой случай у них есть кнопка с ролбеком. Проверял. Работает :)