Заметка

Атомарный выкат статического сайта там, где нет ни git, ни транзакций

Атомарный выкат статического сайта без git на сервере: у приёмника есть только scp и ssh. Транзакцию из этого не собрать, а выкат, который переживает обрыв и повторяется без последствий, — можно.

Сайт из трёхсот страниц лежит на обычном VPS с панелью. На сервере нет git, нет CI-агента, нет переключателя релизов. Публикация одной статьи трогает тридцать файлов: страницы на четырёх языках, картинку, индексы разделов, фиды, пять sitemap-ов. Каждый файл уезжает отдельным scp. Обрыв на пятнадцатом — рабочая ситуация, а не экзотика.

Атомарность такого набора купить нельзя. Купить можно другое свойство: «упало — повтори, и будет как надо». Разница между этими двумя целями определяет весь дизайн.

Два примитива, и оба надо знать точно

rename(2) заменяет цель атомарно: ни один процесс не увидит момента, когда файла нет. Отсюда стандартный приём — залить во временный файл, потом переименовать:

scp file host:/path/page.html.tmp.$RUN_ID
ssh "mv /path/page.html.tmp.$RUN_ID \
        /path/page.html"

Ограничение, которое ломает опрятную реализацию: rename возвращает EXDEV, если пути на разных примонтированных файловых системах — даже когда это одна ФС, смонтированная в двух точках. Общий staging-каталог вроде /tmp/upload выглядит чище, чем временный файл рядом с целевым, и молча превращает переименование в копирование. Копирование не атомарно.

mkdir(2) даёт вторую половину: каталог либо создан вами, либо уже существует (EEXIST). Атомарный test-and-set и единственный mutex, доступный через голый ssh без установки чего-либо на приёмнике.

Взаимное исключение здесь не формальность. Состояние выката хранится в файле, который читают, меняют и записывают обратно. Два одновременных прогона дают потерю обновления, и выглядит она безобидно: файлы первого выката остаются на месте и доступны по своим адресам. Пропадает он только из индексов и sitemap — и не вернётся, потому что следующий прогон возьмёт за основу уже испорченное состояние.

Атомарен файл, а не набор

Тридцать последовательных заливок в одну операцию не сложить. Первое из двух решений — выкатывать в порядке зависимостей, чтобы ничто не появлялось раньше того, на что оно ссылается: сначала ассеты, затем страницы, затем индексы и фиды, и только потом sitemap.

Обрыв в середине даёт недо-выкаченный сайт вместо сайта со ссылками в никуда. Проверять это надо списком адресов, а не глазами: если картинку не включить в проверку, легко получить страницу с кодом 200, у которой og:image отдаёт 404. У меня такой дефект прожил на четырёх страницах разделов ровно потому, что проверка смотрела на документы и не смотрела на ассеты.

Запись состояния пишется последней

Второе решение звучит как «манифест — это commit marker», и очевидная трактовка этой фразы неверна.

Неверно: до записи состояния ничего не видно. Индексы, хабы и sitemap рендерятся из локально изменённого состояния и уезжают на сервер раньше него. К моменту последней стадии живой сайт уже показывает новый материал целиком, а запись на сервере всё ещё старая.

Верно: запись состояния — durable-факт «транзакция дошла до конца» и вход для следующего прогона. Работающий инвариант формулируется так:

Запись никогда не опережает сайт. Сайт опережать запись может — это восстановимо. Обратное — нет.

Разберём обратный порядок. Запись закоммичена первой, заливка упала. Следующий прогон видит объект в записи и откажется выкатывать его как новый — остаётся режим обновления, который предполагает, что предыдущая версия жива. Любой следующий рендер индексов возьмёт объект из записи и поставит ссылку на страницу, которой нет. Sitemap объявит поисковикам адрес, отдающий 404.

При правильном порядке падение оставляет систему в состоянии «не выкачено»: объекта в записи нет, повторный прогон идёт обычным путём и переписывает всё заново. Там, где у цели есть настоящий примитив переключения, эту же задачу решают канареечные выкаты и progressive delivery — здесь их роль играет порядок операций.

Окна отказа стоит выписать

Падение наСайтЗаписьВосстановление
заливка страницстраницы живы, индексы на них не ссылаютсястараяповторить прогон как новый
заливка индексовиндексы ссылаются, sitemap старыйстараято же
проверкавизуально консистентенстараято же
после коммитаконсистентенноваяничего

Сообщение об ошибке должно называть состояние прямо. «Страницы могут быть живы, но не проиндексированы» полезнее, чем «deploy failed»: оператор читает его в худший момент и не должен восстанавливать модель отказа по исходникам. Это тот же вопрос, что и определение «восстановлено» в инциденте — состояние надо назвать, иначе каждый достроит его сам.

Проверка идёт до коммита

Все затронутые адреса проверяются на 200 перед записью состояния. Не 200 хоть один — коммита нет, система остаётся в состоянии «не выкачено». Порядок наоборот превращает гейт в отчёт о случившемся: дефекты те же, чинить уже нечего.

Код ответа при этом подтверждает доступность, а не корректность. Проверка вообще способна давать уверенность там, где её нет, — зелёный заголовок CSP тому пример.

Почему не симлинк и не rsync

Переключение симлинка требует контроля над раскладкой каталогов, которого на панельном хостинге нет. Заодно всплывает деталь: привычная команда делает не то, чего от неё ждут. Замер strace на uutils coreutils 0.8.0:

$ ln -sfn <new> current
unlink("current")           = 0
symlink("<new>", "current") = 0

Две операции, между ними путь не существует. Запрос, попавший в это окно, получит 404. Документация GNU coreutils описывает -f буквально как удаление существующей цели и атомарности не обещает. Атомарный вариант делает одно переименование:

ln -s <new> current.tmp \
  && mv -T current.tmp current

rsync --delay-updates — правильный инструмент, когда выкат означает синхронизацию каталога целиком. Man-страница формулирует честно: файлы копятся во временном каталоге и в конце переименовываются «в быстрой последовательности», что делает обновление «a little more atomic». Не транзакция, а более короткое окно.

Что такой дизайн не даёт

Автоматического отката нет: залитые файлы никто не возвращает, откат делается руками по бэкапам. kill -9 оставляет лок висеть — освобождение живёт в блоке очистки процесса, TTL у лока нет. И rename(2) гарантирует, что промежуточного состояния никто не увидит, но не гарантирует, что переименование доживёт до диска после пропадания питания: для этого нужен fsync каталога. Атомарность и долговечность — разные свойства.

Ни одно из этих ограничений не мешает работать. Мешает делать вид, что их нет.

© 2026 axyi.ru · CC BY 4.0