Что такое идемпотентность деплой-скрипта и почему это важно в 2026 году
Идемпотентность - свойство операции давать одинаковый результат при повторном выполнении. В записи через функцию это выглядит как f(f(x)) = f(x): сколько раз ни применяй действие, состояние системы не меняется. Для деплой-скрипта проверка простая: на второй запуск он завершается кодом 0 и не меняет ни одного файла, пакета, пользователя или записи в базе.
Держится такая безопасность на трёх приёмах: проверки состояния, маркеры выполнения, транзакционные изменения с откатом. Первый отвечает на вопрос "уже сделано?", второй фиксирует факт успешного шага, третий не даёт сбою в середине оставить систему в половинчатом виде. Комбинируйте их, и любой шаг станет безопасным для перезапуска.
Повторные прогоны в 2026 году норма, а не исключение: конвейеры перезапускают упавшие job'ы, GitOps-контроллеры сверяют состояние кластера в цикле, cloud-init выполняет user-data при каждом старте инстанса, автоскейлинг поднимает ноды по одному шаблону. Скрипт, который падает на втором прогоне, превращает рядовое событие в инцидент. Типовую структуру такого сценария и его пять этапов мы разбирали в материале о скрипте развёртывания и его структуре.
Чем опасен повторный запуск неидемпотентного скрипта
useradd без проверки. Вторая попытка вернёт код 9 "user already exists". При set -euo pipefail скрипт остановится на этом шаге и не дойдёт до конфигурирования и перезапуска сервиса: прод останется на старой версии приложения, а пайплайн покажет красный статус. Разбор инцидента займёт больше времени, чем сам деплой.
Дописывание строки в конфиг. Команда вида echo 'server_name example.com;' >> /etc/nginx/conf.d/site.conf при втором прогоне создаст дубль директивы. Получите два серверных блока с одинаковым именем, и nginx -t вернёт ошибку "conflicting server name". Служба либо продолжит работать по старому конфигу, либо развалится при перечитывании.
Установка пакета без фиксации версии. apt-get install nginx на следующий день может принести другую сборку, чем на первом сервере. Появляются расхождения между нодами: на одной конфиг валиден, на другой вызывает ошибку. Диагностика стоит часов, потому что со стороны "все серверы одинаковые".
Общий знаменатель один: неидемпотентный скрипт делает повторный запуск дороже, чем отказ от него. Поэтому изменения в продакшене без ревью и без учёта повторного прогона входят в число типовых провалов администрирования, которые мы разбирали в статье про четыре ошибки сисадминов.
Идемпотентность vs атомарность vs транзакционность: в чём разница
| Свойство | Что гарантирует | Пример в деплое |
|---|---|---|
| Идемпотентность | Повторный запуск не меняет результат | systemctl enable nginx, модуль user в Ansible |
| Атомарность | Операция выполняется целиком или не выполняется | rename(2) и mv в пределах одной файловой системы, переключение symlink current на новый релиз |
| Транзакционность | Есть откат к состоянию до операции | BEGIN; ... COMMIT в миграции, блок block/rescue в Ansible, kubectl rollout undo |
Свойства дополняют друг друга и не заменяют. rm -rf /opt/app/current идемпотентна, но разрушительна. Атомарная замена файла через mv защищает от половинчатой записи, но не уберёт дубликат директивы, если он уже есть. Транзакция в базе откатит неудачную миграцию, но не отменит запрос, уже отправленный во внешний API.
Практический вывод: делайте каждый шаг идемпотентным, критичные шаги атомарными, а транзакции применяйте там, где их поддерживает сама система: СУБД, файловая система, оркестратор.
Типичные ошибки, которые ломают повторный деплой
Повторное создание пользователей и групп
Так делать нельзя:
useradd deploy groupadd docker usermod -aG docker deploy
useradd вернёт код 9, groupadd тоже, и любой из них остановит скрипт при включённом set -e. Рабочий вариант с проверкой:
id -u deploy >/dev/null 2>&1 || useradd --create-home --shell /bin/bash deploy groupadd -f docker usermod -aG docker deploy
groupadd -f (синоним --force) заставляет команду просто завершиться с успешным статусом, если указанная группа уже существует, - то есть шаг молча пропускается. usermod -aG идемпотентен по смыслу: пользователь попадёт в группу один раз, сколько бы раз вы ни запускали команду. Проверка результата: id deploy показывает uid, gid и полный список групп.
В Ansible модуль user идемпотентен по умолчанию: ansible.builtin.user: name=deploy shell=/bin/bash groups=docker append=yes не создаст второго пользователя и не продублирует членство в группе.
Дублирование записей в конфигах
Классическая ошибка - дописывание строки в конец файла:
echo 'worker_processes auto;' >> /etc/nginx/nginx.conf
Идемпотентный минимум - проверка перед добавлением:
CONF=/etc/nginx/conf.d/site.conf grep -qF 'server_name example.com;' "$CONF" || echo 'server_name example.com;' >> "$CONF" nginx -t && systemctl reload nginx
Приём спасает от дубля, но оставляет управление конфигом разбросанным по скрипту. Надёжнее рендерить файл целиком из шаблона: envsubst, Jinja2 в Ansible (модуль template), шаблоны в cloud-init. Полная перезапись даёт предсказуемый результат и позволяет проверить итог командой nginx -t до перезагрузки службы.
Для точечных правок в готовом файле используйте lineinfile или blockinfile в Ansible: они меняют нужную строку, не создавая дубликатов, и возвращают статус changed только при реальном изменении. Если пишете из bash, делайте это атомарно:
TMP=$(mktemp) printf '%s\n' "$CONFIG_BODY" > "$TMP" chmod --reference=/etc/nginx/nginx.conf "$TMP" mv "$TMP" /etc/nginx/nginx.conf
mv в пределах одной файловой системы атомарен: читатель увидит либо старый файл, либо новый, но никогда половинчатый. Это следствие семантики rename(2): если целевой файл уже существует, он атомарно заменяется, так что нет момента, в который другой процесс, обращающийся к файлу, обнаружит его отсутствие. Важная оговорка: rename не работает между разными точками монтирования, даже если одна и та же файловая система смонтирована в обеих, поэтому временный файл создавайте в том же каталоге, что и целевой.
Конфликты версий пакетов
Установка без версии делает результат зависимым от состояния репозиториев на момент запуска:
apt-get install -y nginx
Повторный прогон тут не сломается, но однажды обновит nginx до версии, где директива из вашего конфига устарела. Фиксируйте версию и проверяйте, что она есть в репозитории:
apt list -a nginx 2>/dev/null | head apt-get install -y nginx=1.26.2-1 apt-mark hold nginx
Точную строку версии подставьте из вывода apt list -a nginx или apt-cache policy nginx: она зависит от дистрибутива и подключённых репозиториев, поэтому значение в примере сверяйте у себя. apt-mark hold помечает пакет как удерживаемый, что предотвращает его автоматическую установку, обновление или удаление, и защищает от сюрпризов при apt-get upgrade. В RHEL-совместимых дистрибутивах аналогичную роль обычно играет плагин versionlock для dnf, но это поведение мы здесь не проверяли по первоисточникам и приводим как общее указание, а не как гарантированный факт.
Для языковых зависимостей фиксатором служат lock-файлы: package-lock.json, poetry.lock, go.sum, Gemfile.lock. Установка строго по lock-файлу (npm ci, poetry install, bundle install --deployment) даёт одинаковый набор версий на всех нодах и при повторных запусках. Добавьте неинтерактивный режим: export DEBIAN_FRONTEND=noninteractive и apt-get -y убирают зависание на диалоге, которое выглядит как "скрипт повис".
Приёмы и паттерны для идемпотентности скриптов развёртывания
Проверки состояния перед изменением
Набор проверок, который нужен почти в любом сценарии:
| Что проверяем | Команда |
|---|---|
| Файл или каталог | test -f /etc/app/config.yml; test -d /var/lib/app |
| Наличие команды | command -v nginx >/dev/null |
| Установлен ли пакет | dpkg-query -W -f='${Status}' nginx | grep -q "install ok installed" (Debian), rpm -q nginx (RHEL) |
| Состояние службы | systemctl is-active --quiet nginx; systemctl is-enabled --quiet nginx |
| Занят ли порт | ss -tulpn | grep -q ':80 ' |
| Пользователь | getent passwd deploy >/dev/null |
| Точка монтирования | mountpoint -q /mnt/data |
Дальше проверка становится условием:
if ! systemctl is-active --quiet nginx; then systemctl start nginx fi systemctl enable --now nginx
systemctl start идемпотентен сам по себе: повторный запуск работающей службы ничего не меняет, поэтому условие здесь нужно для понятной логики и сообщений, а не для защиты от поломки. В Ansible те же задачи решают модули stat, package, service и file с параметром state.
Маркеры выполнения и блокировки
Маркер - файл, который появляется после успешного шага и служит пропуском для повторного запуска:
MARKER=/var/lib/deploy/migrated-$(sha256sum /srv/app/migrations.sql | cut -c1-12).done if [ ! -f "$MARKER" ]; then psql -v ON_ERROR_STOP=1 -f /srv/app/migrations.sql install -D -m 0644 /dev/null "$MARKER" fi
Две детали важнее самого приёма. Первая: хеш содержимого артефакта в имени маркера. Если миграция изменилась, хеш станет другим, и шаг выполнится снова. Без этого вы застрянете на первой версии файла. Вторая: каталог /var/lib сохраняется между перезагрузками, в отличие от /tmp и /run, которые часто смонтированы в память.
Параллельные запуски ловятся блокировкой. Короткий вариант в bash:
exec 9>/var/lock/app-deploy.lock
flock -n 9 || { echo "deploy already running"; exit 1; }
В Ansible тот же результат дают serial: 1 вместе с run_once: true на критичных задачах. Маркеры для ansible-playbook обычно не нужны: модули сами сравнивают желаемое состояние с текущим.
Транзакционные изменения и откаты
Остановка при первой ошибке и обработчик отката:
set -euo pipefail
rollback() {
echo "deploy failed, rolling back" >&2
rm -rf /opt/app/releases/"$RELEASE"
}
trap rollback ERR
Атомарное переключение релиза строят на symlink: приложение распаковывается в /opt/app/releases/2026-09-24-1, а ссылка /opt/app/current переключается двумя командами.
ln -sfn /opt/app/releases/2026-09-24-1 /opt/app/current.new mv -T /opt/app/current.new /opt/app/current
Смысл приёма опирается на поведение rename(2): если целевой путь уже существует, он атомарно заменяется, а если целевой путь ссылается на символическую ссылку, перезаписывается сама ссылка, а не файл, на который она указывает. Именно поэтому подмена symlink current выглядит для читателя мгновенной. Конкретно опцию mv -T мы по первоисточникам не сверяли: гарантию атомарности даёт системный вызов rename, а не флаг утилиты, поэтому проверяйте поведение своей версии coreutils на стенде.
Откат в такой схеме сводится к переключению ссылки на предыдущий релиз, поэтому старые каталоги удаляйте не сразу, а храните два-три последних. В Ansible тот же смысл несёт блок: block с рабочими задачами, rescue с откатом, always с завершающими действиями вроде снятия режима обслуживания.
Для баз данных берите инструменты, ведущие таблицу применённых миграций (Flyway, Liquibase, alembic): они не выполнят одну миграцию дважды. Каждую миграцию оборачивайте в транзакцию там, где это поддерживает СУБД. Kubernetes даёт откат из коробки: kubectl rollout undo deployment/app вернёт предыдущий ReplicaSet, а readinessProbe не пустит трафик в неготовые поды.
Честное ограничение: транзакции возможны не всегда. Отправленный во внешний API запрос, изменение DNS-записи или действие на удалённом сервере откатить нельзя. В таких местах выигрывает идемпотентность: повторный запуск должен доводить дело до конца, а не начинать заново.
Как проверить результат деплоя после повторного запуска
Проверка доступности сервисов и эндпоинтов
Минимальный набор команд после прогона:
systemctl is-active nginx
ss -tulpn | grep ':80 '
curl -fsS -m 5 http://127.0.0.1/health
curl -fsS -o /dev/null -w '%{http_code}' https://example.com/
Ключ -f заставляет curl вернуть ненулевой код при ответе 4xx или 5xx, поэтому команда годится для проверки внутри скрипта. Таймаут -m 5 не даёт проверке зависнуть. Для сервисов под Docker и Kubernetes используйте healthcheck в compose-файле или probe в манифесте: livenessProbe перезапускает контейнер, readinessProbe убирает под из балансировки. Логи смотрят выборочно по уровню ошибок:
journalctl -u nginx --since "10 min ago" -p err --no-pager
Метрики после деплоя полезнее разовых проверок: рост доли ответов 5xx, скачок p95-латентности или падение числа обработанных запросов видно в Prometheus и Grafana в первые минуты, ещё до жалоб пользователей. Пороговые значения подскажет история тех же метрик на предыдущих релизах.
Сравнение состояния до и после
Самый честный критерий идемпотентности: второй прогон не меняет состояние. Значит, состояние нужно зафиксировать до и после.
# перед прогоном dpkg --get-selections > /var/lib/deploy/pkg-before.txt sha256sum /etc/nginx/nginx.conf /srv/app/config.yml > /var/lib/deploy/hash-before.txt # после прогона dpkg --get-selections > /var/lib/deploy/pkg-after.txt diff -u /var/lib/deploy/pkg-before.txt /var/lib/deploy/pkg-after.txt sha256sum -c /var/lib/deploy/hash-before.txt
Пустой diff означает, что набор пакетов и содержимое конфигов не изменились. Любая разница указывает на шаг, который стоит доработать. Тот же подход работает для инфраструктурного кода: команда terraform plan -detailed-exitcode возвращает детализированный код выхода, меняя значения кодов для более гранулярной информации о содержимом плана. По документированному поведению Terraform CLI это 0 при отсутствии изменений, 1 при ошибке и 2 при обнаружении изменений, поэтому код 2 после terraform apply означает, что конфигурация дрейфует и манифест не полностью описывает реальность. Команда git diff в репозитории с манифестами и конфигами ловит расхождения ещё на этапе ревью.
Чек-лист для ревью скрипта развёртывания перед продакшеном
Пункты чек-листа с пояснениями
| Пункт | Зачем проверять |
|---|---|
| Создание пользователей и групп обёрнуто в проверку id -u, getent passwd, groupadd -f | Код возврата 9 иначе остановит деплой на ровном месте |
| Конфиги рендерятся целиком из шаблона или правятся lineinfile, без дописывания через >> | Так не появляются дубли директив и конфликтующие серверные блоки |
| Версии системных пакетов зафиксированы и закреплены (apt-mark hold, versionlock) | Защита от расхождений между нодами и неожиданных обновлений |
| Языковые зависимости ставятся строго по lock-файлу (npm ci, poetry install, bundle install --deployment) | Одинаковый набор версий на каждом прогоне и на каждой ноде |
| Установка идёт в неинтерактивном режиме (DEBIAN_FRONTEND=noninteractive, apt-get -y) | Диалог пакета не подвешивает конвейер на десятки минут |
| В начале скрипта стоит set -euo pipefail | Ошибка не остаётся незамеченной, падение видно сразу |
| Есть trap на ERR или block/rescue для отката | Частично применённые изменения убираются, а не остаются висеть |
| Файлы пишутся через временный файл и mv | Конфиг никогда не бывает половинчатым |
| Перед перезагрузкой службы конфиг проверяется (nginx -t, apachectl configtest, sshd -t) | Валидный, но сломанный конфиг не уводит прод в простой |
| Критичные шаги помечены маркерами в /var/lib, имя маркера включает хеш артефакта | Повторный запуск пропускает сделанное, а обновлённый артефакт обрабатывается заново |
| Есть flock или serial: 1 | Параллельные запуски не накладываются друг на друга |
| Коды возврата шагов логируются с меткой времени | По логам видно, на каком шаге остановился прогон |
| В конце есть проверка результата: is-active службы, curl -f по health-эндпоинту, чистый journalctl | Деплой считается успешным только при подтверждении результата |
| Скрипт прогнан дважды в тестовом окружении, включая обрыв на середине | Двойной прогон даёт пустой diff и подтверждает идемпотентность |
Как встроить чек-лист в процесс ревью
Пункты работают, когда попадают в шаблон pull request: ревьюер отмечает галочки, а не вспоминает правила по памяти. Добавьте в CI статические проверки: shellcheck для bash, ansible-lint и yamllint для плейбуков, hadolint для Dockerfile. Автоматика ловит незакавыченные переменные, отсутствие set -e и типовые ошибки до того, как их увидит человек.
Ограничьте и сам скрипт по правам: сканирование секретов в репозитории и защита конвейера от подмены артефактов разобраны в руководстве по аудиту безопасности DevOps-цепочки. Скрипт, который читает .env из корня проекта, легко превращается в источник утечки, поэтому доступ к файлу с паролями тоже попадает в ревью.
Тестирование идемпотентности: локально и в CI
Двойной прогон в тестовом окружении
Схема теста состоит из четырёх шагов: поднять чистое окружение, зафиксировать состояние, запустить скрипт дважды, сравнить снимки. Для чистоты берите контейнер с той же версией ОС, что на серверах:
docker run --rm -d --name deploy-test -v "$PWD:/srv:ro" ubuntu:24.04 sleep infinity docker exec deploy-test bash /srv/deploy.sh docker exec deploy-test bash -c 'dpkg --get-selections > /tmp/after1.txt; sha256sum /etc/nginx/nginx.conf > /tmp/hash1.txt' docker exec deploy-test bash /srv/deploy.sh docker exec deploy-test bash -c 'dpkg --get-selections > /tmp/after2.txt; sha256sum /etc/nginx/nginx.conf > /tmp/hash2.txt' docker exec deploy-test bash -c 'diff /tmp/after1.txt /tmp/after2.txt && diff /tmp/hash1.txt /tmp/hash2.txt && echo idempotent' docker rm -f deploy-test
Второй сценарий важнее первого: обрыв на середине. Запустите скрипт, убейте его через kill -9 посреди работы, затем выполните заново. Так проверяются и повторный успешный запуск, и восстановление после частично применённых изменений, а это как раз тот случай, ради которого идемпотентность и нужна.
Автоматизация проверки в CI
В пайплайне проверка выглядит как отдельный job, который прогоняет деплой дважды и падает при различиях. Пример шага для GitHub Actions:
- name: Idempotency check
run: |
./deploy.sh
sha256sum /etc/app/config.yml > /tmp/before.txt
./deploy.sh
sha256sum -c /tmp/before.txt
Для Ansible используйте check mode вместе с diff: ansible-playbook site.yml --check --diff на втором прогоне не должен показывать changed. Для Terraform критерий строже: terraform plan -detailed-exitcode вернёт 0, если изменений нет, и 2, если они есть; в CI код 2 трактуйте как провал сборки.
Проверку усиливают инструменты, которые смотрят не на действия, а на итоговое состояние сервера: Goss, Testinfra, Molecule для ролей Ansible. Они описывают ожидания декларативно (порт открыт, пакет установлен, файл на месте с нужными правами) и запускаются после деплоя в том же конвейере. Тот же каркас годится для периодического контроля: регулярные проверки по расписанию, связку с Ansible, cron и systemd timers мы разбираем в статье об автоматизации аудита и регулярных проверок.
Про ограничение dry-run держите в голове: check mode в Ansible не выполняет модули command и shell, поэтому часть шагов он просто пропустит. Двойной реальный прогон в изолированном окружении остаётся главным тестом.
Заключение: идемпотентность как стандарт деплоя в 2026 году
Идемпотентность держится на трёх опорах: проверки состояния перед каждым изменением, маркеры выполнения с хешем артефакта в имени, транзакции с откатом там, где их поддерживает система. Декларативные инструменты дают эти свойства из коробки, но bash-скрипты и cloud-init никто не отменял, и принципы в них те же.
Критерий, по которому легко проверить свою работу: второй прогон даёт код 0 и пустой diff по состоянию системы. Если diff не пустой, вы нашли шаг, который нужно доработать, и сделали это на стенде, а не в продакшене.
Практический шаг на сегодня: запустите свой деплой-скрипт дважды в контейнере с версией ОС ваших серверов, сравните sha256sum конфигов и вывод dpkg --get-selections между прогонами, затем повторите сценарий с kill -9 на середине. Прогоните по результатам чек-лист из 14 пунктов и добавьте job с двойным запуском в конвейер. После этого идемпотентность станет обычным требованием к ревью, а не признаком идеального скрипта.