Введение
Введение
Проблемы, которые перед нами встают, когда мы работаем с классической развёрткой приложения:
- загрузка на FTP требует дополнительного времени и сил для обновления (сейчас могут связать подгрузку из гита к серверу)
- конфликтуют версии зависимостей между тем, что использует приложение и тем, что располагается на сервере
- проблемы работы на разных устройствах (на linux одной версии запускается, а на другой уже нет)
- если мы запускаем несколько приложений на одном устройстве, то мы не можем никак гарантировать того, что одно приложение не будет мешать работе другого - отсутствие изоляции
- из вышеописанного пункта идёт ограничение масштабирования

Преимущества Docker:
- Позволяет полностью изолировать приложение в виртуальной машине - изолировать сеть, файловое пространство
- очень просто и быстро можно откатить приложение, если на нём произошла ошибка - мы можем просто откатить image до стабильной версии и приложение будет опять доступно пользователям
- очень легко можно масштабировать приложение на большое количество кластеров, которые не будет конфликтовать портами
- очень легко доставлять приложение на продакшен благодаря тому, что мы просто собираем приложение, пакуем его в образ, выгружаем на сервер и запускаем (нам не нужно развёртывать приложение на удалённом сервере и переустанавливать зависимости)
- удобная работа с сетью
- отсутствие конфликтов между портами
- объединение работы разных машин, которые находятся в разных местах, создавая кластер
- обращение к приложению не по API, а через Service Discovery, который обращает по его имени, где работает внутри DNS

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

Ansible же позволяет нам автоматизировать все рутинные задачи, которые приходится выполнять при использовании Docker и поднятии их image

Обзор проекта
Из браузера будет приходить запрос, NGINX будет перенаправлять запросы, API написано на NestJS, APP на React, обмен сообщениями по сервисами реализован через RMQ

Настройка VM на Linux
Всю актуальную информацию по установке докера всегда можно найти в официальной документации а так же шагах после установки
Установка Docker
Устанавливаем сертификаты
sudo apt-get update
sudo apt-get install ca-certificates curl gnupgДалее устанавливаем ключ докера
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpgУстанавливаем репозиторий
echo \
"deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/nullЕщё раз обновляем утилиту установки пакетов
sudo apt-get updateУстанавливаем пакеты докера
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginПроверяем работу докера
sudo docker run hello-worldДобавляем группу докера
sudo groupadd dockerТут мы добавляем себя в группу докера
sudo usermod -aG docker $USERУстановка виртуалки
Для примера удалённой машины, будет использоваться
- Виртуализация - VirtualBox
- ОС - Ubuntu Server
Под Fedora перед запуском нужно было выполнить следующие команды:
sudo dnf install -y "kernel-devel-$(uname -r)"
sudo /sbin/vboxconfig
sudo rmmod kvm-intelПосле запуска виртуалки с образом, у нас идёт самая стандартная установка, во время которой нам нужно будет подтвердить выбор установки OpenSSH и docker.
Далее в VB нужно прокинуть порты с нашей виртуальной машины на хост: Образ → Настройки → Сеть → Дополнительно → Проброс портов → указываем нужные порты

Далее нам не нужно входить на наш сервер каждый раз по логину/паролю. Достаточно указать данную команду для входа:
ssh <user>@127.0.0.1 -p 2222Далее на хостовой машине получаем публичный ключ
cat ~/.ssh/id_ed25519.pubИ добавляем ключ в виртуалку в файл authorized_keys. Этот файл хранит ключи устройств, которые могут к нему подключаться по ssh.
mkdir ~/.ssh
cd ~/.ssh
nano authorized_keysТеперь нам не нужно будет каждый раз вводить лог/пас для входа.
Базовые понятия Docker
002 Архитектура Docker
Докер и виртуальная машина - это две разные вещи. Докер не использует супервизоры и не поднимает полноценную гостевую ОС, чтобы поднять внутри неё контейнезированное приложение.

htop позволяет просмотреть процессы в linux и управлять ими

При запуске процесса внутри docker, мы запускаем новый namespace, который в себя включается
- Cgroups - определяет ограничения по памяти и ресурсам процессора
- IPC - определяет коммуникацию между процессами
- Network
- Mount - определяет доступность директорий
- PID - свои Process ID
- User
- UTS
Контейнер докера - это изолированный namespace с обвязками докера, который запускается на хостовой машине

Docker использует ядро Linux хоста и image содержит только необходимые бинарные файлы, библиотеки и приложения
Докер делится на две части: клиентская часть CLI и хостовая, которая принимает запросы от CLI и выполняет нужные команды

На клиенте мы отправляем команды в докер по API, который отправляет запрос в докер daemon. Daemon проверяет, есть ли нужный образ локально. Если образ отсутствует, то он обращается в общий registry и подтягивает образ оттуда и потом запускает контейнер

003 Управление контейнерами
Для упрощения работы с контейнерами их команды были вынесены наверх
вместо
docker container start
docker container stop
docker container statsмы можем писать
docker start
docker stop
docker stats
У нас есть достаточное количество команд для контроля ЖЦ контейнера:
docker run- создаст контейнер и запустит егоdocker create- создаст контейнер в остановленном видеdocker kill / stop- останавливает работу контейнераdocker rm- удалит контейнер (--forceубьёт и удалит запущенный контейнер)

Первая команда показывает запущенные контейнеры. Ключ -a покажет все контейнеры, включая остановленные
docker ps
docker ps -a
Удаление контейнера
docker rm имя/idСоздаём контейнер из образа mongo, который будет с указанными именем my-mongo. Флаг -d позволяет отцепить процесс от текущего bash и запустить его в отдельной сессии
docker run --name my-mongo -d mongoКогда мы пользуемся определённой сменой состояния процесса, мы посылаем сигнал

Удаляет все остановленные контейнеры
docker container pruneПереименование контейнера
docker rename имя_контейнера новое_имя004 Логи и статистика работы
Показывает всю статистику занимаемого пространства и ресурсов по контейнерам
docker statsПоказывает всю информацию по нужному контейнеру
docker inspect имя_контейнера
// покажет, включая занимаемое место
docker inspect -s имя_контейнера
// покажет, одно отдельное свойство
docker inspect -f "{{.Status.State}}" имя_контейнераПозволит вывести логи контейнера
docker logs контейнер005 Команды в контейнере
Докер нам так же предоставляет возможность запускать команды внутри

позволяет установить и просмотреть локальные переменные
docker exec -e MYVAR=1 mongo printenv
А тут мы уже залезли внутрь контейнера и можем выполнять различные операции над ним
docker exec -it mongo bash
Так же мы можем вывод из консоли вывести в отдельный файл, если нам потребуется
Тут сразу нужно сказать, что команда вывода и сохранения в новый файл происходит вне контейнера

Если нужно запустить команду внутри контейнера, то можно сделать таким образом:
docker exec mongo bash -c 'mongo --version > mongo.txt'Docker image
Что такое image
Состав изображения
Для начала скачаем образ nginx с docker hub с помощью docker daemon
docker pull nginxКак можно заметить, докер скачивает не целиковый образ, а отдельные слои (aka layers, из которых состоит образ). Каждый образ подписан уникальным идентификатором. Такой подход позволяет экономить пространство на диске.

По-факту, каждый слой - это отдельный образ, который доступен только на Read (чтение). После создания контейнера из image, у нас создастся тонкий слой, который будет доступен для записи информации (ReadWrite).
Это приводит к тому, что сколько бы мы не запустили разных контейнеров изображения, image, на котором (например) nginx будет базироваться, останется всегда один.

Внутрянка image
Теперь мы можем посмотреть, что находится внутри изображения в докере.
Для начала просмотрим список изображений
docker images
Чтобы сохранить архив с внутренними файлами изображения, можно воспользоваться следующей командой
Сразу можно сказать, что данный способ может пригодиться, когда наша машина не имеет доступа в интернет и в неё можно передать изображение только таким образом.
docker save --output nginx.tar nginx
mkdir nginx
tar xvf nginx.tar -C nginxНу и в манифесте можно просмотреть все ссылки на остальные слои изображения.
Структура каждого слоя выглядит похожим образом. Даже можно сказать, что layer - это тоже image, который содержит определённую информацию.
Такая иерархия очень похожа на стандартную работу пакетного менеджера, который собирается из других пакетов.

Так же в отдельном слое может находиться и внутренность его файловой системы

Так же мы можем вывести историю, по которой мы можем понять, как был собран тот или иной image. История снизу вверх идёт и отображает последовательность операций сборки
docker history <package>
Эффективным это переиспользование является потому, что мы не занимаем на диске место несколькими разными контейнерами. У нас поднимаются отдельные верхние слои, которые используют свои модули и пакеты в процессе работы, а так же ссылаются на общие слои.

/var/lib/docker/overlay2 - это группа слоёв изображений, который смёрдживается в одну файловую систему, которая используется в разных контейнерах. Сама по себе она весит немного, так как использует слоёную архитектуру
/var/lib/docker/containers - содержит образы контейнеров, которые мы пульнули из хаба. Сейчас тут 54 килобайта nginx
sudo du -sh /var/lib/docker/overlay2
sudo ls /var/lib/docker/overlay2

После создания отдельного контейнера, мы создаём на базе изображения nginx второй верхний слой, который будет занимать не так много места
То есть прошлое изображение и новое с 56кб начали весить 120кб
docker run -d --name nginx2 nginx
Деление оверлея файловой системы:
- Нижний слой
- link - ссылка на слой
- diff - изменения файловой системы образа
- Верхний слой
- lower - ссылается на нижний слой и может вернуть информацию о том, что там лежит
- link - ссылка для того, чтобы на этот слой мог ссылаться другой слой, который будет выше
- diff - показывает разницу относительно прошлого слоя
- merged - слитый diff с предыдущей файловой системой (diff из нижнего и верхнего слоя). Позволяет собрать правильный слепок файловой системы, на которой будет работать образ
- work - папка для хранения внутренних данных для оверлея

Собственно, все эти папки можно вывести из определённого изображения

Раньше использовались драйверы OverlayFS1 (предыдущая, менее эффективная версия) и AUFS (этот был устроен сложнее, но выполнял всё то же самое, хоть и медленнее)
002 Работа с image
Команды docker image:
history- выведет историю по определённому image со всеми командами для сборки образа

inspect- выведет подробную спеку по image.
LowerDir - хранит дифы (blob’ы связанных слоёв)
MergedDir - мёрдж всех слоёв с FS текущей системы
UpperDir - дифф текущей системы
WorkDir - необходимая для OverlayFS директория

import- это операция, которая позволит руками импортировать image (нужно для систем, у которых нет доступа к интернету)pull- находит registry с нужным образом dockerpush- позволит запушить собранный локально image для того, чтобы потом его скачатьls- выведет все изображения. Так же он имеет флаги:--format {{.Tag}}, который выведет сформатированный ответ--filter "before=node", который отфильтрует по параметрам (изображения, которые идут до ноды)

rm <image name/id>- позволит удалить определённый image
Если в удаляемом контейнере используются слои, которые используются в других образах, то докер нас об этом предупредит и даст удалить image только с --force флагом либо удалить все контейнеры, которые ссылаются на этот image

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

Чтобы решить эту проблему, можно воспользоваться следующей командой:
prune- очистит все image без тэгов
003 Dockerfile
Сам файл
Dockerfile представляет из себя файл с инструкциями докеру, что он должен сделать, чтобы собрать образ с нашим приложением.
Сразу нужно сказать, что каждая новая команда - это слой. Стоит оптимизировать свои команды, чтобы этих слоёв было минимум. Сам докер имеет ограничение в 127 строк в своём файле. Обходится это multistaged билдами.

Контекст сборки
Во время сборки, мы имеем дело с контекстом сборки. Контекст сборки - это набор файлов, к которым сборка может получить доступ. Когда билд собирает контекст, то он собирает все вложенные файлы нашего проекта, которые нужны для запуска приложения.
Сам контекст поднять нельзя. Если нам нужно будет собрать файл из папки вверх, то нам нужно будет подняться по пути самого билда на папку выше.
.dockerignore позволит удалить некоторые файлы из контекста.
Команды
ARG- аргументы - это дополнительные параметры, которые можно передать при сборке. Можно передать как заранее определённую переменную со своим значеннием, так и неопределённую, значение которой мы передадим из вне внутри командыdocker build --build-arg. Второй вариант нужен, когда нам нужно, чтобы значение существовало только в рамках билда, но не попало на продFROM- это старт нашего образа. Всегда и все образы базируются на каком-либо другом образе. Если образ не требуется ни на чём базировать, то мы его базируем наscratch. Так же черезasмы задаём alias для билда, чтобы использовать его в последовательности внутри другого билдаONBUILD- это команда, которая будет выполняться только тогда, когда другой image базируется на этом image, то есть только внутри другого билда во время сборки другого изображенияLABEL- хранит в себе мета-информацию об образе, в котором можно указать версию, автора, компанию и так далееUSER- определяет пользователя, который будет выполнять командыWORKDIR- рабочая директория, относительно которой будут выполняться командыADD- добавляет файлы с хостовой машины в образ. Однако эта команда так же умеет в побочные действия в виде разархивирования в определённую папку и скачивания файла по урлуCOPY- просто копирует файлы в образ. Из побочных действий он умеет копировать файлы из прошлых образов во время multistage-сборки

SHELL- установка нужного нам shellRUN- выполнение команды из оболочки. Самая частая в использовании команда. Для поднятия и сборки билдаENV- переменная окружения сборки. Чтобы обратиться к переменной, нужно написать$ПЕРЕМЕННАЯ. Она будет так же находиться и в финальном образе, поэтому хранить в ней секреты и токены - несекьюрно. Чтобы не сохранять переменную, её можно будет записать с помощьюRUN VAR=data.VOLUME-ENTRYPOINT- это инструкции, которые нужно выполнить после того, как запустится контейнер из этого изображенияCMD- то же самое, что и прошлая команда, но…STOPSIGNAL- вызов стопсигнала для остановки контейнераEXPOSE- это документация о том, какой порт мы прокинули и можем получить снаружи вне нашего контейнера. Сама команда пробросом портов не занимается.#- комментарий внутри изображения. Так же можно туда записать инструкцию для парсера по тому же экранированию

CMD и ENTRYPOINT вляют друг на друга и ведут себя по-разному в разных обстоятельствах. Если нет ниодного из них, то ничего не произойдёт. Если есть только cmd, то выполнится команда и её аргументы. Если мы запишем только энтрипоинт в виде строки, то он покроет выполнение cmd полностью и будет просто выполняться со своей строкой. Если энтри массив, а cmd строка, то выполнится обе операции. Если этри массив и cmd массив, то cmd будет представлять из себя просто уточняющие операции для entry.

004 Создаем свой image
Команда docker build собирает нам приложение.
И она принимает в себя несколько флагов:
-q- подавляет вывод сгенерированных файлов докером-f- позволяет указать путь до Dockerfile. Изначально, билд ищет этот файл в корне контекста, но если его не будет, то вылезет ошибка, поэтому нам и нужно-t- определяет название и тэг для нашего образа
Опишем проект. Это монорепозиторий с бэкэндом (api) и фронтендом (app).

Опишем простой докерфайл, который просто позволит поднять проект. Нам понадобится образ 14 ноды, укажем рабочую директорию как /opt/app, добавим туда весь проект, установим все скрипты, сбилдим проект, запустим его через node
apps / api / Dockerfile
FROM node:14
WORKDIR /opt/app
ADD . .
RUN npm i
RUN npm run build api
CMD ["node", "./dist/apps/api/main.js"]И тут мы должны будем указать путь до Dockerfile, указать тег, чтобы не потерять образ и указать контекст ., чтобы работать со всем проектом
docker build -f ./apps/api/Dockerfile -t test:latest .Далее нам нужно поднять наш образ с запущенным образом. Отцепляем работу от текущей сессии терминала, указываем имя образа, указываем тег проекта, к которому мы подключились.
docker run -d --name api test:latest
005 Улучшаем сборку
Сам образ правильный, он работает и запускается, но вес для обычного маленького проекта - очень большой.

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

Однако если мы изменим каким-либо образом код, то у нас слетит кэш на этапе установки зависимостей и их придётся ставить в проект заново.

Код у нас меняется чаще, чем зависимости, поэтому нам нужно немного поменять подход к их установке.
Первым делом, нам нужно скопировать package.json, затем установить зависимости, а уже только потом собирать проект. Так же более лёгкой версией ноды будет являться не версия на классическом дистрибутиве, а alpine, который весит менее 100 мегабайт. Он отлично подходит для разворачивания приложения
FROM node:14-alpine3.10
WORKDIR /opt/app
ADD *.json ./
RUN npm i
ADD . .
RUN npm run build api
CMD ["node", "./dist/apps/api/main.js"]И теперь наше изображение стало весить в два раза меньше при тех же вводных, а так же у нас закэшировались все шаги до билда, что позволит пропустить этап с установкой зависимостей при каждой новой сборке приложения.

006 Анализируем image
Для анализа образов можно воспользоваться утилитой dive, которая позволяет залезть внутрь образов на каждом этапе сборки.
Эта команда, которая возьмёт наш тестовый образ и построит по его файловой системе граф
dive test:latestТут у нас есть информация по каждому слою и отображение Image Details, в котором есть общая информация по возможной оптимизиации образа

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

007 Многоэтапная сборка
Многоэтапная сборка позволяет нам собрать в одном Dockerfile сразу несколько образов.
Основные плюсы:
- Позволяет иметь более сжатый конечный билд
- Позволяет скрыть секреты, которые использовались на первом этапе, но на втором их уже не будет
Поэтому сейчас мы сделаем первый образ, который в себе соберёт приложение. А во втором образе мы возьмём собранное приложение с помощью обращения через --from=<алиас_сборки> и установим только prod-зависимости
Так же в команде COPY мы можем поменять немного путь расположения
FROM node:14-alpine3.10 as build
WORKDIR /opt/app
ADD *.json ./
RUN npm i
ADD . .
RUN npm run build api
FROM node:14-alpine3.10
WORKDIR /opt/app
ADD package.json ./
RUN npm i --only=prod
COPY --from=build /opt/app/dist/apps/api ./dist
CMD ["node", "./dist/main.js"]
008 Упражнение - Сборка go проекта
Сейчас завернём приложение на Go, которое просто выводит сообщение о своём запуске на определённом порту
go.mod
module docker-demo-2
go 1.15main.go
package main
import (
"fmt"
"net/http"
)
func main() {
fmt.Print("Go проект запущенный в Docker слушает на 9000 порту")
handler := HttpHandler{}
http.ListenAndServe(":9000", handler)
}
type HttpHandler struct{}
func (h HttpHandler) ServeHTTP(res http.ResponseWriter, req *http.Request) {
data := []byte("Hello World!")
res.Write(data)
}Для начала просто соберём приложение
brew install go
go build
./docker-demo-2
Далее нужно будет его перенести в докер, там собрать и запустить. Для этого воспользуемся golang:alpine системой для поднятия образа и уже внутри неё соберём бинарник. Сам по себе бинарник мы собираем под определённую систему, поэтому ничего страшного не будет, если следующий билд мы соберём из scratch и в нём просто запустим наш бинарник
FROM golang:alpine as build
WORKDIR /go/bin
ADD . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build
FROM scratch
COPY --from=build ./go/bin/docker-demo-2 ./go/bin/docker-demo-2
ENTRYPOINT ["./go/bin/docker-demo-2"]
EXPOSE 9000Далее остаётся только сбилдить и запустить образ
docker build -t go-api:latest .
docker run --name go-api-demo -d go-apiСети Docker
bridge host null dockernetwork
Устройство сети Docker
За управление сетями в докере отвечает библиотека Libnetwork. Она пользуется функционалом, доступным в Linux для работы с сетями внутри него.

- Она утилизирует неймспейсы сети
- использует виртуальные мосты для соединения одного образа с другим
- виртуализация интернет-подключений
- управляет правилами iptables

Контейнер, при поднятии, создаёт виртуальный интернет-адаптер. Этот адаптер подключается к виртуальному мосту и уже сам мост получает доступ в интернет. Через мост несколько контейнеров могут общаться в рамках одного хоста.

Так как библиотека для работы с сетью достаточно гибкая, то она может предоставить нам возможность работать с несколькими драйверами, которые определяют поведение работы сети.
bridge- изолирует сеть между всеми контейнерамиhost- контейнер будет работать напрямую с сетью компьютера без дополнительных слоёвoverlay- соединяет множество хост-машин в одну сеть для взаимодействия контейнеровmacvlan- создаёт новое физическое устройство со своим mac (сильно влияет на перфоманс сети)null- не даёт сеть контейнеру

Управляется сеть достаточно просто самыми базовыми командами
connectcreate- создаст сеть по определённому типу драйвераdisconnectinspectls- отображает сетиrm- удаляет сетьprune- удаляет неиспользуемые

Выведем список доступных сетей докера, которые созданы по-умолчанию
docker network ls
Ну и далее можем проинспектировать любую
scope- текущая область сети (локальная, удалённая)driver- текущий драйверEnableIPv6- так же можно подключить ipv6 на сетьIPAM- хранит список подсетей и их драйверов для всех контейнеровContainers- хранит список контейнеров, которые подключены к этой сети. По указанному внутриIPv4Addressможно пингануть контейнер
docker network inspect bridge[
{
"Name": "bridge",
"Id": "ad9007015b1d9bf9dfd4bddf86c95e3ea89d007de5863818083177ae8ded1288",
"Created": "2024-08-21T18:55:22.413658685+03:00",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "172.17.0.0/16",
"Gateway": "172.17.0.1"
}
]
},
"Internal": false,
"Attachable": false,
"Ingress": false,
"ConfigFrom": {
"Network": ""
},
"ConfigOnly": false,
"Containers": {
"623903f47a64bf7c3add8bad48d8cbfcfdcfcb73a5fa9262da787e137ddafcc3": {
"Name": "xenodochial_ptolemy",
"EndpointID": "b77ee64683f51c83a6249fd2258321607ca632e10c91e21edf9ae054a56b005c",
"MacAddress": "02:42:ac:11:00:02",
"IPv4Address": "172.17.0.2/16",
"IPv6Address": ""
}
},
"Options": {
"com.docker.network.bridge.default_bridge": "true",
"com.docker.network.bridge.enable_icc": "true",
"com.docker.network.bridge.enable_ip_masquerade": "true",
"com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
"com.docker.network.bridge.name": "docker0",
"com.docker.network.driver.mtu": "1500"
},
"Labels": {}
}
]Драйвер bridge
Bridge позволяет нам объединить в одну сеть несколько контейнеров в рамках одного хоста.
- Очень прост в конфигурации
- Обеспечивает локальный service discovery (обращение одного контейнера к другому по имени)
- работают только на одной хост-машине
Очень удобно поднимать фронт+бэк на одной машине и на продакшенах, где один хост.
Схема работы
Контейнер подключается к своему виртуальному интернету, который подклчюается к мостовой сети. Каждый контейнер имеет свой ip. А уже сама мостовая сеть смотрит в мир через один ip.

Так же можно выделять связи контейнеров несколькиим бриджами, чтобы разделять их друг от друга. Однако bridge всё так же будет виден из одного ip.
Контейнеры 1 и 2, а так же 2 и 3 - видят друг друга. Контейнеры 1 и 3 никак не могут достучаться друг до друга.

Уже на двух машинах это не будет работать. Только в том случае, если мы напрямую через сеть будем обращаться по определённому ip бриджа на другой локалке.

Если нам потребуется получить доступ к определённому порту извне, то мы сможем переадресовать через Port Mapping нас на определённый порт.

Пример
Далее напишем простую утилиту, которая будет выводить список портов ipv4 на устройстве компьютера
const http = require('http');
const { networkInterfaces } = require('os');
const port = 3000;
const requestHandler = (request, response) => {
const IPs = getIPs();
response.end(JSON.stringify(IPs));
}
const server = http.createServer(requestHandler)
server.listen(port, (err) => {
if (err) {
return console.log('Ошибка', err)
}
console.log(`Сервер запущен на порту ${port}`)
});
const getIPs = () => {
const nets = networkInterfaces();
const results = {};
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) {
if (!results[name]) {
results[name] = [];
}
results[name].push(net.address);
}
}
}
return results;
}И такой простой докер нам понадобится, в которы мы добавим возможность курлиться
FROM node:14-alpine
RUN apk add curl
WORKDIR /opt/app
ADD index.js .
CMD ["node", "./index.js"]Собираем образ и создаём из него два отдельных контейнера
docker build -t bridge:latest
docker run --name node-1 -d bridge:latest
docker run --name node-2 -d bridge:latestУ нас дефолтно есть такие сети docker network ls:

И после инспектинга сети docker inspect 4736089595e6 (либо docker network inspect bridge) можно обнаружить, что в ней находятся оба наших контейнера. Обоим контейнерам были выданы ip из 17ой подсети под местами 0.2 и 0.3

Далее заходим в контейнер docker exec it <контейнер> sh и пытаемся достучаться по ip к другому контейнеру через curl. В ответ мы получаем eh0 ip адрес контейнера.
Оба наших контейнера дефолтно положились в bridge сеть, которую создал docker.

