
👋 Привет!
Меня зовут Андрей, я специалист по управлению IT-инфраструктурой с опытом работы с Windows- и Linux-системами.
Современная IT-инфраструктура — это живой организм, состоящий из множества серверов, виртуальных машин и контейнеров, разбросанных по разным дата-центрам и облачным платформам.
Узлами могут быть:
Рабочие станции пользователей,
Серверы за NAT в серых зонах сети,
Хосты в нестабильных сетевых условиях, периодически исчезающие из доступа.
При работе с такой динамичной инфраструктурой можно столкнуться с рядом сложностей.
Сети могут быть ненадежными – сервер или рабочая станция может быть временно недоступна из-за сбоя связи, перегрузки или политики безопасности.
Узлы за NAT или в изолированных зонах – Ansible Control Node не имеет прямого доступа к таким узлам.
Удалённые устройства перемещаются – например, ноутбуки разработчиков или оборудование в мобильных дата-центрах.
Проблемы классической push-модели Ansible
В push-модели центральный сервер Ansible отвечает за доставку конфигураций узлам при их сетевой доступности.

В условиях нестабильных и распределённых сетей реализация такой модели приводит к ряду проблем:
Несинхронизированность конфигурации
Если узел был недоступен во время выполнения Ansible-задач (playbook), он остаётся устаревшим и может работать с неверными настройками.
Невозможно однозначно определить, какая версия конфигурации сейчас на узле, если он периодически исчезает из сети.
Разные версии конфигурации у разных серверов могут вызывать неожиданные баги и рассинхронизацию работы сервисов.
Перегрузка сервера управления
При традиционном подходе Ansible один управляющий сервер должен обрабатывать все узлы, что становится узким местом.
Масштабирование требует дополнительных ресурсов и усложняет процесс.
Если центральный сервер управления выходит из строя, автоматизация останавливается.
Риск конфигурационного дрейфа
Без постоянного контроля и обновления параметров узлы могут "дрейфовать" от эталонной конфигурации.
Если администратор вручную изменил настройки, а потом Ansible не применялся долгое время – могут возникнуть неожиданные конфликты.
В результате часть серверов может оказаться в непредсказуемом состоянии для команды DevOps
Как решить эти проблемы
Оптимальным решением для таких условий становится ansible-pull
– механизм, который меняет традиционную модель работы Ansible. Вместо того чтобы «толкать» конфигурацию на удалённые узлы, каждый сервер самостоятельно загружает и применяет конфигурацию.
Оговорюсь сразу: по сути, ansible-pull – это выполнение команды: git pull && ansible-playbook -i inventory_with...
Она запускается локально, используя код, загруженный из удалённого репозитория.
Однако, в отличие от Puppet, работа с YAML-синтаксисом, на мой взгляд, гораздо удобнее. Кроме того, функциональные возможности Ansible шире, что делает его более гибким и мощным инструментом для автоматизации.

Возможности ansible-pull
Оборудование подключается нерегулярно — само загружает конфиг после включения.
Не нужен централизованный Ansible Control Node (каждый узел отвечает сам за себя).
Работает даже за NAT / Firewall — тянет конфиг из облачного репозитория (Git).
Обновления распространяются автоматически — нет зависимости от административного доступа.
Легко откатывать изменения — реверт через Git происходит глобально.
Примеры применения ansible-pull
Распределённые станции электропитания, метеостанции и датчики
Геораспределённые системы телефонии (IP-телефоны, Asterisk)
Рабочие станции пользователей (ноутбуки, кассовые терминалы)
Автоматическая настройка новых серверов (автопровижнинг)
IoT-устройства и сетевое оборудование
Доводы приведены, давайте подумаем как нам реализовать этот механизм.
Ход работы
Нашу задачу мы разделим на несколько подзадач:
Подготовка тестового окружения на виртуальных машинах через Vagrant
Настройка узлов к режиму Ansible-pull, выдача им ролей и RSA-ключей
Конфигурирование среды для тестирования качества кода в Docker-контейнере
Настройка CI/CD-пайплайна для автоматизирования процесса развертывания и тестирования в конвейере
Создание ролей для Ansible-pull.
CI/CD-конвейер организуем по схеме:

1. Подготовка тестового окружения на виртуальных машинах через Vagrant
Vagrant это инструмент для быстрого создания и управления виртуальными средами. Он позволяет автоматически поднимать виртуальные машины с заранее заданной конфигурацией.

