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

Расскажу:

  • Как настроить пайплайны в GitLab для сборки и тестирования сервисов

  • Как настроить удаленный сервер для развертывания сервисов

  • Как автоматизировать деплой приложений в Docker Swarm, используя GitLab

Для понимания материала желательно:

  • Уметь пользоваться Git и GitLab, в т. ч. GitLab Pipelines

  • Иметь базовые навыки использования Linux-утилит

  • Понимать, что такое контейнеризация; знать, что такое Docker

  • Иметь представление о том, как устроена архитектура современных web-приложений

Дисклеймер

Эта схема не идеальна! Она не подойдет для больших highload систем с сотнями сервисов. Однако в моём небольшом проекте она работает хорошо. Надеюсь, подойдёт и вам.

Я не считаю себя профессионалом в DevOps. Критикуйте, предлагайте идеи и улучшения в комментариях к этой статье, а так же в Issues и Pull Requests.

Репозиторий с исходными конфигами: https://github.com/Yu-Leo/deploy-to-docker-swarm

Деплоим?

Что деплоим?

Итак, для начала нужно определиться, что именно мы будем деплоить.

В текущей версии моя система состоит из 4-х компонентов:

  • СУБД PostgreSQL

  • Backend на Go

  • Frontend на React + nginx в качестве веб-сервера

  • Nginx proxy. Проксирует запросы от клиентов, распределяя их между backend и frontend. Обеспечивает поддержку протокола HTTPS

Помимо этих компонентов я планирую добавить и другие сервисы: систему сбора метрик (prometheus), мониторинга (grafana), in-memory хранилища (redis), вспомогательные джобы и т. д. и т. п.

Схема сервисов и их взаимодействия
Схема сервисов и их взаимодействия

Куда деплоим?

Для развертывания системы я решил использовать Docker-контейнеры, а для их оркестрации — Docker Swarm. В этой статье описаны его основные возможности. Меня же в первую очередь привлекла простота его настройки и широкие возможности. То, что нужно для небольшого проекта.

В моём случае всё крутится на одном сервере, но Docker Swarm позволяет использовать несколько.

dev и prod окружения

prod и dev окружения
prod и dev окружения

Всю архитектуру я хочу разворачивать в двух окружениях: dev и prod.

prod:

  • Боевое окружение (production)

  • Единая точка входа для пользователей в виде nginx proxy

  • Рабочие версии сервисов

  • Продовые данные

dev:

  • Тестовое окружение

  • Несколько точек входа для разработчиков (backend, frontend, ...), если это необходимо

  • Не всегда рабочие версии сервисов. Используется для тестов и экспериментов

  • Тестовые данные

В текущей реализации и dev и prod окружения разворачиваются на одной manager-ноде Docker Swarm, но в разных стеках. По мере роста проекта эти окружения могут быть разделены на различные сервера или группы серверов.

Как деплоим?

В качестве сервиса для хранения исходного кода проекта я выбрал GitLab. И, конечно, я хочу использовать его возможности для автоматизации сборки, тестирования и развертывания.

Поехали!

Теперь реализуем систему для деплоя этого проекта.

Схема

Схема деплоя сервисов
Схема деплоя сервисов

Все сервисы запускаются в Docker-контейнерах. А значит, нужны их образы (Docker Images). Публичные образы (например, nginx и postgresql) тянутся с Docker Hub. Приватные (backend, frontend) — с приватного GitLab Registry проекта.

Прежде чем спуллить образы с GitLab Registry их нужно туда запушить. Эта операция происходит в паплайнах репозиториев backend и frontend. В них хранятся исходники сервисов, а так же инструкции по сборке их Docker-образов. При необходимости в проект можно добавлять другие аналогичные репозитории.

Сердце этой системы развертывания — репозиторий deploy-manifests (на схеме слева внизу). Он содержит параметры запуска сервисов и их конфиги. Из его пайплайнов происходит всё взаимодействие с manager-нодой Docker Swarm.

Тестирование и сборка

Главная цель пайплайнов в репозиториях backend и frontend, ... — собрать из исходников Docker-образ сервиса(-ов). Попутно можно запускать тесты, линтеры и т. д.

Пайплайн в backend repository

Структура репозитория

Репозиторий

