Автоматизация серверов с Ansible: уроки из продакшена
Два года назад я перешёл с ручного администрирования серверов на Ansible. За это время автоматизировал деплой нескольких приложений, CI/CD-пайплайнов и мониторинга. Расскажу про структуру проектов, подходы к работе с секретами и грабли, на которые успел наступить.
Почему Ansible, а не Salt/Puppet/Chef
У меня был опыт с Puppet и краткое знакомство с SaltStack. Вот почему я остановился на Ansible:
Отсутствие агентов. Ansible работает по SSH. Не нужно устанавливать и обновлять агенты на каждом сервере, не нужно следить за их здоровьем, не нужен отдельный master-сервер. Для моего масштаба (5-10 серверов) это идеальный подход.
Декларативный YAML. Плейбуки читаются как документация. Новый человек в команде может понять, что происходит, даже без знания Ansible. В Puppet с его DSL порог входа значительно выше.
Модульность. Система ролей в Ansible позволяет переиспользовать код между проектами. Роль для настройки Nginx написал один раз — используешь везде.
Огромная экосистема. Ansible Galaxy, коллекции от вендоров (AWS, GCP, Docker), тысячи готовых модулей. Для большинства задач не нужно писать свои модули.
Минусы тоже есть: скорость выполнения (SSH-сессия на каждую задачу), отсутствие постоянного состояния (нет drift detection из коробки), иногда неочевидное поведение переменных. Но для мне плюсы однозначно перевешивают.
Структура проекта
После нескольких итераций я пришёл к такой структуре:
ansible/
├── ansible.cfg
├── inventory.yml
├── group_vars/
│ ├── all/
│ │ ├── vars.yml
│ │ └── vault.yml
│ ├── web_servers/
│ │ └── vars.yml
│ └── db_servers/
│ └── vars.yml
├── playbooks/
│ ├── deploy.yml
│ └── monitoring.yml
└── roles/
├── common/
│ ├── tasks/
│ │ └── main.yml
│ ├── handlers/
│ │ └── main.yml
│ ├── templates/
│ └── defaults/
│ └── main.yml
├── web_app/
├── monitoring/
└── nginx/ Ключевой файл — ansible.cfg, который задаёт разумные значения по умолчанию:
[defaults]
inventory = inventory.yml
roles_path = roles
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
pipelining = True
[privilege_escalation]
become = False
become_method = sudo
become_ask_pass = False
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
pipelining = True Обратите внимание на pipelining = True — это значительно ускоряет выполнение, потому что Ansible не создаёт временные файлы на удалённом хосте, а передаёт код через stdin. Экономия — десятки секунд на плейбуках с большим количеством задач.
Инвентарь
Я использую YAML-формат инвентаря вместо INI — он нагляднее:
all:
children:
web_servers:
hosts:
production:
ansible_host: 194.87.104.208
ansible_port: 6622
ansible_user: alex
db_servers:
hosts:
db-master:
ansible_host: 185.210.45.67
ansible_port: 22
ansible_user: root Для динамических окружений (AWS, Hetzner Cloud) лучше использовать динамические инвентари — плагины, которые автоматически подтягивают список хостов из API провайдера.
Работа с секретами: ansible-vault
Одна из лучших фич Ansible — встроенное шифрование секретов. Никаких сторонних инструментов, никаких утечек паролей в git.
# Создаём зашифрованный файл
ansible-vault create group_vars/all/vault.yml
# Редактируем существующий
ansible-vault edit group_vars/all/vault.yml
# Шифруем отдельную строку (для вставки в обычные файлы)
ansible-vault encrypt_string 'my_secret_password' --name 'vault_db_password' Структура vault-файла:
# group_vars/all/vault.yml (зашифрован)
vault_db_password: "s3cur3_p@ssw0rd"
vault_api_secret: "another_secret"
vault_telegram_bot_token: "123456:ABC-DEF..."
vault_acme_email: "admin@example.com" Важное правило: все переменные в vault начинаются с vault_. В обычном vars.yml делаем ссылки:
# group_vars/all/vars.yml (не зашифрован)
db_password: "{{ vault_db_password }}"
api_secret: "{{ vault_api_secret }}" Это позволяет видеть, какие переменные существуют, не расшифровывая vault. Паттерн подсмотрен в официальных best practices Ansible.
Для удобства можно сохранить пароль vault в файл:
echo "my_vault_password" > ~/.vault_pass
chmod 600 ~/.vault_pass И указать его в ansible.cfg:
[defaults]
vault_password_file = ~/.vault_pass Тогда не придётся каждый раз вводить --ask-vault-pass.
Идемпотентность: главный принцип
Идемпотентность — это гарантия, что повторный запуск плейбука не сломает систему и не изменит то, что уже настроено корректно. Это фундаментальный принцип Ansible, и его легко нарушить.
Плохой пример:
# НЕ ДЕЛАЙТЕ ТАК
- name: Add line to config
ansible.builtin.shell: echo "MaxSessions 10" >> /etc/ssh/sshd_config Каждый запуск будет добавлять строку заново. Правильно:
# Правильный подход
- name: Set MaxSessions in sshd_config
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: '^#?MaxSessions'
line: 'MaxSessions 10'
state: present
notify: restart sshd Ещё лучше — использовать шаблоны. Модуль template рендерит Jinja2-шаблон и копирует файл только если содержимое изменилось:
- name: Deploy sshd config
ansible.builtin.template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0644'
validate: '/usr/sbin/sshd -t -f %s'
notify: restart sshd Обратите внимание на параметр validate — он проверяет конфигурацию перед заменой файла. Если шаблон сгенерировал невалидный конфиг, задача упадёт, и рабочий файл останется нетронутым.
Хендлеры: не перезапускайте сервисы зря
Хендлеры вызываются только при изменении задачи (статус changed). Это предотвращает ненужные рестарты:
# roles/common/handlers/main.yml
- name: restart sshd
ansible.builtin.systemd:
name: sshd
state: restarted
daemon_reload: true
- name: reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded Хендлеры выполняются в конце play, в порядке их определения (не в порядке вызова). Если нужно выполнить хендлер немедленно, используйте meta: flush_handlers:
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: reload nginx
- name: Flush handlers now
ansible.builtin.meta: flush_handlers
- name: Check nginx is responding
ansible.builtin.uri:
url: "http://localhost"
status_code: 200 Роли: переиспользуемые блоки
Типичная роль для установки и настройки сервиса:
# roles/common/tasks/main.yml
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
become: true
- name: Install essential packages
ansible.builtin.apt:
name:
- curl
- wget
- htop
- vim
- ufw
- fail2ban
- unattended-upgrades
state: present
become: true
- name: Configure UFW defaults
community.general.ufw:
direction: incoming
default: deny
become: true
- name: Allow SSH
community.general.ufw:
rule: allow
port: "{{ ansible_port | default(22) }}"
proto: tcp
become: true
- name: Enable UFW
community.general.ufw:
state: enabled
become: true
- name: Set timezone
community.general.timezone:
name: "{{ server_timezone | default('UTC') }}"
become: true Переменные со значениями по умолчанию идут в defaults/main.yml:
# roles/common/defaults/main.yml
server_timezone: "UTC"
ssh_port: 22
enable_fail2ban: true Плейбук, использующий роли:
# playbooks/deploy.yml
---
- name: Configure base server
hosts: all
become: false
roles:
- common
- name: Deploy web application
hosts: web_servers
become: false
roles:
- web_app
- monitoring Практические советы из продакшена
1. Всегда используйте FQCN для модулей
Вместо apt пишите ansible.builtin.apt. Это избавляет от конфликтов имён с коллекциями и является обязательным с Ansible 2.10+.
2. Тестируйте плейбуки в check mode
ansible-playbook playbooks/deploy.yml --check --diff Флаг --check выполняет «сухой прогон» без реальных изменений, --diff показывает, что именно изменится в файлах.
3. Используйте теги для частичного выполнения
- name: Deploy application code
ansible.builtin.git:
repo: "{{ app_repo }}"
dest: /opt/app
version: "{{ app_version }}"
tags: [deploy, app] # Запускаем только задачи с тегом deploy
ansible-playbook playbooks/deploy.yml --tags deploy 4. Не игнорируйте ошибки без причины
# Плохо — скрывает реальные проблемы
- name: Do something
ansible.builtin.command: some_command
ignore_errors: true
# Лучше — явная обработка
- name: Check if service exists
ansible.builtin.command: systemctl status myservice
register: service_status
failed_when: false
changed_when: false
- name: Configure service
ansible.builtin.template:
src: myservice.conf.j2
dest: /etc/myservice.conf
when: service_status.rc == 0 5. Используйте block для группировки и обработки ошибок
- name: Deploy with rollback
block:
- name: Deploy new version
ansible.builtin.git:
repo: "{{ app_repo }}"
dest: /opt/app
version: "{{ new_version }}"
- name: Run migrations
ansible.builtin.command: /opt/app/manage.py migrate
rescue:
- name: Rollback to previous version
ansible.builtin.git:
repo: "{{ app_repo }}"
dest: /opt/app
version: "{{ old_version }}"
- name: Notify about failure
ansible.builtin.debug:
msg: "Deploy failed, rolled back to {{ old_version }}" Типичные ошибки
Хардкод путей и IP-адресов. Всё должно быть в переменных. Вынесите пути в defaults/main.yml, IP-адреса в инвентарь.
Монолитные плейбуки. Если плейбук на 500 строк — разбейте его на роли. Каждая роль отвечает за один сервис или компонент.
Отсутствие become: true. Забыть become на задаче, требующей root — классика. Ansible молча падает с «Permission denied», а вы десять минут ищете причину.
Запуск без --diff. Всегда запускайте с --diff, чтобы видеть, что именно меняется на серверах.
Заключение
Ansible — не серебряная пуля, но для команд из 1-5 человек, управляющих десятками серверов, это оптимальный инструмент. Порог входа низкий, документация отличная, а правильная структура проекта позволяет масштабироваться без боли.
Главный совет: начните с малого. Автоматизируйте один сервер, одну задачу. Потом добавляйте роли и плейбуки по мере необходимости. Идеальная структура проекта формируется итеративно, а не проектируется заранее.