В нашем случае мы подготовим тестовое окружение из четырёх узлов:
webserver (веб-сервер) Debian 12,
application (приложение) Debian 12,
client-1 Ubuntu 18.04
client-2 CentOS 7.
Количество клиентских узлов не ограничено, их число определяется различием в используемых дистрибутивах и особенностях их работы.
В качестве провайдера виртуальных машин мы будем использовать libvirt. Конфигурационный файл Vagrantfile для vagrant/libvirt/
будет содержать необходимые настройки для развертывания данного окружения.
Составляем Vagrantfile:
Скрытый текст
# vagrant\libvirt\Vagrantfile
# Интерфейс для моста
BRIDGE_NET = "192.168.2."
BRIDGE_NAME = "br0"
BRIDGE_DNS = "192.168.2.1"
# Путь к образам виртуальных машин
IMAGE_PATH = "/kvm/images"
# Домен который будем использовать для всей площадки
DOMAIN = "ch.ap" # debian 12
BOX_1 = "d12" # debian 12
BOX_2 = "u1804" # ubuntu 18.04
BOX_3 = "c7" # centos 7
# Укажите путь к общей папке на хосте и путь к точке монтирования в ВМ
HOST_SHARED_FOLDER = "."
VM_SHARED_FOLDER = "/home/vagrant/shared"
# Имена хостов
HOSTNAME_1 = "t-webserver"
HOSTNAME_2 = "t-application"
HOSTNAME_3 = "t-client-1"
HOSTNAME_4 = "t-client-2"
# Массив из хешей, в котором задаются настройки для каждой виртуальной машины.
MACHINES = {
HOSTNAME_1.to_sym => {
:box_name => BOX_1,
:host_name => HOSTNAME_1,
:ip_brig => BRIDGE_NET + "211",
:ip_brig_dns => BRIDGE_DNS,
:disk_size => "20G",
:int_model_type => "e1000",
:cpu => 1,
:ram => 512,
:vnc => 5971,
:host_role => "webserver",
},
HOSTNAME_2.to_sym => {
:box_name => BOX_1,
:host_name => HOSTNAME_2,
:ip_brig => BRIDGE_NET + "212",
:ip_brig_dns => BRIDGE_DNS,
:disk_size => "20G",
:int_model_type => "e1000",
:cpu => 1,
:ram => 1024,
:vnc => 5972,
:host_role => "application",
},
HOSTNAME_3.to_sym => {
:box_name => BOX_2,
:host_name => HOSTNAME_3,
:ip_brig => BRIDGE_NET + "213",
:ip_brig_dns => BRIDGE_DNS,
:disk_size => "20G",
:int_model_type => "e1000",
:cpu => 1,
:ram => 512,
:vnc => 5973,
:host_role => "client",
},
HOSTNAME_4.to_sym => {
:box_name => BOX_3,
:host_name => HOSTNAME_4,
:ip_brig => BRIDGE_NET + "214",
:ip_brig_dns => BRIDGE_DNS,
:disk_size => "20G",
:int_model_type => "e1000",
:cpu => 1,
:ram => 512,
:vnc => 5974,
:host_role => "client",
},
}
## модуль применения настроек
Vagrant.configure("2") do |config|
MACHINES.each do |boxname, boxconfig|
config.vm.define boxname do |box|
box.vm.box = boxconfig[:box_name]
box.vm.hostname = boxconfig[:host_name]
# Указываем публичную сеть с параметрами моста
box.vm.network "public_network",
bridge: BRIDGE_NAME,
dev: BRIDGE_NAME,
type: "bridge",
ip: boxconfig[:ip_brig]
box.vm.provider "libvirt" do |libvirt|
libvirt.cpus = boxconfig[:cpu]
libvirt.memory = boxconfig[:ram]
libvirt.nic_model_type = boxconfig[:int_model_type]
libvirt.management_network_name = BRIDGE_NAME
libvirt.storage_pool_name = "images"
libvirt.graphics_port = boxconfig[:vnc]
libvirt.graphics_autoport = false
end
# # Настройка синхронизированной папки
box.vm.synced_folder ".", VM_SHARED_FOLDER, type: "nfs", nfs_version: 4, nfs_udp: false
# --- rsa ключи
box.vm.provision "file", source: "#{ENV['HOME']}/.ssh/id_rsa.pub", destination: "/tmp/authorized_keys"
box.vm.provision "shell", inline: <<-SHELL
# Определяем дистрибутив
OS_NAME=$(grep -Eoi 'ubuntu|debian|centos|rhel' /etc/os-release | head -1)
# 📌 Обновление системы
if [[ "$OS_NAME" == "Ubuntu" ]] || [[ "$OS_NAME" == "Debian" ]]; then
sudo apt-get update
# sudo apt-get upgrade -y
elif [[ "$OS_NAME" == "CentOS" ]] || [[ "$OS_NAME" == "rhel" ]]; then
OS_VERSION=$(grep -oP '(?<=VERSION_ID=")[0-9]+' /etc/os-release)
# 🔄 Переключение репозиториев на архивные для CentOS 7 и 8
if [[ "$OS_VERSION" == "7" ]] || [[ "$OS_VERSION" == "8" ]]; then
echo "🔄 Переключаю CentOS $OS_VERSION на архивный репозиторий..."
sudo sed -i 's|^mirrorlist=.*|#mirrorlist removed|' /etc/yum.repos.d/CentOS-Base.repo
sudo sed -i 's|^#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|' /etc/yum.repos.d/CentOS-Base.repo
fi
# ✅ Очистка кэша и обновление пакетов
sudo yum clean all
sudo yum makecache
# sudo yum update -y
fi
# 📌 RSA ключи
if [ -f /tmp/authorized_keys ]; then
cat /tmp/authorized_keys >> /home/vagrant/.ssh/authorized_keys
fi
# 📌 Таймзона
sudo timedatectl set-timezone Europe/Moscow
# 📌 Ansible факт
sudo mkdir -p /etc/ansible/facts.d
echo '{ "host_role": "#{boxconfig[:host_role]}" }' | sudo tee /etc/ansible/facts.d/custom.fact > /dev/null
sudo chmod 0644 /etc/ansible/facts.d/custom.fact
echo "✅ Настройка завершена"
SHELL
if !boxconfig[:prov].nil?
box.vm.provision "shell", path: boxconfig[:prov]
end
end
end
end
2. Настройка узлов в режиме Ansible-pull, выдача ролей и RSA-ключей
Ansible playbook для pull-режима будет применять роли к узлам, используя заданные пользовательские факты.
Файл плейбука:
Скрытый текст
# ansible/ansible/infra_ansible_pull.yaml
- name: Apply roles dynamically from inventory
hosts: "{{ target_hosts | default('localhost') }}"
gather_facts: true
tasks:
- name: Explicitly gather custom facts
ansible.builtin.setup:
filter: ansible_local
- name: Include web role
ansible.builtin.include_role:
name: web
when: ansible_local.custom.host_role is defined and ansible_local.custom.host_role == "webserver"
- name: Include app role
ansible.builtin.include_role:
name: app
when: ansible_local.custom.host_role is defined and ansible_local.custom.host_role == "application"
- name: Include client role
ansible.builtin.include_role:
name: client
when: ansible_local.custom.host_role is defined and ansible_local.custom.host_role == "client"
Чтобы узлы понимали, какие роли им применять, создаём пользовательские факты Ansible.
Выглядеть они будут так:
Скрытый текст
# /etc/ansible/facts.d/custom.fact
{
"host_role": "{{ host_role }}"
}
Настройка доступа к репозиторию:
Генерируем единую пару RSA-ключей.
Приватный ключ записываем на клиентские узлы.
Публичный ключ регистрируем в GitLab для доступа к репозиторию.
Скрытый текст
ssh-keygen -t rsa -C "id_ansible_pull" -f ~/.ssh/id_ansible_pull
cat ~/.ssh/id_ansible_pull.pub
Создание инвентаря клиентских узлов
Необходимо создать файл инвентаря, где будут перечислены:
список узлов
логины, пароли. Они приемлемы для стендового окружения, однако в продуктовой среде используем только RSA-ключи!
Также рекомендую зашифровать этот файл с помощью Ansible Vault.
Скрытый текст
# ansible\node_setting_to_pull\inventory.yml
all:
hosts:
webserver:
ansible_host: 192.168.2.221
ansible_user: vagrant
ansible_password: vagrant
ansible_become_pass: vagrant
host_role: webserver
application:
ansible_host: 192.168.2.222
ansible_user: vagrant
ansible_password: vagrant
ansible_become_pass: vagrant
host_role: application
client_1:
ansible_host: 192.168.2.223
ansible_user: vagrant
ansible_password: vagrant
ansible_become_pass: vagrant
host_role: client
client_2:
ansible_host: 192.168.2.224
ansible_user: vagrant
ansible_password: vagrant
ansible_become_pass: vagrant
host_role: client
Чтобы Ansible мог аутентифицироваться через пароль, устанавливаем пакет sshpass на управляющий узел.
Запуск настройки узлов для работы с Ansible-pull
Файл плейбука настройки узлов составлен мною так, чтобы работать с несколькими дистрибутивами, что достигается условиями when: ansible_os_family == "Debian"
и when: ansible_os_family == "RedHat"
Основные функции:
✅ Создание пользователя Ansible – добавляет пользователя ansible с root-доступом без пароля.
✅ Установка необходимых пакетов – устанавливает Ansible и Git.
✅ Настройка SSH-доступа к Git-репозиторию – настраивает ключи и Known Hosts для безопасного подключения.
✅ Добавление cron-задания – автоматически запускает ansible-pull дважды в час для синхронизации с репозиторием.
✅ Создание фактической роли хоста – записывает host_role в кастомные facts.
✅ Тестирование настройки – проверяет выполнение ansible-pull и cron-задачи.
Скрытый текст
# ansible\node_setting_to_pull\setup_ansible_pull.yml
---
- name: Setup ansible-pull on Debian, Ubuntu, and CentOS
hosts: all
vars:
run_ansible_user: "ansible"
git_playbook_path: "ansible/infra_ansible_pull.yml"
git_branch: "release" # ветка с которой ansible будет подтягивать код, обычно "release"
ssh_key_src: "~/.ssh/id_ansible_pull"
ssh_key_dest: "/home/{{ run_ansible_user }}/.ssh/id_ansible_pull"
git_server: "gitlab.ch.ap"
git_server_ip: "192.168.2.34"
git_repo: "git@gitlab.ch.ap:AndreyChuyan/ansible_pull_cicd.git"
tasks:
### 🔹 Создание пользователя ansible
- block:
- name: Ensure ansible user exists (Debian-based)
user:
name: "{{ run_ansible_user }}"
shell: /bin/bash
create_home: yes
groups: sudo
append: yes
become: yes
when: ansible_facts['os_family'] == "Debian"
- name: Ensure ansible user exists (RHEL-based)
user:
name: "{{ run_ansible_user }}"
shell: /bin/bash
create_home: yes
groups: wheel
append: yes
become: yes
when: ansible_facts['os_family'] == "RedHat"
- name: Allow ansible user to run ansible-pull without password
copy:
dest: "/etc/sudoers.d/ansible"
content: "{{ run_ansible_user }} ALL=(ALL) NOPASSWD: ALL"
mode: '0440'
become: yes
tags: user_setup
### 🔹 Установка пакетов (Ansible и Git)
- block:
- name: Install Ansible and Git on Debian/Ubuntu
apt:
name:
- ansible
- git
state: present
update_cache: yes
become: yes
when: ansible_os_family == "Debian"
- name: Install Ansible and Git on CentOS/RedHat
package:
name:
- epel-release
- ansible
- git
state: present
become: yes
when: ansible_os_family == "RedHat"
# 🔹 Устанавливаем коллекцию ansible.posix, если она отсутствует
# - name: Ensure ansible.posix collection is installed
# ansible.builtin.command:
# cmd: ansible-galaxy collection install ansible.posix --ignore-errors
# changed_when: false
tags: ansible_install
### 🔹 Настройка SSH-ключей для доступа к Git
- block:
- name: Ensure GitLab server IP is in /etc/hosts
lineinfile:
path: /etc/hosts
line: "{{ git_server_ip }} {{ git_server }}"
regexp: ".*\\s+{{ git_server }}$"
state: present
become: yes
- name: Ensure SSH directory exists for ansible user
file:
path: "/home/{{ run_ansible_user }}/.ssh"
state: directory
mode: '0700'
owner: "{{ run_ansible_user }}"
group: "{{ run_ansible_user }}"
become: yes
- name: Copy private SSH key for ansible user
copy:
src: "{{ ssh_key_src }}"
dest: "{{ ssh_key_dest }}"
owner: "{{ run_ansible_user }}"
group: "{{ run_ansible_user }}"
mode: '0600'
no_log: true
become: yes
- name: Add Git server fingerprint to known_hosts
known_hosts:
path: "/home/{{ run_ansible_user }}/.ssh/known_hosts"
name: "{{ git_server }}"
key: "{{ lookup('pipe', 'ssh-keyscan -H ' + git_server) }}"
become: yes
# become_user: "{{ run_ansible_user }}"
tags: ssh_setup
### 🔹 Добавление Cron-задачи для ansible-pull
- block:
- name: Calculate random cron minute per host
set_fact:
random_minute_offset: "{{ (inventory_hostname | hash('md5') | int(base=16) % 30) | int }}"
- name: Add ansible-pull cron job
cron:
name: "Run ansible-pull"
minute: "{{ (random_minute_offset | int + 0) % 60 }},{{ (random_minute_offset | int + 30) % 60 }}"
job: >
/usr/bin/ansible-pull -U {{ git_repo }}
-C {{ git_branch }}
--private-key {{ ssh_key_dest }}
{{ git_playbook_path }}
| tee /var/log/ansible-pull.log
user: "{{ run_ansible_user }}"
become: yes
- name: Ensure ansible-pull log file exists
file:
path: /var/log/ansible-pull.log
state: touch
owner: "{{ run_ansible_user }}"
group: "{{ run_ansible_user }}"
mode: '0644'
become: yes
tags: cron_setup
### 🔹 Установка ролей для хостов
- block:
- name: Create directoryfor custom fact
file:
path: /etc/ansible/facts.d
state: directory
mode: '0755'
become: true
- name: Create JSON-file with fact "host_role"
copy:
dest: /etc/ansible/facts.d/custom.fact
content: |
{
"host_role": "{{ host_role }}"
}
mode: '0644'
become: true
tags: host_role
### 🔹 Тестирование
- block:
- name: Check ansible-pull process
shell: "pgrep -fa ansible-pull || true"
register: ansible_pull_status
changed_when: false
- name: Show running ansible-pull status
debug:
msg: "📌 [INFO] - Ansible-pull running: {{ansible_pull_status.stdout_lines }}"
when: ansible_pull_status.stdout | length > 0
# cronjob
- name: Check ansible-pull cron job
shell: "crontab -l -u {{ run_ansible_user }} | grep ansible-pull || true"
register: cron_job_status
changed_when: false
failed_when: false # Избежать ошибки, если crontab пустой
become: true
- name: Show cron job status
debug:
msg: "📌 [INFO] - Ansible-pull cron job: {{ cron_job_status.stdout_lines }}"
when: cron_job_status.stdout | length > 0
# Принудительное обновление фактов (чтобы загрузился новый файл)
- name: Update ansible_facts
ansible.builtin.setup:
filter: ansible_local
# Проверка наличия кастомного факта "host_role"
- name: Show custom fact
ansible.builtin.debug:
msg: "Роль хоста - host_role: {{ ansible_local.custom.host_role }}"
tags: test
Обращаю внимание на любопытный алгоритм настройки рандомного времени обновления в задачах Calculate random cron minute per host и Add ansible-pull cron job. Генерация времени обновления осуществляется на основе хеш-суммы имени хоста, что обеспечивает идемпотентность значений. Каждый раз уникальное время обновления каждого узла будет иметь одно и то же значение. Люблю такие элегантные решения.
В итоге у нас должна получится структура:
.
├── inventory.yml
└── setup_ansible_pull.yml
Игнорирование фингерпринтов SSH
Добавляем параметр StrictHostKeyChecking=no для автоматического подтверждения подключения.
Запускаем плейбук для настройки узлов.
Скрытый текст
ansible-playbook -i inventory.yml setup_ansible_pull.yml
Если тесты плейбука прошли успешно, мы увидим такую картину.