.
├── backend  # Директория одного сервиса 
├── build  # Директория сборки
│   └── backend  # Исполняемый файл одного сервиса
│   └── build_version.tmp  # Временный файл с тегом
├── common  # Общий для нескольких сервисов код
├── Dockerfile  # Общий Dockerfile
├── .gitignore
├── .gitlab-ci.yml  # Описание GitLab Pipeline
├── .golangci.yml  # Конфиг для линтера
├── go.mod
├── go.sum
└── Makefile  # Файл с инструкциями по сборке и тестированию демонов

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

Сборка

.gitlab-ci.yml

Пайплайн в репозитории backend
Пайплайн в репозитории backend

Пайплайн состоит из двух джоб:

  • build-lint-test

  • docker-images

В build-lint-test, как следует из названия, происходят три действия: сборка исполняемого файла, запуск линтера и запуск юнит-тестов. Если на оном из этапов джоба падает, пайплайн дальше не идёт. Если же все этапы выполнились успешно, в GitLab сохраняется артефакт работы джобы — набор исполняемых файлов для каждого микросервиса (демона).

В джобе docker-images происходит сборка Docker-образов демонов на основе исполняемых файлов, а так же Dockerfile. После сборки полученные образы пушатся в GitLab Registry. Каждый образ получает уникальный тег, состоящий из даты и 8-ми цифр от хэш-суммы коммита (20240725_4605cc6e).

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

Правила запуска джоб

В моей реализации триггером запуска джоб являются лейблы на MR в GitLab.

  • Лейбл tests запускает только джобу build-lint-test

  • Лейбл build-<service-name>, где <service-name> — название микросервиса, запускает обе джобы: build-lint-test и docker-images. Build и push операции будут выполняться только для сервиса <service-name>. Если указать несколько: build-<service-name-1>, build-<service-name-2>, будут собраны и запушены все соответствующие образы.

Джобу build-lint-test можно также запустить вручную, без каких либо лейблов.

Пайплайны в других репозиториях

Аналогичным образом можно настроить пайплайны в других репозиториях.

В качестве примера — мой репозиторий frontend. Его пайплайн состоит из единственной джобы, которая собирает и публикует Docker-образ.

Важно! В настройках каждого подключаемого репозитория необходимо дать доступ до скачивания образов из пайплайнов репозтория deploy-manifests:

Фактически скачивание будет происходить не в самом пайплайне, а на удаленном сервере при развертывании, но для доступа в приватный GitLab Registry используется токен из GitLab Job-ы.

Комментариями с тегами образов

Каждый образ собирается и пушится с уникальным тегом (пример: 20240722_ac34b89g). Далее этот тег понадобится нам для деплоя конкретной версии сервиса.

Чтобы не искать сгенерированный тег вручную, я настроил их автоматическую публикацию в комментарии к MR.

Как это выглядит

Сообщение об успешной сборке образа сервиса ping:

Сообщение о фейле сборки и/или публикации образа:

Как это настроить

В файле .gitlab-ci.yaml

Эта фишка реализована при помощи GitLab API. Для его использования необходимо получить токен доступа.

В GitLab Free недоступны токены для джоб, но вместо них можно использовать токен от персонального аккаунта. Строго говоря, этот способ не безопасен, однако имеет место быть для небольших проектов. При этом комментарии будут публиковаться от имени владельца токена.

Чтобы это повторить, нужно:

  1. Добавить ещё один токен для своего аккаунта

  1. Указать этот токен в ENV-переменной для пайплайна в репозитории

После этого станет доступна публикация комментариев к MR в репозитории.

Сервер

Подготовка

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

Пользователь gitlab

Помимо основного пользователя www, которого мы создавали в вышеупомянутой статье, нужно завести пользователя gitlab. От его имени в джобах GitLab-пайплайна будет происходить подключение к серверу.

  1. Создаем пользователя (на сервере)

useradd -s /bin/bash -m gitlab
  1. Создаем ключи (на локальной машине)

ssh-keygen -t ed25519
mv id_ed25519.pub gitlab.pub
mv id_ed25519 gitab

Пароль от приватного SSH-ключа задавать не нужно.

  1. Редактируем файл /etc/ssh/sshd_config (на сервере)

PasswordAuthentication yes  # Временно разрешаем авторизацию по паролю
AllowUsers www gitlab  # Добавляем пользователя gitlab
  1. Закидываем публичный ключ с локальной машины на сервер

ssh-copy-id -i ~/.ssh/gitlab.pub gitlab@<ip>
  1. Редактируем файл /etc/ssh/sshd_config (на сервере)

PasswordAuthentication no  # Закрываем обратно
  1. Добавляем пользователя gitlab в группу docker (на сервере)

sudo usermod -aG docker gitlab

Docker Swarm

Инициализируем Docker Swarm командой

docker swarm init

Далее появится сообщение

Swarm initialized: current node (bvz81updecsj6wjz393c09vti) is now a manager.

To add a worker to this swarm, run the following command:

    docker swarm join --token SWMTKN-1-3pu6hszjas19xyp7ghgosyx9k8atbfcr8p2is99znpy26u2lkl-1awxwuwd3z9j1z3puu7rcgdbx 172.17.0.2:2377

To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.

в котором Docker сообщает, что текущая нода будет являться manager-нодой, и показывает, каким образом можно добавить worker-ноды в кластер.

Для деплоя необязательно иметь worker-ноды. Можно использовать единственную manager-ноду и разворачиваться на ней.

Структура директорий на сервере

.  # /
├── ...
└── data 
    └── PROJECT-NAME  # Название проекта
        ├── data  # Данные сервисов
        │   └── prod  # Данные prod-сервисов
        │       ├── nginx-proxy
        │       │   └── ssl
        │       │       ├── ca.crt
        │       │       ├── hostname.crt
        │       │       └── hostname.key
        │       └── postgres
        └── deploy  # Данные деплоя
            ├── dev  # Конфиги dev-сервисов
            │   └── backend
            │       └── config.toml
            ├── docker-compose.dev.yaml
            ├── docker-compose.prod.yaml
            ├── get-config-versions.py
            └── prod  # Конфиги prod-сервисов
                ├── nginx
                │   └── default.conf
                ├── nginx-proxy
                │   └── default.conf
                └── backend
                    └── config.toml

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

mkdir -p /data/PROJECT-NAME/data/prod/postgres
mkdir -p /data/PROJECT-NAME/data/prod/nginx-proxy
mkdir -p /data/PROJECT-NAME/deploy

При необходимости структуру можно поменять, не забыв обновить пути в файлах репозитория deploy-manifests.

Docker Secrets

Docker Secrets — механизм Docker для управления приватными данными (секретами). Типичные примеры секретов: пароли, ключи, сертификаты. Секреты монтируются к Docker-контейнерам: содержимое секрета находится в файле /run/secrets/secret-name внутри контейнера. Ниже я опишу их использование подробнее.

Рассмотрим создание секретов на примере двух кейсов:

1. Пароль для пользователя в PostgreSQL

Автоматически сгенерируем 32-х символьный пароль для юзера PostgreSQL и запишем его в Docker Secret с названием postgres-passwd:

tr -dc A-Za-z0-9 </dev/urandom | head -c 32 | docker secret create postgres-passwd

а можно сгенерировать пароль, после чего одновременно вывести его в терминал и записать в Docker Secret:

tr -dc A-Za-z0-9 </dev/urandom | head -c 32 | tee /dev/stderr | docker secret create postgres-passwd

2. Сертификат для nginx

Ситуация: на локальной машине наличествует в наличии файл с SSL-сертивикатом для nginx. Нужно закинуть его в Docker Secret на удалённом сервере, чтобы в дальнейшем использовать в nginx.

Копируем файл на удаленный сервер:

scp /path/to/file/on/local usename@servername:/path/to/file/on/server

Кладем содержимое файла в Docker Secret командой:

cat /path/to/file/on/server | docker secret create nginx-crt -

deploy-manifests

Перейдём к главному компоненту моей системы развертывания — репозиторию deploy-manifests. Из его пайплайнов происходит запуск сервисов в Docker Swarm.

Структура репозитория

.
├── copy-files.sh  # Файл, копирующий содержимое из репозитория на сервер
├── deploy.sh  # Файл с инструкциями по развертыванию системы
├── dev  # Конфиги для dev-окружения
│   └── backend  # Конфиги сервиса backend в dev-окружении
│       └── config.toml
├── docker-compose.dev.yaml  # Описание стека dev-окружения
├── docker-compose.prod.yaml  # Описание стека prod-окружения
├── get-config-versions.py  # Файл, генерирующий версии конфигов
├── .gitignore
├── .gitlab-ci.yml  # Описание GitLab Pipeline
├── prod  # Конфиги для prod-окружения
│   ├── backend  # Конфиги сервиса backend в prod-окружении
│   │   └── config.toml
│   └── nginx-proxy  # Конфиги сервиса nginx-proxy в prod-окружении
│       └── default.conf
└── requirements.txt  # Зависимости для скипта get-config-versions.py

Подключение к серверу

