Ansible-pull и GitLab CI/CD: когда лучше тянуть, чем толкать

от автора

👋 Привет!

Меня зовут Андрей, я специалист по управлению IT-инфраструктурой с опытом работы с Windows- и Linux-системами.

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

Узлами могут быть:

  • Рабочие станции пользователей,

  • Серверы за NAT в серых зонах сети,

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

При работе с такой динамичной инфраструктурой можно столкнуться с рядом сложностей.

  • Сети могут быть ненадежными – сервер или рабочая станция может быть временно недоступна из-за сбоя связи, перегрузки или политики безопасности.

  • Узлы за NAT или в изолированных зонах – Ansible Control Node не имеет прямого доступа к таким узлам.

  • Удалённые устройства перемещаются – например, ноутбуки разработчиков или оборудование в мобильных дата-центрах.

Мой телеграмм канал — сообщество, где делятся опытом

https://t.me/IT_Chuyana

Проблемы классической 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-устройства и сетевое оборудование

Доводы приведены, давайте подумаем как нам реализовать этот механизм.

Ход работы

Нашу задачу мы разделим на несколько подзадач:

  1. Подготовка тестового окружения на виртуальных машинах через Vagrant

  2. Настройка узлов к режиму Ansible-pull, выдача им ролей и RSA-ключей

  3. Конфигурирование среды для тестирования качества кода в Docker-контейнере

  4. Настройка CI/CD-пайплайна для автоматизирования процесса развертывания и тестирования в конвейере

  5. Создание ролей для 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

Если тесты плейбука прошли успешно, мы увидим такую картину.

Убеждаемся в том, что созданы cron задачи

Убеждаемся в том, что созданы cron задачи
Убеждаемся в том, что хостам назначены кастомные роли

Убеждаемся в том, что хостам назначены кастомные роли

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 действует по алгоритму:

  1. Сбор фактов: Ansible получает информацию о хосте, включая его роль

  2. Динамическое назначение ролей:

    • Если роль хоста — webserver, применяется роль web

    • Если роль application, загружается роль app

    • Если роль client, выполняется настройка клиента

  3. Автономное применение конфигурации с 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

Делаем коммит и пуш, и наслаждаемся успешными тестами CI/CD!

Делаем коммит и пуш, и наслаждаемся успешными тестами CI/CD!

Спустя 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, но это уже тема для отдельной статьи.

Спасибо, что дочитали статью до конца! Буду рад вашей обратной связи.

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

Какой инструментарий вы используете для pull-а конфигураций?

31.25% Ansible-pull5
18.75% SaltStack3
18.75% Puppet3
6.25% Chef1
12.5% Custom Bash/Python-скрипты2
31.25% Другие инструменты? Напишите в комментариях!5

Проголосовали 16 пользователей. Воздержались 10 пользователей.

ссылка на оригинал статьи https://habr.com/ru/articles/890276/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *