
👋 Привет!
Меня зовут Андрей, я специалист по управлению 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, но это уже тема для отдельной статьи.
Спасибо, что дочитали статью до конца! Буду рад вашей обратной связи.