Для подключения к удаленному серверу из джоб пайплайна необходимо указать следующие ENV-переменные в настройках репозитория:

  • SERVER_HOST — ip-адрес manager-ноды Docker Swarm

  • USER_NAME — пользователь на удаленном сервере, от имени которого будет происходить деплой. Как его создать я описывал выше, в разделе "Сервер" -> "Пользователь gitlab"

  • SSH_KEY — приватный ssh-ключ для вышеупомянутого пользователя

Пайплайн

.gitlab-ci.yaml

Пайплайн состоит из двух независимых джоб: dev-deploy и prod-deploy. Как следует из названия, каждая из них запускает деплой соответствующего окружения. Обе джобы имеют одинаковую логику; отличаются только окружениями

Суть каждой джобы сводится к двум действиям:

  1. Скорпировать на удаленный сервер файлы из репозитория. При этом копируются конфиги только соответствующего джобе окружения

  2. Запустить на удалённом сервере скрипт deploy.sh для развертывания стека сервисов. О нем подробнее далее

docker-compose.mode.yml

Это файл, в котором задается конфигурация для деплоя соответствующего mode стека сервисов: dev либо prod. Если вы уже работали с docker-compose, его структура вам знакома, однако для использования в режиме Docker Swarm есть дополнительные настройки.

Описание docker-compose.prod.yml:

На самом верхнем уровне docker-compose.mode.yml состоит из нескольких секций:

Services

Блок, в котором описывается набор сервисов, входящих в стек. В качестве примера рассмотрим базовые настройки сервиса backend:

services:
  # ...
  backend:
    hostname: backend
    image: ${REGISTRY}/PROJECT-NAME/backend/backend:20240727_a5fa34c7
    environment:
      DB_PASS_FILE: /run/secrets/project-prod-postgres-passwd
    depends_on:
      - postgres
  • hostname — имя сервиса во внутренней сети Docker-стека

  • image: ${REGISTRY}/PROJECT-NAME/backend/backend:20240727_a5fa34c7 — путь до образа сервиса. После двоеточия указывается тег.

  • environment — описание переменных окружения

  • depends_on — зависимости между сервисами. backend будет запущен после сервиса postgres

Configs

services:
  # ...
  backend:  
    # ...
    configs:  
      - source: backend-config  
        target: /app/config.toml
	# ...
configs:  
  backend-config:  
    name: ${PROJECT_PROD_BACKEND_CONFIG}
    file: ./prod/backend/config.toml

Docker-конфиги — файлы, хранящие в себе нечувствительную информацию. Не подходят для хранения паролей и сертификатов, но подходят (как и следует из названия) для хранения конфигов.

Docker не умеет автоматически обновлять сервис, если конфиг изменился. Для решения этой проблемы придумано несколько решений; я расскажу о своём.

Каждому конфигу, описанному в глобальной секции configs: docker-compose файла, присваивается имя (name:). Его значение берётся из переменной окружения (${PROJECT_PROD_BACKEND_CONFIG}).

Каждый раз при запуске скрипта deploy.sh (о нём подробнее далее) значения этих переменных инициализируются:

  1. Запускается скрипт get-config-versions.py . Результатом его работы являются строки вида PROJECT_PROD_BACKEND_CONFIG=md5(config-file), где md5(config-file) — часть хэша от содержимого файла с конфигом. Путь до файла берётся из поля file: docker-compose файла.

  2. В скрипте deploy.sh на основе полученных строк инициализируются переменные окружения.

Как это влияет на работу сервисов:

  • Конфиг не изменился => не изменился его хэш => не изменилось имя Docker-конфига => Docker не будет обновлять сервис => сервис продолжит работу со своим конфигом

  • Конфиг изменился => изменился его хэш => изменилось имя Docker-конфига => Docker будет обновлять сервис => после обновления сервис будет использовать новый конфиг

Описание настроек из docker-compose файла:

  • source: backend-config — название Docker-конфига, который нужно подключить к сервису

  • target: /app/config.toml — путь, по которому конфиг будет расположен в контейнере сервиса

  • name: ${PROJECT_PROD_BACKEND_CONFIG} — имя Docker-конфига

  • file: ./prod/backend/config.toml — путь до конфига на manager-ноде

Для работы скрипта get-config-versions.py требуется установить зависимости (на сервере):

pip3 install -r ./requirements.txt

Secrets

services:
  # ...
  backend:  
    # ...
    secrets:  
      - project-prod-postgres-passwd
	# ...
secrets:  
  project-prod-postgres-passwd:  
    external: true