И далее мы хотим сделать так, чтобы контейнер к контейнеру могли обращаться по имени контейнера.
Для начала создадим сеть и добавим в неё наши контейнеры:
# создаём сеть
docker network create my-b-network
# подключаем к ней интересующие нас контейнеры
docker network connect my-b-network node-1
docker network connect my-b-network node-2
# инспектим нашу сеть и проверяем наличие контейнеров
docker network inspect my-b-networkКонтейнеры остаются в дефолтной bridge сети и добавляются дополнительно в нашу (воткнули ещё один ethernet в контейнеры). В нашей сети появляется возможность резолвить имена контейнеров и обращаться по ним к другому контейнеру, что даёт при curl такой вывод:
/opt/app # curl node-2:3000
{"eth0":["172.17.0.3"],"eth1":["172.18.0.3"]}Так же мы сразу при создании контейнера можем указать нужный нетворк. В таком случае контейнер не попадёт в дефолтный bridge, а сразу полетит в my-b-network.
docker run --name node-3 --network my-b-network -d bridge:latestЧтобы вывести порт контейнера наружу, нам нужно будет запустить контейнер с ключём -p, который прокинет на порт хоста:контейнера порт
docker run --name node-4 -p 3000:3000 --network my-b-network -d bridge:latestТеперь мы можем себе позволить запустить curl с локалхоста прямо в нужный контейнер

Драйвера host и null
Host - это сеть, которая убирает абстракцию в виде docker-сетей и пробрасывает всю сеть из хостовой машины в докер-контейнер.
Зачастую такая сеть нужна, когда на нашем хосте стоит только одна программа - та же база данных. Под неё нет смысла возиться и выделять хосты.

docker run --name node-6 --network host -d bridge:latestИ теперь мы можем спокойно курлиться сами в себя по нашему локальному ip, так как докер прокинул порт 3000 из контейнера прямо на хост

Null - это сеть, которая не предоставляет сетевого доступа.
Зачастую нужна, чтобы просто выполнять какие-то операции над файлами в машине. Выполняется Ad-hoc контейнер, который потом сразу убивается. Рабтает чисто за счёт данных с хост-машины, либо сам генерит какие-то данные.

docker run --name node-7 --network none -d bridge:latestЭта сеть не имеет никаких подключений

DNS
DNS - это компьютерная распределённая система для получения информации о доменах. Чаще всего используется для получения ip-адреса по имени хоста.
Чтобы посмотреть адрес DNS у себя либо прямо внутри контейнера docker с alpine, можно чекнуть /etc/resolv.conf (обычно этот файл берётся с хостовой машины)
cat /etc/resolv.conf
# Generated by NetworkManager
nameserver 192.168.1.1Но так же ничто не мешает нам запустить контейнер с кастомным dns
docker run --name node-9 --dns 8.8.8.8 -d bridge:latestВ итоге заменится тот самый resolv.conf и dns попадёт в него

Docker volumes
Устройство и типы volumes
Volumes - это механизм, который позволяет ссылаться на данные с хостовой машины из контейнера
Мы можем столкнуться с такой проблемой, что при удалении docker-контейнера, мы так же удаляем и данные, которые были в этом контейнере, которые могут быть нам нужны.

Первы способ - Volumes
Volumes в большинстве случаев используется локально (а не в том же swarm). Она представляет собой подключение области хостовой машины к контейнеру. То есть docker создаёт в своей специальной области директорию, которая биндится к папке на системе пользователя и постоянно обновляется.
Второй способ - Bind mounts
В таком случае мы подклоючаем файловую систему к контейнеру и он будет смотреть целиково на неё.
Третий способ - tmpfs
Создаёт быструю файловую систему и помещает её полностью в память нашего устройства.

Для чего можно использовать volumes
- Персистентное хранение данных (статичное распределение данных по контейнерам из БД)
- Экспортирование логов (контейнер генерирует данные, которые читает уже другой контейнер)
- Передача конфигов в контейнер
- Share данных между контейнерами
Использование volumes
Для примера напишем сервер, который будет считывать файлы в нашей папке с данными и возвращать их
src / index.js
const express = require("express");
const { writeFileSync, readFileSync } = require("fs");
const fse = require("fs-extra");
const app = express();
const port = 3000;
app.get("/set", async (request, response) => {
await fse.ensureDir("data");
writeFileSync("./data/req", request.query.id);
response.send("done!");
});
app.get("/get", (request, response) => {
const res = readFileSync("./data/req");
response.send(res.toString());
});
app.listen(port, (err) => {
if (err) {
return console.log("something bad happened", err);
}
console.log(`server is listening on ${port}`);
});Докерфайл выглядит следующим образом:
FROM node:14-alpine as build
WORKDIR /opt/app
ADD *.json ./
RUN npm install
ADD . .
CMD ["node", "./src/index.js"]Программа выполняет следующие операции:

Далее попробуем создать с помощью команды docker volume новый volume для наших будущих данных.
Как можно увидеть, все данные для этого пространства располагаются в директории volumes
$ docker volume create demo
demo
$ docker volume ls
DRIVER VOLUME NAME
local demo
$ docker volume inspect demo
[
{
"CreatedAt": "2025-01-08T12:21:30+03:00",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/demo/_data",
"Name": "demo",
"Options": null,
"Scope": "local"
}
]Чтобы привязать контейнер докера к определённому пространству, нужно будет запустить следующую команду:
docker run --name volume-1 -d -v demo:/opt/app/data -p 3000:3000 demo4:latestКогда мы привязываемся к пространству через -v, нам нужно указать через пространство:путь_в_контейнере, какие именно данные мы хотим связать из контейнера с нашей внешней папкой с данными на хосте. Путь в контейнере мы считаем от WORKDIR /opt/app, который мы указали как рабочую папку внутри контейнера. data непременно будет находиться в контейнере именно в /opt/app/data, так как у нас в проекте она располагается в <project>/data.
И теперь мы можем отправить запрос в докер и из пространства получить сразу данные, которые docker из контейнера положил на нашу хост-машину в /var/lib/docker/volumes/demo/_data. Все данные из data находятся тут, так как мы только их подцепили из докера.
$ curl "127.0.0.1:3000/set?id=1234"
done!%
$ curl "127.0.0.1:3000/get"
1234%
$ sudo cat /var/lib/docker/volumes/demo/_data/req
1234%Если мы поднимем другой контейнер, но на порту 3001, который так же подцеплен под этот же volume и дёрнем из него запрос, то мы получим тот же самый вывод, так как оба сервера сейчас смотрят на одну и ту же хостовую директорию.
Если два контейнера подцеплены под один и тот же volume, то данные между этими двумя контейнерами будут шейриться.
$ docker run --name volume-2 -d -v demo:/opt/app/data -p 3001:3000 demo4:latest
$ curl "127.0.0.1:3001/get"
1234%Чтобы удалить volume, нужно будет сначала удалить все связанные с ним контейнеры
$ docker rm $(docker ps -a -q) -f
8a6d2f71508f
$ docker volume rm demo
demo
$ sudo ls /var/lib/docker/volumes
backingFsBlockDev metadata.dbТак же есть и альтернативная запись создания volumes через флаг --mount, где мы должны указать:
- type - тип пространства (volume / bind / tmpfs)
- src - исходный путь хост-машины
- dst - выходной путь контейнера
docker run --mount type=bind,src=<host-path>,dst=<container-path>VOLUME в Dockerfile
Так же мы можем напрямую в файле контейнера указать volume, который нам нужно будет подключить
FROM node:14-alpine AS build
WORKDIR /opt/app
ADD *.json ./
RUN npm install
ADD . .
VOLUME ["/opt/app/data"]
CMD ["node", "./src/index.js"]После сборки и инспекции контейнера, тут так же можно будет увидеть место пространства и файлы, которые к нему подцеплены
$ docker build -t demo4:latest .
$ docker image inspect demo4:latest
"Volumes": {
"/opt/app/data": {}
},И теперь при создании контейнера, мы получаем сгенерированный volume со своим сложным хешем
$ docker volume ls
DRIVER VOLUME NAME
$ docker run --name volume-3 -d -p 3000:3000 demo4
$ docker volume ls
DRIVER VOLUME NAME
local cb72c2cba4e40437...Он так же будет хранить данные, как и созданный ранее именованный volume
$ curl "127.0.0.1:3000/set?id=1234"
done!%
$ sudo cat /var/lib/docker/volumes/cb72c2cba4e404379e2af659fa65a3ca495e0cade6aaa0df51718d5e42e45f4d/_data/req
1234%Однако мы сталкиваемся с той проблемой, что при пересоздании контейнера, данные не будут биндиться и каждый раз будет создаваться новый volume, который будет занимать много места. Та же монга может просто два раза занять по 300 мб, вместо использования своего именованного участка.
Поэтому мы можем так же прибиндить наш контейнер к именованному volume.
$ docker run --name volume-4 -d -v demo:/opt/app/data -p 3000:3000 demo4
d7fdcdaa487eee48...
$ docker volume ls
DRIVER VOLUME NAME
local cb72c2cba4e40...
local demoЧтобы очистить неиспользуемые неименованные volume, мы можем почистить их через prune
$ docker volume prune
WARNING! This will remove anonymous local volumes not used by at least one container.
Are you sure you want to continue? [y/N] y
Deleted Volumes:
cb72c2cba4e404379e2af659fa65a3ca495e0cade6aaa0df51718d5e42e45f4d
Total reclaimed space: 4BИспользование bind mounts
Bind mounts - это биндинг файловой системы в произвольную область нашего хоста (а не внутри докера)
Чтобы подцепить определённую область с нашего хоста, мы можем вместо задания имени volume указать путь на нашей машине и подцепить его под docker.
docker run --name volume-5 -d -v /home/zeizel/data:/opt/app/data -p 3000:3000 demo4И теперь мы получаем текущую папку на нашей хостовой машине
$ curl "127.0.0.1:3000/set?id=1234"
done!%
$ cat ~/data/req
1234%Для чего это может быть нужно?
Зачастую такое может понадобиться, когда мы хотим менять какую-нибудь конфигурацию внутри контейнера.
Подсовываем файл - bind mount Работаем с БД - именованный volume
В списке volume этот тип пространств не появляется
Использование tmpfs
TempFS - это хранение кусочка файловой системы прямо в память хоста.
В списке volume этот тип пространств так же не появляется
Используется для временного хранения куска данных вне слоя Docker (обычно приватных).
- Не работает в Swarm
- Нельзя шейрить между контейнерами
- Удаляются после остановки контейнера
Для запуска выделенной части данных из контейнера в ОЗУ достаточно указать флаг --tmpfs с указанием пути внутри контейнера
docker run --name volume-6 -d --tmpfs /opt/app/data -p 3000:3000 demo4Вызываем команды программы - результат есть. После перезагрузки контейнера через stop/start, мы получаем ошибку, так как файл с данными пропал из оперативной памяти.
$ curl "127.0.0.1:3000/set?id=1234"
done!%
$ curl "127.0.0.1:3000/get"
1234%
$ docker stop volume-6
volume-6
$ docker start volume-6
volume-6
$ curl "127.0.0.1:3000/get"
<pre>Error: ENOENT: no such file or directory, openТакой подход полезен для хранения секретов, но никак не подходит для персистентных данных.
Копирование данных
Иногда нам нужно скопировать данные в контейнер без имения какого-либо volume. В этом случае нам поможет docker cp <host_path> <container>:<container_path>, который скопирует нам данные с хоста на контейнер.
Данная команда скопирует папку с хоста прямо в докер
docker cp /home/zeizel/data volume-9:/opt/app/dataЕсли нам нужно наоборот скопировать из контейнера на хост, то пишем команду в обратном порядке
$ docker cp volume-9:/opt/app/data ~/data
Successfully copied 2.56kB to /home/zeizel/data
$ cat ~/data/req
123Если мы хотим скопировать только содержимое папки, то можно написать через .
docker cp volume-9:/opt/app/data/. ~/dataDocker compose
YAML
YAML (yaml ain’t markup language) - это надмножество JSON, которое позволяет в более понятном формате писать людям конфиги

И так примерно мы можем заполнять файл:
main.yml
# Строки
firstname: 'Olegov'
name: Oleg
surname: "Olegovich \n"
# Числа
version: 1.2.3
age: 23
# boolean
isDev: true
isTest: off # on
isProd: no # yes
# объект
user:
name: Oleg
age: 23
# список
users:
- name: Oleg
age: 24
- name: Vera
age: 22
# список значений
userList:
- Oleg
- Vera
# запись в виде массива
userNames: [Oleg, Vera, 1.2.3]
# YML является надмножеством над JSON поэтому такая запись тоже валидна
myObject: {
"key": "value",
string: 1.2.3
}
# так же мы можем писать многострочные строки
multiline: |
Эта строка
пойдёт на
несколько
строк.
Вопросы?
# если нам нужно записать большую многострочную запись в виде одной строки, мы можем воспользоваться данной конструкцией
singleline: >
Сколько бы тут не было текста,
он всегда будет считаться одной строкой
# Такая черта позволит отделить одно описание ямла от другого (создаётся новое пространство имён)
---
name: OlegУстановка docker compose
Для установки достаточно повторить шаги из документации. Желательно установить его отдельным плагином Docker, а не standalone.
docker compose --helpDocker compose
Docker compose - это утилита, которая позволяет заранее описать всё нужное состояние контейнера и запустить его из под конфига

Конфигурация
- version - это описание версии текущего композа, фичи которого будут поддерживаться. Сейчас писать эту строку необязательно.
- services - это ключ описания сервисов, которые мы будем поднимать. Внутри него мы создаём объект с сервисами, в которых нужно будет указать параметры для запуска образов.
- networks - описание сетей, которые нужно создать или подключиться к ним
- volumes - описание пространств, которые нужно подготовить для сервисов
docker-compose.yml
# версия compose
version: '3'
# описание сервисов
services:
api: # сервис с именем api
image: demo4 # наше изображение или из registry
container_name: my-name # имя контейнера, которое не будет работать в swarm
ports:
- "3000:3000" # указание проброса портов
networks:
- servers # доступные сети
volumes:
- data:/opt/app/data # пространства с маппингом
# описание сетей, которые нужно будет создать
networks:
servers:
driver: bridge
# ЛИБО можно не создавать, а подключиться к существующей сети
networks:
default:
external: true
name: servers
# описание пространств данных
volumes:
data:Команды
Далее мы можем одной командой поднять все сервисы, описанные в docker-compose.yml:
# поднимает текущий compose
docker compose up
# остановит текущий compose
docker compose stop
# стартанёт обратно текущий compose
docker compose start
# остановит и удалит неиспользуемые элементы (удалит контейнер и сеть, но оставит volume, так как он персистентен)
docker compose downВажно понимать, что команды compose - контекстозависимы!
То есть все операции будут выполняться в первую очередь для текущей папки, опираясь на
docker-compose.yml
Так же команды:
- restart - перезапуск
- pull / push - пулит и пушит image с registry
- port - выведет занятые порты
- logs - выведет логи из всех контейнеров. Может быть полезно, когда у нас запущены в
-d. - images - выведет список используемых образов
- top - покажет запущенные в текущий момент процессы
[$] docker compose up -d
[+] Running 1/1
✔ Container my-name Started 0.2s
[$] docker compose top
my-name
UID PID PPID C STIME TTY TIME CMD
root 1210000 1209978 1 18:55 ? 00:00:00 node ./src/index.js
[$] docker compose images
CONTAINER REPOSITORY TAG IMAGE ID SIZE
my-name demo4 latest 1fc568913222 122MBЕсли нам нужно следить за логами поднятого через -d контейнера, нам нужно воспользоваться -f
docker compose logs <контейнер> -fЗаключение
docker compose - это удобный инструмент для оркестрирования сразу несколькими контейнерами. Он позволяет делать почти всё то же самое, что мы делали, когда собирали, запускали, останавливали и перезапускали образы самостоятельно.
Оркестрация сервисов
Docker compose позволяет нам удобно оркестрировать множеством сервисов, которые будут подняты одновременно и взаимодействовать друг с другом по описанным нами правилами.
В примере будет использоваться монорепозиторий, где в apps будут находиться проекты: api, app, converter. Каждый из этих проектов содержит в себе Dockerfile, который all-in-one собирает в себе проект. Отдельно серверные сервисы общаются через rabbitmq, который нужно будет поднять отдельным docker-контейнером, чтобы происходило общение внутри compose.
- Здесь нам нужны volumes, так как через них мы будем добавлять
.envфайл в билд приложения. В рамках композа на одной ноде - это хороший вариант. Если мы будем поднимать в swarm, то там уже энвы распространяются через механизм секретов. - Когда мы запускаем образ через
docker run, мы можем передать через-e ENV_NAME=value -e ENV_NAME_2=value_2переменные окружения. Так же мы можем сделать и внутри compose ключомenvironment - Для каждого сервиса обязательно нужно указать либо build, либо image из которых будет собираться проект. Image мы берём из registry нашей компании или dockerhub. Build принимает в себя множество параметров, основными из которых являются: context (область, которая будет использоваться для создания образа) и dockerfile (сам файл для сборки приложения).
docker-compose.yml
---
# описываем все сервисы
services:
# сервис апишки
api:
container_name: api
# указываем откуда будем собирать образ
build:
context: . # за контекст берём всю директорию проекта
dockerfile: apps/api/Dockerfile # укаызваем путь до проекта в монорепе
# перезапускаем всегда при падении
restart: always
# указываем путь до .env файла
volumes: [./.env:/opt/app/.env]
# укаызваем сеть, в которой будет находиться контейнер
networks: [my-network]
# образ зависим от RMQ и запустится уже после него
depends_on: [rmq]
app:
container_name: app
build:
context: .
dockerfile: apps/app/Dockerfile
restart: always
volumes: [./.env:/opt/app/.env]
networks: [my-network]
converter:
container_name: converter
build:
context: .
dockerfile: apps/converter/Dockerfile
restart: always
volumes: [./.env:/opt/app/.env]
networks: [my-network]
depends_on: [rmq]
# сервис брокера сообщений между сервисами
rmq:
# указываем image из registry docker hub
image: rabbitmq:3-management
networks: [my-network]
restart: always
# передаём переменные окружения для входа
environment:
- RABBITMQ_DEFAULT_USER=admin
- RABBITMQ_DEFAULT_PASS=admin
networks:
my-network:
driver: bridge
volumes:
data:После запуска, у нас поднимаются сразу все нужные сервисы
docker compose up
Профили
Профили - это список в конфиге, котрый позволяет нам группировать контейнеры
---
services:
api:
container_name: api
build:
context: .
dockerfile: apps/api/Dockerfile
restart: always
volumes: [./.env:/opt/app/.env]
networks: [my-network]
depends_on: [rmq]
profiles: [backend]Указание профилей позволит нам запускать только те сервисы, которые нам нужны из командной строки. То есть все backend сервисы поднимутся, когда мы укажем --profile <профиль>
Важно указать флаги до
up
$ docker compose --profile backend --profile frontend up
# либо можно вызывать нужные профили так
COMPOSE_PROFILES=backend,frontend docker compose up
Так же мы можем поднять отдельно выбранный сервис. Поднимется только он и все остальные сервисы, которые мы указали в depends_on.
run вызывает профили неявно просто благодаря его запуску.
Такой подход может быть полезен, когда нам нужно запустить образы с какой-нибудь миграцией или отдельными скриптами с операциями.
docker compose run apiОднако, если у одного из контейнеров одного профиля есть зависимость из другого профиля, то мы столкнёмся с проблемой

Переменные окружения
Ко всему прямо внутри файла с конфигом композа, мы можем использовать переменные окружения. Вставлять их можно в строки через '{$<переменная>}' либо просто вставляя $<переменная>
services:
api:
container_name: '{$API_CONTAINER_NAME}'
build:
context: .
dockerfile: apps/api/Dockerfile
restart: always
volumes: [./.env:/opt/app/.env]
networks: [my-network]
depends_on: [rmq]
profiles: [backend]Для этого дела создадим отдельный файл
.env.compose
API_CONTAINER_NAME=apiИ через флаг --env-file можно указать путь до энва, который будет использоваться для конфига.
Можно и не указывать флаг и тогда будет использоваться дефолтный файл .env.
docker compose --env-file .env.compose --profile backend upТак же нужно указать возможность вызвать docker compose config, который позволяет нам взглянуть на итоговый конфиг, который попдаёт в композ и будет крутиться.
К нему нужно добавить --env, чтобы взглянуть на переменные, которые он подставит и обязательно указать --profile, если мы задали его для наших контейнеров
$ docker compose --env-file .env.compose --profile queue config
name: docker-demo
services:
rmq:
profiles:
- queue
environment:
RABBITMQ_DEFAULT_PASS: admin
RABBITMQ_DEFAULT_USER: admin
image: rabbitmq:3-management
networks:
my-network: null
restart: always
networks:
my-network:
name: docker-demo_my-network
driver: bridge
Так же нужно отдельно упомянуть тот факт, что мы можем в сам контейнер передать переменные окружения не только через environment, но и через указания файла с энвами в ключе env_file
rmq:
image: rabbitmq:3-management
networks: [my-network]
restart: always
env_file: [.env.rmq]
environment:
- RABBITMQ_DEFAULT_USER=admin
- RABBITMQ_DEFAULT_PASS=admin
profiles: [queue]Так же мы можем передать переменную окружения COMPOSE_PROJECT_NAME, которая заменит название проекта в билде композа с названия папки, в которой находится docker-compose.yml на наш, который мы задали
COMPOSE_PROJECT_NAME=mycompose docker compose --env-file .env.compose --profile backend --profile queue up
Упражнение - Выкладываем полное приложение
Схема нашего прилоежния:
- на хосте запущен только браузер
- на иммитации сервера (virtualbox) располагается композ со всеми контейнерами
- браузер долбится по порту 3001 на порт 3001 сервера, а сервер выводит через 3001 порт фронта, который запущен в контейнере на 80 порту
- из браузерного клиента мы отправляем запрос в VB на порт 3002, порт 3002 на сервере смотрит на 3000 порт из контейнера
- API общается с RMQ
- RMQ передаёт сообщения между API и Converter
- API возвращает в браузер ответы, с которыми работает App фронта

Нужно убедиться, что нужные порты прокинуты из нашей виртуалки на хост.
Далее описываем вслед за схемой все нужные порты для наших контейнеров. Для конвертера нам порты не нужны, потому что он общается только с нашим api, который находится в локальной сети.
docker-compose.yml
services:
app:
container_name: app
build:
context: .
dockerfile: apps/app/Dockerfile
restart: always
ports: [3001:80]
networks: [my-network]
api:
container_name: api
build:
context: .
dockerfile: apps/api/Dockerfile
restart: always
ports: [3002:3000]
volumes: [./.env:/opt/app/.env]
networks: [my-network]
depends_on: [rmq]
converter:
container_name: converter
build:
context: .
dockerfile: apps/converter/Dockerfile
restart: always
volumes: [./.env:/opt/app/.env]
networks: [my-network]
depends_on: [rmq]
rmq:
image: rabbitmq:3-management
networks: [my-network]
restart: always
env_file: [.env.rmq]
environment:
- RABBITMQ_DEFAULT_USER=admin
- RABBITMQ_DEFAULT_PASS=admin
networks:
my-network:
driver: bridge
volumes:
data:Shared конфигурации
Так же, когда нам требуется сделать разные docker-compose.yml для локальной разработки и деплоя, мы можем поделить нашу конфигурацию на несколько файлов.
Композиция из конфигов
И вот пример дополнения прошлого конфига, когда мы открываем порт для менеджер-панели RMQ
docker-compose.dev.yml
---
services:
rmq:
ports: [15672:15672]Чтобы скомбинировать эти конфиги и запустить их вместе, нам нужно будет передать через -f все доступные наши конфиги
docker compose -f docker-compose.yml -f docker-compose.dev.yml upExtend
Ну и так же мы можем напряму экстендиться от других файлов прямо в конфиге
Опишем конфиг сервиса в одном файле
docker-compose.api.yml
---
services:
api:
container_name: api
build:
context: .
dockerfile: apps/api/Dockerfile
restart: always
ports: [3002:3000]
volumes: [./.env:/opt/app/.env]
networks: [my-network]И заэкстендим этот конфиг из другого файла
docker-compose.yml
services:
api:
extends:
file: docker-compose.api.yml
service: api
depends_on: [rmq]Экстендить ключ
depends_onиз другого файла - не получитсяЗависимости ищутся в оригинальном файле и, если они не разрезолвятся, мы обязательно получим ошибку отсутсвующей зависимости
Заключение
Такой подход с разбитием большого композа на подфайлы позволяет нам проще поддерживать конфигурацию и повышать её читаемость.
Docker registry
Работа docker-registry
Docker registry - это приложение, которое предоставляет API, с которым можно взаимодействовать для того, чтобы стянуть или положить на него образ.
Когда мы с нашей локальной машины (host) используем изображение для нашего образа без указанного registry, мы дефолтно обращаемся в dockerhub, где стягиваем (pull) по <image>:<tag> изображение к нам.
Когда к нашей конструкции образа registry/image:tag добавляется registry, мы добавляем указание, куда этот образ полетит при пуше и откуда стянется при пулле.

Чтобы опубликовать registry, мы можем:
- залить его на github (+ там имеется возможность залить приватно)
- залить на gitlab (на селфхост решении из коробки есть registry)
- залить на dockerhub
- развернуть registry локально
Сложности в локальном использовании заключаются в том, что нужно:
- покупать доменное имя либо настраивать его локально у себя самому
- иметь подписанные серты для домена
# запуллит определённый образ
docker pull <image>
# запушит определённый образ
docker push <image>
# тегнет определённый образ
docker tag <image> <tag>
# запуллит все образы, которые описаны в docker-compse.yml
docker compose pull
# опубликует описанные образы
docker compose push
# поиск образов по докерхабу
docker search --no-trunc <image>GitHub registry
Логин и пуллинг
Чтобы у docker была возможность работать с registry, нам нужно создать токен для GH с нужными привилегиями и сохранить его на компьютере