3. Конфигурирование среды для тестирования качества кода в Docker-контейнере
Выбор тестовой среды
В качестве среды тестирования будет использоваться контейнер Docker.
Контейнер будет настроен с помощью Docker Compose.
Тестирование будет осуществляться с подошью Ansible-lint.

Файл Docker-Compose:
Скрытый текст
# docker-compose_control.yml
version: '3.9'
services:
ansible_control:
build:
context: ./docker
dockerfile: Dockerfile.control
args:
DEBIAN_VERSION: ${DEBIAN_VERSION}
HTTP_PROXY: ${HTTP_PROXY}
HTTPS_PROXY: ${HTTP_PROXY}
NO_PROXY: ${NO_PROXY}
environment:
HTTP_PROXY: ${HTTP_PROXY}
HTTPS_PROXY: ${HTTP_PROXY}
NO_PROXY: ${NO_PROXY}
container_name: ansible_control
hostname: ansible_control
networks:
test_network:
ipv4_address: 192.168.100.200
volumes:
- ./ansible:/ansible
- /var/run/docker.sock:/var/run/docker.sock
- /usr/bin/docker:/usr/bin/docker
tty: true
stdin_open: true
restart: unless-stopped
networks:
test_network:
driver: bridge
ipam:
config:
- subnet: 192.168.100.0/24
Для описания пакетов и зависимостей, необходимых в контейнере, создадим Dockerfile:
Скрытый текст
# docker\Dockerfile.control
ARG DEBIAN_VERSION
FROM debian:12
# Задаем увеличенный таймаут и число повторных попыток для apt-get
RUN echo 'Acquire::http::Timeout "600";' >> /etc/apt/apt.conf.d/99timeout && \
echo 'Acquire::Retries "5";' >> /etc/apt/apt.conf.d/99timeout
# Устанавливаем Ansible, ansible-lint и зависимости
RUN apt-get update && apt-get install -y \
ansible \
ansible-lint \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Создаем ansible-пользователя с sudo без пароля
RUN useradd -m ansible \
&& echo "ansible ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ansible \
&& chmod 0440 /etc/sudoers.d/ansible
# Проверяем установку
RUN ansible --version && ansible-lint --version
# Устанавливаем рабочую директорию
WORKDIR /ansible
CMD ["/bin/bash"]
Отмечу, что существует Molecule для тестирования ролей Ansible, но я редко использую этот инструмент. Причина — избыточная сложность при тестировании целого плейбука.
4. Настройка CI/CD-пайплайна для автоматизации процесса развертывания и тестирования
Процесс работы с кодом организован следующим образом:

Процесс разработки и выпуска инфраструктурного кода
🔹 Разработка
Разработчики работают в отдельных ветках, например:
app – конфигурация приложений
web – настройка веб-серверов
client – клиентские компоненты
🔹 Проверка кода
При каждом коммите автоматически выполняются:
✅ Проверка синтаксиса – анализ кода на ошибки
✅ Тестирование в тестовой среде – развертывание и оценка работоспособности
🔹 Объединение изменений
Перед финальной интеграцией итоговый плейбук с ролями тестируется повторно, чтобы исключить конфликты и ошибки
🔹 Выпуск релиза
Если все тесты пройдены успешно:
✅ Плейбук отправляется в ветку release
✅ Из ветки release плейбук становится доступен клиентам через ansible-pull
Для работы нашего конвейера мы создадим gitlab-ci файл, а также токен для доступа к репозиторию, который мы определяем в переменных GitLab под именем RUNNER_TOKEN
Разберем что делает каждый job
💠 check-ansible-syntax
Запускает контейнер ansible_control через docker-compose.
Проверяет синтаксис infra_ansible_pull.yml.
Запускает ansible-lint, останавливает процесс при критических ошибках.
💠 test-deployment
Проверяет соединение с тестовыми серверами (ansible all -m ping).
Запускает ansible-playbook для тестового развертывания.
Проверяет работу Nginx и отдачу тестового шаблона через curl.
💠 deploy-ansible-role
Если check и test прошли успешно → пушит код в ветку release.
Скрытый текст
# .gitlab-ci.yml
variables:
CLIENT_IP: 192.168.2.213
CLIENT_SSH_PASSWORD: vagrant
APP_TEMPLATE: "Это тестовый Flask-сервер"
APP_IP: "192.168.2.212"
WEBSERVER_IP: "192.168.2.211"
stages:
- check
- test
- deploy
# 🔹 Проверяем синтаксис и Ansible-lint перед тестами
check-ansible-syntax:
stage: check
tags:
- shell
script:
- echo "🐳 Building Ansible control node..."
# - docker-compose -f docker-compose_control.yml down --remove-orphans
# - docker-compose -f docker-compose_control.yml up -d --build --force-recreate
- docker-compose -f docker-compose_control.yml up -d
- echo "🔁 Waiting for containers to be ready..."
- until docker ps | grep "ansible_control"; do sleep 2; done
- echo "⏳ Waiting for 5 seconds to ensure all services are up..."
- sleep 5
- echo "📌 Checking Ansible playbook syntax..."
- |
docker exec -i ansible_control ansible-playbook infra_ansible_pull.yml --syntax-check
if [ $? -eq 0 ]; then
echo "✅ Синтаксис плейбука infra_ansible_pull.yml корректен!"
else
echo -e "\033[1;31m❌ ERROR: Ошибка в синтаксисе playbook!\033[0m"
exit 1 # Прерываем job, если есть ошибки
fi
- echo "📌 Linting Ansible playbook and roles..."
- |
set -o pipefail # --- вывод ansible-lint ---
# Для отладки можно включить отладочный режим:
# set -x
lint_log="lint_output.txt"
# Выполнение ansible-lint, вывод логируем в файл.
if ! docker exec -i ansible_control ansible-lint infra_ansible_pull.yml | tee "$lint_log"; then
echo -e "\033[1;31m❌ ERROR: ansible-lint выявил критические ошибки! Устараните их согласно рекомендациям выше!\033[0m"
exit 1
fi
echo "" # Дополнительная пустая строка для корректного сканирования grep
# Проверяем наличие предупреждений в выводе
if grep -q "WARNING" "$lint_log"; then
echo -e "\033[1;33m⚠️ WARNING: Обнаружены предупреждения в ansible-lint!\033[0m"
else
echo -e "\033[1;32m✅ Ansible-lint успешно пройден!\033[0m"
fi
except:
- release # Исключаем выполнение в ветке release
# 🔹 Развертывание Ansible playbook в тестовом окружении
test-deployment:
stage: test
before_script:
- echo "♻️ Обновление окружения..."
tags:
- shell
needs:
- check-ansible-syntax # ✅ Ждем успешного завершения синтакс-чека
script:
- echo "📌 Running Ansible tests - checking connection..."
- export ANSIBLE_HOST_KEY_CHECKING=False
- ansible all -i ansible/inventory_test.yml -m ping --extra-vars 'ansible_ssh_extra_args="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"'
- echo "📌 Running Ansible playbook execution..."
- ansible-playbook -i ansible/inventory_test.yml ansible/infra_ansible_pull.yml -e "target_hosts=all ansible_ssh_extra_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'" # переопределяем в инвентаре целевые хосты
# 🔹 Тесты
# проверка доступности приложения на узле
- echo "🧪 --- Run tests ---"
# --- Проверка доступности Nginx
- echo "🔍 --- Checking Nginx status"
- |
NGINX_STATUS=$(sshpass -p "$CLIENT_SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null "vagrant@$WEBSERVER_IP" "systemctl is-active nginx" 2>/dev/null)
if [[ "$NGINX_STATUS" == "active" ]]; then
echo "✅ Nginx service is running"
else
echo "❌ Nginx service is NOT running"
exit 1
fi
- echo "🔍 --- Checking app content for client"
- |
OUTPUT=$(sshpass -p "$CLIENT_SSH_PASSWORD" ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null "vagrant@$CLIENT_IP" "echo Узел:; hostname; echo 🌍 HTTP-ответ:; curl -s http://$WEBSERVER_IP")
if echo "$OUTPUT" | grep -q "Узел:" && echo "$OUTPUT" | grep -q "🌍 HTTP-ответ:" && echo "$OUTPUT" | grep -q "$APP_TEMPLATE"; then
echo "✅ Проверка успешна! HTTP-ответ содержит текст: "$APP_TEMPLATE""
exit 0
else
echo "❌ Ошибка! HTTP-ответ не содержит тестовый шаблон! Ответ: "$OUTPUT""
exit 1
fi
- echo "✅ All tests passed successfully!"
except:
- release # Исключаем выполнение в ветке release
# 🔹 Деплой
deploy-ansible-role:
stage: deploy
tags:
- shell
needs:
- check-ansible-syntax # ✅ Ждем успешного завершения синтакс-чека
- test-deployment # ✅ Ждем успешного запуска кода на тестовых контейнерах
script:
- echo "🐳 Building and starting test Docker containers..."
# create $RUNNER_TOKEN - roles - developer - read_repository, write_repository. -> Add variable to project
- git config --global user.email "your-email@example.com"
- git config --global user.name "andreychuyan"
- git push --force https://oauth2:$RUNNER_TOKEN@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git HEAD:refs/heads/release
only:
- main
5. Создание ролей для Ansible-pull.
Для примера развернем в нашем окружении простое тестовое приложение на Flask и настроим веб-сервер для обработки трафика.
На клиентских машинах будет выполнено:
✅ Создание сервисного пользователя
✅ Настройка доступа по RSA-ключам
✅ Вывод баннера при подключении по SSH
Структура Ansible-ролей
Каталоги и файлы организованы следующим образом:
Скрытый текст
.
├── ansible.cfg
├── group_vars
│ └── all.yml
├── infra_ansible_pull.yml
├── inventory_test.yml
├── node_setting_to_pull
│ ├── inventory.yml
│ └── setup_ansible_pull.yml
└── roles
├── app
│ ├── files
│ │ └── app.py
│ ├── handlers
│ │ └── main.yml
│ ├── README.md
│ ├── tasks
│ │ └── main.yml
│ ├── templates
│ │ └── hello-app.service.j2
│ └── vars
│ └── main.yml
├── client
│ ├── files
│ │ ├── id_rsa.pub
│ │ └── ssh_banner
│ ├── README.md
│ ├── tasks
│ │ ├── main.yml
│ │ ├── motd_setup.yml
│ │ └── user_setup.yml
│ └── templates
│ └── motd.j2
└── web
├── handlers
│ └── main.yml
├── README.md
├── tasks
│ └── main.yml
└── templates
└── nginx_application.conf.j2
Инвентарь для тестового окружения:
Скрытый текст
# ansible/inventory_test.yml
all:
vars:
network_prefix: "192.168.2"
hosts:
webserver:
host_role: webserver
ansible_host: "{{ network_prefix }}.211"
ansible_user: vagrant
ansible_password: vagrant
ansible_become: yes
ansible_become_method: sudo
application:
host_role: application
ansible_host: "{{ network_prefix }}.212"
ansible_user: vagrant
ansible_password: vagrant
ansible_become: yes
ansible_become_method: sudo
children:
clients:
hosts:
client_1:
host_role: client
ansible_host: "{{ network_prefix }}.213"
ansible_user: vagrant
ansible_password: vagrant
ansible_become: yes
ansible_become_method: sudo
client_2:
host_role: client
ansible_host: "{{ network_prefix }}.214"
ansible_user: vagrant
ansible_password: vagrant
ansible_become: yes
ansible_become_method: sudo
Не забудем про конфигурационный файл Ansible
Скрытый текст
# ansible/ansible.cfg
[defaults]
remote_tmp = /tmp/.ansible/tmp
roles_path = roles
remote_user = ansible
become = True
become_method = sudo
host_key_checking = False
forks = 10
timeout = 30
retry_files_enabled = False
log_path = ./ansible.log
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 600
[ssh_connection]
pipelining = True
scp_if_ssh = True
В файле групповых переменных укажем точку доступа к нашему приложению
Скрытый текст
# ansible/group_vars/all.yml
app_endpoint: "http://192.168.2.212:8080"
Вернемся к нашему корневому плейбуку и изучим его подробнее.
Обращаясь к нему, Ansible действует по алгоритму:
Сбор фактов: Ansible получает информацию о хосте, включая его роль
Динамическое назначение ролей:
Если роль хоста — webserver, применяется роль web
Если роль application, загружается роль app
Если роль client, выполняется настройка клиента
Автономное применение конфигурации с Ansible-pull
Скрытый текст
# ansible/ansible/infra_ansible_pull.yaml
- name: Apply roles dynamically from inventory
hosts: "{{ target_hosts | default('localhost') }}"
gather_facts: true
tasks:
- name: Explicitly gather custom facts
ansible.builtin.setup:
filter: ansible_local
- name: Include web role
ansible.builtin.include_role:
name: web
when: ansible_local.custom.host_role is defined and ansible_local.custom.host_role == "webserver"
- name: Include app role
ansible.builtin.include_role:
name: app
when: ansible_local.custom.host_role is defined and ansible_local.custom.host_role == "application"
- name: Include client role
ansible.builtin.include_role:
name: client
when: ansible_local.custom.host_role is defined and ansible_local.custom.host_role == "client"
Кратко рассмотрим как наши роли работают
Роль web разворачивает nginx для доступа внешних клиентов к приложению:
Устанавливает Nginx (Ubuntu/Debian)
✅ Запускает и включает сервис
✅ Размещает конфигурационный файл из шаблона
✅ Включает сайт, удаляет дефолтный конфиг
✅ Проверяет конфигурацию и перезапускает Nginx
Скрытый текст
# ansible/roles/web/handlers/main.yml
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
become: true
# ansible/roles/web/tasks/main.yml
# установка nginx
- name: Ensure Nginx is installed (Ubuntu/Debian)
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
become: true
when: ansible_os_family == "Debian"
- name: Ensure Nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
become: true
when: ansible_os_family == "Debian"
- name: Create Nginx configuration file for application
ansible.builtin.template:
src: nginx_application.conf.j2 # Путь к вашему шаблону Jinja2
dest: /etc/nginx/sites-available/application
mode: '0644'
become: true
- name: Enable Nginx site configuration
ansible.builtin.file:
src: /etc/nginx/sites-available/application
dest: /etc/nginx/sites-enabled/application
state: link
become: true
notify: Reload Nginx
# ❌ Удаляем дефолтный конфиг Nginx
- name: Remove default Nginx site configuration
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
become: true
- name: Test Nginx configuration
ansible.builtin.command: nginx -t
become: true
changed_when: false # <- Сообщает Ansible, что это не изменяющее действие
- name: Restart Nginx to apply changes
ansible.builtin.service:
name: nginx
state: restarted
enabled: true
become: true
# ansible/roles/web/templates/nginx_application.conf.j2
server {
listen 80;
server_name _;
location / {
# Укажите IP-адрес или доменное имя вашего приложения и порт:
proxy_pass {{ app_endpoint }};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Роль app разворачивает простое веб приложение для демонстрации:
✅ Установка Python и pip
✅ Создание виртуального окружения
✅ Установка Flask и requests
✅ Развёртывание Flask-приложения
✅ Настройка systemd-сервиса для его автоматического запуска
Скрытый текст
# ansible/roles/app/tasks/main.yml
# *Установка Python и Pip на Debian/Ubuntu*
- name: Install Python and Pip (Debian)
become: true
ansible.builtin.apt:
name:
- python3-apt
- python3
- python3-pip
- python3-venv
state: present
update_cache: true
when: ansible_os_family == "Debian"
# Создание виртуального окружения
- name: Create Python virtual environment
ansible.builtin.command: "python3 -m venv {{ venv_path }}"
args:
creates: "{{ venv_path }}"
# Обновление pip внутри виртуального окружения
- name: Upgrade pip inside virtual environment
ansible.builtin.pip:
name: pip
state: present
extra_args: --upgrade
virtualenv: "{{ venv_path }}"
# Установка дополнительных пакетов (Flask и requests) в виртуальное окружение
- name: Install required Python packages in virtual environment
ansible.builtin.pip:
name:
- requests
- flask
virtualenv: "{{ venv_path }}"
# *Проверка установки Python*
- name: Verify Python installation
ansible.builtin.command: python3 --version
register: python_version
changed_when: false
- name: Debug Python version
ansible.builtin.debug:
msg: "Installed Python version: {{ python_version.stdout }}"
# *Проверка установки Pip (системного)*
- name: Verify Pip installation
ansible.builtin.command: pip3 --version
register: pip_version
changed_when: false
- name: Debug Pip version
ansible.builtin.debug:
msg: "Installed Pip version: {{ pip_version.stdout }}"
# *Проверка, что виртуальное окружение успешно создано*
- name: Verify virtual environment
ansible.builtin.stat:
path: "{{ venv_path }}/bin/activate"
register: venv_check
- name: Debug virtual environment status
ansible.builtin.debug:
msg: "Virtual environment exists at {{ venv_path }}"
when: venv_check.stat.exists
# ====== Развёртывание Flask-приложения ======
# Создание директории для приложения
- name: Create application directory
ansible.builtin.file:
path: "{{ app_dir }}"
state: directory
mode: '0755'
# Развёртывание файла приложения app.py
- name: Deploy Flask application (app.py)
ansible.builtin.copy:
src: app.py
dest: "{{ app_dir }}/app.py"
mode: '0755'
# Создание systemd-сервиса для автоматического запуска приложения
- name: Create systemd service for Flask app
become: true
ansible.builtin.template:
src: hello-app.service.j2
dest: /etc/systemd/system/hello-app.service
mode: '0644'
notify: Reload systemd
# Включение и запуск Flask приложения через systemd
- name: Ensure hello-app service is enabled and started
become: true
ansible.builtin.systemd:
name: hello-app
state: restarted
enabled: true
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ansible/roles/app/files
from flask import Flask, render_template_string
from datetime import datetime
import random
app = Flask(__name__)
QUOTES = [
"Жизнь — это то, что с тобой происходит, пока ты строишь планы. — Джон Леннон",
"Секрет успеха в том, чтобы начать. — Марк Твен",
"Сложные дороги ведут к красивым направлениям. — Неизвестный автор",
"Начни делать — и энергия появится. — Джеймс Клир",
"Будущее принадлежит тем, кто верит в красоту своей мечты. — Элеонор Рузвельт"
]
def get_current_time():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def get_random_quote():
return random.choice(QUOTES)
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Тестовое приложение</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet">
<style>
body { background-color: #f8f9fa; text-align: center; padding: 5rem; }
.container { background: white; padding: 2rem; border-radius: 10px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
h1 { color: #007bff; }
p { font-size: 1.2rem; }
.quote { font-style: italic; color: #6c757d; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 Добро пожаловать!</h1>
<p>Это тестовый Flask-сервер.</p>
<p><strong>Текущее время:</strong> {{ time }}</p>
<p class="quote">📜 Цитата дня: "{{ quote }}"</p>
<a href="/time" class="btn btn-primary mt-3">Показать только время</a>
</div>
</body>
</html>
"""
@app.route('/')
def home():
return render_template_string(HTML_TEMPLATE, time=get_current_time(), quote=get_random_quote())
@app.route('/time')
def show_time():
return {"current_time": get_current_time()}
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080, debug=True)
# ansible/roles/app/handlers/main.yml
- name: Reload systemd
become: true
ansible.builtin.systemd:
daemon_reload: true
# ansible/roles/app/templates/hello-app.service.j2
[Unit]
Description=Hello Flask App Service
After=network.target
[Service]
User={{ ansible_user_id }}
WorkingDirectory={{ app_dir }}
ExecStart={{ venv_path }}/bin/python app.py
Restart=always
[Install]
WantedBy=multi-user.target
# ansible/roles/app/vars/main.yml
venv_path: "{{ ansible_env.HOME }}/.venv"
app_dir: "{{ ansible_env.HOME }}/hello_app"
Роль client настраивает сервисного пользователя, rsa-авторизацию и ssh баннер. Не забываем перенести публичный ключ в ansible/roles/client/files/id_rsa.pub
Скрытый текст
# ansible/roles/client/tasks/main.yml
---
- name: Создание пользователя и настройка SSH
ansible.builtin.import_tasks: user_setup.yml
- name: Настройка MOTD / SSH баннера
ansible.builtin.import_tasks: motd_setup.yml
# ansible/roles/client/tasks/motd_setup.yml
---
- name: Setup MOTD (message day)
ansible.builtin.template:
src: templates/motd.j2
dest: /etc/motd
owner: root
group: root
mode: '0644' # Установим безопасные права
become: true
- name: Install SSH banner
ansible.builtin.copy:
src: files/ssh_banner
dest: /etc/issue.net
owner: root
group: root
mode: '0644'
become: true
- name: Enable SSH banner
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?Banner'
line: 'Banner /etc/issue.net'
state: present
notify: Restart SSH
become: true
# ansible/roles/client/tasks/user_setup.yml
---
- name: Create user ladmin
ansible.builtin.user:
name: ladmin
shell: /bin/bash
groups: "{{ 'sudo' if ansible_os_family == 'Debian' else 'wheel' }}"
append: true
create_home: true
state: present
become: true
- name: Copy SSH-keys for user ladmin
ansible.posix.authorized_key:
user: ladmin
state: present
key: "{{ lookup('file', 'files/id_rsa.pub') }}"
- name: Create sudo no pass user ladmin
ansible.builtin.copy:
dest: /etc/sudoers.d/ladmin
content: "ladmin ALL=(ALL) NOPASSWD:ALL"
mode: "0440"
become: true
ansible/roles/client/files/ssh_banner
🔒 Внимание! Это частный сервер. Неавторизованный доступ запрещён! 🔒
# ansible/roles/client/handlers/main.yml
- name: Restart SSH
ansible.builtin.service:
name: "{{ 'sshd' if ansible_os_family == 'RedHat' else 'ssh' }}"
state: restarted
- name: Reload environment
ansible.builtin.command: source /etc/environment
changed_when: false
ansible/roles/client/templates/motd.j2
Добро пожаловать на сервер {{ ansible_hostname }}!
Дата и время: {{ ansible_date_time.date }}, {{ ansible_date_time.time }}
ОС: {{ ansible_distribution }} {{ ansible_distribution_version }}
CPU: {{ ansible_processor_cores }} ядер
RAM: {{ ansible_memtotal_mb }} MB
Проверка работы ansible-pull

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

Чтобы проверить как клиенты применяют плейбуки мы можем проверить лог одной из клиентских машин /var/log/ansible-pull.log
Скрытый текст
/var/log/ansible-pull.log
ansible@p-application:~$ cat /var/log/ansible-pull.log
[WARNING]: Could not match supplied host pattern, ignoring: p-application
localhost | SUCCESS => {
"after": "ba7fba1e999fe5e3a4f49d09834601e4c2481e6e",
"before": "ba7fba1e999fe5e3a4f49d09834601e4c2481e6e",
"changed": false,
"remote_url_changed": false
}
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that
the implicit localhost does not match 'all'
[WARNING]: Could not match supplied host pattern, ignoring: p-application
PLAY [Apply roles dynamically from inventory] **********************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Explicitly gather custom facts] ******************************************
ok: [localhost]
TASK [Include web role] ********************************************************
skipping: [localhost]
TASK [Include app role] ********************************************************
TASK [app : Install Python and Pip (Debian)] ***********************************
ok: [localhost]
TASK [app : Create Python virtual environment] *********************************
ok: [localhost]
TASK [app : Upgrade pip inside virtual environment] ****************************
ok: [localhost]
TASK [app : Install required Python packages in virtual environment] ***********
ok: [localhost]
TASK [app : Verify Python installation] ****************************************
ok: [localhost]
TASK [app : Debug Python version] **********************************************
ok: [localhost] => {
"msg": "Installed Python version: Python 3.11.2"
}
TASK [app : Verify Pip installation] *******************************************
ok: [localhost]
TASK [app : Debug Pip version] *************************************************
ok: [localhost] => {
"msg": "Installed Pip version: pip 23.0.1 from /usr/lib/python3/dist-packages/pip (python 3.11)"
}
TASK [app : Verify virtual environment] ****************************************
ok: [localhost]
TASK [app : Debug virtual environment status] **********************************
ok: [localhost] => {
"msg": "Virtual environment exists at /home/ansible/.venv"
}
TASK [app : Create application directory] **************************************
ok: [localhost]
TASK [app : Deploy Flask application (app.py)] *********************************
ok: [localhost]
TASK [app : Create systemd service for Flask app] ******************************
ok: [localhost]
TASK [app : Ensure hello-app service is enabled and started] *******************
changed: [localhost]
TASK [Include client role] *****************************************************
skipping: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=16 changed=1 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
Starting Ansible Pull at 2025-03-14 23:29:53
/usr/bin/ansible-pull -U git@gitlab.ch.ap:AndreyChuyan/ansible_pull_cicd.git -C release --private-key /home/ansible/.ssh/id_ansible_pull ansible/infra_ansible_pull.yml
Из вывода можно наблюдать, что все задачи успешно применены.
Мониторинг ошибок и обновлений
В дальнейшем можно отслеживать ошибки и статус обновлений с помощью пользовательских экспортёров Prometheus или ELK, но это уже тема для отдельной статьи.
Спасибо, что дочитали статью до конца! Буду рад вашей обратной связи.