Spot-инстанс стоит на 60-90 процентов дешевле on-demand, но AWS вправе забрать его с предупреждением ровно за две минуты. Две минуты это достаточно, чтобы приложение успешно дописало данные, сняло регистрацию из балансировщика и ушло красиво, но недостаточно для паники. В этой статье разбираем, как реально работает механизм termination notice, как поймать его через systemd-юнит плюс простой скрипт, и как сохранить состояние приложения, пока часы тикают.
Как AWS предупреждает о прерывании spot-инстанса
Механика устроена просто. Через instance metadata service (IMDS) появляется специальный эндпоинт. Пока инстансу ничего не грозит, запрос к нему отдаёт 404. Как только AWS решил прервать инстанс, эндпоинт начинает отдавать JSON с полем time - момент, когда инстанс будет остановлен или выключен. Запрос делается по link-local адресу 169.254.169.254, и с IMDSv2 требуется сначала получить токен через PUT-запрос с заголовком X-aws-ec2-metadata-token-ttl-seconds.
Отдельно событие прерывания уходит в EventBridge с типом "EC2 Spot Instance Interruption Warning", и его можно слушать централизованно, чтобы сразу помечать ноду в балансировщике как уходящую. Но для самого приложения всё же надёжнее локальный поллинг: сеть до EventBridge не случайная вещь, а за две минуты до отключения инстанса любая дополнительная зависимость это риск.
Есть нюансы. Поле action отражает, что произойдёт: terminate, stop или hibernate - в зависимости от конфигурации spot-запроса. По умолчанию это terminate. Времени стоит доверять буквально: поле time это момент, когда AWS начнёт операцию, а не когда "уже всё пропало". Фактическое выключение может наступить на несколько секунд раньше, потому что система внутри AWS инициирует операцию до указанной отметки. Поэтому в скриптах безопасно ориентироваться на 110-115 секунд реального запаса, а не на все 120.
Ещё одна деталь: две минуты это не гарантия, это обещание сверху. Исторически встречались случаи, когда инстанс уходил раньше, особенно при dry-run или при ручном прерывании через API. Поэтому архитектура под spot строится так, будто предупреждения может вообще не быть: любая работа инстанса должна переживать внезапную потерю машины. Graceful shutdown это приятный бонус, а не краеугольный камень надёжности.
Systemd-юнит, который следит за termination notice
Стандартный паттерн: создаётся systemd service, который раз в 5 секунд спрашивает IMDS, а при обнаружении 200-ответа запускает процедуру остановки. Сам сервис живёт вечно и перезапускается при падении.
Пример юнита /etc/systemd/system/spot-termination-watch.service:
[Unit]
Description=Watch for EC2 spot termination notice
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/spot-watch.sh
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Сам скрипт /usr/local/bin/spot-watch.sh:
#!/bin/bash
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
while true; do
CODE=$(curl -s -o /tmp/termination.json -w "%{http_code}" \
-H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/spot/instance-action)
if [ "$CODE" = "200" ]; then
logger -t spot-watch "termination notice received, starting graceful shutdown"
/usr/local/bin/graceful-shutdown.sh
break
fi
sleep 5
done
Период 5 секунд это разумный баланс: чаще опрашивать бессмысленно, у IMDSv2 нет жёсткого rate limit, который бы укусил, но каждые 5 секунд достаточно, чтобы поймать окно с большим запасом. Считаем: в худшем случае мы узнаем о предупреждении через 5 секунд после его появления, остаётся порядка 110-115 секунд на всю процедуру. Если стадия drain нагрузки занимает 30 секунд, запаса хватает впритык, поэтому для тяжёлых приложений интервал поллинга уменьшают до 1-2 секунд.
Важно, что spot-watch это отдельный юнит, а не часть самого приложения. Причина: когда приложение начнёт останавливаться, systemd остановит его юнит, а watcher должен продолжать жить, чтобы довести процедуру до конца и записать в лог итог.
Процедура graceful shutdown, drain, snapshot, выход
Содержимое graceful-shutdown.sh зависит от типа нагрузки, но общий каркас одинаковый:
#!/bin/bash
set -e
# 1. Снять регистрацию из балансировщика
aws elbv2 deregister-targets --target-group-arn "$TG_ARN" \
--targets Id=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/instance-id)
# 2. Сказать приложению завершить приём новых соединений
systemctl stop myapp.service
# 3. Дать задачам доработать (максимум 60 секунд)
timeout 60 /usr/local/bin/wait-for-jobs.sh || true
# 4. Сохранить состояние
/usr/local/bin/save-state.sh
# 5. Отметить факт чистой остановки
echo "graceful shutdown complete at $(date -Is)" | logger -t spot-watch
Ключевой момент это снятие с балансировщика до остановки приложения. ALB имеет настройку deregistration delay (по умолчанию 300 секунд), и это одна из первых цифр, которую меняют под spot: уменьшают до 30-60 секунд, иначе цель будет числиться draining дольше, чем живёт сам инстанс. Пока инстанс в draining, новые запросы к нему не идут, а старые дожимаются, это и есть graceful.
Сохранение состояния - самое специфичное место. Три типовых сценария. Первый: stateless-приложение, состояние не нужно, достаточно drain. Второй: worker долгих задач (ffmpeg, компиляция, batch-обработка) - состояние скидывается в S3 или на EFS, задача помечается как прерванная в очереди (SQS visibility timeout позаботится о возврате задачи в работу). Третий: сервис с локальным кэшем, который было бы приятно согреть на новом инстансе - тогда в save-state.sh просто копируют горячие данные на сетевой ресурс: aws s3 sync /var/cache/myapp s3://myapp-state/$(date +%s)/ --only-show-errors. Две минуты хватает на сотни мегабайт при хорошей сети, но никто не гарантирует эту полосу, поэтому состояние обычно ограничивают действительно нужным.
Кстати про очереди: если приложение читает SQS с visibility timeout в 30-60 секунд, то за две минуты до terminate видимость сообщения истечёт, сообщение вернётся в очередь, и его подберёт другой worker. Главное, чтобы остановка приложения случилась до того, как обработчик успел удалить сообщение, но не успел записать результат. Поэтому в обработчиках всегда делают delete сообщения самым последним шагом после записи результата. Тогда даже жёсткое выключение обрабатывается очередью само по себе, а graceful остановка просто делает картину чище и предсказуемее.
Сохранение состояния, что именно копировать и куда
Вопрос "что сохранять" решается не в момент предупреждения, а на этапе проектирования. Подходящая практика: инстанс не должен быть единственным местом хранения чего-либо важного. Тогда save-state.sh становится коротким скриптом, а не попыткой за 90 секунд сдампить базу на 200 гигабайт.
Для приложений с очередями состояние это, по сути, список незавершённых задач. Если очередь внешняя, SQS или RabbitMQ на отдельном сервисе, то инстансу вообще нечего сохранять: механизм redelivery поднимет недоделанное на другом worker. Хранить на инстансе надо только локальный эфемерный контекст, например скачанный большой файл, который обрабатывается последние 20 минут. Практический компромисс: такие операции разбивают на этапы с чекпоинтами. Вместо "скачать 10 ГБ и обработать" делается цикл: скачать кусок, обработать, записать промежуточный результат в S3, удалить кусок. Тогда при прерывании теряется максимум один кусок, а не вся работа.
Для баз данных spot-инстансы используют только для read-replica, где состояние реплицируется с master. При прерывании нода просто уходит, другая реплика берёт нагрузку. Сохранять ничего не нужно, нужно только снять ноду с DNS Discovery и позволить connection пулам устареть. Отдельный случай - кэши уровня Memcached или Redis в режиме replica: состояние эфемерно, заменяется за миллисекунды, ничего не сохраняется. Это лучший сценарий для spot: существенная экономия без сложной логики shutdown.
Вариант через EventBridge и место в общей стратегии spot
Для охвата всей флотилии spot-инстансов удобно дополнительно ловить событие в EventBridge. Правило с типом "EC2 Spot Instance Interruption Warning" триггерит Lambda, которая: помечает инстанс в service discovery, чистит сессии в Redis, отправляет метрику в CloudWatch о событии. Это централизованная реакция - она идёт параллельно с локальной. Оба механизма не исключают друг друга, а дополняют: локальный юнит отвечает за чистую остановку самого инстанса, а центральное правило решает задачи уровня инфраструктуры.
Полезно в этой схеме вспомнить про автомасштабирование: если spot-инстанс входит в ASG mixed instances policy, AWS сам запустит замену. Но замена поднимется через несколько минут, поэтому capacity planning под spot делается с запасом, обычно 2-3 лишних инстанса on-demand в минимальном составе, чтобы провал spot capacity pool не привёл к деградации сервиса.
И последнее практическое замечание. Проверить всю цепочку "получили предупреждение - сделали drain - сохранили состояние - ушли" можно без реального прерывания. AWS Fault Injection Simulator предлагает эксперимент aws:ec2:send-spot-instance-interruptions, который эмулирует termination notice на выбранных инстансах. Это единственный вменяемый способ прогнать сквозной сценарий в тестовой среде, потому что ручное terminate через консоль не имитирует двухминутное предупреждение: инстанс просто выключится, и репетиция не состоится.
Метрики и алертинг вокруг прерываний
Если spot-инстансы используются широко, стоит следить за частотой прерываний. AWS публикует метрики на уровне ASG, а события уходят в EventBridge. Из этих событий строят простой алертинг: если за час прерываний больше, чем N процентов флота, это сигнал о том, что выбранный spot pool перегрет, и capacity следует расширять на другие типы инстансов или зоны доступности. Процент прерываний для конкретного типа инстанса по регионам публикуется в AWS Spot Instance Advisor - там и берут данные для выбора пула.
На уровне самого инстанса watcher может отправлять собственную метрику: put-metric-data с именем SpotTerminationCaught. По этой метрике потом строится график корректных остановок против некорректных. Если график показывает много terminate без graceful shutdown - значит, watcher не успевает: либо интервал поллинга большой, либо приложение зависает на остановке. По этому графику понимают, где копать, прежде чем инцидент ударит по пользователям.
Отдельное наблюдение из практики: самая частая ошибка в graceful-shutdown.sh это использование длительных команд без timeout. Например, aws s3 sync без ограничения по времени на медленной сети может не уложиться в 90 секунд, systemd прибьёт юнит по TimeoutStopSec, и состояние не сохранится. Поэтому каждая операция в скрипте обёрнута своим timeout, а общий бюджет времени заложен с запасом: если фактическое выключение может наступить на несколько секунд раньше, то всю процедуру ужимают в 80-90 секунд, а на остаток оставляют буфер для systemd заморозить и отключить инстанс.
Контрольный список перед вводом spot в нагруженное продакшен-окружение
Перед тем как переводить реальный сервис на spot, по практике проходятся по короткому списку. Первое: лимит времени на остановку приложения измерен и укладывается в 80 секунд с запасом. Второе: прогон через Fault Injection Simulator выполнен и завершился без потери данных, результат зафиксирован в логах. Третье: очереди настроены с visibility timeout так, чтобы сообщение вернулось в работу, если worker умирает. Четвёртое: deregistration delay у балансировщика уменьшен примерно до длительности остановки приложения. Пятое: есть метрика и алерт по числу прерываний. Шестое: on-demand capacity в ASG имеет минимальный резерв, чтобы мгновенный отказ spot pool не уронил сервис.
Если хотя бы один пункт не выполняется, spot используют только для тестовых нагрузок и worker-пулов, где внезапная потеря инстанса ничем не грозит.