Сохраняем в любом месте хоста, где будет удобно дёрнуть токен
nvim ~/TOKEN.txtДля авторизации используем вывод токена через pipe и передачу его в login докера с флагом --password-stdin, который принимает в себя pipe данные
cat ~/TOKEN.txt | docker login https://ghcr.io -u <github_username> --password-stdinИ теперь мы можем позволить себе спуллить из gh любой публичный образ либо наш приватный
$ docker pull ghcr.io/alaricode/top-api-demo/top-api-test:latest
latest: Pulling from alaricode/top-api-demo/top-api-test
ddad3d7c1e96: Downloading 785.6kB/2.816MB
f845e0f7d73a: Downloading 7.159MB/36.12MB
47d471c4d820: Downloading 801.9kB/2.24MB
1a88008f9c83: Waiting
f7a72abda4da: Waiting
6106deb0d93a: Waiting
0ef759e161b4: Waiting
0ea68650b52d: WaitingПуш
Далее нам нужно затегать наш образ по данной структуре, чтобы gh смог его в себя принять и сохранить
docker tag <image> ghcr.io/<gh_username>/<repo>/<image_name>:<tag>docker tag docker-demo-api:latest ghcr.io/alaricode/top-api-demo/top-api-test:latest
docker push ghcr.io/alaricode/top-api-demo/top-api-test:latest
Работа с другими registry аналогична той, что есть на гитхабе
Поднимаем свой registry
Преобразуем описанную команду из документации в docker-compse конфигурацию, чтобы быстрее и проще запускать registry
docker-compse.yml
services:
registry:
image: registry:2
container_name: registry
restart: always
volumes: [data:/var/lib/registry]
ports: [5000:5000]
volumes:
data:Далее нам нужно запустить registry, протегировать нужный нам образ и запушить его в тот же самый registry
Тегирование образа обязательно проходит с указанием домена в начале образа
docker compose up -d
docker tag docker-demo-api:latest localhost:5000/api
docker push localhost:5000/api
И теперь мы можем спокойно удалить образ с нашей локалки и подтянуть с нашего локально-развёрнутого registry
docker image rm localhost:5000/api
docker pull localhost:5000/api
Категорически не стоит использовать в качестве наименования домена ip-адрес
При переезде сервера, нужно будет так же прописывать старый ip-адрес, но если указать нормальный домен, то никаких подобных проблем не будет и все образы останутся на месте.
Куда больше действий придётся выполнить, когда мы будем работать с реальным доменом и придётся указать адрес, который будет разбирать балансировщик, серты и ключи
docker run -d \
--restart=always \
--name registry \
-v "$(pwd)"/certs:/certs \
-e REGISTRY_HTTP_ADDR=0.0.0.0:443 \
-e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
-e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
-p 443:443 \
registry:2Базовые понятия Ansible
Ansible - это утилита для автоматиизации выполнения скриптов.
Задачи Ansible
Для чего нам нужно его использовать:
- Автоматиизирует повторяющиеся задачи. Сразу позволяет выполнить все нужные операции на большом количестве машин.
- Автоматизирует сложные задачи. Позволяет повторять сложные задачи раз за разом без совершения определённых ошибок.
- Поддерживает IAC концепцию (инфраструктура как код).
Какие задачи он решает:
- Отметает человеческий фактор при исполнении скриптов.
- Решает проблему недостатка прозрачности настроек и документации
- Устраняет сложность повторения определённой операции
- Экономит кучу времени
- Повышает переносимость решения
Какие плюсы именно Ansible:
- Нет доп ПО на сервере - только python
- Можно легко дописывать свои модули
- Низкий порог вхождения
- Возможность интеграции API через AWX (через который можно настроить автоматическое выполнение скриптов и scheduling)
Схема работы Ansible
- YML-конфиг (то, что мы выполняем). Описывает то состояние, к которому нам нужно привести сервера. Тут мы описываем последовательно состояние, к которому мы должны привести систему.
- Инвентарь (на чём выполняем). Это модуль. Описывает все сервера, их состояния, как к ним подключаться, под какими пользователями выполнять операции, ip и переменные.
- Ansible. Он уже приводит сервера к описанному ранее состоянию.
- Первым делом, он собирает факты о серверах. Подключается / не подключается, тип ОС, происходит первичный коннект.
- Сборка конфигов с серверов.
- Подключение плагинов
- Подключение модулей
- Транспиляция YAML в python и исполнение кода. Все конфигурации разбираются на одной хостовой машине в самом Ansible.

Системы автоматизации обычно делятся на pull и push. Pull требуют наличия агента на серверах, чтобы проверять изменения и подтягивают исполняемый конфиг сами в себя. Push уже требуют, чтобы мы сами запихнули конфиг на сервер, чтобы тот выполнился. Второй вариант является более удобным, так как не требует на сервере никакого дополнительного ПО и агентов.
Когда мы используем Ansible на том же Ubuntu сервере (на котором уже есть python) нам не нужно ничего устанавливать кроме ssh, который позволит исполнять нам удалённо скрипты.
Модули и плагины - это подключаемые к Ansible куски кода. Сами отличия:
| Модули | Плагины |
|---|---|
| Выполняются на клиентах | Выполняются на host-машине |
| Выполняются при подключении | Выполняются до подключения |
Установка
Через любой пакетный мендежер (включая pip)
brew install ansible
# Или
sudo [apt|dnf|pacman] install ansibleInventory
Инвентарь - это описание всех хостов и серверов, на которых будут исполняться команды Ansible и playbooks.
Написать конфиг инвентаря можно с помощью менее многословного .ini либо через .yml
Когда мы указываем домены [group] в квадратных скобках, то мы можем исполнять скрипты на целых группах доменов. Такие группы можно, например, использовать для установки nginx на множество серверов.

Так же мы можем:
- Если у машины есть порядковые номера, то можно указать период
- Можем использовать определённые переменные
- Так же можно для сервера без названия указать алиас, к которому будет удобно обращаться (так на третьем примере сервер будет называться
coolhost, а не192.68...)

- Так же можно спокойно указать переменные для группы через
[group:vars]

Параметры подключения к хосту состоят из 4ёх блоков:
- Параметры подключения
ansible_connection- ssh / sftp / scphost- имя хоста, к которому подключаемсяport- порт (тут может понадобится кастомный ssh-порт не 22, а 2222)user- имя пользователяpassword- пароль. Используется, если не заданы ssh-ключи для подключения.
- Параметры ssh / scp / sftp
private_key_file- путь к приватному ключу, но дефолтно используется файл в.sshcommon_args- общие аргументы для всех типов подключенияextra_args- дополнительные аргументыpipelining- ограничение количества ssh-подключений (чтобы не открывать 100 подключений одновременно)executable- дополнительная настройка выполнения
- Привилегии (применение команды из под sudo)
- become - нужно ли выполнять от sudo
- method - метод перехода (su/sudo)
- exe, flags - настраивает поведение перехода к sudo
- Настройки shell
shell_type- выбор shell (bash/zsh)- интерпретатор питона
- екзекутер скрипта

Напишем файл инвентаря нашего клиента. Тут мы описали сервер, который крутится у нас на localhost и подключаемся к нему по заданному пользователю. Порт другой, чтобы не занимать наш корневой ssh.
hosts.ini
[demo]
127.0.0.1 ansible_user=zeizel ansible_port=2222
Далее через -i указываем путь к инвентарю и дёргаем модуль ping из ansible по всей группе доменов demo
ansible -i hosts.ini -m ping demoИ далее нам прилетел ответ с сервера (на котором у нас стоял python, чтобы триггернуть эту операцию)

Modules
Модули - это отдельные блоки кода, которые можно использовать для выполнения команд на хостах и сбора возвращаемых значений.
service- поднимает сервисcommand- выполняет команду в shell
# пример использования модуля service и передачи в него аргументов -a
ansible webservers -m service -a "name=httpd state=started"
# указание модуля -m и аргументов -a
ansible webservers -m command -a "/sbin/reboot -t now"Генерирует документацию
ansible-doc userAd-hoc
Ad-hoc - это команда для быстрого выполнения скрипта, которую мы не хотим сохранять для дальнейшего использования.
Используется для:
- быстрых фиксов (например, упал сервер)
- для получения информации с сервера
- для тестирования отдельных команд
Это аналог docker run -it sh, когда мы напрямую попадаем в крутящийся докер и выполняем в нём команды.
Ad-hoc команда выглядит следующим образом:
- инвентарь
- модуль - только один в ad-hoc
- аргументы
- указание хостов (all / ip / группа)
Текущая команда создаст определённого пользователя по name на всех хостах

Теперь, после выполнения команды, мы знаем, что пользователь представлен на сервере.
У нас есть несколько типов вывода:
- Success - Зелёный - операция ничего не изменила
- Changed - Жёлтый - операция что-то изменила
- Failed - Красный - операция не выполнена
➜ ansible -i hosts -m user -a "name=zeizel state=present" localhost
localhost | SUCCESS => {
"append": false,
"changed": false,
"comment": ",,,",
"group": 1000,
"home": "/home/zeizel",
"move_home": false,
"name": "zeizel",
"shell": "/usr/bin/zsh",
"state": "present",
"uid": 1000
}
Чтобы операция выполнилась от sudo, добаляем become через -b и запрашиваем интерактивно пароль через -K. Это первый способ использования sudo.
➜ ansible -m user -a "name=zeizel state=present" -b -K localhost
BECOME password:
localhost | SUCCESS => {
"append": false,
"changed": false,
"comment": ",,,",
"group": 1000,
"home": "/home/zeizel",
"move_home": false,
"name": "zeizel",
"shell": "/usr/bin/zsh",
"state": "present",
"uid": 1000
}


Ну и поменяв параметр на absent мы удаляем пользователя из системы (потому что тут мы декларативно управляем пользователями системы)

Второй способ - параметры. Так же вместо ключей мы можем просто передать всё параметрами через -e
ansible -i hosts -m user -a "name=zeizel state=absent" -e "ansible_become=true ansible_become_password=123" demoТак же можно пойти третьим способом и выполнить операцию от sudo, передав все параметры сразу в инвентарь.
Это нерекоммендуемый способ, так как в инвентарь класть секретные данные - плохо!
Положить ip, алиас - это норм. Но работать с паролем и командами - это задача отдельного домена.
hosts.ini
[demo]
127.0.0.1 ansible_user=zeizel ansible_port=2222 ansible_become=true ansible_become_password=123
И дёрнуть
ansible -i hosts -m user -a "name=zeizel state=absent" demoAnsible playbooks
Playbook
Ansible Playbook - это описание конфигурации, оркестрации или выкладки. Оно описывает состояние удалённой системы или шаги отдельного процесса.
Это самодокументируемая последовательность операций, которая описана в скрипте. Она сохранятся на компьютере и может повторяться из раза в раз на всех связанных устройствах.
name- имя плейбукаhosts- машины, на которых должны будут выполниться командыtasks- это набор ad-hoc команд. В них передаются те же самые параметры, что и в обычные команды ansible.

Если нужно запустить команду в том числе и локально, то можно добавить ansible_connection=local, чтобы все операции выполнить на нашем ПК
hosts.ini
[demo]
127.0.0.1 ansible_user=zeizel ansible_connection=localА далее напишем скрипт playbook
- name - это имя выполняемой операции
- user - это модуль, который подтянется для выполнения команды
user.yml
---
- name: user
hosts: demo
tasks:
- name: create user
become: true # предоставит возможность запросить sudo пароль
user:
name: zeizel
state: presentИ далее запускаем ansible-playbook команду. Добавляем -K, чтобы в случае чего запросить sudo пароль
ansible-playbook -i hosts.ini user.yml -K
Переменные
Переменные - это переиспользуемые величины в playbook
- переиспользуются в разных местах
- позволяет агрегировать конфиг в одном месте
- позволяет использовать различные значения для разных окружений и сервисов
В качестве переменных нельзя использовать:
- ключевые слова из python (async)
- зарезервированные слова playbook
*myvar- wildcarts и спецсимволы в началеmy var- пробелыmy-var- дефисы5my_var- начинать с числа
Использование
Перменные можно задавать в: playbook, block, tasks, group_vars, host_vars, inventory, extra_vars
Мы можем задать переменные через ключ vars и использовать их через синтаксис '{{ environment }}'
Модуль
Запись переменной для модуля
---
- name: user
hosts: demo
tasks:
- name: create user
vars: # <- переменная модуля
user: zeizel
become: true
user:
name: '{{ user }}'
state: presentВесь плейбук
И запись переменной для всего плейбука
---
- name: user
hosts: demo
vars: # <- переменная плейбука
user: zeizel
tasks:
- name: create user
become: true
user:
name: '{{ user }}'
state: presentЗадание переменной из промпта
Через vars_prompt мы можем указать, какие переменные нас должен попросить ввести playbook в процессе выполнения тасок
- name: user
hosts: demo
vars_prompt:
- name: user # имя переменной
prompt: 'Input user name' # выводимый лейбл для ввода переменной
private: no # скрывать ли вводимое значение
tasks:
- name: create user
become: true
user:
name: '{{ user }}'
state: present
Из отдельного файла
Создаём отдельный файл чисто под переменные окружения
user_vars.yml
---
user: zeizelТут уже мы импортируем через поле vars_files список назначений с переменными окружения
---
- name: user
hosts: demo
vars_files: [./user_vars.yml]
tasks:
- name: create user
become: true
user:
name: '{{ user }}'
state: present
Неявно указание
Так же в ansible присутствует плагин, который неявно берёт переменные по различным группам данных
Вот базовый инвентарь
[demo]
127.0.0.1 ansible_user=zeizel ansible_connection=localПо названию группы из описанного инвентаря
- Из инвентаря берём наименование группы
demo - Создаём папку
group_vars, в которую помещаем папку с группой и фиксированным именемvars.yml
group_vars / demo / vars.yml
---
user: zeizel- В файле playbook ничего указывать не нужно - просто используем переменную
---
- name: user
hosts: demo
tasks:
- name: create user
become: true
user:
name: '{{ user }}'
state: presentПо хостам
- Из инвентаря берём наименование хоста (можно алиас, если есть)
127.0.0.1 - Создаём папку
host_vars, в которой мы создаём ямл с именем хоста127.0.0.1.yml
host_vars / 127.0.0.1.yml
---
user: zeizel- И переменные так же работают без прямого импорта
Задавать переменные прямо в инвентаре
Так же переменная напрямую попадёт в playbook, если мы укажем её прямо в инвентаре
[demo]
127.0.0.1 ansible_user=zeizel ansible_connection=local user=zeizelОбъединение инвентарей
Так же ничто нам не мешает вместо одного инвентаря использовать сразу несколько, чтобы складывать большое количество хостов и данных по ним
Создадим папку demo-server, в которую поместим просто файл demo
demo-server / demo
[demo]
127.0.0.1 ansible_user=zeizel ansible_connection=local user=zeizel
И в качестве инвентаря указываем не отдельный файл, а всю папку целиком, где все инвентари из неё сконкатенируются в один
ansible-playbook -i demo-server user.yml -KУстановка переменных в группе инвентарей
При таком подходе, мы можем положить переменные прямо в ту же папку с инвентарём и не пихать их прямо в сам файл с хостами
group_vars / all.yml
---
user: zeizelПолучается примерно такая структура папок
.
├── demo-server
│ ├── group_vars
│ │ └── all.yml
│ └── hosts
└── user.ymlУстановка переменных через extra-vars
Ну и передача переменных прямо в команде через флаг --extra-vars
ansible-playbook -i demo-server user.yml -K --extra-vars "user=zeizel"Порядок переменных по приоритету
Переменные автоматически мёрджатся из разных источников. Применяются самые конкретно заданные переменные (группа < хост < роль < блок < таска)
--extra-vars - это самые приоритетные переменные из всех.

Отладка
При написании любых скриптов, появляется необходимость дебажить код.
В Ansible есть несколько способов дебага значений:
Дебаг через плейбук
- Нам нужно зарегистрировать вывод результатов таски через ключ
register(грубо говоря, это создание переменной с результатом выполнения) - Создать таску, которая проинициализирует дебаг через
debug: var: <name>
user.yml
---
- name: user
hosts: local
tasks:
- name: create user
user:
name: '{{ user }}'
state: present
become: true
register: out # <- регистрация дебага
- debug: # <- инициализация дебага
var: outПолучаем дебаг-значения
•% ➜ ansible-playbook -i all-servers user.yml -K
BECOME password:
PLAY [user] *****************
TASK [Gathering Facts] *****************
[WARNING]: Host '127.0.0.1' is using the discovered Python interpreter at '/opt/homebrew/bin/python3.14', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [127.0.0.1]
TASK [create user] ***************
ok: [127.0.0.1]
TASK [debug] ***************
ok: [127.0.0.1] => {
"out": {
"append": false,
"changed": false,
"comment": "zeizel",
"failed": false,
"group": 20,
"home": "/Users/zeizel",
"move_home": false,
"name": "zeizel",
"shell": "/bin/zsh",
"state": "present",
"uid": 501
}
}
PLAY RECAP ****************
127.0.0.1 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Debugger
Либо мы можем активировать дебаг-режим
Активируется он при включении поля debugger в плейбук со значчениями:
always- дебаггер будет отрабатывать всегдаnever- никогда не будет отрабатыватьon_failed- только при провале заданияon_unreachable- когда хост недоступенon_skipped- когда задача пропускается
---
- name: user
hosts: local
tasks:
- name: create user
user:
name: '{{ user }}'
state: present
become: true
debugger: alwaysНаходясь в режиме дебага, мы получаем непосредственный доступ к данным текущей выполняемой операции
У нас в доступе появляются команды:
p-print- позволяет вывести определённое значениеr-retry- повторит текущую задачу и вернёт в режим дебага (после того, как мы сделали изменения в шаге)u- полностью перезапустить таску с новыми переменнымиc- переходит на следующий шаг выполнения задачи
Доступные значения находятся в документации, но вот некоторые из них:
task- объект с текущей задачейname- имя таскиargs- аргументы запуска таскиvars- переменные таски
task_vars- все переменные, которые попали в таску
•% ➜ ansible-playbook -i all-servers user.yml -K
BECOME password:
PLAY [user] ************
TASK [Gathering Facts] **********
[WARNING]: Host '127.0.0.1' is using the discovered Python interpreter at '/opt/homebrew/bin/python3.14', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [127.0.0.1]
TASK [create user] ************
ok: [127.0.0.1]
[127.0.0.1] TASK: create user (debug)> p task
TASK: create user
[127.0.0.1] TASK: create user (debug)> p task.args
{'_ansible_check_mode': False,
'_ansible_debug': False,
'_ansible_diff': False,
'_ansible_ignore_unknown_opts': False,
'_ansible_keep_remote_files': False,
'_ansible_module_name': 'user',
'_ansible_no_log': False,
'_ansible_remote_tmp': '~/.ansible/tmp',
'_ansible_selinux_special_fs': ['fuse',
'nfs',
'vboxsf',
'ramfs',
'9p',
'vfat'],
'_ansible_shell_executable': '/bin/sh',
'_ansible_socket': None,
'_ansible_syslog_facility': 'LOG_USER',
'_ansible_target_log_info': None,
'_ansible_tmpdir': '/Users/zeizel/.ansible/tmp/ansible-tmp-1766230032.075149-16637-257421265470080/',
'_ansible_tracebacks_for': [],
'_ansible_verbosity': 0,
'_ansible_version': '2.20.1',
'name': 'zeizel',
'state': 'present'}
[127.0.0.1] TASK: create user (debug)> p task_vars['inventory_hostname']
'127.0.0.1'Так же мы можем напрямую менять значения во время исполнения и повторить операцию r, чтобы увидеть, что она выполнилась с изменённым значением
[127.0.0.1] TASK: create user (debug)> task.args['name']='asdasd'
[127.0.0.1] TASK: create user (debug)> r
changed: [127.0.0.1]
[127.0.0.1] TASK: create user (debug)> task.args['name']='zeizel'
[127.0.0.1] TASK: create user (debug)> r
ok: [127.0.0.1]И далее нам остаётся продолжать выполняемые шаги таски через c. А в самом конце можем полностью проверить проходку по всем операциям с нашими новыми переменными через u.
Такой интерактивный режим очень полезен, так как у нас появляется возможность в моменте настроить нужное значение, повторить операцию и сразу убедиться - прошла она правильно или нет.
Блоки и отработка ошибок
Все выполняемые задачи и обработку ошибок мы можем сгруппировать в блоки в рамках Ansible и задать одинаковые параметры для них
Блоки
Все таски мы можем сгруппировать в block для провайда общих параметров
---
- name: user
hosts: local
tasks:
- name: Preconfig block
become: true
block:
- name: create user
user:
name: '{{ user }}'
state: present
- name: install curl
apt:
name: curl
update_cache: yesОбработка ошибок
Обработка ошибок происходит с помощью ключа rescue. В нём мы должны описать действие, которое должно выполниться, если произойдёт ошибка: откатить сервер, удалить пользователя.
В ключе always мы должны описать то, что должно будет происходить всегда (после ошибки или успешного выполнения тасок). Например, перезагрузка сервера.

rescue и always
Опишем поля rescue и always. Они представляют собой просто таски, которые будут выполняться.
---
- name: user
hosts: local
tasks:
- name: Preconfig block
become: true
block:
- name: create user
register: error
user:
name: '{{ user }}'
state: present
- name: install curl
register: error
apt:
name: curl
update_cache: yes
# выполнится при ошибке таски
rescue:
- name: Error print
debug:
var: error
# этот блок будет выполняться всегда после ошибки или успеха
always:
- name: Rebooting
debug:
msg: 'Rebooting pc...'Обработка нестандартных ошибок
Так же мы можем обработать ошибку, которая не является классической ошибкой (exit_code !== 0) и определить самим условия ошибки
Поле failed_when позволит нам проверить в данном случае, что строка FAILED находится в поле stdout объекта echo_failed
---
- name: user
hosts: local
tasks:
- name: Preconfig block
become: true
block:
- name: create user
register: error
user:
name: '{{ user }}'
state: present
- name: install curl
register: error
apt:
name: curl
update_cache: yes
- name: failed on FAILED
command: echo "FAILED"
register: echo_failed
# если в поле stdout переменной echo_failed есть FAILED, то таска выполнилась с ошибкой
failed_when: "'FAILED' in echo_failed.stdout"
rescue:
- name: Error print
debug:
var: error
always:
- name: Rebooting
debug:
msg: 'Rebooting pc...'Работа с условиями
Так же мы можем добавить условий в наш блок для выполнения задач:
any_errors_fatal- любая произошедшая ошибка будет валить все дальнейшие таскиignore_errors- позволяет проигнорировать ошибку в таске
Ключ when - самый многофункциональный блок. В нём мы можем пользоваться register переменными, фактами, которые собирает ansible и выполнять по условию блоки операций.
В данном примере мы выполним операцию только тогда, когда нашей целевой системой будет Ubuntu
---
- name: user
hosts: local
any_errors_fatal: true # любая ошибка будет предотвращать выполнение ansible
tasks:
- name: Preconfig block
become: true
when: ansible_facts['distribution'] == 'Ubuntu'
block:
- name: create user
register: error
ignore_errors: yes # игнорирование ошибки
user:
name: '{{ user }}'
state: present
- name: install curl
register: error
apt:
name: curl
update_cache: yes
- name: failed on FAILED
command: echo "FAILED"
register: echo_failed
# если в поле stdout переменной echo_failed есть FAILED, то таска выполнилась с ошибкой
failed_when: "'FAILED' in echo_failed.stdout"
# выполнится при ошибке таски
rescue:
- name: Error print
debug:
var: error
# этот блок будет выполняться всегда после ошибки или успеха
always:
- name: Rebooting
debug:
msg: 'Rebooting pc...'Теперь, так как текущая система не Ubuntu, то все таски в блоке скипаются
•% ➜ ansible-playbook -i all-servers user.yml -K
BECOME password:
PLAY [user] *********
TASK [Gathering Facts] *********
[WARNING]: Host '127.0.0.1' is using the discovered Python interpreter at '/opt/homebrew/bin/python3.14', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [127.0.0.1]
TASK [create user] ************
skipping: [127.0.0.1]
TASK [install curl] **********
skipping: [127.0.0.1]
TASK [failed on FAILED] *************************
skipping: [127.0.0.1]
TASK [Rebooting] *******************
skipping: [127.0.0.1]
PLAY RECAP ***********
127.0.0.1 : ok=1 changed=0 unreachable=0 failed=0 skipped=4 rescued=0 ignored=0
Асинхронные задачи
Очень часто бывает так, что операций над серверами может быть достаточно много. Так же эти операции могут выполняться длительное время. Но не всегда все эти операции нам нужно дожидаться и ускорить этот процесс можно распараллелив задачи.
Ansible предоставляет нам возможность асинхронно выполнять задачи без ожидания других тасок.
Для реализации асинхронности, у нас есть ключи:
async- время, которое максимально должна выполняться таска.poll- время, раз в которое нужно будет проверять выполнение операции. Если установлено в 0, то следующая операция начнёт выполняться незамедлительно, пока выполняется прошлая.
Если нам нужно в дальнейшей таске отловить выполнение старой асинхронной таски, то нам нужно будет выполнять эти операции под одним юзером

