Введение

Введение

Проблемы, которые перед нами встают, когда мы работаем с классической развёрткой приложения:

  • загрузка на 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 с нужным образом docker
  • push - позволит запушить собранный локально 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 - установка нужного нам shell
  • RUN - выполнение команды из оболочки. Самая частая в использовании команда. Для поднятия и сборки билда
  • 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 сразу несколько образов.

Основные плюсы:

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

Поэтому сейчас мы сделаем первый образ, который в себе соберёт приложение. А во втором образе мы возьмём собранное приложение с помощью обращения через --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.15

main.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 - не даёт сеть контейнеру

Управляется сеть достаточно просто самыми базовыми командами

  • connect
  • create - создаст сеть по определённому типу драйвера
  • disconnect
  • inspect
  • ls - отображает сети
  • 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/. ~/data

Docker 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 --help

Docker 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 up

Extend

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

Опишем конфиг сервиса в одном файле

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 - это утилита для автоматиизации выполнения скриптов.

Задачи Ansible

Для чего нам нужно его использовать:

  1. Автоматиизирует повторяющиеся задачи. Сразу позволяет выполнить все нужные операции на большом количестве машин.
  2. Автоматизирует сложные задачи. Позволяет повторять сложные задачи раз за разом без совершения определённых ошибок.
  3. Поддерживает IAC концепцию (инфраструктура как код).

Какие задачи он решает:

  1. Отметает человеческий фактор при исполнении скриптов.
  2. Решает проблему недостатка прозрачности настроек и документации
  3. Устраняет сложность повторения определённой операции
  4. Экономит кучу времени
  5. Повышает переносимость решения

Какие плюсы именно Ansible:

  1. Нет доп ПО на сервере - только python
  2. Можно легко дописывать свои модули
  3. Низкий порог вхождения
  4. Возможность интеграции API через AWX (через который можно настроить автоматическое выполнение скриптов и scheduling)

Схема работы Ansible

  1. YML-конфиг (то, что мы выполняем). Описывает то состояние, к которому нам нужно привести сервера. Тут мы описываем последовательно состояние, к которому мы должны привести систему.
  2. Инвентарь (на чём выполняем). Это модуль. Описывает все сервера, их состояния, как к ним подключаться, под какими пользователями выполнять операции, ip и переменные.
  3. Ansible. Он уже приводит сервера к описанному ранее состоянию.
    1. Первым делом, он собирает факты о серверах. Подключается / не подключается, тип ОС, происходит первичный коннект.
    2. Сборка конфигов с серверов.
    3. Подключение плагинов
    4. Подключение модулей
  4. Транспиляция YAML в python и исполнение кода. Все конфигурации разбираются на одной хостовой машине в самом Ansible.

Системы автоматизации обычно делятся на pull и push. Pull требуют наличия агента на серверах, чтобы проверять изменения и подтягивают исполняемый конфиг сами в себя. Push уже требуют, чтобы мы сами запихнули конфиг на сервер, чтобы тот выполнился. Второй вариант является более удобным, так как не требует на сервере никакого дополнительного ПО и агентов.

Когда мы используем Ansible на том же Ubuntu сервере (на котором уже есть python) нам не нужно ничего устанавливать кроме ssh, который позволит исполнять нам удалённо скрипты.

Модули и плагины - это подключаемые к Ansible куски кода. Сами отличия:

МодулиПлагины
Выполняются на клиентахВыполняются на host-машине
Выполняются при подключенииВыполняются до подключения

Установка

Через любой пакетный мендежер (включая pip)

brew install ansible
 
# Или
 
sudo [apt|dnf|pacman] install ansible

Inventory

Инвентарь - это описание всех хостов и серверов, на которых будут исполняться команды Ansible и playbooks.

Написать конфиг инвентаря можно с помощью менее многословного .ini либо через .yml

Когда мы указываем домены [group] в квадратных скобках, то мы можем исполнять скрипты на целых группах доменов. Такие группы можно, например, использовать для установки nginx на множество серверов.

Так же мы можем:

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

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