Как я уже говорил, Docker-секреты — это файлы, хранящие в себе чувствительную информацию: пароли, ключи и т. п.

external: true указывает, что Docker-секрет заранее создан в Docker Swarm. Выше я описывал процесс создания Docker-секретов на сервере

Файлы с Docker-секретами будут примонтированы в директорию /run/secrets/ внутри контейнера с сервисом. Описанный выше секрет project-prod-postgres-passwd расположится в файле /run/secrets/project-prod-postgres-passwd.

Официальная документация

Healthcheck

services:
  # ...
  backend:  
    # ...
    healthcheck:  
      test: curl --fail http://127.0.0.1:8080/api/service/ping || exit 1
      interval: 10s
      timeout: 3s  
      retries: 5

Проверяет работоспособность каждой реплики сервиса.

  • test: — команда, которая будет выполняться в контейнере. Если она завершится с кодом 0 — сервис жив и готов к работе, если c кодом 1 — сервис нездоров. В примере: стучимся на ручку ping сервиса; если она не отвечает, сервис не проходит healthcheck

  • interval: — интервал проверки

  • timeout: — таймаут на выполнение хелсчек-команды

  • retries: — сколько раз проверка должна упасть — вернуть ненулевой код ошибки — прежде чем на контейнер будет признан unhealthy

Официальная документация

Deploy

services:
  # ...
  backend:  
    # ...
    deploy:  
      replicas: 1  
      restart_policy:  
        delay: 5s  
        condition: on-failure  
      update_config:  
        parallelism: 1  
        order: start-first  
        failure_action: rollback  
        delay: 10s  
      rollback_config:  
        parallelism: 0  
        order: stop-first  
      resources:  
        limits:  
          cpus: "0.7"  
          memory: "800M"

Секция, в которой описываются параметры, относящиеся к развертыванию сервиса. Разберём подробнее.

Официальная документация

RollingUpdate

services:
  # ...
  backend:  
    # ...
    deploy:  
      update_config:  
      	parallelism: 1  
      	order: start-first  
        failure_action: rollback  
      	delay: 10s  
      rollback_config:  
	      parallelism: 0  
	      order: stop-first

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

Конфигурация, приведённая выше, описывает следующее поведение:

  1. Поднимается одна реплика новой версии, при этом остаются все реплики старой версии.

  2. После того как реплика успешно поднялась и helthcheck-проверка успешно прошла, выключается одна реплика старой версии.

  3. Пункты 1 и 2 повторяются, пока не будут обновлены все реплики

  4. Если в какой-то момент раскатка прервётся, все новые реплики разом остановятся и будут подняты реплики старой версии.

Чтобы проверить и наглядно показать работу этой функции я написал небольшой скрипт. Каждую секунду он посылает запрос на ручку /api/service/version моего сервиса, поле чего выводит ответ.

while true; do echo -n "$(date) "; curl -sS http://SERVER_ADDRES:8080/api/service/version; echo ''; sleep 1; done

Далее я запустил этот скрипт на локальной машине параллельно с запуском выкатки новой версии сервиса. Результат:

Ср 17 июл 2024 21:52:08 MSK {"name":"backend","version":"20240704_63586a62"}
Ср 17 июл 2024 21:52:09 MSK {"name":"backend","version":"20240704_63586a62"}
Ср 17 июл 2024 21:52:10 MSK {"name":"backend","version":"20240717_8a4f53ae"}
Ср 17 июл 2024 21:52:11 MSK {"name":"backend","version":"20240704_63586a62"}
Ср 17 июл 2024 21:52:12 MSK {"name":"backend","version":"20240717_8a4f53ae"}
Ср 17 июл 2024 21:52:13 MSK {"name":"backend","version":"20240717_8a4f53ae"}

Сначала все запросы попадали на поды со старой версией сервиса (20240704_63586a62), затем один запрос попал на новую (20240717_8a4f53ae) и одни на старую, после чего все запросы стали попадать только на новую версию сервиса.

Это объясняется тем, что во время раскатки нового сервиса в некоторый момент времени существовали одновременно поды старой и новой версии. Docker автоматически распределяет запросы между подами, поэтому часть запросов попала на новую версию, часть — на старую. По завершении процесса раскатки подов со старой версией не осталось и все запросы стали приходить только на поды с новой версией.

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

rollback: update rolled back due to failure or early termination of task y4skuy8si13qy1hrzui7inzcn

Этот лог можно обработать и вывести алерт о произошедшем откате.