Ожидание асинхронной операции
Описание асинхронной операции с таймаутом в 100 секунд (async) и периодом проверки в 5 секунд (poll)
- name: user
hosts: local
tasks:
- name: Preconfig block
become: true
block:
- name: sleep
command: /bin/sleep 10
async: 100
poll: 5
- name: echo
command: echo "DONE"Ansible сначала будет ожидать длительную операцию и только потом выполнит вторую таску echo
$ ansible-playbook -i all-servers user.yml -K
BECOME password:
PLAY [user] *************
TASK [Gathering Facts] **********
[WARNING]: Host '127.0.0.1' is using the discovered Python interpreter at '/opt/homebrew/bin/python3.14', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [127.0.0.1]
TASK [sleep] ***********
ASYNC POLL on 127.0.0.1: jid=j592691861136.57376 started=True finished=False
ASYNC OK on 127.0.0.1: jid=j592691861136.57376
changed: [127.0.0.1]
TASK [echo] *********
changed: [127.0.0.1]
PLAY RECAP *********
127.0.0.1 : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Параллельные асинхронные таски
Для создания параллельно выполняемых тасок, нам нужно:
pollустановить в 0 секунд (отключить проверку)- в таске, которая ждёт результата выполнения предыдущей таски, нужно отследить статус асинхронности
async_status- в
async_statusуказатьjid(job id) задачи, от которой зависим
- в
- зарегистрировать текущую таску
register - Ожидать
untilвыполнение текущей таскиjob_result.finished - проверять
retriesвыполнение операции с задержкойdelay
---
- name: user
hosts: local
tasks:
- name: Preconfig block
become: true
block:
- name: sleep
command: /bin/sleep 10
async: 100
poll: 0
register: sleep
- debug:
var: sleep
- name: echo
command: echo "DONE"
- name: check sleep status
# цепляем job id из асинхронной таски
async_status:
jid: '{{ sleep.ansible_job_id }}'
# регистрируем джобу и ожидаем её выполнения
register: job_result
until: job_result.finished
retries: 100 # повторных попыток
delay: 1 # задержка между retries
become: trueВ итоге у нас сначала стартануло выполнение таски sleep и сразу за ней debug и echo. После них стартанул check sleep status, который ждал выполнения таски sleep
$ ansible-playbook -i all-servers user.yml -K
BECOME password:
PLAY [user] *****
TASK [Gathering Facts] ********
[WARNING]: Host '127.0.0.1' is using the discovered Python interpreter at '/opt/homebrew/bin/python3.14', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [127.0.0.1]
TASK [sleep] *******
changed: [127.0.0.1]
TASK [debug] *********
ok: [127.0.0.1] => {
"sleep": {
"ansible_job_id": "j990495658061.63641",
"changed": true,
"failed": false,
"finished": false,
"results_file": "/var/root/.ansible_async/j990495658061.63641",
"started": true
}
}
TASK [echo] *******
changed: [127.0.0.1]
TASK [check sleep status] *****
FAILED - RETRYING: [127.0.0.1]: check sleep status (100 retries left).
FAILED - RETRYING: [127.0.0.1]: check sleep status (99 retries left).
FAILED - RETRYING: [127.0.0.1]: check sleep status (98 retries left).
FAILED - RETRYING: [127.0.0.1]: check sleep status (97 retries left).
FAILED - RETRYING: [127.0.0.1]: check sleep status (96 retries left).
FAILED - RETRYING: [127.0.0.1]: check sleep status (95 retries left).
FAILED - RETRYING: [127.0.0.1]: check sleep status (94 retries left).
FAILED - RETRYING: [127.0.0.1]: check sleep status (93 retries left).
changed: [127.0.0.1]
PLAY RECAP *************
127.0.0.1 : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Исключительные операции
Некоторые операции в рамках Ansible просто невозможно выполнить без указания асинхронности операции. Например, reboot сервера - тут обязательно операцию нужно сделать асинхронной, потому что от команды reboot нет внятного результата.
---
- name: user
hosts: local
tasks:
- name: Preconfig block
become: true
block:
- name: reboot
command: reboot
async: 100
poll: 0Но для бОльшей части таких операций уже есть свой модуль (в т.ч. reboot модуль), которые:
- более многофункциональны
- легче читаются
- проще в поддержке
Так же для ожидания результата другой таски стоит использовать builtin модуль в Ansible - wait_for
Упражнение - Настройка сервера
Установка докера будет происходить в несколько этапов.
В качестве референса будут выступать:
config.yml
---
- name: Preconfig
hosts: home
tasks:
- name: Install Docker Engine on Ubuntu
become: true
block:
# удаление конфликтующих пакетов
- name: Remove conflicting Docker packages
ignore_errors: true
apt:
state: absent
purge: true
name:
- docker.io
- docker-compose
- docker-compose-v2
- docker-doc
- podman-docker
- containerd
- runc
# добавление универсального репозитория
- name: Add universe repo
apt_repository:
# в качестве целевой системы с учётом версии, мы берём ansible_distribution_release
repo: "deb http://us.archive.ubuntu.com/ubuntu/ {{ ansible_distribution_release }} universe"
state: present
# установка требуемых пакетов для docker
- name: Install required packages
apt:
update_cache: true
cache_valid_time: 86400
name:
- ca-certificates
- curl
- gnupg
- lsb-release
# создание директории ключей
- name: Create /etc/apt/keyrings directory
file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
# добавление официальных GPG ключей
- name: Add Docker official GPG key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
# добавление репозитория
- name: Add Docker apt repository
copy:
dest: /etc/apt/sources.list.d/docker.sources
mode: "0644"
content: |
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: {{ ansible_lsb.codename | default(ansible_distribution_release) }}
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
# обновление кэша
- name: Update apt cache
apt:
update_cache: true
# установка docker пакетов
- name: Install Docker Engine and components
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
state: present
# запуск сервиса Docker
- name: Ensure Docker service is enabled and started
service:
name: docker
state: started
enabled: true
# установка python docker SDK
- name: Install Python Docker SDK
become: true
pip:
name: docker
# блок задач после установки
- name: Post-install Docker
become: true
block:
- name: Ensure docker group exists
group:
name: docker
state: present
- name: Add current user to docker group
user:
name: "{{ ansible_user | default(ansible_env.SUDO_USER) }}"
groups: docker
append: true
- name: Reboot to apply docker group membership
when: docker_reboot | default(true)
reboot:
msg: "Rebooting to apply docker group membership"
connect_timeout: 5
reboot_timeout: 600
Запуск конфига на сервере
ansible-playbook -i demo-server config.yml -KПолучение последней версии пакета с Github
У Github есть удобное публичное API для работы с репозиториями.
Если перед нами встанет такая ситуация, что нам нужно динамически получить последнюю версию пакета, то мы можем перейти на страницу релизов репозитория:
https://github.com/docker/compose/releases

И трансформировать ссылку в такой род:
https://api.github.com/repos/docker/compose/releases/latest

Где можно будет найти поле tag_name
И если нам нужно будет вставить определённую версию какой-либо библиотеки (или последнюю), то можно курлануть данные, записать их в регистр и переиспользовать в следующей таске
- name: Установка Docker-compose
block:
- name: Получение последней версии Docker-compose
uri:
url: https://api.github.com/repos/docker/compose/releases/latest
body_format: json
register: page
- name: Установка Docker-compose
get_url:
url: "https://github.com/docker/compose/releases/download/{{ page.json.tag_name }}/docker-compose-Linux-x86_64"
dest: /usr/local/bin/docker-compose
mode: 0755
become: trueAnsible Lint
Для поддержания стандарта качества кода нашего конфига состояний и скриптов Ansible, мы можем использовать линтер.
Как самую распространённую ошибку, можно выделить использование command вместо специализированного модуля, который позволит декларативно описать состояние, к которому должна прийти система после выполнения задачи.
Ansible lint
Устанавливается линтер отдельным пакетом
brew install ansible-lintДалее останется только запустить проверку файла и ansible подсветит все возможные исправления для нашего конфига, чтобы стандартизировать его и улучшить
$ ansible-lint config.yml
/opt/homebrew/Cellar/python@3.14/3.14.2/Frameworks/Python.framework/Versions/3.14/lib/python3.14/tempfile.py:484: ResourceWarning: Implicitly cleaning up <HTTPError 304: 'Not Modified'>
_warnings.warn(self.warn_message, ResourceWarning)
WARNING Listing 15 violation(s) that are fatal
ignore-errors: Use failed_when and specify error conditions instead of using ignore_errors.
config.yml:8 Task/Handler: Remove conflicting Docker packages
fqcn[action-core]: Use FQCN for builtin module actions (apt).
config.yml:10:11 Use `ansible.builtin.apt` or `ansible.legacy.apt` instead.
fqcn[action-core]: Use FQCN for builtin module actions (apt_repository).
config.yml:23:11 Use `ansible.builtin.apt_repository` or `ansible.legacy.apt_repository` instead.
fqcn[action-core]: Use FQCN for builtin module actions (apt).
config.yml:29:11 Use `ansible.builtin.apt` or `ansible.legacy.apt` instead.
fqcn[action-core]: Use FQCN for builtin module actions (file).
config.yml:39:11 Use `ansible.builtin.file` or `ansible.legacy.file` instead.
fqcn[action-core]: Use FQCN for builtin module actions (apt_key).
config.yml:45:11 Use `ansible.builtin.apt_key` or `ansible.legacy.apt_key` instead.
fqcn[action-core]: Use FQCN for builtin module actions (copy).
config.yml:50:11 Use `ansible.builtin.copy` or `ansible.legacy.copy` instead.
fqcn[action-core]: Use FQCN for builtin module actions (apt).
config.yml:61:11 Use `ansible.builtin.apt` or `ansible.legacy.apt` instead.
fqcn[action-core]: Use FQCN for builtin module actions (apt).
config.yml:65:11 Use `ansible.builtin.apt` or `ansible.legacy.apt` instead.
fqcn[action-core]: Use FQCN for builtin module actions (service).
config.yml:75:11 Use `ansible.builtin.service` or `ansible.legacy.service` instead.
fqcn[action-core]: Use FQCN for builtin module actions (pip).
config.yml:82:7 Use `ansible.builtin.pip` or `ansible.legacy.pip` instead.
fqcn[action-core]: Use FQCN for builtin module actions (group).
config.yml:89:11 Use `ansible.builtin.group` or `ansible.legacy.group` instead.
fqcn[action-core]: Use FQCN for builtin module actions (user).
config.yml:94:11 Use `ansible.builtin.user` or `ansible.legacy.user` instead.
fqcn[action-core]: Use FQCN for builtin module actions (reboot).
config.yml:101:11 Use `ansible.builtin.reboot` or `ansible.legacy.reboot` instead.
yaml[empty-lines]: Too many blank lines (1 > 0)
config.yml:105
Read documentation for instructions on how to ignore specific rule violations.
# Rule Violation Summary
1 yaml profile:basic tags:formatting,yaml
1 ignore-errors profile:basic tags:unpredictability
13 fqcn profile:basic tags:formatting
Failed: 15 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Last profile that met the validation criteria was 'min'.Исправим конфигурацию таким образом, чтобы:
- избавиться от deprecated полей (заменим
ansible_distribution_releaseнаansible_facts['distribution_release']) - правильно укажем модули через
ansible.builtin., потому что у нас доступен так же вызов старого APIansible.legacy.
config.yml
---
- name: Install Docker Engine on Ubuntu
hosts: cluster
become: true
gather_facts: true
tasks:
- name: Pre-install cleanup
block:
- name: Remove conflicting Docker packages
ansible.builtin.apt:
name:
- docker.io
- docker-compose
- docker-compose-v2
- docker-doc
- podman-docker
- containerd
- runc
state: absent
purge: true
failed_when: false
- name: Remove old Docker files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/apt/keyrings/docker.asc
- /etc/apt/keyrings/docker.gpg
- /usr/share/keyrings/docker-archive-keyring.gpg
- /etc/apt/trusted.gpg.d/docker.gpg
- /etc/apt/sources.list.d/docker.list
- /etc/apt/sources.list.d/docker.sources
- name: Fix ARM64 repositories
block:
- name: Remove duplicate repository files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/apt/sources.list.d/ports_ubuntu_com_ubuntu_ports.list
- /etc/apt/sources.list.d/ubuntu.sources
- name: Replace sources.list for ARM64
ansible.builtin.copy:
dest: /etc/apt/sources.list
mode: "0644"
backup: true
content: |
deb http://ports.ubuntu.com/ubuntu-ports {{ ansible_facts['distribution_release'] }} main restricted universe multiverse
deb http://ports.ubuntu.com/ubuntu-ports {{ ansible_facts['distribution_release'] }}-updates main restricted universe multiverse
deb http://ports.ubuntu.com/ubuntu-ports {{ ansible_facts['distribution_release'] }}-security main restricted universe multiverse
when: ansible_facts['architecture'] in ['aarch64', 'arm64']
- name: Install prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
update_cache: true
- name: Setup Docker repository and install
ansible.builtin.shell: |
set -e
# Cleanup old Docker files
rm -f /etc/apt/keyrings/docker.* /usr/share/keyrings/docker* /etc/apt/sources.list.d/docker.*
# Create directory
mkdir -p /etc/apt/keyrings
# Download and import GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod 644 /etc/apt/keyrings/docker.gpg
# Detect architecture
ARCH=$(dpkg --print-architecture)
# Add Docker repository
echo "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_facts['distribution_release'] }} stable" > /etc/apt/sources.list.d/docker.list
# Update and install Docker
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
args:
executable: /bin/bash
register: docker_install
changed_when: "'Setting up docker-ce' in docker_install.stdout or 'is already the newest version' not in docker_install.stdout"
- name: Ensure Docker service is running
ansible.builtin.service:
name: docker
state: started
enabled: true
- name: Post-install configuration
block:
- name: Ensure docker group exists
ansible.builtin.group:
name: docker
state: present
- name: Add user to docker group
ansible.builtin.user:
name: "{{ ansible_user | default(ansible_env.SUDO_USER | default('ubuntu')) }}"
groups: docker
append: true
- name: Reboot if needed
ansible.builtin.reboot:
msg: "Rebooting after Docker installation"
when: docker_reboot | default(false) | boolПосле исправления всех ошибок, выйдет такое сообщение
$ ansible-lint config.yml
Passed: 0 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Last profile that met the validation criteria was 'production'.Pre-commit
Для добавления pre-commit хуков, которые не дадут нам возможности закоммитить и отправить ansible конфигурацию с ошибками, нам нужно сначала установить pre-commit
brew install pre-commitДалее инициализируем его в проекте
pre-commit installА теперь добавляем .yaml конфигурацию в проект
.pre-commit-config.yaml
---
ci:
autoupdate_schedule: monthly
repos:
- repo: https://github.com/ansible-community/ansible-lint.git
rev: v25.12.1 # подставляем версию из репозитория
hooks:
- id: ansible-lint
files: \.(yaml|yml)$
additional_dependencies:
- ansibleРазвёртка машин с Vagrant
Установка
Установка могла бы быть простейшей, если бы vagrant не заблокировали установку для России.
Если использовать ВПН, то так:
brew tap hashicorp/tap
brew install hashicorp/tap/vagrantЕсли нет, то нужно будет устанавливать способами под определённые дистры из доки
Чтобы была возможность поднять Vagrant, нам нужно добавить в начало Vagrantfile энву с селфхост сервером:
ENV['VAGRANT_SERVER_URL'] = 'https://vagrant.elab.pro'Скачать последнюю версию Vagrant мы можем из репозиториев Yandex или Mail.ru
Vagrant поддерживает только определённые версии VB, поэтому лучше всего скачивать их последние версии, чтобы не было проблем с совместимостью!
Развёртка машин
Генерируем RSA ключ на нашей машине
ssh-keygen -t rsaКонфиг vagrant написан на Ruby
Vagrantfile
# миррор вагранта
ENV['VAGRANT_SERVER_URL'] = 'https://vagrant.elab.pro'
# конфигурация для второй версии
Vagrant.configure("2") do |config|
# повторяется 5 раз, чтобы поднять 5 машин
(1..5).each do |i|
# выполнение команд для сервера под индексом (web - переменная конфига, которую мы именуем сами)
config.vm.define "server#{i}" do |web|
# box - это конкретная виртуалка, которую нужно развернуть
web.vm.box = "ubuntu/focal64"
# пробрасываем порты (хост порты 2223, 2224... на гостевой 22)
web.vm.network "forwarded_port", id: "ssh", host: 2222 + i, guest: 22
# поднятие приватной сети, чтобы машины видели друг друга
web.vm.network "private_network", ip: "10.11.10.#{i}", virtualbox__intnet: true
# именуем хост
web.vm.hostname = "server#{i}"
# открываем шелл, чтобы установить ssh-ключи
web.vm.provision "shell" do |s|
# путь к публичному ключу
ssh_pub_key = File.readlines("#{Dir.home}/.ssh/id_rsa.pub").first.strip
# вставляем ssh-ключ в vagrant (он используется тут как дефолтный пользователь удалённой машины)
s.inline = <<-SHELL
echo #{ssh_pub_key} >> /home/vagrant/.ssh/authorized_keys
echo #{ssh_pub_key} >> /root/.ssh/authorized_keys
SHELL
end
# подключаемся к провайдеру virtualbox, чтобы поднять 5 машин на убунте
web.vm.provider "virtualbox" do |v|
v.name = "server#{i}"
# выделяем 2 гига озу
v.memory = 2048
# и по одному процессору
v.cpus = 1
end
end
end
endИ поднимаем всё окружение виртуальных машин следующей командой
vagrant up
Если мы поднимаем вагрант из под WSL, то нам нужно:
- Хранить папку проекта на диске c Windows (не в директории linux
~/)- Разрешить Vagrant обращаться к Windows из под WSL через:
export VAGRANT_WSL_ENABLE_WINDOWS_ACCESS="1"
Если выходит эта ошибка, то нужно удалить папку
.vagrantThe VirtualBox VM was created with a user that doesn’t match the current user running Vagrant. VirtualBox requires that the same user be used to manage the VM that was created. Please re-run Vagrant with that user. This is not a Vagrant issue. The UID used to create the VM was: 1000 Your UID is: 0
Теперь мы можем подключиться к любому нашему серверу
ssh vagrant@127.0.0.1 -p 2223желательно сразу подключиться к каждому серверу и прописать
yesпри подключении к ssh

Troubleshooting
Отклонение подключения
Так же подключение может быть отклонено, если есть мусор в файле хостов Для решения проблем, нужно перейти в указанный файл

И удалить эти строки подключений к серверам

У меня Arm
Самый лёгкий способ - это найти в поисковике боксов для вагранта нужный образ, например, bento/ubuntu-24.04 и в web.vm.box использовать его

Подготовка серверов
Обновляем инвентарь нашими серверами, поднятыми вагрантом
inventory / cluster
[cluster]
server1 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2223
server2 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2224
server3 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2225
server4 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2226
server5 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2227И в плейбуке поменять имя хостов
config.yml
---
- name: Preconfig
hosts: cluster
tasks:И запускаем наш плейбук
ansible-playbook -i inventory config.yml -KDocker Swarm
Архитектура
Docker Swarm - это технология кластеризации, встроенная в докер.
Плюсы:
- Очень маленькая когнитивная нагрузка, по сравнению с Kubernetes
- Встроен в Docker
- Даёт много бенефитов по сравнению с обычным Docker Compose
Преимущества
- позволяет управлять кластером серверов
- декларативная модель серверов
- масштабирует нагрузку
- обеспечивает безопасное соединение между серверами
- просто накатывает обновления
- позволяет организовать балансировку нагрузки
Архитектура
В сварме сервера делятся на две роли:
- Manager - аналог master ноды из кубера, который управляет всеми задачами в кластере. Он знает всё о кластере: сколько серверов, что на них запущено, какие для них стоят задачи.
- Worker - нода, которая выполняет задачу от менеджера. Если ему прилетела задача развернуть два сервиса, то он развернёт два определённых сервиса. Воркер ничего не знает о кластере, кроме места, откуда он получает задачи на выполнение.
Все менеджеры объединены по консенсусу RAFT, который позволяет эффективно назначить другую мастер ноду, если основная вышла из строя.
Все сервера менеджеров хранят данные в общем сторе (аналог etcd).
Менеджер так же является подвидом воркера и может планировать поступающие в него таски и сам же их и выполнять.

Ограничения отказоустойчивости:
- мы можем менять роли для каждой ноды
- сами менеджеры работают как воркеры
- максимальное число менеджеров 7 (больше - выше нагрузка из-за обеспечения консенсуса RAFT)
- кластер с
Nменеджерами выдержит потерю(N-1)/2менеджеров (то есть количество менеджеров должно быть нечётным)
Если у нас 3 сервера, то мы можем поднять 3 менеджера, которые будут выступать так же в роли воркеров
Запуск
Сейчас мы будем инициализировать 5 нод - 3 менеджер ноды и 2 воркера.
Создание воркер ноды
Подключаемся к одному из наших удалённых серверов и нам нужно триггернуть инициализацию кластера.
Так как у нас два интернет соединения, то нам нужно будет для сварма через --advertise-addr указать ip приватной сети между машинами, который задали в Vagrant в поле private_network
$ docker swarm init
Error response from daemon: could not choose an IP address to advertise since this system has multiple addresses on different interfaces (10.0.2.15 on eth0 and 10.11.10.2 on eth1) - specify one with --advertise-addr
$ docker swarm init --advertise-addr 10.11.10.2
Swarm initialized: current node (dlas9sxdfvkeldou4ethqyt7h) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-325mhyi3i0y0frhcuarls23985yry9m70k1krkbwukift5bapp-dpmyjrjewqwi0skzss0lcdnnl 10.11.10.2:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.Создание мастер ноды
В рамках менеджерской ноды, мы можем вызвать команду docker swarm join-token и указать роль manager|worker для генерации токена для подключения другой машины с определённой ролью для этого кластера
vagrant@server2:~$ docker swarm join-token manager
To add a manager to this swarm, run the following command:
docker swarm join --token SWMTKN-1-325mhyi3i0y0frhcuarls23985yry9m70k1krkbwukift5bapp-5ck5qux8z4qwqvj4xutrxhmab 10.11.10.2:2377И на втором сервере вызываем эту команду, сгенерированную на прошлом шаге, чтобы подключиться к воркер-ноде, как мастер-нода
vagrant@server3:~$ docker swarm join --token SWMTKN-1-325mhyi3i0y0frhcuarls23985yry9m70k1krkbwukift5bapp-5ck5qux8z4qwqvj4xutrxhmab 10.11.10.2:2377
This node joined a swarm as a manager.Все ноды можно глянуть через node ls
vagrant@server2:~$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
dlas9sxdfvkeldou4ethqyt7h * server2 Ready Active Leader 29.1.3
dj7rowwp31k4ky7jart6nu9j0 server3 Ready Active Reachable 29.1.3Выход и переподключение к сварму
Все операции сварма описаны в хелпе и они достаточно просты
vagrant@server1:~$ docker swarm
Usage: docker swarm COMMAND
Manage Swarm
Commands:
ca Display and rotate the root CA
init Initialize a swarm
join Join a swarm as a node and/or manager
join-token Manage join tokens
leave Leave the swarm
unlock Unlock swarm
unlock-key Manage the unlock key
update Update the swarm
Run 'docker swarm COMMAND --help' for more information on a command.Этой командой мы заставляем покинуть ноду наш сварм-кластер
vagrant@server5:~$ docker swarm leave
Node left the swarm.И теперь, в списке нод, этот сервер будет в статусе Down
vagrant@server1:~$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
vf8zoum7p4oczrx0d32dcvf1i * server1 Ready Active Leader 29.1.3
dewtmbfux0190y4319m6ejrld server2 Ready Active Reachable 29.1.3
vwo3k8p811iybzp8ceh6zyj81 server3 Ready Active 29.1.3
nk2txuihfmoqoqgptepxb90s6 server4 Ready Active Reachable 29.1.3
upqzkpffp7mom2d8epabxpdtg server5 Down Active 29.1.3И заново подключается машина таким же токеном для подключения
vagrant@server5:~$ docker swarm join --token SWMTKN-1-0sy59jtf5wnfx0heevio1s27t4rxlie6qsq3eo1diarj8mbwev-0hc8wrrl429v2wo476k8nq8ja 10.11.10.1:2377
This node joined a swarm as a manager.Теперь у нас будет выыведено две ноды server5.
Чтобы почистить список, нужно воспользоваться командой node rm, которая удалит определённую воркер-ноду.
vagrant@server1:~$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
vf8zoum7p4oczrx0d32dcvf1i * server1 Ready Active Leader 29.1.3
dewtmbfux0190y4319m6ejrld server2 Ready Active Reachable 29.1.3
vwo3k8p811iybzp8ceh6zyj81 server3 Ready Active 29.1.3
nk2txuihfmoqoqgptepxb90s6 server4 Ready Active Reachable 29.1.3
cac83v7bbd2stb1ppa0h09ebp server5 Ready Active Reachable 29.1.3
upqzkpffp7mom2d8epabxpdtg server5 Down Active 29.1.3
vagrant@server1:~$ docker node rm upqzkpffp7mom2d8epabxpdtg
upqzkpffp7mom2d8epabxpdtg
vagrant@server1:~$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
vf8zoum7p4oczrx0d32dcvf1i * server1 Ready Active Leader 29.1.3
dewtmbfux0190y4319m6ejrld server2 Ready Active Reachable 29.1.3
vwo3k8p811iybzp8ceh6zyj81 server3 Ready Active 29.1.3
nk2txuihfmoqoqgptepxb90s6 server4 Ready Active Reachable 29.1.3
cac83v7bbd2stb1ppa0h09ebp server5 Ready Active Reachable 29.1.3Управление ролью ноды
docker node провайдит команды pomote / demote, которые позволяют поднять / опустить роль ноды до воркер-менеджер.
Сейчас мы сняли роль менеджера с серверов 5 и 4 и добавили роль для сервера 3
vagrant@server1:~$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
vf8zoum7p4oczrx0d32dcvf1i * server1 Ready Active Leader 29.1.3
dewtmbfux0190y4319m6ejrld server2 Ready Active Reachable 29.1.3
vwo3k8p811iybzp8ceh6zyj81 server3 Ready Active 29.1.3
nk2txuihfmoqoqgptepxb90s6 server4 Ready Active Reachable 29.1.3
cac83v7bbd2stb1ppa0h09ebp server5 Ready Active Reachable 29.1.3
vagrant@server1:~$ docker node demote server4
Manager server4 demoted in the swarm.
vagrant@server1:~$ docker node demote server5
Manager server5 demoted in the swarm.
vagrant@server1:~$ docker node promote server3
Node server3 promoted to a manager in the swarm.
vagrant@server1:~$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
vf8zoum7p4oczrx0d32dcvf1i * server1 Ready Active Leader 29.1.3
dewtmbfux0190y4319m6ejrld server2 Ready Active Reachable 29.1.3
vwo3k8p811iybzp8ceh6zyj81 server3 Ready Active Reachable 29.1.3
nk2txuihfmoqoqgptepxb90s6 server4 Ready Active 29.1.3
cac83v7bbd2stb1ppa0h09ebp server5 Ready Active 29.1.3Инпекция
Если нам нужно будет просмотреть: статусы, сетевую информацию, информацию по доступным ресурсам, то мы всегда можем обратиться к docker node inspect
vagrant@server1:~$ docker node inspect server4 --pretty
ID: nk2txuihfmoqoqgptepxb90s6
Hostname: server4
Joined at: 2026-01-08 11:31:14.591976229 +0000 utc
Status:
State: Ready
Availability: Active
Address: 10.11.10.4
Platform:
Operating System: linux
Architecture: aarch64
Resources:
CPUs: 1
Memory: 1.785GiB
Plugins:
Log: awslogs, fluentd, gcplogs, gelf, journald, json-file, local, splunk, syslog
Network: bridge, host, ipvlan, macvlan, null, overlay
Volume: local
Engine Version: 29.1.3
TLS Info:
TrustRoot:
-----BEGIN CERTIFICATE-----
MIIBaTCCARCgAwIBAgIUJWvvlgPc98Slpfkx7B9799HbNHYwCgYIKoZIzj0EAwIw
EzERMA8GA1UEAxMIc3dhcm0tY2EwHhcNMjYwMTA4MDgzNzAwWhcNNDYwMTAzMDgz
NzAwWjATMREwDwYDVQQDEwhzd2FybS1jYTBZMBMGByqGSM49AgEGCCqGSM49AwEH
A0IABNJ5aitJHauAqXPqTKQ6DS4CcxOAo4cS4yc1IxW0G+E4zLpoJj3TMQpQ+mU6
kLxu6jIFwYAjASNhE9FSeagI2/6jQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMB
Af8EBTADAQH/MB0GA1UdDgQWBBQWppVsbIGn81T1IrYw1D34eWZiJjAKBggqhkjO
PQQDAgNHADBEAiA7FpBQ74RKDrNB0H2W9bWOXKMEDvY8d4olDNhz2Usc7gIgDd72
6lKrjQHyFhwG6loJ2/FMVQhP5HsAvbeWDAexcv4=
-----END CERTIFICATE-----
Issuer Subject: MBMxETAPBgNVBAMTCHN3YXJtLWNh
Issuer Public Key: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0nlqK0kdq4Cpc+pMpDoNLgJzE4CjhxLjJzUjFbQb4TjMumgmPdMxClD6ZTqQvG7qMgXBgCMBI2ET0VJ5qAjb/g==Одним из важных параметров для нас является Availability, который отвечает за статус возможности принимать новые задачи. Есть несколько статусов:
- active
- может принимать задачи
- paused
- старые задачи работают, но новые задачи не принимает
- например, когда нам нужно проинспектировать высокое потребление ресурсов и отключить добавление задач, но оставить работающими старые сервисы
- drained
- новые задачи не принимаются, а старые переносятся на другие ноды
- например, если у нас спала нагрузка и мы хотим сузить количество используемых серверов (новые задачи не принимай, а старые убивай и переноси на другие ноды)
С помощью node update, мы можем обновить статус доступности ноды по флагу --availability
vagrant@server1:~$ docker node update --availability=pause server4
server4
vagrant@server1:~$ docker node inspect server4 --pretty
ID: nk2txuihfmoqoqgptepxb90s6
Hostname: server4
Joined at: 2026-01-08 11:31:14.591976229 +0000 utc
Status:
State: Ready
Availability: Pause
Address: 10.11.10.4И так же восстановить старый статус работы ноды
vagrant@server1:~$ docker node update --availability=active server4
server4
vagrant@server1:~$ docker node inspect server4 --pretty
ID: nk2txuihfmoqoqgptepxb90s6
Hostname: server4
Joined at: 2026-01-08 11:31:14.591976229 +0000 utc
Status:
State: Ready
Availability: Active
Address: 10.11.10.4Так же команда node update позволяет через флаги задать нам:
- Лейблы для ноды
- Например,
database=true, который будет говорить, что на этой ноде мы будем запускать базы данных. Это может быть полезно, так как изначально, мы не знаем, на какой ноде будет запущен сервис
- Например,
- Удалять лейблы с ноды
- определять роль ноды (воркер/менеджер)
vagrant@server1:~$ docker node update --help
Usage: docker node update [OPTIONS] NODE
Update a node
Options:
--availability string Availability of the node ("active", "pause", "drain")
--label-add list Add or update a node label ("key=value")
--label-rm list Remove a node label if exists
--role string Role of the node ("worker", "manager")Сервисы и задачи
Сервис - это описание, которое хранится внутри Swarm и описывает систему Задача - это операция, которую должен выполнить воркер Контейнер - это коробка с нашим сервисом, которая находится внутри ноды
У нас есть задача: нам нужно описать сервис, который создаст задачу на развёртку на двух нодах АПИ сервиса

