Сайт из трёхсот страниц лежит на обычном 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 каталога. Атомарность и долговечность — разные свойства.
Ни одно из этих ограничений не мешает работать. Мешает делать вид, что их нет.