Официальная документация по update_config

Официальная документация по rollback_config

Resources

services:
  # ...
  backend:  
    # ...
    deploy:
      resources:  
        limits:  
          cpus: "0.7"  
      	  memory: "800M"

В этой секции задаются требования и лимиты по CPU и оперативной памяти. Подробнее — в официальной документации.

Volumes

Docker Volume — механизм, позволяющий расшарить данные (файлы, директории) между контейнерами и хостовой машиной. Преимущество использования Docker Volumes состоит в том, что после остановки или удаления контейнера данные остаются на хостовой ОС. Это можно использовать для хранения файлов БД и подключения их в контейнер с СУБД:

services:  
  postgres:  
    # ... 
    volumes:  
      - /data/PROJECT-NAME/data/prod/postgres:/var/lib/postgresql/data

Директория /data/PROJECT-NAME/data/prod/postgres на хостовой ОС будет соответствовать директории /var/lib/postgresql/data внутри Docker-контейнера. Таким образом все изменения, которые PostgreSQL производит внутри контейнера, будут сохраняться и на хостовой ОС. Если контейнер остановится, данные не потеряются.

Правила запуска пайплайна

Есть 3 триггера для запуска джоб деплоя

  • Лейблы на MR. Лейбл dev-deploy триггерит джобы деплоя dev-окружения, prod-deploy — prod-окружения. Лейблы не зависят друг от друга.

  • Запуск в MR вручную. Любую джобу можно независимо запустить из любого MR

  • Merge в main-ветку. При мердже в main-ветку автоматически запускается джоба prod-deploy. dev-deploy не запускается, поскольку иногда полезно оставить в dev-среде то состояние, которое было развернуто с ещё не смердженного MR

deploy.sh

Описание файла deploy.sh.

Этот скрипт запускается на удаленном сервере и обновляет сервисы соответствующего окружения (--dev или --prod при запуске).

Процесс обновления окружения состоит из нескольких этапов:

  1. Подготовительный этап №1: определение режима запуска скрипта (dev или prod). Переход в рабочую директорию.

  2. Подготовительный этап №2: обновление конфигов. Как я уже говорил, Docker не умеет в автоматичекое обновление конфигов, поэтому эту проблему нужно решить самостоятельно (см. раздел "docker-compose.mode.yml" -> "configs"). На этом этапе запускается скрипт get-config-versions.py и инициализируются сгенерированные им переменные окружения.

  3. Основной этап: обновление деплоймента средствами Docker Swarm. Здесь стоит упомянуть, что Docker Swarm умный: он не будет убивать контейнеры и поднимать новые, если у сервиса не изменился конфиг, тег или параметры в docker-compose.mode.yml

  4. Заключительный этап: очистка неиспользуемых объектов Docker. В том числе удаление лишних конфигов: в скрипте запускается команда на удаление всех конфигов; Docker знает, какие из них ещё используются, и удаляет только неиспользуемые

Итоги

Исходники: https://github.com/Yu-Leo/deploy-to-docker-swarm

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

А что дальше?

Бесконечный простор для творчества. Можно улучшать и добавлять новую функциональность как пайпалайнам сборки, так и пайплайну деплоя.

Вот несколько идей, которые я собираюсь реализовать в своей системе:

  • end-2-end тестирование. Поднятие полноценного тестового окружения в пайплайне репозитория backend, запуск e2e-тестов в пайплайне

  • Добавление системы метрик, графиков и алертов

  • Добавление системы сбора, фильтрации и отображения логов и ошибок

  • Улучшения системы оповещения о сбоях в процессе деплоя

Предлагайте свои идеи улучшений в комментариях, а так же в Issues и Pull Requests в репозитории!