Внутри путь по поднятию сервиса выглядит следующим образом:
- Manager
- Мы вызываем через API докер сварма сервис, который он должен поднять
- Дальше происходит создание задачи
- Потом выделяется ip адрес для исполнения задачи
- Затем происходит привязывание задачи к ноде
- Потом Scheduler распределяет задачи
- Worker
- пингует Scheduler на наличие задач
- если задача есть, то он собирает её и передаёт в executer

Так же есть несколько статусов наших тасок:
- new - новая
- pending - выделены ресурсы на задачу
- assigned - определена нода под задачу
- accepted - нода приняла задачу
- preparing - docker подготавливает задачу
- starting - задача запускается
- running - задача работает
- complete - задача завершена без ошибки
- failed - задача завершена с ненулевым кодом
- shutdown - поступил сигнал о завершении задачи от Docker
- rejected - нода не приняла задачу
- orphaned - нода очень давно не отвечает
- remove - задача не была завершена, но сервис удалён
Управление сервисами
Для работы с сервисами нужно использовать команду docker service. Она по синтаксису аналогична команде docker run, только запускает сервисы в рамках кластера.
vagrant@server1:~$ docker service create --name redis redis
m8vrt78ta6325kxnfsfimdxu1
overall progress: 1 out of 1 tasks
1/1: running [==================================================>]
verify: Service m8vrt78ta6325kxnfsfimdxu1 converged
vagrant@server1:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
m8vrt78ta632 redis replicated 1/1 redis:latestНе стоит на одной машине запускать отдельно
docker runконтейнеры и контейнеры сервиса.Дело в том, что обычно созданные контейнеры не отображаются в
docker serviceсписке и не управляются свармом. Такой подход может привести к неразберихе и тяжело будет найти нужный сервис в рамках нашего кластера, что однозначно приведёт к утечке памяти и других ресурсов.
Инспект сервиса прозволяет увидеть режим (с репликой / без) сервиса, его параметры, изображение, ресурсы
vagrant@server1:~$ docker service inspect redis --pretty
ID: m8vrt78ta6325kxnfsfimdxu1
Name: redis
Service Mode: Replicated
Replicas: 1
Placement:
UpdateConfig:
Parallelism: 1
On failure: pause
Monitoring Period: 5s
Max failure ratio: 0
Update order: stop-first
RollbackConfig:
Parallelism: 1
On failure: pause
Monitoring Period: 5s
Max failure ratio: 0
Rollback order: stop-first
ContainerSpec:
Image: redis:latest@sha256:47200b04138293fae39737e50878a238b13ec0781083126b1b0c63cf5d992e8d
Init: false
Resources:
Endpoint Mode: vipps выведет подробную информацию о всех контейнерах созданного сервиса и покажет сервер, где он расположился
vagrant@server1:~$ docker service ps redis
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
ngi4h2ha3d35 redis.1 redis:latest server5 Running Running 2 minutes agoЛоги выводятся с информацией о названии сервиса + таской, по которой он поднят, чтобы различить логи из всех похожих сервисов
vagrant@server1:~$ docker service logs redis
redis.1.ngi4h2ha3d35@server5 | Starting Redis Server
redis.1.ngi4h2ha3d35@server5 | 1:C 08 Jan 2026 13:26:44.608 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
redis.1.ngi4h2ha3d35@server5 | 1:C 08 Jan 2026 13:26:44.609 * oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0OoНу и удаляется сервис командой rm
vagrant@server1:~$ docker service rm redis
redis
vagrant@server1:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTSРасширение инстансов
Очень легко можно дублировать количество сервисов посредством команды scale и указать количество сервисов нужное нам
vagrant@server1:~$ docker service scale redis=2
redis scaled to 2
overall progress: 2 out of 2 tasks
1/2: running [==================================================>]
2/2: running [==================================================>]
verify: Service redis convergedВывод логов будет происходить по двум сервисам сразу
vagrant@server1:~$ docker service logs redis
redis.1.0vf4ym0h7vef@server1 | Starting Redis Server
redis.1.0vf4ym0h7vef@server1 | 1:C 08 Jan 2026 13:37:03.940 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
redis.1.0vf4ym0h7vef@server1 | 1:C 08 Jan 2026 13:37:03.942 * oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
redis.1.0vf4ym0h7vef@server1
redis.2.bijaalawbdaz@server2 | 1:M 08 Jan 2026 13:37:55.408 * <search> Initialized thread pools!
redis.2.bijaalawbdaz@server2 | 1:M 08 Jan 2026 13:37:55.408 * <search> Disabled workers threadpool of size 0
redis.2.bijaalawbdaz@server2 Обновление
Так же мы можем обновлять сервисы и делать это безболезненно, так как они будут обновляться каскадом (сначала первый, потом второй)
vagrant@server1:~$ docker service update redis --image=redis:5
redis
overall progress: 2 out of 2 tasks
1/2: running [==================================================>]
2/2: running [==================================================>]
verify: Service redis convergedОбновить можно множество параметров на лету: версия image, ресурсы, параметры сервиса
Секреты и конфиги
Docker позволяет нам создать две сущности на нашем устройстве:
- конфиги - хранятся в открытом виде
- секреты - хранятся на устройстве в зашифрованном виде
В команду docker create передаём нужные флаги --config/--secret с указанием пути до них. Потом эти конфигурации подтянутся для создания сервиса с этими параметрами

Секреты хранятся в контейнере в расшированном виде и могут быть сворованы!
- Секреты, как и в кубере, хранятся напрямую в контейнере, и, если злоумышленник получит доступ в сервер, то расшифрованные секреты будут ему доступны.
- Однако команды
commitиarchiveне будут сохранять секреты контейнера и он у нас сохранится пустой - секреты хранятся в контейнере покуда он в состоянии running
Секреты
Создание секрета
Создадим plain текстовый файл с секретом
vagrant@server1:~$ echo '123asddd2111d' > sec.txt
vagrant@server1:~$ cat sec.txt
123asddd2111dДалее от этого plain файла создадим секрет через docker create secret и на него можно будет взглянуть через secret ls
vagrant@server1:~$ docker secret create my_pass sec.txt
mqc5ddo401q5f8f4ihio6tnsr
vagrant@server1:~$ docker secret ls
ID NAME DRIVER CREATED UPDATED
mqc5ddo401q5f8f4ihio6tnsr my_pass 5 seconds ago 5 seconds agoИ в конце останется только запустить сервис с секретом
vagrant@server1:~$ docker service create --secret my_pass --name redis redis
uri3scf3y1o7o8fwia1kvewyp
overall progress: 1 out of 1 tasks
1/1: running [==================================================>]
verify: Service uri3scf3y1o7o8fwia1kvewyp convergedСекрет так же можно проинспектировать
vagrant@server1:~$ docker secret inspect my_pass
[
{
"ID": "mqc5ddo401q5f8f4ihio6tnsr",
"Version": {
"Index": 255
},
"CreatedAt": "2026-01-08T14:54:53.086712153Z",
"UpdatedAt": "2026-01-08T14:54:53.086712153Z",
"Spec": {
"Name": "my_pass",
"Labels": {}
}
}
]Вывод секрета из контейнера
После того, как у нас поднялся сервис, мы можем:
- в
service psнайти сервер, где он поднялся - перейти на найденный сервер
- вывести все контейнеры через
docker ps - вызвать шелл найденного контейнера
sh - перейти в
/run/secrets, где и будет лежать секрет
vagrant@server1:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
uri3scf3y1o7 redis replicated 1/1 redis:latest
vagrant@server1:~$ docker service ps redis
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
fhxciekfwolz redis.1 redis:latest server1 Running Running 2 minutes ago
vagrant@server1:~$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
13ab2bcf8127 redis:latest "docker-entrypoint.s…" 4 minutes ago Up 4 minutes 6379/tcp redis.1.fhxciekfwolz0u4z9whkxaqiw
vagrant@server1:~$ docker exec -it redis.1.fhxciekfwolz0u4z9whkxaqiw sh
# ls
# cd /run/secrets/
# cat my_pass
123asddd2111dУдаление секрета
Удалить секрет будет возможно только после удаления всех связанных с ним сервисов
vagrant@server1:~$ docker secret rm my_pass
Error response from daemon: rpc error: code = InvalidArgument desc = secret 'my_pass' is in use by the following service: redis
vagrant@server1:~$ docker service rm redis
redis
vagrant@server1:~$ docker secret rm my_pass
my_passКонфиги
Создание выглядит подобным образом, как и секреты. Что секреты, что конфиги можно передать как файлами, так и stdin в пайплайне shell оболочки
vagrant@server1:~$ echo "23479867sdf" | docker config create my_conf -
3273iwb8f2nl4dz8df452blzt
vagrant@server1:~$ docker config ls
ID NAME CREATED UPDATED
3273iwb8f2nl4dz8df452blzt my_conf 6 seconds ago 6 seconds agoТак как конфиги хранятся в открытом виде, то прямо в поле Data инспекта находятся данные в фрмате base64
vagrant@server1:~$ docker config inspect my_conf
[
{
"ID": "3273iwb8f2nl4dz8df452blzt",
"Version": {
"Index": 277
},
"CreatedAt": "2026-01-08T15:13:16.636565647Z",
"UpdatedAt": "2026-01-08T15:13:16.636565647Z",
"Spec": {
"Name": "my_conf",
"Labels": {},
"Data": "MjM0Nzk4NjdzZGYK"
}
}
]Далее нужно поднять сервис с данным конфигом
vagrant@server1:~$ docker service create --config my_conf --name redis redis
v2qr1wd33lqiqij6w4t2vi621
overall progress: 1 out of 1 tasks
1/1: running [==================================================>]
verify: Service v2qr1wd33lqiqij6w4t2vi621 convergedА сам конфиг находится прямо в корне поднятого контейнера
vagrant@server1:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
v2qr1wd33lqi redis replicated 1/1 redis:latest
vagrant@server1:~$ docker service ps redis
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
zb0rej6vu7pk redis.1 redis:latest server1 Running Running 19 seconds ago
vagrant@server1:~$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
9ff7238b7c26 redis:latest "docker-entrypoint.s…" 36 seconds ago Up 36 seconds 6379/tcp redis.1.zb0rej6vu7pkt60xvum8wn5i3
vagrant@server1:~$ docker exec -it redis.1.zb0rej6vu7pkt60xvum8wn5i3 sh
# cd /
# cat my_conf
23479867sdfStatefull сервисы
Для работы с Docker Swarm, нам нужно иметь уже готовые образы. Собрать образ, как в композе build, у нас не получится, так как мы не знаем, где запустится таска. Самый надёжный вариант в таком случае - поднять собственный registry, откуда будут тянутся образы собранного приложения.
Statefull cервисы - это сервисы, которые хранят своё состояние, подтягивая данные с диска
Варианты реализации:
- Shared Mount - с помощью кастомных драйверов или через локальный драйвер подключаем определённое хранилище, на которое должны будут смотреть контейнеры и стягивать оттуда данные
- Constraint - задаём ограничение на поднятие определённого сервиса на определённых нодах (postgresql только на ноде server1, на которой хранятся данные постгреса)
Constraints мы можем задавать через лейблы на ноду или через роль ноды

Создание registry на ноде
Сейчас создадим docker registry на одной выделенной ноде, с которой будем тянуть наши кастомные образы
Сначала добавим лейбл на данную ноду, чтобы подцепить запускаемый сервис под эту ноду
vagrant@server1:~$ docker node update server1 --label-add registry=true
server1Далее нужно проинспектировать сервис и найти, что наш лейбл установился
vagrant@server1:~$ docker node inspect server1
[
{
"ID": "vf8zoum7p4oczrx0d32dcvf1i",
"Version": {
"Index": 285
},
"CreatedAt": "2026-01-08T08:41:34.14463565Z",
"UpdatedAt": "2026-01-08T17:44:46.512081485Z",
"Spec": {
"Labels": {
"registry": "true"
},
"Role": "manager",
"Availability": "active"
},Далее нам нужно поднять сам registry. В команде service create нет параметра --volume, поэтому нужно будет подробно указывать --mount и указать полностью путь до него.
vagrant@server1:~$ mkdir registry
vagrant@server1:~$ docker service create --name registry --publish 5000:5000 --constraint node.labels.registry==true --mount type=bind,source=/home/vagrant/registry,destination=/var/lib/registry -e REGISTRY_HTTP_ADDR=0.0.0.0:5000 registry:latest
udiqsdthzpr8wee31481pvms1
overall progress: 1 out of 1 tasks
1/1: running [==================================================>]
verify: Service udiqsdthzpr8wee31481pvms1 convergedЕсли сервис не удалось поднять, то нужно дёрнуть команду
docker service rm <service_name>, потому что в другом случае Swarm будет тянуть информацию о деплое сервиса по старым данным
Проверяем поднятый registry
vagrant@server1:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
v2qr1wd33lqi redis replicated 1/1 redis:latest
udiqsdthzpr8 registry replicated 1/1 registry:latest *:5000->5000/tcpСборка и публикация в registry
Далее переходим на соседнюю воркер-ноду, в которой тянем кодовую базу, собираем образ и пушим в локальный registry
vagrant@server3:~$ git clone https://github.com/AlariCode/docker-demo.git
vagrant@server3:~/docker-demo$ git checkout block-7
vagrant@server3:~/docker-demo$ docker build -t localhost:5000/api:latest -f apps/api/Dockerfile .
vagrant@server3:~/docker-demo$ docker push localhost:5000/api
Using default tag: latest
The push refers to repository [localhost:5000/api]
da36886f26c8: Pushed
fb0aa897862c: Pushed
44d73c408d59: Pushed
683339ce8d6b: Pushed
686172e40c38: Pushed
7547e952c0e5: Pushed
51171dc7e41f: Pushed
c41833b44d91: Pushed
4cf6a83c0e2a: Pushed
latest: digest: sha256:7a0b9df2f995c393fcd499cdaffce2b360ddac44dd9fa299c2a569247240acf0 size: 856Пуллинг образа
А теперь проверяем, что пулл на соседнем сервере работает
vagrant@server5:~$ docker pull localhost:5000/api:latest
latest: Pulling from api
51171dc7e41f: Pull complete
c41833b44d91: Pull complete
683339ce8d6b: Pull complete
4cf6a83c0e2a: Pull complete
686172e40c38: Pull complete
7547e952c0e5: Pull complete
da36886f26c8: Pull complete
fb0aa897862c: Pull complete
44d73c408d59: Download complete
Digest: sha256:7a0b9df2f995c393fcd499cdaffce2b360ddac44dd9fa299c2a569247240acf0
Status: Downloaded newer image for localhost:5000/api:latest
localhost:5000/api:latestOverlay network
Сеть overlay - это исключительная сеть, которая нужна, чтобы обеспечивать общение между контейнерами разных нод друг с другом.
В обычном случае, нам бы понадобилось, чтобы наш контейнер мог общаться с нодой, а потом нода с другой нодой, а потом вторая нода могла направлять траффик внутрь контейнера. Но в случае с overlay, мы можем поднять виртуальную сеть, которая позволит общаться нашим контейнерам внутри друг с другом.
overlay network- позволяет поднять overlay сеть- флаг
--attachableпозволяет общаться с обычными контейнерами в ноде, которые не связаны со свармом (не стоит использовать) --opt encryptedпозволяет зашифровать сеть, но снизит скорость работы сети. Однако можно поднять эту сеть только для общения определённых сервисов.

Но при этом существует дефолтная ingress-сеть, которая позволяет выводить порты наружу. Некоторый балансировщик и Gateway всего траффика.
Созданный ранее registry выводится через ingress (0.0.0.0)

Собираем ip-утилиту
Собрали утилиту и запушили
vagrant@server3:~$ git clone https://github.com/AlariCode/docker-demo-3.git
vagrant@server3:~/docker-demo-3$ docker build -t localhost:5000/ip:latest .
[+] Building 8.2s (9/9) FINISHED docker:default
vagrant@server3:~/docker-demo-3$ docker push localhost:5000/ip:latest
The push refers to repository [localhost:5000/ip]
36444af81f88: PushedПоднимаем IP сервис
Собираем сервис сразу в трёх репликах. Он поднимется сейчас сразу на трёх разных нодах.
vagrant@server1:~$ docker service create --publish 3000:3000 --name ip --replicas=3 localhost:5000/ip:latest
og0eym33zy6gv6cq4baawkkoy
overall progress: 3 out of 3 tasks
1/3: running [==================================================>]
2/3: running [==================================================>]
3/3: running [==================================================>]
verify: Service og0eym33zy6gv6cq4baawkkoy converged
vagrant@server1:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
og0eym33zy6g ip replicated 3/3 localhost:5000/ip:latest *:3000->3000/tcp
v2qr1wd33lqi redis replicated 1/1 redis:latest
udiqsdthzpr8 registry replicated 1/1 registry:latest *:5000->5000/tcp
vagrant@server1:~$ docker service ps ip
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
9qic5ihdxhxs ip.1 localhost:5000/ip:latest server5 Running Running 25 seconds ago
mepaei1js72s ip.2 localhost:5000/ip:latest server2 Running Running 22 seconds ago
wo2wa6ujg6k7 ip.3 localhost:5000/ip:latest server3 Running Running 25 seconds agoПроверка извне
Далее на любом из серверов прокидываем наружу порт 3000 (тут server1)

Далее с хостовой машины отправляем curl запросы на получение ip внутри контейнера. И каждый раз ingress балансирует нагрузку перебирая по очереди каждую ноду, на которой расположен нужный сервис aka Round Robin algorithm.
curl localhost:3000
{"eth0":["10.0.0.12"],"eth1":["172.18.0.3"]}%
curl localhost:3000
{"eth0":["10.0.0.11"],"eth1":["172.18.0.3"]}%
curl localhost:3000
{"eth0":["10.0.0.13"],"eth1":["172.18.0.3"]}%
curl localhost:3000
{"eth0":["10.0.0.12"],"eth1":["172.18.0.3"]}%
curl localhost:3000
{"eth0":["10.0.0.11"],"eth1":["172.18.0.3"]}%
curl localhost:3000
{"eth0":["10.0.0.13"],"eth1":["172.18.0.3"]}%Docker stack
В Docker Swarm есть несколько способов описания сервисов, которые нам нужно выложить. Первый из них - Docker Stack.
Этот подход выглядит по синтаксису ровно так же, как и Docker Compose.
Наш оригинальный композ:
docker-compose.yml
services:
app:
build:
context: .
dockerfile: ./apps/app/Dockerfile
restart: always
container_name: app
ports:
- 3001:80
networks:
- myNetwork
converter:
build:
context: .
dockerfile: ./apps/converter/Dockerfile
restart: always
container_name: converter
volumes:
- .env:/opt/app/.env
depends_on:
- rmq
networks:
- myNetwork
api:
build:
context: .
dockerfile: ./apps/api/Dockerfile
restart: always
container_name: api
volumes:
- .env:/opt/app/.env
ports:
- 3002:3000
networks:
- myNetwork
depends_on:
- rmq
rmq:
image: rabbitmq:3-management
restart: always
networks:
- myNetwork
environment:
- RABBITMQ_DEFAULT_USER=admin
- RABBITMQ_DEFAULT_PASS=admin
networks:
myNetwork:
driver: bridgeСобираем Stack compose
Сначала создаём из .env наш секрет, так как volume отсутствует в композе. Создаём этот секрет на сервере, на котором находятся энвы. Это обязательно должна быть менеджерская нода
vagrant@server3:~/docker-demo$ cat .env
AMQP_EXCHANGE=xchg_integrations
AMQP_USER=admin
AMQP_PASSWORD=admin
AMQP_HOSTNAME=rmq
AMQP_QUEUE=q_imageProcessor
vagrant@server3:~/docker-demo$ docker secret create api.env .env
72scitjrv9809u4nlvrgtft5g
vagrant@server3:~/docker-demo$ docker secret ls
ID NAME DRIVER CREATED UPDATED
72scitjrv9809u4nlvrgtft5g api.env 18 seconds ago 18 seconds agoПересобранный Stack:
- Указываем
secrets, так какvolumesотсутствует.targetдолжен заменять файл.envпоэтому нужно будет указать выходной файл для приложения - имя контейнера убираем, так как оно будет присвоено автоматически
- добавляем
secretsблок
docker-stack.yml
services:
api:
image: localhost:5000/api:latest
restart: always
secrets:
- source: api.env
target: /opt/app/.env
ports:
- "3002:3000"
networks:
- myNetwork
depends_on:
- rmq
rmq:
image: rabbitmq:3-management
restart: always
networks:
- myNetwork
environment:
- RABBITMQ_DEFAULT_USER=admin
- RABBITMQ_DEFAULT_PASS=admin
networks:
myNetwork:
driver: overlay
secrets:
api.env:
external: trueОстаётся только запустить сервисс указанием пути до стека
vagrant@server3:~$ docker stack deploy -c docker-stack.yml my_app
Creating network my_app_myNetwork
Creating service my_app_rmq
Creating service my_app_apiИ у нас поднимутся три наших сервиса из описания стека
vagrant@server3:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
ubejo9n7pel1 my_app_api replicated 1/1 localhost:5000/api:latest *:3002->3000/tcp
tnu22kzmt0bf my_app_rmq replicated 1/1 rabbitmq:3-management
udiqsdthzpr8 registry replicated 1/1 registry:latest *:5000->5000/tcpТаким образом мы можем посмотреть на сами запущенные сервисы
vagrant@server3:~$ docker stack ls
NAME SERVICES
my_app 2
vagrant@server3:~$ docker stack services my_app
ID NAME MODE REPLICAS IMAGE PORTS
ubejo9n7pel1 my_app_api replicated 1/1 localhost:5000/api:latest *:3002->3000/tcp
tnu22kzmt0bf my_app_rmq replicated 1/1 rabbitmq:3-management
А через ps мы можем посмотреть на таски, которые запустили наши сервисы
vagrant@server3:~$ docker stack ps my_app
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
k1q5ofd5q9vn my_app_api.1 localhost:5000/api:latest server4 Running Running 6 minutes ago
tdy8kh9rk2sg my_app_rmq.1 rabbitmq:3-management server4 Running Running 5 minutes agoНу и на логи можно так же взглянуть через service модуль
vagrant@server3:~$ docker service logs my_app_api
my_app_api.1.k1q5ofd5q9vn@server4 | [Nest] 1 - 01/08/2026, 7:52:00 PM [NestFactory] Starting Nest application...
my_app_api.1.k1q5ofd5q9vn@server4 | [Nest] 1 - 01/08/2026, 7:52:00 PM [InstanceLoader] DiscoveryModule dependencies initialized +14ms
my_app_api.1.k1q5ofd5q9vn@server4 | [Nest] 1 - 01/08/2026, 7:52:27 PM [RMQModule] Successfully connected to RMQ +5098msHealthcheck
С помощью Healthcheck механизма, мы можем проверять работспособность сервиса и вовремя давать среагировать ноде, если сервис упал.

Обязательно нужно смотреть на ресурсоёмкость такой проверки, потому что часто вызывать hc, который на минуту будет съедать все ресурсы - это невыгодно!
Все hc зависят от конкретной реализации внутри приложения, поэтому нужно проверять, сколько ресурсов такой запрос может потреблять.
Добавим в сервис для проверки ip контейнера строку healthcheck:
FROM node:14-alpine
RUN apk add curl
WORKDIR /opt/app
ADD index.js .
HEALTHCHECK --interval=5s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000 || exit 1
CMD ["node", "./index.js"]Далее собираем, пушим и выкладываем в сварм
docker build -t localhost:5000/ip:latest .
docker push localhost:5000/ip:latest
docker service create --name ip localhost:5000/ip:latestДалее найдём на какой ноде располагается этот сервис
vagrant@server3:~$ docker service ps ip
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
y1uk1ymbp6md ip.1 localhost:5000/ip:latest server5 Running Running 52 seconds agoИ теперь рядом со статусом появляется подпись (healthy), о которой знаем мы и Swarm. Последний, в свою очередь, следит за этим статусом и при unhealthy перезапустит контейнер.
vagrant@server5:~$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
41f8e5af5a3f localhost:5000/ip:latest "docker-entrypoint.s…" 2 minutes ago Up 2 minutes (healthy) ip.1.y1uk1ymbp6mdqslup2n1rleoqОтказоустойчивость
Поднимем 10 сервисов ip. И поднимем ip сервис с --mode global, который поднимет по одному экземпляру сервиса на каждой ноде.
server1:~$ docker service scale ip=10
ip scaled to 10
overall progress: 10 out of 10 tasks
1/10: running [==================================================>]
2/10: running [==================================================>]
3/10: running [==================================================>]
4/10: running [==================================================>]
5/10: running [==================================================>]
6/10: running [==================================================>]
7/10: running [==================================================>]
8/10: running [==================================================>]
9/10: running [==================================================>]
10/10: running [==================================================>]
verify: Service ip convergedserver1:~$ docker service create --name ip-global --mode global localhost:5000/ip:latest
wli8enepyx4sebz7n6hxhye5e
overall progress: 5 out of 5 tasks
dewtmbfux019: running [==================================================>]
cac83v7bbd2s: running [==================================================>]
vf8zoum7p4oc: running [==================================================>]
nk2txuihfmoq: running [==================================================>]
vwo3k8p811iy: running [==================================================>]
verify: Service wli8enepyx4sebz7n6hxhye5e convergedserver1:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
l39dqp87gk4z ip replicated 10/10 localhost:5000/ip:latest
wli8enepyx4s ip-global global 5/5 localhost:5000/ip:latest
ubejo9n7pel1 my_app_api replicated 1/1 localhost:5000/api:latest *:3002->3000/tcp
tnu22kzmt0bf my_app_rmq replicated 1/1 rabbitmq:3-management
udiqsdthzpr8 registry replicated 1/1 registry:latest *:5000->5000/tcpДалее после выполнения задачи на дрейнирование ноды, с неё уйдут все контейнеры и создадутся задачи на перенос сервисов
docker node update server4 --availability drainИ в списке сервисов можно увидеть, что сервисов ip-global стало 4 (только 4 ноды используются) и перенеслись 2 сервиса ip на свободные ноды
vagrant@server1:~$ docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
l39dqp87gk4z ip replicated 10/10 localhost:5000/ip:latest
wli8enepyx4s ip-global global 4/4 localhost:5000/ip:latest
ubejo9n7pel1 my_app_api replicated 1/1 localhost:5000/api:latest *:3002->3000/tcp
tnu22kzmt0bf my_app_rmq replicated 1/1 rabbitmq:3-management
udiqsdthzpr8 registry replicated 1/1 registry:latest *:5000->5000/tcpvagrant@server1:~$ docker service ps ip
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
y1uk1ymbp6md ip.1 localhost:5000/ip:latest server5 Running Running 13 hours ago
zexvspir1lqm ip.2 localhost:5000/ip:latest server3 Running Running 9 minutes ago
n6t931uj2rpa ip.3 localhost:5000/ip:latest server3 Running Running 9 minutes ago
3wsots893idb ip.4 localhost:5000/ip:latest server2 Running Running 9 minutes ago
nlh96g787ak7 ip.5 localhost:5000/ip:latest server1 Running Running 9 minutes ago
mxwl3u60w8lj ip.6 localhost:5000/ip:latest server5 Running Running 6 seconds ago
xq5ysoxh8lwb \_ ip.6 localhost:5000/ip:latest server4 Shutdown Shutdown 11 seconds ago
fpl565cv6jm3 ip.7 localhost:5000/ip:latest server5 Running Running 9 minutes ago
hdi1xpsdr8bu ip.8 localhost:5000/ip:latest server1 Running Running 9 minutes ago
qspbwcr3uvkw ip.9 localhost:5000/ip:latest server2 Running Running 9 minutes ago
js80s5hpkqbb ip.10 localhost:5000/ip:latest server3 Running Running 6 seconds ago
ewdvpoa3l9r8 \_ ip.10 localhost:5000/ip:latest server4 Shutdown Shutdown 11 seconds agoПосле чего можно спокойно вывести сервер из строя и удалить его из сварама
docker node rm server4Ansible - продвинутые темы
Роли
Плейбуки нередко достигают достаточно больших размеров, чтобы с ними уже становилось тяжело работать. Поэтому были добавлены роли.
Роли - это механизм разделения скриптов на отдельные файлы. Их можно запускать для определённых хостов, переиспользовать в других плейбуках, публиковать в galaxy.
Определение роли происходит по имеющейся нотации в ansible:
- В папку
rolesмы складываем роли - Именуем роль её доменом использования (для понимания основной её задачи) -
deploy,docker - В самой роли есть своя структура директорий:
defaults- переменные по умолчаниюfiles- файлы роли, которые кладутся на целевую машинуtemplates- шаблоны для выкладки (jinja-шаблоны), которые сначала прогоняются через шаблонизатор и наполняются данными, а потом уже отправляются на целевой хостhandlers- обработчики плейбука, которые будут вызываться по окончанию таскиlibrary- модули ролиmeta- метаданные роли и её зависимостиtasks(обязательно) - задачи ролиvars- переменныеservicesи<etc>- любые другие папки так же можно использовать внутри
- Точкой входа в роль является
tasks / main.yml

Переместим все задачи из нашего корневого плейбука в роль preconfig
roles / preconfig / tasks / main.yml
---
- name: Pre-install cleanup
block:
- name: Remove conflicting Docker packages
ansible.builtin.apt:
name:
- docker.io
- docker-compose
- docker-compose-v2
- docker-doc
- podman-docker
- containerd
- runc
state: absent
purge: true
failed_when: false
- name: Remove old Docker files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/apt/keyrings/docker.asc
- /etc/apt/keyrings/docker.gpg
- /usr/share/keyrings/docker-archive-keyring.gpg
- /etc/apt/trusted.gpg.d/docker.gpg
- /etc/apt/sources.list.d/docker.list
- /etc/apt/sources.list.d/docker.sources
- name: Fix ARM64 repositories
block:
- name: Remove duplicate repository files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/apt/sources.list.d/ports_ubuntu_com_ubuntu_ports.list
- /etc/apt/sources.list.d/ubuntu.sources
- name: Replace sources.list for ARM64
ansible.builtin.copy:
dest: /etc/apt/sources.list
mode: "0644"
backup: true
content: |
deb http://ports.ubuntu.com/ubuntu-ports {{ ansible_facts['distribution_release'] }} main restricted universe multiverse
deb http://ports.ubuntu.com/ubuntu-ports {{ ansible_facts['distribution_release'] }}-updates main restricted universe multiverse
deb http://ports.ubuntu.com/ubuntu-ports {{ ansible_facts['distribution_release'] }}-security main restricted universe multiverse
when: ansible_facts['architecture'] in ['aarch64', 'arm64']
- name: Install prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
update_cache: true
- name: Setup Docker repository and install
ansible.builtin.shell: |
set -e
# Cleanup old Docker files
rm -f /etc/apt/keyrings/docker.* /usr/share/keyrings/docker* /etc/apt/sources.list.d/docker.*
# Create directory
mkdir -p /etc/apt/keyrings
# Download and import GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod 644 /etc/apt/keyrings/docker.gpg
# Detect architecture
ARCH=$(dpkg --print-architecture)
# Add Docker repository
echo "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_facts['distribution_release'] }} stable" > /etc/apt/sources.list.d/docker.list
# Update and install Docker
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
args:
executable: /bin/bash
register: docker_install
changed_when: "'Setting up docker-ce' in docker_install.stdout or 'is already the newest version' not in docker_install.stdout"
- name: Ensure Docker service is running
ansible.builtin.service:
name: docker
state: started
enabled: true
- name: Post-install configuration
block:
- name: Ensure docker group exists
ansible.builtin.group:
name: docker
state: present
- name: Add user to docker group
ansible.builtin.user:
name: "{{ ansible_user | default(ansible_env.SUDO_USER | default('ubuntu')) }}"
groups: docker
append: true
- name: Reboot if needed
ansible.builtin.reboot:
msg: "Rebooting after Docker installation"
when: docker_reboot | default(false) | boolИ создадим роль deploy
roles / deploy / tasks / main.yml
---
- name: Demo
command: echo "Demo"И далее в корне обозначим поле roles, которое обозначит роль, как выполняемую.
Правила:
- роли выполняются последовательно, по списку, друг за другом (если не указано иного поведения в meta)
- роль выполняется только один раз, даже если она указана в списке несколько раз (кроме случаев, когда с ролью передаются ещё и аргументы)
all.yml
---
- name: Install Docker Engine on Ubuntu
hosts: cluster
become: true
gather_facts: true
roles:
- preconfig
- deployИ далее у нас должны запуститься обе роли
ansible-playbook -i inventory all.yml -KMeta
Так же мы можем описать полезные метаданные для нашей роли, которые позволят поменять её поведение и оставить информацию по поддержке данного продукта
- Поле
dependenciesскажет ansible, что нужно прогнорироватьrolesмассив и выполнить сначала зависимости, а потом уже эту роль. Конкретно тут сначала выполнитсяdeploy, а потомpreconfig - Поле
allow_duplicatesпозволяет запустить дубликат роли без передачи аргументов. Таким образом, списокroles: [ deploy, deploy ]дважды выполнит рольdeploy
roles / preconfig / meta / main.yml
# спеки нашей роли
galaxy_info:
role_name: preconfig
description: preconfig server to work with Docker
author: Lvov Valery
license: MIT
min_ansible_version: 2.20
platforms:
- name: Ubuntu
versions:
- all
# сначала выполнится роль deploy
dependencies:
- deploy
# позволяет запустить дубликат роли
allow_duplicates: trueАргументы роли
Мы можем повторно выполнять действия роли, если передадим вместе с ними дополнительные аргументы таким образом:
all.yml
---
- name: Install Docker Engine on Ubuntu
hosts: cluster
become: true
gather_facts: true
roles:
- role: preconfig
message: 'asdasda'
- role: deploy
message: 'o23uhdiou3hf'Ansible galaxy
Ansible Galaxy - это пакетный менеджер, который позволяет публиковать и скачивать роли
Поиск
Все роли, коллекции и плагины сообщества находятся на galaxy.ansible

Сама работа с galaxy происходит через ansible-galaxy, который ставится вместе с ansible. Он работает с двумя типами пакетов: collection и role
Мы можем так же искать пакеты по galaxy через командную строку
$ ansible-galaxy role search mongo
Found 193 roles matching your search:
Name Description
---- -----------
030.ansible_mongodb_org_shell ansible-mongodb-org-shell
aaronpederson.mongodb MongoDB is a document-oriented database.
abrararshad.mongo_db_push Archive and import mongo database to the remote machine
adriano-di-giovanni.mongodb Ansible role for MongoDB Community Edition
AerisCloud.mongodb Installs and configure MongoDBУстановка пакетов
Для установки коллекции, у нас будет команда в galaxy, которой мы можем воспользоваться
ansible-galaxy collection install debops.roles03Однако, если мы установим себе эти пакеты, то только мы сможем воспользоваться нашей ролью, в которой мы применяем эти плагины, потому что они у нас нигде не зафиксированы
Самый простой способ описать требования по пакетам для нашего репозитория - это requirements. Сюда мы можем вписать collections и roles, которые требуются для запуска описанного ansible playbook
requirements.yml
collections:
- name: community.dockerИ далее осталось только установить требуемые пакеты
ansible-galaxy install -r requirements.ymlПроверка
Просмотреть список установленных пакетов можно с помощью list из определённого типа пакета
$ ansible-galaxy collection list
[WARNING]: Collection at '/opt/homebrew/Cellar/ansible/13.1.0/libexec/lib/python3.14/site-packages/ansible/_internal/ansible_collections/ansible/_protomatter' does not have a MANIFEST.json file, nor has it galaxy.yml: cannot detect version.
# /Users/zeizel/.ansible/collections/ansible_collections
Collection Version
---------------------------------------- -------
debops.roles03 3.0.3
# /opt/homebrew/Cellar/ansible/13.1.0/libexec/lib/python3.14/site-packages/ansible/_internal/ansible_collections
Collection Version
---------------------------------------- -------
ansible._protomatter *
# /opt/homebrew/Cellar/ansible/13.1.0/libexec/lib/python3.14/site-packages/ansible_collections
Collection Version
---------------------------------------- -------
amazon.aws 10.1.2
ansible.netcommon 8.2.0
ansible.posix 2.1.0
ansible.utils 6.0.0
ansible.windows 3.3.0
arista.eos 12.0.0
awx.awx 24.6.1
azure.azcollection 3.12.0
check_point.mgmt 6.7.0
chocolatey.chocolatey 1.5.3
cisco.aci 2.13.0Генератор
Так же тут присутствует генератор ролей, который сразу соберёт нужную структуру папок под определённый объект
$ ansible-galaxy role init roles/name
- Role roles/name was created successfully
Подготовка сервера
В Docker Swarm у нас есть два способа добавления сервисов:
- service
- плюсы
- есть возможность использовать rolling update
- изменить секреты получится просто перезапуском одного сервиса
- автоматически проверяет изменения latest
- минусы
- тут не получится описать зависимости сервисов друг от друга
- плюсы
- stack
- плюсы
- все зависимости описываются в одном месте - это удобно
- все сервисы описаны в одном yml
- минусы
- если нужно изменить секреты, то придётся уронить весь стек
- image с тегом latest не получится обновить и придётся ронять весь stack - этому багу уже более 7 лет
- плюсы
Из чего можно выделить:
- stack - удобно, но для небольших проектов
- service - лучший вариант для больших проектов, чего не сможет дать stack

Ansible поддерживает и docker swarm нотацию, и docker service.
Обновление инвентаря
Но тут перед нами встаёт проблема: сейчас инвентарь описан как [cluster], а запускать сервис нужно только с одной ноды, чтобы не поднимать его несколько раз на каждом.
В связи с этим, обновим инвентарь и определим deploy-ноду (обычно, Leader), выделим менеджер и воркер ноды
inventory / cluster
[deploy]
server1 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2223
[managers]
server2 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2224
server3 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2225
[workers]
server4 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2226
server5 ansible_host=127.0.0.1 ansible_user=vagrant ansible_port=2227Обновление конфигурации
Разделим конфигурации настройки для трёх разных типов наших устройств
all.yml
---
- name: Deploy server
hosts: deploy
become: true
gather_facts: true
roles:
- role: preconfig
# - role: deploy
# - role: swarm_init
- name: Manager
hosts: managers
become: true
gather_facts: true
roles:
- role: preconfig
# - role: swarm_join
- name: Worker
hosts: workers
become: true
gather_facts: true
roles:
- role: preconfig
# - role: swarm_joinДалее упростим конфигурацию, установив Docker поставляемым скриптом от его разработчиков и добавим установку docker через pip
roles / preconfig / tasks / main.yml
---
- name: Pre-install cleanup
block:
- name: Remove conflicting Docker packages
apt:
purge: true
state: absent
name:
- docker.io
- docker-compose
- docker-compose-v2
- docker-doc
- podman-docker
- containerd
- runc
failed_when: false
- name: Install prerequisites
apt:
update_cache: true
cache_valid_time: 86400
name:
- ca-certificates
- curl
- gnupg
- python3-pip
- name: Install Docker using official convenience script
shell: |
set -e
curl -fsSL https://get.docker.com | sh
args:
executable: /bin/bash
register: docker_install
changed_when: "'Docker installed' in docker_install.stdout or 'docker is already the newest version' not in docker_install.stdout"
- name: Ensure Docker service is running
service:
name: docker
state: started
enabled: true
- name: Setting additional python packages
block:
- name: Installing pip packages
pip:
name: docker
break_system_packages: true
- name: Post-install configuration
block:
- name: Ensure docker group exists
ansible.builtin.group:
name: docker
state: present
- name: Add user to docker group
ansible.builtin.user:
name: "{{ ansible_user | default(ansible_env.SUDO_USER | default('ubuntu')) }}"
groups: docker
append: true
- name: Reboot if needed
ansible.builtin.reboot:
msg: Rebooting after Docker installation
when: docker_reboot | default(false) | boolЗапускаем установку роли
ansible-playbook -i inventory all.yml -KТеги
Теги - это инструмент, который позволяет нам разметить те блоки тасок, которые нам нужно выполнять для определённой группы.
Это крайне удобный механизм, так как он позволяет нам модифицировать поведение ролей в зависимости от переданных в неё данных
Дополним корневой YML и добавим поля:
tags- теги, по которым можно будет выбрать исполняемые задачиtest- переменная, которая попадёт в роль при выполнении
all.yml
- name: Deploy server
hosts: deploy
become: true
gather_facts: true
roles:
- role: preconfig
tags: preconfig
- role: deploy
tags: deploy
test: 0Добавим роль деплоя, которая должна вывести нам переданную переменную test
deploy / tasks / main.yml
---
- name: Deploy
command: "echo {{ test }}"
register: res
- debug:
var: resТеперь с помощью флага --tags можно определить роли, которые мы хотим сейчас запустить
И глянем на структуру вывода. Тут есть поле stdout, по которому можно подцепиться и модифицировать дальнейшее поведение задач
•% ➜ ansible-playbook -i inventory all.yml -K --tags "deploy"
BECOME password:
PLAY [Deploy server] *****************************************************************************************************************************************************
TASK [Gathering Facts] ***************************************************************************************************************************************************
[WARNING]: Host 'server1' is using the discovered Python interpreter at '/usr/bin/python3.12', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [server1]
TASK [deploy : Deploy] ***************************************************************************************************************************************************
changed: [server1]
TASK [deploy : debug] ****************************************************************************************************************************************************
ok: [server1] => {
"res": {
"changed": true,
"cmd": [
"echo",
"0"
],
"delta": "0:00:00.004076",
"end": "2026-01-09 13:12:39.177753",
"failed": false,
"msg": "",
"rc": 0,
"start": "2026-01-09 13:12:39.173677",
"stderr": "",
"stderr_lines": [],
"stdout": "0",
"stdout_lines": [
"0"
]
}
}Теперь переопределим результат таски. Она сейчас не будет считаться изменённой, так как в её выводе есть 0. Мы переопределили её поведение.
deploy / tasks / main.yml
---
- name: Deploy
command: "echo {{ test }}"
register: res
changed_when: "'0' not in res.stdout"Таким образом у нас появляется механизмы:
- которые позволяют нам передавать переменные для отдельных ролей
- которые позволяют запускать только определённые группы ролей по тегам
Циклы
Циклы в Ansible позволяют выполнить одну и ту же задачу с разными значениями по переданному списку элементов.
Выполнение цикла
Записываем нужные нам значения для итерации через ключ loop
roles / deploy / tasks / main.yml
---
- name: Deploy
debug:
msg: '{{ item }}'
loop:
- a
- bВ старых версиях ansible-скриптов можно заметить запись цикла через
with_*, чего нужно избегать и переходить полностью наloop
И после запуска данной роли, можно заметить, что у нас запускается одна и та же задача несколько раз, но повторяется по циклу с разными значениями
$ ansible-playbook -i inventory all.yml --tags "deploy"
PLAY [Deploy server] *****************************************************************************************************************************************************
TASK [Gathering Facts] ***************************************************************************************************************************************************
[WARNING]: Host 'server1' is using the discovered Python interpreter at '/usr/bin/python3.12', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [server1]
TASK [deploy : Deploy] ***************************************************************************************************************************************************
ok: [server1] => (item=a) => {
"msg": "a"
}
ok: [server1] => (item=b) => {
"msg": "b"
}Перебор массива объектов
И перебор объектов будет выглядеть точно так же
roles / deploy / tasks / main.yml
---
- name: Deploy
debug:
msg: '{{ item.name }} is {{ item.role }}'
loop:
- name: Alex
role: admin
- name: Carina
role: manager$ ansible-playbook -i inventory all.yml --tags "deploy"
PLAY [Deploy server] *****************************************************************************************************************************************************
TASK [Gathering Facts] ***************************************************************************************************************************************************
[WARNING]: Host 'server1' is using the discovered Python interpreter at '/usr/bin/python3.12', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [server1]
TASK [deploy : Deploy] ***************************************************************************************************************************************************
ok: [server1] => (item={'name': 'Alex', 'role': 'admin'}) => {
"msg": "Alex is admin"
}
ok: [server1] => (item={'name': 'Carina', 'role': 'manager'}) => {
"msg": "Carina is manager"
}Преобразование в dictionary
Так же мы можем преобразовать список значений в виде ключей из переменных окружения (задачи, роли и других)
roles / deploy / tasks / main.yml
---
- name: Deploy
vars:
data:
admin: Alex
manager: Carina
debug:
msg: '{{ item.key }} is {{ item.value }}'
loop: "{{ data | dict2items }}"•% ✘2 ➜ ansible-playbook -i inventory all.yml --tags "deploy"
PLAY [Deploy server] *****************************************************************************************************************************************************
TASK [Gathering Facts] ***************************************************************************************************************************************************
[WARNING]: Host 'server1' is using the discovered Python interpreter at '/usr/bin/python3.12', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information.
ok: [server1]
TASK [deploy : Deploy] ***************************************************************************************************************************************************
ok: [server1] => (item={'key': 'admin', 'value': 'Alex'}) => {
"msg": "admin is Alex"
}
ok: [server1] => (item={'key': 'manager', 'value': 'Carina'}) => {
"msg": "manager is Carina"
}Обновление выхода
В операции с циклом будем выполнять команду и сохраним результат в переменную res
roles / deploy / tasks / main.yml
---
- name: Deploy
command: echo "{{ item }}"
loop:
- a
- b
register: res
- name: DEBUG
debug:
var: resТеперь результатом операции в res будет находиться не result, а массив результатов наших операций results
•% ➜ ansible-playbook -i inventory all.yml --tags "deploy"
ok: [server1] => {
"res": {
"changed": true,
"msg": "All items completed",
"results": [
{
"ansible_loop_var": "item",
"changed": true,
"cmd": [
"echo",
"a"
],
"delta": "0:00:00.006424",
"end": "2026-01-10 18:33:49.411015",
"failed": false,
"invocation": {
"module_args": {
"_raw_params": "echo \"a\"",
"_uses_shell": false,
"argv": null,
"chdir": null,
"cmd": null,
"creates": null,
"executable": null,
"expand_argument_vars": true,
"removes": null,
"stdin": null,
"stdin_add_newline": true,
"strip_empty_ends": true
}
},
"item": "a",
"msg": "",
"rc": 0,
"start": "2026-01-10 18:33:49.404591",
"stderr": "",
"stderr_lines": [],
"stdout": "a",
"stdout_lines": [
"a"
]
},
{
"ansible_loop_var": "item",
"changed": true,
"cmd": [
"echo",
"b"
],
"delta": "0:00:00.001196",
"end": "2026-01-10 18:33:49.559114",
"failed": false,
"invocation": {
"module_args": {
"_raw_params": "echo \"b\"",
"_uses_shell": false,
"argv": null,
"chdir": null,
"cmd": null,
"creates": null,
"executable": null,
"expand_argument_vars": true,
"removes": null,
"stdin": null,
"stdin_add_newline": true,
"strip_empty_ends": true
}
},
"item": "b",
"msg": "",
"rc": 0,
"start": "2026-01-10 18:33:49.557918",
"stderr": "",
"stderr_lines": [],
"stdout": "b",
"stdout_lines": [
"b"
]
}
],
"skipped": false
}
}Итерация по встроенным объектам
Так же мы можем проходиться по встроенным в ansible глобальным объектам
roles / deploy / tasks / main.yml
---
- name: Deploy
debug:
msg: '{{ item }}'
loop: "{{ groups['all'] }}"$ ansible-playbook -i inventory all.yml --tags "deploy"
ok: [server1] => (item=server1) => {
"msg": "server1"
}
ok: [server1] => (item=server2) => {
"msg": "server2"
}
ok: [server1] => (item=server3) => {
"msg": "server3"
}
ok: [server1] => (item=server4) => {
"msg": "server4"
}
ok: [server1] => (item=server5) => {
"msg": "server5"
}
ok: [server1] => (item=127.0.0.1) => {
"msg": "127.0.0.1"
}
ok: [server1] => (item=home-server) => {
"msg": "home-server"
}
ok: [server1] => (item=external-server) => {
"msg": "external-server"
}Контроль итераций
Мы можем изменять поведение loop-итерации:
labelизменит выводимый label в поле(item=<label>)и не будет выводить весь объект в качестве лейблаextendedоткроет доступ к объектуansible_loopindex_varпредоставит возможность обращаться к индексу операцииpauseнакинет паузу между выполнением операций в цикле
roles / deploy / tasks / main.yml
---
- name: Deploy
debug:
msg: '{{ my_index }}. {{ item.name }} and {{ ansible_loop.allitems }}'
loop:
- name: Oleg
group: admin
specs:
age: 24
role: common
- name: Peter
- name: Kate
loop_control:
extended: true # предоставляем доступ к ansible_loop
index_var: my_index # имя для индекса элемента
label: "{{ item.name }}" # новый лейбл
pause: 3 # пауза в 3 секунды$ ansible-playbook -i inventory all.yml --tags "deploy"
ok: [server1] => (item=Oleg) => {
"msg": "0. Oleg and [{'name': 'Oleg', 'group': 'admin', 'specs': {'age': 24, 'role': 'common'}}, {'name': 'Peter'}, {'name': 'Kate'}]"
}
ok: [server1] => (item=Peter) => {
"msg": "1. Peter and [{'name': 'Oleg', 'group': 'admin', 'specs': {'age': 24, 'role': 'common'}}, {'name': 'Peter'}, {'name': 'Kate'}]"
}
ok: [server1] => (item=Kate) => {
"msg": "2. Kate and [{'name': 'Oleg', 'group': 'admin', 'specs': {'age': 24, 'role': 'common'}}, {'name': 'Peter'}, {'name': 'Kate'}]"
}Вложенные циклы
С помощью loop_var мы можем переопределить дефолтное наменование текущего элемента итерации.
Например, зададим outer_item и теперь нам нужно будет использовать это имя для обращения к элементу итерации
А с помощью ключа include_tasks, мы можем подключить задачи из другого файла
Таким образом, объединив эти два подхода, мы можем выполнить вложенный цикл и выполнить inner.yml в цикле
roles / deploy / tasks / main.yml
---
- name: Deploy
include_tasks: inner.yml
loop:
- name: Oleg
- name: Peter
- name: Kate
loop_control:
loop_var: outer_itemroles / deploy / tasks / inner.yml
---
- name: Inner
debug:
msg: 'inner {{ item }} - outer {{ outer_item }}'
loop:
- 1
- 2
- 3ok: [server1] => (item=1) => {
"msg": "inner 1 - outer {'name': 'Oleg'}"
}
ok: [server1] => (item=2) => {
"msg": "inner 2 - outer {'name': 'Oleg'}"
}
ok: [server1] => (item=3) => {
"msg": "inner 3 - outer {'name': 'Oleg'}"
}
TASK [deploy : Inner] ****************************************************************************************************************************************************
ok: [server1] => (item=1) => {
"msg": "inner 1 - outer {'name': 'Peter'}"
}
ok: [server1] => (item=2) => {
"msg": "inner 2 - outer {'name': 'Peter'}"
}
ok: [server1] => (item=3) => {
"msg": "inner 3 - outer {'name': 'Peter'}"
}
TASK [deploy : Inner] ****************************************************************************************************************************************************
ok: [server1] => (item=1) => {
"msg": "inner 1 - outer {'name': 'Kate'}"
}
ok: [server1] => (item=2) => {
"msg": "inner 2 - outer {'name': 'Kate'}"
}
ok: [server1] => (item=3) => {
"msg": "inner 3 - outer {'name': 'Kate'}"
}Lookup
Lookup позволяет искать информацию в зависимости от самого плагина.
File
Поиск файла
Лукап по файлу очень полезен может быть в ситуациях, когда нам нужно условно инклюдить файлы, если они есть, или в зависимости от данных в файле триггерить задачу
roles / deploy / tasks / main.yml
---
- name: Deploy
debug:
msg: "{{ lookup('file', '../meta/main.yml') }}"И теперь в выводе мы получили файлы
ok: [server1] => {
"msg": "---\ngalaxy_info:\n role_name: deploy\n description: Deploy microservices on decoker cluster\n author: Lvov Valery\n license: MIT\n min_ansible_version: 2.20\n platforms:\n - name: Ubuntu\n versions: [all]"
}Обработка ошибок
Поле errors определит, что нужно делать ansible для обработки ошибок
---
- name: Deploy
debug:
msg: "{{ lookup('file', '../meta/mains.yml', errors='warn') }}"Для более подробного вывода, нужно воспользоваться флагом -vvvvv, который выведет подробную информацию исполнения операций ansible
$ ansible-playbook -i inventory all.yml --tags "deploy" -vvvvv
TASK [deploy : Deploy] ***************************************************************************************************************************************************
task path: /Users/zeizel/projects/ansible/roles/deploy/tasks/main.yml:2
looking for "../meta/mains.yml" at "/Users/zeizel/projects/ansible/roles/deploy/files/../meta/mains.yml"
looking for "../meta/mains.yml" at "/Users/zeizel/projects/ansible/roles/deploy/../meta/mains.yml"
looking for "../meta/mains.yml" at "/Users/zeizel/projects/ansible/roles/deploy/tasks/files/../meta/mains.yml"
looking for "../meta/mains.yml" at "/Users/zeizel/projects/ansible/roles/deploy/tasks/../meta/mains.yml"
looking for "../meta/mains.yml" at "/Users/zeizel/projects/ansible/files/../meta/mains.yml"
looking for "../meta/mains.yml" at "/Users/zeizel/projects/ansible/../meta/mains.yml"
looking for "../meta/mains.yml" at "/Users/zeizel/projects/ansible/files/../meta/mains.yml"
looking for "../meta/mains.yml" at "/Users/zeizel/projects/ansible/../meta/mains.yml"
File lookup using None as file
[WARNING]: An error occurred while running the lookup plugin 'file': Unable to access the file '../meta/mains.yml': File not found. Use -vvvvv to see paths searched.
ok: [server1] => {
"msg": null
}Сейчас операция выполнилась без ошибок и сама ошибка перешла в разряд предупреждения. Однако, мы можем заметить, что ansible ищет файлы в папке files
Дефолтный files
Положим мету в deploy / files / main.yml и обратимся в поиске просто по имени файла
---
- name: Deploy
debug:
msg: "{{ lookup('file', 'main.yml') }}"И файл окажется так же найденным
ok: [server1] => {
"msg": "---\ngalaxy_info:\n role_name: deploy\n description: Deploy microservices on decoker cluster\n author: Lvov Valery\n license: MIT\n min_ansible_version: 2.20\n platforms:\n - name: Ubuntu\n versions: [all]"
}Vars
Этот тип операции позволяет найти среди всех переменных нужную
---
- name: Deploy
vars:
name: a
debug:
msg: "{{ lookup('vars', 'name') }}"ok: [server1] => {
"msg": "a"
}Документация
Чтобы вывести список возможных значений для поиска из различных источников. Притом можно заметить, что не все источники находятся локально.
ansible-doc -t lookup -lamazon.aws.aws_account_attribute Look up AWS account attributes
amazon.aws.aws_collection_constants expose various collection related constants
amazon.aws.aws_service_ip_ranges Look up the IP ranges for services provided in AWS such as EC2 and S3
amazon.aws.secretsmanager_secret Look up secrets stored in AWS Secrets Manager
amazon.aws.ssm_parameter gets the value for a SSM parameter or all parameters under a path
ansible.builtin.config Display the 'resolved' Ansible option values
ansible.builtin.csvfile read data from a TSV or CSV file
ansible.builtin.dict returns key/value pair items from dictionaries
ansible.builtin.env Read the value of environment variablesЧтобы узнать, как работает отдельный плагин лукапа, можно вывести для него документацию
ansible-doc -t lookup passwordФильтры
Фильтры - это функции, которые позволяют преобразовать данные из одного формата в другой
default
Фильтр default позволяет установить дефолтное значение.
В этом месте у нас устанавливается значение 5, как дефолтное, когда не прилетит никакого значения.
---
- name: Deploy
debug:
msg: "{{ item | default(5) }}"Так же default позволяет исключить значение с помощью omit и опустить передачу аргумента, тем самым избегая ошибок из-за отсутствия значения в неконсистентных исходных данных
---
- name: Deploy
file:
dest: '{{ item.path }}'
state: touch
mode: '{{ item.mode | default(omit) }}' # если mode отсутствует, то сюда попадёт undefined
loop:
- path: './test'
- path: './test'
mode: '0755'type_debug
type_debug выведет тип переданного значения
---
- name: Deploy
vars:
admin: true
debug:
msg: '{{ admin | type_debug }}' # boolitems2dict
Фильтр items2dict позволяет перевести объект либо массив с ключами key/value в список
---
- name: Deploy
vars:
admin:
- key: a
value: 1
- key: b
value: 2
debug:
msg: '{{ admin | items2dict }}'ok: [server1] => {
"msg": {
"a": 1,
"b": 2
}
}Если мы передаём массив и у нас кастомные наименования полей, то можно задать имя для полей ключа и значения с помощью key_name / value_name
---
- name: Deploy
vars:
admin:
- keys: a
values: 1
- keys: b
values: 2
debug:
msg: "{{ admin | items2dict(key_name='keys', value_name='values') }}"chaining
bool, по возможности, переведёт попадающее в него значение в boolean тип. Строка "true" преобразуется в true.
---
- name: Deploy
vars:
admin: "true"
debug:
msg: '{{ admin | bool | type_debug }}'ok: [server1] => {
"msg": "bool"
}pretty
Так же присутствуют фильтры для преобразования данных в YAML / JSON
to_yaml/to_json- перевод в yaml / jsonto_nice_yaml/to_nice_json- перевод с отступами
---
- name: Deploy
vars:
admin: { a: 1, b: 2 }
debug:
msg: '{{ admin | to_nice_yaml }}'ok: [server1] => {
"msg": "a: 1\nb: 2\n"
}combine
Фильтр combine позволяет объединять словари. Можно использовать для слияния конфигураций или добавления новых ключей к существующему объекту.
---
- name: Deploy
vars:
admin: { a: 1, b: 2 }
debug:
msg: "{{ admin | combine({ 'c': 4 }) }}"ok: [server1] => {
"msg": {
"a": 1,
"b": 2,
"c": 4
}
}Zip
Фильтр zip объединяет два списка в список кортежей, где элементы связаны по индексу. Полезно для синхронного обхода нескольких списков.
---
- name: Deploy
vars:
admin: [1,2,3]
debug:
msg: "{{ admin | zip(['a','b','c']) }}"ok: [server1] => {
"msg": [
[
1,
"a"
],
[
2,
"b"
],
[
3,
"c"
]
]
}map
Фильтр map применяет функцию или фильтр к каждому элементу последовательности. В примере extract извлекает элементы по индексам.
---
- name: Deploy
vars:
admin: [ 0, 1 ]
debug:
msg: "{{ admin | map('extract', ['a','b','c']) | list }}"ok: [server1] => {
"msg": [
"a",
"b"
]
}join
Фильтр join объединяет элементы списка в строку с указанным разделителем. Удобно для формирования командных строк или путей.
---
- name: Deploy
vars:
admin: [ '0', '1' ]
debug:
msg: "{{ admin | join(',') }}" # 0,1json_query
Этот фильтр позволяет нам запросить JSON данные по какой-нибудь апишке или стянуть JSON данные из файла, используя синтаксис JMESPath для сложных запросов к структурам данных.
---
- name: Deploy
vars:
admin: [ '0', '1' ]
debug:
msg: "{{ admin | json_query('app.name[*]') }}"random
Данный фильтр позволяет стянуть рандомное значение из переданного списка
---
- name: Deploy
vars:
admin: [ '0', '1' ]
debug:
msg: "{{ admin | random }}"ok: [server1] => {
"msg": "1"
}flatten и unique
flatten переведёт многомерный (в зависимости от параметра level, который дефолтно равен 2) массив в одномерный
unique выделит только уникальные значения из списка
---
- name: Deploy
vars:
admin: [ 0, 1, [0, 1], [ 3, 4, 5 ] ]
debug:
msg: "{{ admin | flatten | unique }}"ok: [server1] => {
"msg": [
0,
1,
3,
4,
5
]
}intersect и union
intersect объединяет только пересекающиеся значения массива
union позволяет объединить два массива (сохраняя только уникальные значения)
---
- name: Deploy
vars:
admin: [ 0, 1 ]
debug:
msg: "{{ admin | intersect([1,2]) }}" # 1---
- name: Deploy
vars:
admin: [ 0, 1 ]
debug:
msg: "{{ admin | union([1,2]) }}" # 0, 1, 2urlsplit
Данный фильтр позволяет нам вытащить определённую часть урла
---
- name: Deploy
vars:
admin: "https://foundry.zeizel.ru"
debug:
msg: "{{ admin | urlsplit('hostname') }}"ok: [server1] => {
"msg": "foundry.zeizel.ru"
}regex_search и regex_replace
regex_search ищет первое совпадение по регулярному выражению и возвращает найденную строку или группу
regex_replace заменяет все совпадения по регулярному выражению на указанную строку. Поддерживает группы захвата \1, \2 для переиспользования частей совпадения
---
- name: Deploy
vars:
url: "test.staging.domain.com"
debug:
msg: "{{ url | regex_replace('(.+\\.)(.+)$', '\\1') }}" # test.---
- name: Deploy
vars:
text: "Version 9.1.85"
debug:
msg: "{{ text | regex_search('(\\d+\\.\\d+\\.\\d+)') }}" # 9.1.85password_hash и hash
password_hash генерирует хеш пароля для использования в /etc/shadow или других системных конфигурациях. Поддерживает алгоритмы sha256, sha512, md5_crypt, bcrypt
hash создаёт простой хеш строки с помощью алгоритмов sha1, sha256, sha512, md5. Полезно для проверки контрольных сумм или идентификаторов
---
- name: Deploy
vars:
password: "mysecret"
debug:
msg: "{{ password | password_hash('sha512') }}"---
- name: Deploy
vars:
text: "hello"
debug:
msg: "{{ text | hash('sha256') }}" # простой хешb64encode и b64decode
b64encode кодирует строку в формат Base64, что необходимо для передачи бинарных данных в текстовом формате или работы с Kubernetes Secrets
b64decode декодирует Base64-строку обратно в исходный формат
---
- name: Deploy
vars:
text: "hello world"
debug:
msg: "{{ text | b64encode }}" # aGVsbG8gd29ybGQ=---
- name: Deploy
vars:
encoded: "aGVsbG8gd29ybGQ="
debug:
msg: "{{ encoded | b64decode }}" # hello worldselect и reject
select фильтрует список, оставляя только элементы, которые проходят указанный тест. Противоположный ему reject оставляет элементы, которые не проходят тест
---
- name: Deploy
vars:
numbers: [ 0, 1, 2, false, '', 3 ]
debug:
msg: "{{ numbers | select() | list }}" # [1, 2, 3] - только truthy значения---
- name: Deploy
vars:
numbers: [ 1, 2, 3, 4, 5 ]
debug:
msg: "{{ numbers | select('odd') | list }}" # [1, 3, 5]selectattr и rejectattr
selectattr фильтрует список словарей по значению конкретного атрибута, применяя к нему тест. rejectattr работает противоположно - отклоняет элементы, которые проходят тест
---
- name: Deploy
vars:
servers:
- name: web1
status: active
- name: web2
status: inactive
- name: web3
status: active
debug:
msg: "{{ servers | selectattr('status', 'equalto', 'active') | map(attribute='name') | list }}"ok: [server1] => {
"msg": [
"web1",
"web3"
]
}ternary
Фильтр ternary реализует тернарный оператор - выбирает одно из двух значений в зависимости от условия. Условие должно быть заключено в скобки, результат передаётся через pipe в ternary(true_value, false_value)
---
- name: Deploy
vars:
env: "prod"
debug:
msg: "{{ (env == 'dev') | ternary('localhost', 'db.prod.internal') }}"ok: [server1] => {
"msg": "db.prod.internal"
}Альтернативный вариант для проверки с defined:
---
- name: Deploy
vars:
debug_mode: true
debug:
msg: "{{ debug_mode | ternary('DEBUG', 'INFO') }}" # DEBUGВыкладка
Нам нужно реализовать скрипты, которые будут в себя включать:
- тег
deployдолжен развернуть всю систему, включая overlay сеть, апишку, фронт и rmq с сопутствующим подцеплением секретов - тег
appдолжен предоставить возможность отдельно установить сервис фронта
Идеал: один тег, который устанавливает всё и отдельные теги для установки отдельных сервисов

Опишем метаданные роли
roles / deploy / meta / main.yml
---
galaxy_info:
role_name: deploy
description: Deploy microservices on docker cluster
author: Lvov Valery
license: MIT
min_ansible_version: 2.20
platforms:
- name: Ubuntu
versions: [all]Заранее определим структуру нашего скрипта:
- нам нужно отдельно иметь возможность запускать задачи под отдельные сервисы
- сервисы нужно уметь скейлить
- имя сети мы должны чётко определить
Все сервисы будут находиться в services / <сервис>
Переменные определения инфраструктуры можно оставить в самой роли. Это валидный вариант.
roles / deploy / vars / main.yml
---
services:
- api
network_name: app_networkДалее нам нужна таска, которая будет создавать нам секреты на базе docker_secret модуля.
- Тут мы в
envFileпередаёмlookupфильтр для поиска исходных энвов (в гитигнор) и формирование из них секретов. Этот файл ищется в папке необходимого нам сервиса. - Далее
nameгенерируем по имени сервиса dataдля секретов передаём в формате base64- Для того, чтобы у нас была возможность обновлять секреты в сервисах, нам нужно под что-то подцепиться. Самый простой способ это сделать - изменять лейблы. Изменять лейблы можно легко беря файл и переводя его в
hash.
roles / deploy / services / secret-create.yml
---
- name: "[{{ name }}] creating secrets"
vars:
env_file: "{{ lookup('file', '{{ name }}/.env') }}"
community.docker.docker_secret:
name: "{{ name }}.env"
data: "{{ env_file | b64encode }}"
labels:
secret: "{{ env_file | hash('sha1') }}"
data_is_b64: true
state: presentДалее пишем полноценную обработку секрета.
Тут нам нужно:
- заинклюдить ранее созданный файл создания секрета
- так как после повторной прогонки создания секрета, у нас таска обязательно упадёт, то нам нужно воспользоваться блоком
rescue, в котором нужно будет удалить сервис, а потом заново выполнить задачу на создание секрета (скопировать изblock)
roles / deploy / services / secret.yml
---
- name: "[{{ name }}] Configure secret"
tags: "{{ name }}"
block:
- name: "[{{ name }}] Creating secret"
include: "secret-create.yml"
rescue:
- name: "[{{ name }}] Removing service"
community.docker.docker_swarm_service:
name: "{{ name }}"
state: absent
- name: "[{{ name }}] Creating secret"
include: "secret-create.yml"Ну и создадим пока рядом пустой roles / deploy / services / api / .env файл с переменными этого сервиса
Сейчас нам нужно написать деплой для нашего сервиса в swarm:
- Имплементируем создание секрета.
- Далее выполняем деплой сервиса. Плагин
docker_swarm_serviceстянет образ из локального registrylocalhost:5000и создаст сервис по описанным нами данным.
roles / deploy / services / api / service.yml
---
- name: "[{{ name }}] Configuring secret"
include: "../secret.yml"
- name: "[{{ name }}] Deploying service"
tags: "{{ name }}"
block:
- name: "[{{ name }}] Deploy service"
community.docker.docker_swarm_service:
name: "{{ name }}"
image: "localhost:5000/{{ name }}:latest"
state: present
force_update: true # обновление в любом случае
networks: # подключение к общей сети
- name: "{{ network_name }}"
publish: # публикация будет происходить через ingress
- mode: ingress
protocol: tcp
published_port: 3002
target_port: 3000
secrets: # стягиваем созданный секрет и кладём в `/opt/app`
- secret_name: "{{ name }}.env"
filename: "/opt/app/.env"И вот представление того самого кульминационного момента - в main.yml нам остаётся только:
- подставить переданный
networkNameдля создания overlay сети - далее создаём задачу на деплой сервисов, которая будет инклюдить динамически конфиг под имя сервиса и повторять их на все сервисы
roles / deploy / tasks / main.yml
---
- name: creating overlay network
community.docker.docker_network:
name: "{{ network_name }}"
driver: overlay
- name: deploy services
vars:
- name: "{{ item }}"
include: "../services/{{ item }}/service.yml"
loop: "{{ services }}"ansible-playbook -i inventory all.yml --tags="deploy"Vault
Vault - встроенный модуль ansible, который позволяет зашифровать чувствительные данные. С ним мы можем позволить положить секреты в репозиторий в закрытом виде.
Что позволяет нам сделать ansible-vault:
create- создаёт зашифрованный файлdecrypt- расшифровывает файлedit- позволяет редактировать файлview- позволяет просмотреть файлencrypt- зашифровывает файлrekey- пересоздаёт ключ
Создание запароленного секрета
Создадим файл, который будет хранить секретные данные
group_vars / all / vault.yml
rmq:
user: admin
password: adminЧтобы зашифровать и расшифровать файл, нужно использовать:
# зашифровываем по паролю
$ ansible-vault encrypt group_vars/all/vault.yml
New Vault password:
Confirm New Vault password:
Encryption successful
# расшифровываем по паролю
$ ansible-vault decrypt group_vars/all/vault.yml
Vault password:
Decryption successfulВо время encrypt контент внутри файла кодируется
group_vars / all / vault.yml
$ANSIBLE_VAULT;1.1;AES256
32386538613862393132396239333661636136333363626535366366633933356636373432646333
3861636133376466346565633066633037383064303937350a316637623039323963316430636430
32653866626632626533363033363263313337393132636236636633643336613136383830666161
3536663064613331660a643736643632346463653834306465336162613333336138653430636533
35353933373934656336346264303438366139643334613239626133656530333137623838666531
6439346461633966323061393537306636316134386538643630
Пока фейково заполним наш .env данными, которые не будут работать до тех пор, пока из энвов не сделаем шаблон
roles / deploy / services / api / .env
AMQP_EXCHANGE=xchg_integrations
AMQP_USER={{rmq.user}}
AMQP_PASSWORD={{rmq.password}}
AMQP_HOSTNAME=rmq
Для того, чтобы операция запустилась, нужно передать флаг --ask-vault-pass
ansible-playbook -i inventory all.yml --tags "deploy" --ask-vault-passСохранение пароля по роли
Чтобы подцепить пароль по роли, нужно зашифровать файл с флагом --vault-id, где мы укажем роль@способ. Роль может быть любая, а способ у нас может выглядеть, как ввод пароля (prompt) или указать путь до него (сам путь до файла).
Роль мы можем в шифровании и дешифровании указывать любую - она не будет влиять никак на операции, только визуально разделяет принадлежность пароля какой-либо группе или окружению (dev, prod, devops)
prompt
$ ansible-vault encrypt group_vars/all/vault.yml --vault-id dev@prompt
New vault password (dev):
Confirm new vault password (dev):
Encryption successfulТеперь у нас зашифрован файл по роли dev
group_vars / all / vault.yml
$ANSIBLE_VAULT;1.2;AES256;devfile
Создадим файл .pass и занесём пароль в него. Но обязательно нужно добавить его в .gitignore.
.pass
1
И энкриптим файл с указанием пути до пароля
$ ansible-vault encrypt group_vars/all/vault.yml --vault-id dev@.pass
Encryption successfulПрименение в плейбуках
И в плейбуке нам нужно указать ту же самую строку с --vault-id
ansible-playbook -i inventory all.yml --tags "deploy" --vault-id dev@.passШифрование отдельной строки
Иногда нам не нужно шифровать полностью весь файл и для этого ansible предоставляет возможность сгенерить в консоли отдельный зашифрованный элемент. Для этого используется encrypt_string
$ ansible-vault encrypt_string --vault-pass-file .pass 'app_network' --name 'networkName'
Encryption successful
networkName: !vault |
$ANSIBLE_VAULT;1.1;AES256
64343066396236383963356465643132333734616162313465333463346138643730633034316664
6361643561303338386533656430356134303037366263630a343566633437316533306632356263
33646562646331313037373563616461323363313164383665626564633036636233613466356630
3465343063656436630a633730346466376532383431323738356235326161383764333331336134
6266
И далее просто вставляем его руками в YML
roles / deploy / vars / main.yml
---
services:
- api
networkName: !vault |
$ANSIBLE_VAULT;1.1;AES256
64343066396236383963356465643132333734616162313465333463346138643730633034316664
6361643561303338386533656430356134303037366263630a343566633437316533306632356263
33646562646331313037373563616461323363313164383665626564633036636233613466356630
3465343063656436630a633730346466376532383431323738356235326161383764333331336134
6266Так же в эту команду мы можем передать --vault-id dev@.pass, чтобы пометить шифрование
Шаблоны
Ansible использует jinja шаблонизатор, который позволяет в yaml подставлять шаблоны вида {{ value }} и генерировать финальные yaml файлы.
Вернёмся к нашим паролям Vault. Тут у нас так же user и password. Мы их спокойно можем зашифровать.
group_vars / all / vault.yml
---
rmq:
user: admin
password: adminДалее перенесём переменные окружения для rmq в vars.yml. Их мы сюда переносим из .env в api сервисе
group_vars / all / vars.yml
---
rmq_defaults:
- name: AMQP_EXCHANGE
value: xchg_integrations
- name: AMQP_USER
value: "{{rmq.user}}"
- name: AMQP_PASSWORD
value: "{{rmq.password}}"
- name: AMQP_HOSTNAME
value: rmqДалее заменим .env файл на генерируемый с помощью jinja шаблон. Этот шаблон будет собирать переменные окружения для файла.
roles / deploy / services / api / .env.j2
{% for item in rmq_defaults %}
{{ item.name }}={{ item.value }}
{% endfor %}В конце остаётся только заменить lookup с file на template и подставить наш .env.j2 шаблон в генерторе секретов.
И, чтобы посмотреть вывод, мы можем повторить лукап в другой таске и вывести в дебаге результат
roles / deploy / services / secret-create.yml
---
- name: "[{{ name }}] creating secrets"
vars:
env_file: "{{ lookup('template', '{{ name }}/.env.j2') }}"
community.docker.docker_secret:
name: "{{ name }}.env"
data: "{{ env_file | b64encode }}"
labels:
secret: "{{ env_file | hash('sha1') }}"
data_is_b64: true
state: present
- name: "Debug"
vars:
env_file: "{{ lookup('template', '{{ name }}/.env.j2') }}"
debug:
msg: "{{ env_file }}"Далее только останется запустить api
ansible-playbook -i inventory all.yml --tags "api"Сборка контейнеров
Далее нам нужно написать автоматизацию сборки сервисов посредством отдельной роли build.
Воркфлоу достаточно простой:
- нужно скопировать проект из гит-репозитория
- собрать образ
- удалить гит-репозиторий
Опишем её меты
roles / build / meta / main.yml
galaxy_info:
role_name: build
description: Monorepo Building
author: Lvov Valery
license: MIT
min_ansible_version: 2.9
platforms:
- name: Ubuntu
versions:
- allГлобальные переменные
Для этой роли опишем дефолтными значеними папку с гит репозиторием, куда прилетит образ
roles / build / defaults / main.yml
git_folder: /home/vagrant/dockerУберём из переменных деплоя сервисы, так как это более глобальная сущность, которая понадобится нам во время сборки, чтобы сохранить консистентные имена (создание образа и его стягивание)
roles / deploy / vars /main.yml
---
network_name: app_networkИ сами сервисы опишем в глобальных переменных
group_vars / all / vars.yml
---
rmq_defaults:
- name: AMQP_EXCHANGE
value: xchg_integrations
- name: AMQP_USER
value: "{{rmq.user}}"
- name: AMQP_PASSWORD
value: "{{rmq.password}}"
- name: AMQP_HOSTNAME
value: rmq
registry_name: "localhost:5000/"
services:
- name: api
version: latest
- name: rmq
version: latest
non_build_services:
- name: rmq
version: latestВ итоге у нас получается данный набор задач:
- клонируем репозиторий с нашим проектом, где ветку выбираем с помощью
version - собираем образ с помощью модуля
docker_image - удаляем репозиторий с устройства
Но у нас появляется проблема - не все сервисы, которые мы будем деплоить, нам нужно собирать. Для этого мы добавили поле non_build_services, которое мы можем передать в difference и они будут исключены из списка, который есть в services.
roles / build / tasks / main.yml
---
- name: Клонируем репозиторий
ansible.builtin.git:
repo: "https://github.com/AlariCode/docker-demo.git"
dest: "{{ git_folder }}"
version: block-7
- name: Собираем image
community.docker.docker_image:
name: "{{ registry_name }}{{ item.name }}"
tag: "{{ item.version }}"
push: true
force_source: true # даже если ничего в сурсах не поменялось - собирать
force_tag: true # пересобирать, даже если есть такой тег
source: build # операция сборки
build: # параметры сборки
path: "{{ git_folder }}"
dockerfile: "{{ git_folder }}/apps/{{ item.name }}/Dockerfile"
# Собираем все сервисы из списка собираемых сервисов
loop: "{{ services | difference(non_build_services) }}"
- name: Удаляем репозиторий
file:
state: absent
path: "{{ git_folder }}"Deploy service
Далее прокинем registry_name в задачу по деплою сервиса
roles/deploy/services/api/service.yml
- name: "[{{ name }}] Выкладка сервиса"
block:
- name: "[{{ name }}] Выкладываем сервис"
community.docker.docker_swarm_service:
name: "{{ name }}"
image: "{{ registry_name }}{{ name }}:{{ version }}"Теперь в задаче по выкладке сервисов, у нас обращение должно идти к объекту item, который сразу хранит и имя, и версию
roles/deploy/tasks/main.yml
- name: Выкладка сервисов
include: "../services/{{ item.name }}/service.yml"
vars:
- name: "{{ item.name }}"
- version: "{{ item.version }}"
loop: "{{ services }}"RMQ service
Добавим деплой RMQ сервиса
roles/deploy/services/rmq/service.yml
---
- name: "[{{ name }}] Конфигурация секрета"
include: "../secret.yml"
- name: "[{{ name }}] Выкладка сервиса"
tags: "{{ name }}"
block:
- name: "[{{ name }}] Выкладываем сервис"
community.docker.docker_swarm_service:
name: "{{ name }}"
image: "{{ registry_name }}{{ name }}:{{ version }}"
state: present
networks:
- name: "{{ network_name }}"
publish:
- mode: ingress
protocol: tcp
published_port: 3002
target_port: 3000
secrets:
- secret_name: "{{ name }}.env"
filename: "/opt/app/.env"Финал
В корневой файл подтянем созданную роль build и навесим тег, по которому сможем вызвать роль
all.yml
- name: Deploy / build server
hosts: deploy
roles:
- role: preconfig
tags: preconfig
- role: build
tags: build
- role: deploy
tags: deploy
# - swarm_initДалее такой командой запустится сборка api сервиса
ansible-playbook -i inventory all.yml --tags="build"Финал выкладки
Далее осталось доработать скрипт для выкладки фронта, самого конвертера и rmq
Для начала, нужно дополнить переменные окружения для наших сервисов:
- Укажем
registry_name, так как он будет повторяться во всех сервисах - Добавим в
servicesнашиappиconverter - Добавим поле
configs, которое будет хранить публичную конфигурацию конвертера
group_vars / all / vars.yml
---
rmq_defaults:
- name: AMQP_EXCHANGE
value: xchg_integrations
- name: AMQP_USER
value: "{{rmq.user}}"
- name: AMQP_PASSWORD
value: "{{rmq.password}}"
- name: AMQP_HOSTNAME
value: rmq
registry_name: "localhost:5000"
services:
- name: api
version: latest
- name: app
version: latest
- name: converter
version: latest
- name: rmq
version: 3-management
non_build_services:
- name: rmq
version: 3-management
configs:
converter:
queue: q_imageProcessorapp
Опишем выкладку сервиса фронта
roles / deploy / services / app / service.yml
---
- name: "[{{ name }}] Выкладка сервиса"
tags: "{{ name }}"
block:
- name: "[{{ name }}] Выкладываем сервис"
community.docker.docker_swarm_service:
name: "{{ name }}"
image: "{{ registry_name }}/{{ name }}:{{ version }}"
state: present
force_update: true # обновление в любом случае
networks:
- name: "{{ network_name }}"
publish:
- mode: ingress
protocol: tcp
published_port: 3001
target_port: 80converter
Выкладка конвертера так же аналогична api, но тут нам не понадобится проброс портов, так как общение будет происходить с RMQ
roles / deploy / services / converter / service.yml
---
- name: "[{{ name }}] Конфигурация секрета"
include: "../secret.yml"
- name: "[{{ name }}] Выкладка сервиса"
tags: "{{ name }}"
block:
- name: "[{{ name }}] Выкладываем сервис"
community.docker.docker_swarm_service:
name: "{{ name }}"
image: "{{ registry_name }}/{{ name }}:{{ version }}"
state: present
force_update: true # обновление в любом случае
networks:
- name: "{{ network_name }}"
secrets:
- secret_name: "{{ name }}.env"
filename: "/opt/app/.env"Передадим такой же список секретных переменных для подключения к RMQ и укажем имя очереди AMQP_QUEUE из конфига
roles / deploy / services / converter / .env.j2
{% for item in rmq_defaults %}
{{ item.name }}={{ item.value }}
{% endfor %}
AMQP_QUEUE={{ configs.converter.queue }}
rmq
Передаём переменные окружения в этот сервис напрямую через зашифрованный волтом env и тянем образ из репозитория rabbitmq
roles / deploy / services / rmq / service.yml
---
- name: "[{{ name }}] Выкладка сервиса"
tags: "{{ name }}"
block:
- name: "[{{ name }}] Выкладываем сервис"
community.docker.docker_swarm_service:
name: "{{ name }}"
image: "rabbitmq:{{ version }}"
state: present
networks:
- name: "{{ network_name }}"
env:
- RABBITMQ_DEFAULT_USER={{ rmq.user }}
- RABBITMQ_DEFAULT_PASS={{ rmq.password }}Далее мы можем отдельно запустить сборку всех сервисов и выложить их в наш кластер
ansible-playbook -i inventory all.yml --tags="build"
ansible-playbook -i inventory all.yml --tags="deploy"Прокидываем порты собранных приложений.

И теперь на localhost:3002 доступно приложение с api, rmq, фронтом и самим конвертером

Deploy приложения на кластер
Настройка NGINX
Сейчас нам нужно добавить reverse-proxy, который позволит нам не стучаться на какой-то определённый порт, чтобы отправить запрос, а биться в один домен.
group_vars / all / vars.yml
# ...
services:
- name: api
version: latest
- name: app
version: latest
- name: converter
version: latest
- name: rmq
version: 3-management
- name: nginx
version: latest
non_build_services:
- name: rmq
version: 3-management
- name: nginx
version: latest
# ...И сменим версию тега на block-14, где базовым путём до фронта станет http://image.local
roles / build / tasks / main.yml
---
- name: Клонируем репозиторий
ansible.builtin.git:
repo: "https://github.com/AlariCode/docker-demo.git"
dest: "{{ git_folder }}"
version: block-14Опишем конфигурацию, которая будет использоваться для проксирования запросов.
Тут у нас есть два подхода:
- Мы можем биться в сервис напрямую через внутренний DNS докера
http://api:3000, но тогда нам нужно будет перезапускать и NGINX при обновлении деплоя сервиса - Мы можем долбиться по ip устройства и его внешнему порту (
published_port), чтобы игресс сам разрулил адресата.
Тут будет использоваться первый подход.
Игресс описан так:
- Рабочих процесса 2
- Подключений на каждый воркер максимум по 1024
- сервер слушает 80 порт и выводит доменное имя
image.local - при отпрвке запроса на
upload(s), запрос летит на сервер - при отправке запроса на
/, мы попадаем на фронт
roles / deploy / services / nginx / nginx.conf.j2
worker_processes 2;
events { worker_connections 1024; }
http {
server {
listen 80;
server_name image.local;
location ~ (/uploads/|/upload) {
proxy_pass http://api:3000;
}
location ~ (/) {
proxy_pass http://app;
}
}
}Далее нам нужно описать задачи для создания конфигов (рядом с описанием секретов), которое мы будем передавать в сервисы
Создаём конфиг аналогично секретам
roles / deploy / services / config-create.yml
---
- name: "[{{ name }}] Создаём конфиг"
vars:
config_file: "{{ lookup('template', '{{ name }}/{{ config_item }}.j2') }}"
community.docker.docker_config:
name: "{{ config_item }}"
data: "{{ config_file | b64encode }}"
labels:
config: "{{ config_file | hash('sha1') }}"
data_is_b64: true
state: presentДалее подставляем создание конфига в наш сервис. Тут тоже всё аналогично секретам
roles / deploy / services / config.yml
---
- name: "[{{ name }}] Конфигурация конфига"
block:
- name: "[{{ name }}] Создаём конфиг"
include: "config-create.yml"
tags: "{{ name }}"
rescue:
- name: "[{{ name }}] Удаляем сервис"
community.docker.docker_swarm_service:
name: "{{ name }}"
state: absent
- name: "[{{ name }}] Создаём конфиг"
include: "config-create.yml"И опишем сервис, который будет поднимать сам NGINX:
- конфигурация конфига
- подключаем сюда используемый
config.yml - прогоняем в цикле все конфиги nginx (пока он у нас только один)
- внутрь первой таски нужно передать кастомное имя итерационного элемента (конфиге)
config_item, которое мы используем внутриservice.yml
- подключаем сюда используемый
- выкладка сервиса
- цепляем образ nginx
- вклиниваем его в нашу общую сеть
- прокидываем его порт 80 на наш 80 (http)
- прокидываем описанный конфиг nginx в директорию, откуда его стянет nginx после запуска
roles / deploy / services / nginx / service.yml
---
- name: "[{{ name }}] Конфигурация конфига"
include: "../config.yml"
loop:
- nginx.conf
loop_control:
loop_var: config_item
- name: "[{{ name }}] Выкладка сервиса"
tags: "{{ name }}"
block:
- name: "[{{ name }}] Выкладываем сервис"
community.docker.docker_swarm_service:
name: "{{ name }}"
image: "nginx:{{ version }}"
state: present
networks:
- name: "{{ network_name }}"
publish:
- mode: ingress
protocol: tcp
published_port: 80
target_port: 80
configs:
- config_name: nginx.conf
filename: "/etc/nginx/nginx.conf"Прокидываем порт балансировщика с server1

Далее нам нужно изменить доменные записи нашей хостовой машины через sudo nano /etc/hosts, где добавим описанное нами имя для локального ip
127.0.0.1 localhost demo.test image.local
И теперь мы можем запустить динамический деплой одного nginx
ansible-playbook -i inventory all.yml --tags="nginx"Результат находится на image.local

Локальные действия
Далее нам нужно описать роль, которая позволит инициализировать Docker Swarm сервисы и подключать к ним другие ноды.
Мы будем использовать токены в папке /tokens, поэтому добавляем их в игнор.
.gitignore
.DS_store
.pass
/tokens
Сразу определим наш основной хост, из которого мы будем выполнять все операции
group_vars / all / vars.yml
advertise_addr: 10.11.10.1инициализация сварма
Опишем мету для роли инициализации сварма
roles / swarm_init / meta / main.yml
role_name: swarm_init
description: Init swarm cluster
author: Lvov Valery
license: MIT
min_ansible_version: 2.9
platforms:
- name: Ubuntu
versions:
- allДалее нам нужно инициализировать сварм и для этого:
- С помощью модуля
docker_swarmинициализируем сервис сadvertise_addr(выбираем одну из доступных сетей на машине) равному тому, что указали ранее в переменных окружения - Далее создаём локальную директорию
./tokens, которая будет хранить токены подключения между свармами - Потом токен manager берём из контекста
token.swarm_facts.JoinTokens.Managerбудем сохранять вtoken-manager - А токен воркера возьмём из
token.swarm_facts.JoinTokens.Workerи положим вtoken-worker
roles / swarm_init / tasks / main.yml
---
- name: Инициализация swarm
community.docker.docker_swarm:
state: present
advertise_addr: "{{ advertise_addr }}"
register: token
- name: Наличие директории
local_action:
module: file
path: ./tokens
state: directory
- name: Сохранение токена manager
local_action:
module: copy
dest: ./tokens/token-manager
content: "{{ token.swarm_facts.JoinTokens.Manager }}"
- name: Сохранение токена worker
local_action:
module: copy
dest: ./tokens/token-worker
content: "{{ token.swarm_facts.JoinTokens.Worker }}"На этом инициализация сварма закончена и все токены подключения получены для подключения остальных машин
local_action
Данный модуль позволяет нам выполнить в моменте любую операцию на нашем хосте, а не на целевой машине. Например, когда нам нужно стянуть данные с удалённой машины и передать их в другое место.
Такая запись выполнит операцию на машине из инвентаря
- name: Наличие директории
file:
path: ./tokens
state: directoryА уже такая операция выполнит действие на нашей машине. Тут мы в module передаём тот модуль, которым мы хотим воспользоваться.
- name: Наличие директории
local_action:
module: file
path: ./tokens
state: directoryподключение к сварму
Далее создаём роль swarm_join, которая будет отвечать за подключение к сварму ноды
roles / swarm_join / meta / main.yml
galaxy_info:
role_name: swarm_join
description: Connecting node to swarm
author: Lvov Valery
license: MIT
min_ansible_version: 2.9
platforms:
- name: Ubuntu
versions:
- allИ подключение будет происходить по переданному type машины через тот же модуль docker_swarm, но уже который приводить машину к состоянию join
roles / swarm_join / tasks / main.yml
---
- name: Подключение
vars:
token: "{{ lookup('file', '../../tokens/token-{{ type }}') }}"
community.docker.docker_swarm:
state: join
remote_addrs: "{{ advertise_addr }}"
join_token: "{{ token }}"подключение
Инициализация сварма swarm_init будет происходить на deploy машине (server1). Остальные машины должны иметь swarm_join операцию.
all.yml
---
- name: Deploy / build server
hosts: deploy
roles:
- role: preconfig
tags: preconfig
- role: build
tags: build
- role: deploy
tags: deploy
- role: swarm_init
tags: swarm_init
- name: Manager
hosts: managers
roles:
- role: preconfig
tags: preconfig
- role: swarm_join
tags: swarm_join
type: manager # тип машины - manager
- name: Worker
hosts: workers
roles:
- role: preconfig
tags: preconfig
- role: swarm_join
tags: swarm_join
type: worker # а тут будут подключаться worker-машиныТеперь мы можем запустить инициализацию сварма на нашей корневой машине и запустить подключение остальных машин к сварму
ansible-playbook -i inventory all.yml --tags="swarm_init"
ansible-playbook -i inventory all.yml --tags="swarm_join"Делегирование задач
Вместо использования local_action, мы можем применять более удобную запись, которая не меняет синтаксиса команды - delegate_to. Она позволяет перенаправить выполнение таски на любое инвентарное устройство, либо на нашу локальную машину через обращение по localhost.
roles / swarm_init / tasks / main.yml
---
- name: Инициализация swarm
community.docker.docker_swarm:
state: present
advertise_addr: "{{ advertise_addr }}"
register: token
- name: Наличие директории
file:
path: ./tokens
state: directory
mode: "0777"
delegate_to: localhost
- name: Сохранение токена manager
copy:
dest: ./tokens/token-manager
content: "{{ token.swarm_facts.JoinTokens.Manager }}"
mode: "0644"
delegate_to: localhost
- name: Сохранение токена worker
copy:
dest: ./tokens/token-worker
content: "{{ token.swarm_facts.JoinTokens.Worker }}"
mode: "0644"
delegate_to: localhostНа данный момент
local_actiondeprecated
Pre_post_tasks and handlers
Handlers отрабатывают всегда, когда таска завершается в статусе changed
handlers роли
Хендлеры для роли описываются в папке handlers
roles / swarm_init / handlers / main.yml
---
- name: Тест
debug:
msg: Тестовый хендлерИ якорем для их выполнения является поле notify, в которое мы кладём name хендлера
roles / swarm_init / tasks / main.yml
---
- name: Инициализация swarm
community.docker.docker_swarm:
state: present
advertise_addr: "{{ advertise_addr }}"
register: token
notify: Тестpre_tasks and post_task
Так же мы можем для самой описываемой группы роли описать:
pre_tasks- это блок тасок, которые выполнятся перед выполнением тасокpost_tasks- блок тасок, которые выполнятся после выполнения роли
Примечания:
- эти таски запускаются для всех хостов, как и остальные таски
- они так же должны иметь теги, если мы запускаем задачи по тегам
all.yml
# ...
- name: Worker
hosts: workers
roles:
- role: preconfig
tags: preconfig
- role: swarm_join
type: worker
tags: swarm_join
post_tasks:
- name: Очистка папки token
file:
path: ./tokens
state: absent
ignore_errors: true
delegate_to: localhost
tags: swarm_joinРабота с фактами
Сейчас мы сильно оптимизируем работу передачи токенов из главной машины, где инициализировался сварм с помощью модуля set_facts.
В инициализации сварма воспользуемся set_fact, чтобы сохранить полученные токены и закэшируем их
roles / swarm_init / tasks / main.yml
---
- name: Инициализация swarm
community.docker.docker_swarm:
state: present
advertise_addr: "{{ advertise_addr }}"
register: token
- name: Сохранение токенов
set_fact:
token_manager: "{{ token.swarm_facts.JoinTokens.Manager }}"
token_worker: "{{ token.swarm_facts.JoinTokens.Worker }}"
cacheable: true # факты должны кэшироватьсяДалее в подключении к сварму, нам нужно стянуть хостовые переменные с машины server1 и добавлять их сюда по type
roles / swarm_join / tasks / main.yml
---
- name: Подключение
community.docker.docker_swarm:
state: join
remote_addrs: "{{ advertise_addr }}"
join_token: >
{{
# выводим пременную token_worker
hostvars['server1']['ansible_facts']['token_worker']
# если type == 'worker', либо
if type == 'worker' else
# передаём токен token_manager
hostvars['server1']['ansible_facts']['token_manager']
}}Отключение нод
Далее нужно реализовать отключение ноды по скрипту.
Шаги:
- переводим ноду в drain статус
- нужно убедиться, что на ней нет запущенных контейнеров
- удаляем ноду из кластера
Добавляем роль swarm_leave, которую будем вызывать по тегу. Оставим пока тут же глобальное имя node_name и выведем менеджер ноду server2. Однако лучше передавать имя ноды через --extra-args
all.yml
- name: Deploy / build server
hosts: deploy
roles:
- role: preconfig
tags: preconfig
- role: build
tags: build
- role: deploy
tags: deploy
- role: swarm_init
tags: swarm_init
- role: swarm_leave
node_name: server2
tags: swarm_leaveСоздаём роль и описываем её мету
roles / swarm_leave / meta / main.yml
galaxy_info:
role_name: swarm_leave
description: Remove node from swarm
author: Lvov Valery
license: MIT
min_ansible_version: 2.9
platforms:
- name: Ubuntu
versions:
- allДалее описываем задачи на вывод ноды:
- Переводим ноду в drain
- Проверяем через модуль
docker_host_infoколичество работающих контейнеров в течение минуты (30 ретраев с делеем в 2 секунды). Эту задачу мы делегируем на ту целевую ноду, на которой нам нужно проверять отключение всех сервисов. - Удаляем ноду через
docker_swarm, передав состояниеremoveи имя ноды вnode_id
roles / swarm_leave / tasks / main.yml
---
- name: Перевод в статус drain
community.docker.docker_node:
hostname: "{{ node_name }}"
availability: drain
- name: Ожидание остановки
community.docker.docker_host_info:
containers: true
register: result
retries: 30
delay: 2
until: result.host_info.ContainersRunning == 0
delegate_to: "{{ node_name }}"
- name: Удаление ноды
community.docker.docker_swarm:
state: remove
node_id: "{{ node_name }}"Но удаление прошлым вариантом может не сработать и поэтому может понадобиться выполнение команды
roles / swarm_leave / tasks / main.yml
- name: Удаление ноды
command: "docker node rm {{ node_name }} --force"Troubleshooting
Обновления
- Вместо
include, сейчас используютсяinclude_tasks
Установка Docker на виртуальную машину на MacOS
# 1. Удалим текущий GPG-ключ и источник
sudo rm -f /etc/apt/keyrings/docker.gpg
sudo rm -f /etc/apt/sources.list.d/docker.list
# 2. Установим GPG-ключ заново
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# 3. Добавим репозиторий как `jammy` (вместо noble!)
echo \
"deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu jammy stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# 4. Обновим apt и установим Docker
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-pluginsudo systemctl start dockersudo systemctl enable docker