Параметры подключения к хосту состоят из 4ёх блоков:

  1. Параметры подключения ansible_
    • connection - ssh / sftp / scp
    • host - имя хоста, к которому подключаемся
    • port - порт (тут может понадобится кастомный ssh-порт не 22, а 2222)
    • user - имя пользователя
    • password - пароль. Используется, если не заданы ssh-ключи для подключения.
  2. Параметры ssh / scp / sftp
    • private_key_file - путь к приватному ключу, но дефолтно используется файл в .ssh
    • common_args - общие аргументы для всех типов подключения
    • extra_args - дополнительные аргументы
    • pipelining - ограничение количества ssh-подключений (чтобы не открывать 100 подключений одновременно)
    • executable - дополнительная настройка выполнения
  3. Привилегии (применение команды из под sudo)
    • become - нужно ли выполнять от sudo
    • method - метод перехода (su/sudo)
    • exe, flags - настраивает поведение перехода к sudo
  4. Настройки 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 user

Ad-hoc

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

Используется для:

  • быстрых фиксов (например, упал сервер)
  • для получения информации с сервера
  • для тестирования отдельных команд

Это аналог docker run -it sh, когда мы напрямую попадаем в крутящийся докер и выполняем в нём команды.

Ad-hoc команда выглядит следующим образом:

  • инвентарь
  • модуль - только один в ad-hoc
  • аргументы
  • указание хостов (all / ip / группа)

Текущая команда создаст определённого пользователя по name на всех хостах

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

У нас есть несколько типов вывода:

  1. Success - Зелёный - операция ничего не изменила
  2. Changed - Жёлтый - операция что-то изменила
  3. 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" demo

Ansible 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
По названию группы из описанного инвентаря
  1. Из инвентаря берём наименование группы demo
  2. Создаём папку group_vars, в которую помещаем папку с группой и фиксированным именем vars.yml

group_vars / demo / vars.yml

---
user: zeizel
  1. В файле playbook ничего указывать не нужно - просто используем переменную
---
- name: user
  hosts: demo
  tasks:
      - name: create user
        become: true
        user:
            name: '{{ user }}'
            state: present
По хостам
  1. Из инвентаря берём наименование хоста (можно алиас, если есть) 127.0.0.1
  2. Создаём папку host_vars, в которой мы создаём ямл с именем хоста 127.0.0.1.yml

host_vars / 127.0.0.1.yml

---
user: zeizel
  1. И переменные так же работают без прямого импорта
Задавать переменные прямо в инвентаре

Так же переменная напрямую попадёт в 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 есть несколько способов дебага значений:

Дебаг через плейбук

  1. Нам нужно зарегистрировать вывод результатов таски через ключ register (грубо говоря, это создание переменной с результатом выполнения)
  2. Создать таску, которая проинициализирует дебаг через 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=0

Debugger

Либо мы можем активировать дебаг-режим

Активируется он при включении поля 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

Параллельные асинхронные таски

Для создания параллельно выполняемых тасок, нам нужно:

  1. poll установить в 0 секунд (отключить проверку)
  2. в таске, которая ждёт результата выполнения предыдущей таски, нужно отследить статус асинхронности async_status
    1. в async_status указать jid (job id) задачи, от которой зависим
  3. зарегистрировать текущую таску register
  4. Ожидать until выполнение текущей таски job_result.finished
  5. проверять 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: true

Ansible 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., потому что у нас доступен так же вызов старого API ansible.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, то нам нужно:

  1. Хранить папку проекта на диске c Windows (не в директории linux ~/)
  2. Разрешить Vagrant обращаться к Windows из под WSL через: export VAGRANT_WSL_ENABLE_WINDOWS_ACCESS="1"

Если выходит эта ошибка, то нужно удалить папку .vagrant

The 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 -K

Docker 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:	vip

ps выведет подробную информацию о всех контейнерах созданного сервиса и покажет сервер, где он расположился

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": {}
        }
    }
]
Вывод секрета из контейнера

После того, как у нас поднялся сервис, мы можем:

  1. в service ps найти сервер, где он поднялся
  2. перейти на найденный сервер
  3. вывести все контейнеры через docker ps
  4. вызвать шелл найденного контейнера sh
  5. перейти в /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
23479867sdf

Statefull сервисы

Для работы с Docker Swarm, нам нужно иметь уже готовые образы. Собрать образ, как в композе build, у нас не получится, так как мы не знаем, где запустится таска. Самый надёжный вариант в таком случае - поднять собственный registry, откуда будут тянутся образы собранного приложения.

Statefull cервисы - это сервисы, которые хранят своё состояние, подтягивая данные с диска