Эта статья в моём блоге: https://yu-leo.github.io/yu0dev/posts/deploy-to-docker-swarm/

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


  1. baldr
    19.08.2024 07:22
    +5

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

    У меня, до недавнего времени, был целый зоопарк скриптов и сервисов (ок 100 штук), которые выполнялись на нескольких серверах в контейнерах и без. В какой-то момент я понял что надо навести в этом порядок и долго выбирал какой-нибудь оркестратор. Поскольку работаю я один, то k8s пришлось отмести из-за сложности с сопровождением. В итоге выбрал Docker Swarm и всё туда перенес за пару месяцев. Нормальной оркестрации и шедулера так и не нашел, пришлось написать самому.

    В вашем проекте вы используете nginx - однако, насколько я знаю, он резолвит доменные имена при старте и, если IP сервиса меняется - то он об этом не узнает. Если вы обновляете frontend - то приходится и nginx тоже рестартовать? Именно из-за этого я выбрал Traefik для динамической маршрутизации. Он сам находит новые сервисы без рестарта. Впрочем, конфиги в labels мне не нравятся, да и вообще nginx я тоже люблю больше, конечно..

    С другой стороны, у вас фронтенд - это чисто статика, насколько я вижу.. Тогда такой проблемы, наверное, нет.

    Вы выставляете порты для базы данных наружу - это очень небезопасно. Тем более что они доступны по всем интерфейсам хоста. Тем более это не нужно в overlay сетях - сервисы прекрасно найдут базу внутри swarm. Вы можете возразить что вам нужно к ней иногда подключаться, но для этого можно придумать промежуточный временный контейнер, который подключится одним концом к базе, а порт прокинет наружу - только на localhost и только в моменты когда вам это нужно. Можно сделать его на socat, и можно просто на ssh tunnel.

    Конфиги и секреты - да, это боль. Нельзя изменить конфиг/секрет если его использует хотя бы один сервис, то есть чтобы поменять SSL-сертификат - надо удалить сервис, удалить конфиг с сертификатом, создать новый конфиг и снова создать сервис. У меня тоже самописное решение - примерно как у вас, создаются временные конфиги, а в labels к ним пишется время обновления..


    1. knightly_flo
      19.08.2024 07:22
      +2

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

      Сейчас к вам тут набегут хейтеры, которые объявят что он умер и туда ему и кубернетес

      а затем:

      Нормальной оркестрации и шедулера так и не нашел, пришлось написать самому.

      Может поэтому сварм и не такой популярный, раз его приходится вот так вот допиливать? Опять же, не знаю почти ничего о сварме, но мне казалось, что главная ценность оркестратора контейнеров — это, собственно, нормальная оркестрация и шедулинг... А что не так с этим у сварма из коробки?


      1. baldr
        19.08.2024 07:22
        +1

        Видите ли - я не уверен что нормальный шедулинг есть и в кубернетесе. По крайней мере я не нашёл того, что я хотел бы видеть. А хотел я видеть что-то вроде дашборда ДАГов в Apache Airflow. Но только не хотел использовать Airflow для запуска, потому что уж больно сложная схема там получается внутри. А, как известно, если программисту не нравится чужая программа, то он пишет свою (фатальный недостаток). Начальство не возражало - ну так надо пользоваться случаем.

        Для докера нет родного UI (как нет и в k8s), но есть API, через который сторонние инструменты могут им управлять. Для Swarm всё, действительно, остановилось в развитии пару лет назад, но сам он достаточно законченный инструмент (хотя, есть баги и они уже чинятся плохо, я сам наткнулся на парочку). Впрочем, есть ощущение, что и сам Docker теряет позиции - приходят containerd и podman, и теснят его с рынка.


        1. olku
          19.08.2024 07:22

          Есть Portainer


          1. baldr
            19.08.2024 07:22

            Да, им пользуюсь, но у него этих фатальных недостатков полно.


    1. Yu-Leo Автор
      19.08.2024 07:22

      Большое спасибо за такой подробный и развернутый комментарий!

      В вашем проекте вы используете nginx - однако, насколько я знаю, он резолвит доменные имена при старте и, если IP сервиса меняется - то он об этом не узнает. Если вы обновляете frontend - то приходится и nginx тоже рестартовать?

      В моём случае всё крутится на одном сервере, пеезжать пока не приходилось. Так что все адреса, можно сказать, постоянные.

      Нет, при обновлении frontend'а nginx-proxy не нужно рестартить. Он так же продолжает перенаправлять соответствующий трафик на фронтовый сервис.

      Вы выставляете порты для базы данных наружу - это очень небезопасно. Тем более что они доступны по всем интерфейсам хоста. Тем более это не нужно в overlay сетях - сервисы прекрасно найдут базу внутри swarm.

      Согласен. Порт был открыт именно из-за необходимости периодически подключаться к базе напрямую. Про то, что сервисы базу внутри swarm найдут и без этого - знаю.

      Вы можете возразить что вам нужно к ней иногда подключаться, но для этого можно придумать промежуточный временный контейнер, который подключится одним концом к базе, а порт прокинет наружу - только на localhost и только в моменты когда вам это нужно. Можно сделать его на socat, и можно просто на ssh tunnel.

      Не знал про такие решения. Приму на вооружение, спасибо!


  1. pvzh
    19.08.2024 07:22
    +1

    Небольшое примечание: в текущей версии спецификации Compose-файла его имя по-умолчанию уже не содержит префикса docker-, там просто compose.yaml: https://docs.docker.com/compose/compose-application-model/


    1. Yu-Leo Автор
      19.08.2024 07:22

      Спасибо за замечание! Работу моих скриптов это не ломает, поскольку везде явно указываются пути до файлов. На будущее буду иметь в виду


  1. Marsezi
    19.08.2024 07:22

    Несколько вопросов

    1) у вас на схеме идёт от одной базы связь к другой из продакшена в test, что это значит?

    2) зачем такая сложная схема с лейблами если образ не был изменён он и не появится соответственно и не обновится. обновиться только те образы которые появились от изменений в коде.

    3) в какой момент происходит обновление продакшена - это ручное нажатие скрипта? Обновиться абсолютно весь production (для того чтобы ваше лейблы были одинаковой версии) или только те сервисы которые изменились?

    4) как происходит замена тестового окружения если нужно откатить изменения в том числе на старую базу данных?


    1. Yu-Leo Автор
      19.08.2024 07:22

      1) у вас на схеме идёт от одной базы связь к другой из продакшена в test, что это значит?

      Там показана связь от сервиса "Backend API" в окружении "dev" до двух баз: в "prod" и в "dev". Строго говоря, это не совсем верно, поскольку окружения должны быть полностью изолированные.

      Во время разработки и тестирования новых версий backend-а иногда было полезно сходить в продовую базу, поэтому связь осталась в конфигах и на схеме

      2) зачем такая сложная схема с лейблами если образ не был изменён он и не появится соответственно и не обновится. обновиться только те образы которые появились от изменений в коде.

      Предполагаю, речь про лейблы в пайплайнах репозиториев backend/frontend.

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

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

      3) в какой момент происходит обновление продакшена - это ручное нажатие скрипта? Обновиться абсолютно весь production (для того чтобы ваше лейблы были одинаковой версии) или только те сервисы которые изменились?

      Есть 3 триггера для запуска джобы деплоя продакшена

      • Лейбл prod-deploy на MR.

      • Ручной запуск джобы в MR

      • Merge в main-ветку. При мердже в main-ветку автоматически запускается джоба prod-deploy.

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

      4) Как происходит замена тестового окружения если нужно откатить изменения в том числе на старую базу данных?

      Аналогично выкатке, но вместо более новой версии указывается более старая. Автоматизации управления содержимым БД в моей реализации нет, тут нужно будет обновлять вручную.


  1. olku
    19.08.2024 07:22

    Что сейчас актуально для скудулинга джобов по расписанию и одноразовых в Swarm?


    1. baldr
      19.08.2024 07:22
      +3

      Хороший вопрос. Я изучал его с полгода назад. На самом деле, оказывается, что не так уж и много вариантов.

      • swarm-cronjob. Запускается отдельный сервис, смотрит на labels у сервисов.

      • Ofelia. Примерно то же самое, но ещё может из конфига брать расписания.

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

      Есть Apache AirFlow, он немного для другого, но у него есть оператор для запуска Docker/Swarm контейнеров. Можно шедулить и UI есть. Однако, это получается немного некрасиво: расписание запуска хардкодится на питоне в коде DAG'а, он запускает celery-таск, которая ничего не делает, но запускает ещё Docker-контейнер и висит ждёт его окончания. 40 активных тасков == 40 celery-тасков, да ещё и рестартовать их нельзя нормально. Ну как-то перебор.

      В принципе, наверное, можно для этого приспособить Jenkins - у него есть и UI, и скриптовать что угодно можно. Но как-то я его не очень люблю.

      Я оценил варианты и написал своё. Оно и запускать может, и Docker Images из исходников строит, и секретами динамически управляет.

      Скриншоты
      Список сервисов, их там под сотню
      Список сервисов, их там под сотню
      Статистика по одному из сервисов
      Статистика по одному из сервисов


      1. olku
        19.08.2024 07:22

        Выглядит заманчиво. Open source?


        1. baldr
          19.08.2024 07:22
          +1

          К сожалению, нет. Но, возможно, удастся когда-нибудь уговорить начальника на то, чтобы выпустить в OpenSource. Однако, для этого нужно потратить ещё некоторое время для того чтобы причесать код, добавить недостающие фичи и, самое главное - написать хоть какую-то документацию..

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