Инстанс EC2 считается готовым к работе не в момент, когда AWS переводит его в состояние running, а когда на нём установлены пакеты, создан пользователь деплоя, отданы ключи, поднят firewall и стартует приложение. Делать это руками после первого входа по SSH значит каждый раз повторять одну и ту же последовательность и гарантированно где-нибудь ошибиться. Cloud-init решает задачу иначе: конфигурация передаётся инстансу при создании через механизм user-data, выполняется при первой загрузке, а результат можно проверить в логах ещё до того, как кто-то вообще подключится к машине. В этой статье разбираем реальную конфигурацию bootstrap для EC2, где cloud-init готовит систему и запускает ansible-pull, чтобы всё дальнейшее шло через уже привычный плейбук.
Что происходит с user-data при первом запуске инстанса
Когда вы создаёте EC2, в поле user-data можно передать до 16 килобайт произвольных данных. Это жёсткий лимит: если base64-кодированное содержимое больше, запрос на запуск инстанса отклоняется. Данные доступны инстансу через link-local адрес 169.254.169.254 в экземплярных метаданных (IMDSv2, где сначала запрашивается токен через PUT-запрос). Cloud-init, который предустановлен в официальных AMI (Amazon Linux 2023, Ubuntu, Debian, RHEL), забирает эти данные и выполняет их согласно формату.
У cloud-init три этапа: init (ранний, до применения сетевой конфигурации), config и final. Большая часть модулей отрабатывает на стадиях config и final. Если user-data начинается с "#!/bin/bash", содержимое обрабатывается как обычный shell-скрипт и выполняется один раз на стадии final. Если начинается с "#cloud-config", это YAML-конфигурация, где каждый модуль описывается декларативно. Есть и third-party форматы: include-файл со списком URL для скачивания дополнительных частей, gzip-сжатые данные, mime multipart для комбинации нескольких частей.
Важная деталь: user-data выполняется с правами root, без интерактивного терминала. Поэтому всё, что требует TTY или ввода, надо отключать: DEBIAN_FRONTEND=noninteractive для apt, флаг -y у пакетных менеджеров, NEEDRESTART_MODE=a в Ubuntu 22.04+, чтобы needrestart не останавливал выполнение вопросом о перезапуске сервисов. Повторный запуск user-data при ребуте не происходит по умолчанию: cloud-init один раз отрабатывает сценарий и складывает маркеры в /var/lib/cloud/instances/<id>. Инстансу с новым instance-id (например, после создания из AMI) конфигурация выполнится снова, потому что маркеры привязаны к идентификатору.
Логи bootstrap лежат в /var/log/cloud-init.log (детальный вывод модулей) и /var/log/cloud-init-output.log (stdout и stderr ваших скриптов). Первым делом при отладке смотрят туда, а командная утилита cloud-init status показывает общий итог: done, error или running. Полезно знать и про cloud-init analyze show, который выводит хронометраж этапов: на медленных AMI видно, что bootstrap съедает, скажем, 4 минуты на apt update.
Декларативный cloud-config, пользователи, пакеты, файлы
Реальный bootstrap обычно комбинирует декларативные модули и немного императивного кода. Вот рабочий шаблон, который создаёт пользователя деплоя, ставит базовые пакеты и записывает systemd-юнит:
#cloud-config
hostname: app-node-01
preserve_hostname: false
manage_etc_hosts: true
users:
- name: deploy
groups: [sudo, docker]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
ssh_authorized_keys:
- ssh-ed25519 AAAA... комментарий-ключ
package_update: true
package_upgrade: false
packages:
- git
- curl
- python3-pip
- ufw
write_files:
- path: /etc/sysctl.d/90-app.conf
permissions: "0644"
content: |
net.core.somaxconn=4096
vm.swappiness=10
runcmd:
- sysctl --system
- ufw allow 22/tcp
- ufw allow 443/tcp
- ufw --force enable
Несколько практических моментов. package_upgrade: false стоит осознанно: полное обновление системы при каждом bootstrap добавляет минуты к запуску и ломает воспроизводимость. Обновляют базовый образ периодически через отдельный pipeline, а не на каждый инстанс. ssh_authorized_keys вставляется прямо в конфиг, что удобно, но в продакшене чаще забирают ключи из IAM instance profile или Secrets Manager, чтобы не светить публичные ключи в Terraform-коде. Модуль write_files отрабатывает раньше runcmd, поэтому файлы, нужные командам, уже на месте. Отступы в YAML критичны: один лишний пробел в write_files, и cloud-init выдаст ошибку разбора, причём инстанс при этом всё равно запустится, просто голым.
Склейка с ansible-pull и порядок выполнения
Декларативных модулей хватает на простые задачи, но настоящая конфигурация приложения живёт в плейбуках. Связка cloud-init + ansible-pull выглядит так: cloud-init ставит минимум (git, python, pip, ansible), а затем запускает ansible-pull, который клонирует репозиторий с плейбуком и применяет его локально. Схема хороша для автомасштабирования: инстанс сам себя настраивает, центральный ansible-хост не нужен, нет проблемы "инстансы поднялись быстрее, чем центральный сервер успел до них дойти".
Типичный кусок runcmd под ansible-pull:
runcmd:
- pip3 install ansible
- mkdir -p /var/log/bootstrap
- |
ansible-pull -U https://git.example.com/infra/bootstrap.git \
-C main -i localhost, \
--vault-password-file /etc/ansible/vault-pass \
playbooks/ec2-node.yml >> /var/log/bootstrap/ansible-pull.log 2>&1
- echo "bootstrap finished $(date -Is)" > /var/log/bootstrap/done
Аутентификацию к приватному репозиторию делают либо deploy key, который cloud-init записывает через write_files, либо AWS-специфично: репозиторий в CodeCommit с доступом через instance role. Vault-пароль для зашифрованных переменных кладут в AWS Systems Manager Parameter Store (SecureString) и вытаскивают в runcmd через aws ssm get-parameter --with-decryption. Важно: instance profile с правом ssm:GetParameter должен быть прикреплён к инстансу с самого старта, потому что менять роль до выполнения user-data нельзя.
Порядок имеет значение. Сначала сеть и DNS, потом пакеты, потом репозиторий и плейбук. Если плейбук должен запускать docker-контейнеры, модуль docker в пакеты надо ставить до вызова ansible-pull, иначе плейбук упадёт на первом же docker_container task. Логично и то, что cloud-init запускает runcmd в финальной фазе, когда systemd-модули cloud-final уже позволяют юнитам стартовать, поэтому systemctl enable --now из runcmd работает корректно.
Повторный запуск, идемпотентность и отладка
Главная боль cloud-init в эксплуатации это повторяемость. User-data выполняется один раз per instance. Если bootstrap упал на середине, простой ребут не повторит сценарий: маркер instance уже стоит. На практике есть три способа добиться повтора. Первый - удалить маркеры командой cloud-init clean --logs и перезагрузить инстанс, тогда user-data отработает заново. Второй - использовать cloud-init modules с режимом always вместо per-instance в /etc/cloud/cloud.cfg.d, что в CI сценариях встречается редко, потому что это требует правки самого образа. Третий - встроить повторяемую часть логики в systemd-юнит с ConditionFirstBoot, но обычно дешевле просто пересоздать инстанс, потому что EC2 изначально проектируется под immutable-характер.
Идемпотентность runcmd - это ваша ответственность. Команда ufw --force enable дважды отработает нормально, а вот строка echo 'export X=1' >> /etc/profile при каждом повторном запуске допишет дубль. Поэтому скрипты пишут с проверками существования: grep -q или create-only флаги пакетных менеджеров. Ansible-pull тут помогает сам по себе, потому что плейбуки идемпотентны по своей природе.
Отладка начинается с консоли: во вкладке инстанса EC2 есть "Get system log", и cloud-init пишет туда ключевые строки. Если инстанс поднялся, но плейбук не сработал, входим по Session Manager (либо SSH) и смотрим: sudo cloud-init status --long покажет время прогона и ошибки. Быстрый тест всего user-data без пересоздания инстанса можно сделать на другом свежем инстансе: в Terraform меняют user_data с force_new на отдельной ветке, или просто запускают t3.micro с той же конфигурацией. Шаблон с nano вместо полноценного типа инстанса на dev-стенде экономит и время, и деньги: прогон стоит копейки, а синтаксические ошибки YAML выявляются мгновенно.
Ещё одна ловушка это кодирование. AWS Console сама принимает plain text и кодирует в base64. А вот aws CLI при вызове run-instances ждёт от вас уже base64-строку в параметре --user-data, либо file:// для файла. Terraform ресурс aws_instance принимает user_data в raw-виде, а user_data_base64 для прекодированного. Путаница даёт классическую ситуацию "инстанс поднялся, но bootstrap не сработал", потому что cloud-init не смог распарсить base64-мусор как YAML.
Минимизация образа и скорость bootstrap
Bootstrap при каждом запуске инстанса занимает время: apt update плюс установка пакетов плюс ansible-pull вместе легко занимают 2-4 минуты. Для автомасштабирования это критично: пока идёт bootstrap, new instance не принимает трафик, и autoscaling group не может отреагировать на всплеск. Поэтому тяжёлую часть выносят в образ AMI через Packer. В AMI запекают всё, что можно заранее: пакеты, docker-образы пулами, статические файлы. В user-data остаются только действия, которые зависят от конкретного запуска: hostname, регистрация в service discovery, вписывание текущего instance-id в конфиг приложения.
После такой оптимизации bootstrap сводится к 15-30 секундам. Разделение обязанностей очерчивается просто: AMI отвечает за "что установлено", user-data отвечает за "как именно этот инстанс включился в кластер". Тонкий баланс ещё и в размере user-data: 16 КБ быстро кончаются, если начать писать туда плейбуки. Поэтому в user-data лежит от силы пара сотен строк, а вся логика - в git-репозитории, к которому обращается ansible-pull.
Финальная дисциплина проста: любой bootstrap считается завершённым только тогда, когда проверена вся цепочка. Лог /var/log/cloud-init-output.log говорит, что runcmd отработал. systemctl status приложения говорит, что сервис поднялся. И отдельный smoke-test, который дергает healthcheck-эндпоинт приложения снаружи, подтверждает, что инстанс реально обслуживает трафик, а не просто прошёл через cloud-init. Именно такая проверка отличает "инстанс создан" от "инстанс готов", и именно до этого уровня стоит довести user-data, чтобы ansible-pull и cloud-init работали как единая, воспроизводимая машина запуска.
Сборка всего вместе через Terraform и проверка готовности
В реальном проекте user-data не пишут вручную в консоли, а генерируют из шаблона. В Terraform для этого есть templatefile, который подставляет переменные окружения прямо в cloud-config:
user_data = base64encode(templatefile("bootstrap.yml.tftpl", {
env = "prod"
app_version = "1.42.0"
vault_param = "/prod/ansible/vault"
playbook_repo = "https://git.example.com/infra/bootstrap.git"
}))
Такой подход даёт чистую историю изменений: правка bootstrap идёт через pull request, а не через редактирование в веб-консоли. Второй плюс - версионирование самого шаблона, потому что templatefile читает файл из репозитория инфраструктурного кода. Чувствительные значения (vault-пароль, токены) в шаблон не вставляют, их bootstrap забирает во время выполнения из Parameter Store, о чём говорилось выше.
Проверка готовности оформляется как отдельный шаг. Минимальный рабочий вариант: у приложения есть эндпоинт /healthz, а в автомасштабной группе выставлен health_check_grace_period = 300 секунд. Load balancer через target group health check сам убирает инстанс из ротации, если проверка не проходит. Это страховка: если cloud-init упал на середине, инстанс не получит трафик, и автомасштабирование через какое-то время его заменит. Логика замкнутая: bootstrap либо доводит инстанс до живого состояния, либо инстанс уходит в пересоздание без человеческого участия.
Для долгих плейбуков есть хитрость с сигнализацией. User-data может после успешного ansible-pull дернуть aws autoscaling complete-lifecycle-action, чтобы ASG поняла, что инстанс готов. Если используется lifecycle hook на запуск, то именно сигнал из user-data переводит инстанс из Pending:Wait в InService. Без сигнала инстанс зависнет в ожидании на дефолтный таймаут hook, поэтому в runcmd в самом конце вставляют гарантированный heartbeat-таймаут и отправку сигнала даже при ошибке, иначе ASG будет ждать час впустую.
И последнее наблюдение из практики: храните user-data максимально тупым. Вся сложность - в плейбуках, в git, с ревью и тестами. User-data это тонкий клей между "AWS создал виртуалку" и "плейбук взял управление". Чем тоньше этот клей, тем реже вы будете лазить в /var/log/cloud-init-output.log в три часа ночи, разбираясь, почему новый батч инстансов поднялся без сертификатов.