Варианты реализации:

  1. Shared Mount - с помощью кастомных драйверов или через локальный драйвер подключаем определённое хранилище, на которое должны будут смотреть контейнеры и стягивать оттуда данные
  2. 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:latest

Overlay 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:

  1. Указываем secrets, так как volumes отсутствует. target должен заменять файл .env поэтому нужно будет указать выходной файл для приложения
  2. имя контейнера убираем, так как оно будет присвоено автоматически
  3. добавляем 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 +5098ms

Healthcheck

С помощью 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 converged
server1:~$ 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 converged
server1:~$ 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/tcp
vagrant@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 server4

Ansible - продвинутые темы

Роли

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

Роли - это механизм разделения скриптов на отдельные файлы. Их можно запускать для определённых хостов, переиспользовать в других плейбуках, публиковать в galaxy.

Определение роли происходит по имеющейся нотации в ansible:

  1. В папку roles мы складываем роли
  2. Именуем роль её доменом использования (для понимания основной её задачи) - deploy, docker
  3. В самой роли есть своя структура директорий:
    1. defaults - переменные по умолчанию
    2. files - файлы роли, которые кладутся на целевую машину
    3. templates - шаблоны для выкладки (jinja-шаблоны), которые сначала прогоняются через шаблонизатор и наполняются данными, а потом уже отправляются на целевой хост
    4. handlers - обработчики плейбука, которые будут вызываться по окончанию таски
    5. library - модули роли
    6. meta - метаданные роли и её зависимости
    7. tasks (обязательно) - задачи роли
    8. vars - переменные
    9. services и <etc> - любые другие папки так же можно использовать внутри
  4. Точкой входа в роль является 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 -K

Meta

Так же мы можем описать полезные метаданные для нашей роли, которые позволят поменять её поведение и оставить информацию по поддержке данного продукта

  • Поле 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_loop
  • index_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_item

roles / deploy / tasks / inner.yml

---  
- name: Inner  
  debug:  
    msg: 'inner {{ item }} - outer {{ outer_item }}'  
  loop:  
    - 1  
    - 2  
    - 3
ok: [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 -l
amazon.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 }}' # bool

items2dict

Фильтр 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 / json
  • to_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,1

json_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, 2

urlsplit

Данный фильтр позволяет нам вытащить определённую часть урла

---  
- 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.85

password_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 world

select и 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 стянет образ из локального registry localhost: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;dev
file

Создадим файл .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_imageProcessor

app

Опишем выкладку сервиса фронта

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: 80

converter

Выкладка конвертера так же аналогична 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

Опишем конфигурацию, которая будет использоваться для проксирования запросов.

Тут у нас есть два подхода:

  1. Мы можем биться в сервис напрямую через внутренний DNS докера http://api:3000, но тогда нам нужно будет перезапускать и NGINX при обновлении деплоя сервиса
  2. Мы можем долбиться по ip устройства и его внешнему порту (published_port), чтобы игресс сам разрулил адресата.

Тут будет использоваться первый подход.

Игресс описан так:

  1. Рабочих процесса 2
  2. Подключений на каждый воркер максимум по 1024
  3. сервер слушает 80 порт и выводит доменное имя image.local
  4. при отпрвке запроса на upload(s), запрос летит на сервер
  5. при отправке запроса на /, мы попадаем на фронт

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

Далее нам нужно инициализировать сварм и для этого:

  1. С помощью модуля docker_swarm инициализируем сервис с advertise_addr (выбираем одну из доступных сетей на машине) равному тому, что указали ранее в переменных окружения
  2. Далее создаём локальную директорию ./tokens, которая будет хранить токены подключения между свармами
  3. Потом токен manager берём из контекста token.swarm_facts.JoinTokens.Manager будем сохранять в token-manager
  4. А токен воркера возьмём из 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_action deprecated

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

Далее описываем задачи на вывод ноды:

  1. Переводим ноду в drain
  2. Проверяем через модуль docker_host_info количество работающих контейнеров в течение минуты (30 ретраев с делеем в 2 секунды). Эту задачу мы делегируем на ту целевую ноду, на которой нам нужно проверять отключение всех сервисов.
  3. Удаляем ноду через 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

Setup VM’s Ubuntu

Обновления

  • Вместо 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-plugin
sudo systemctl start docker
sudo systemctl enable docker