На первый взгляд проблема выглядела парадоксально.
Новая версия находилась на VPS. Новый код действительно был в production-каталоге. Backend отвечал. Новый release был активным.
Но браузер мог продолжать загружать предыдущую версию JavaScript и CSS.
Причём проблема проявлялась особенно неприятно именно потому, что Ritm — PWA. Между исходным файлом на сервере и кодом, который в итоге выполняется в браузере, находилось сразу несколько слоёв кеширования.
В релизе 3.0.2 мы перестроили эту часть delivery-механизма. В итоге задача оказалась хорошим напоминанием о том, что успешный deployment и фактически полученный пользователем release — это не одно и то же.
Что мы меняли
В приложении перерабатывались два связанных раздела — «План» и «Дневник».
План должен был перестать превращаться в длинную страницу после нескольких лет использования. Появились режимы:
День
Неделя
Месяц
Все задачиДля общей истории была добавлена настоящая пагинация вместо бесконечного накопления DOM.
Дневник получил месячную историю и связь с задачами конкретного дня.
Это потребовало решить ещё одну интересную проблему: задача является изменяемым объектом, а история дня — нет.
Если пользователь вчера планировал задачу, а сегодня перенёс её на завтра, вчерашний дневник не должен внезапно забыть, что задача когда-то существовала.
Поэтому в проекте появился отдельный слой исторических snapshots задач.
Но после реализации новой функциональности обнаружилась другая задача: необходимо было гарантировать, что новый frontend действительно доходит до пользователя после deployment.
Как был устроен production
Проект использует достаточно простую, но полноценную production-схему.
Backend работает на Node.js.
В production одновременно запускаются два API-процесса:
API :4173
API :4174Перед ними находится Nginx.
Кроме API работает отдельный background worker.
Основные данные находятся в PostgreSQL, runtime-состояние и сессии — в Redis, пользовательские объекты — в S3-compatible storage.
Frontend при этом не собирается тяжёлым SPA-фреймворком. Он состоит из нативных ES-модулей:
/js/app.mjs
/js/store.mjs
/js/views.mjs
/js/views/plan.mjs
/js/views/journal.mjs
/js/domain/task-history.mjs
...Для PWA используется Service Worker.
Именно сочетание стабильных имён JavaScript-файлов, Nginx и Service Worker оказалось важным.
Где возникла проблема
До исправления Nginx мог отдавать JavaScript, MJS и CSS со сроком свежести кеша до одного часа.
Условно ситуация выглядела так:
Выполнили deployment
↓
/opt/ritm/current
уже указывает на новый release
↓
Nginx действительно видит новые файлы
↓
но браузер считает старый app.mjs свежим
↓
пользователь получает старый интерфейсСамое неприятное здесь — сервер может быть полностью исправен.
Можно зайти по SSH и увидеть новый файл.
Можно проверить symlink.
Можно увидеть активные новые процессы.
Можно получить успешный ответ:
/api/readyИ всё равно пользователь будет работать со старым JavaScript.
Почему исправление заголовков Node.js не решало проблему
В подобных ситуациях первым желанием бывает изменить cache headers, которые выставляет приложение.
Но здесь находилась важная архитектурная деталь.
Статические файлы обслуживал непосредственно Nginx:
/opt/ritm/current/publicТо есть запрос к:
/js/views/plan.mjsвообще мог не доходить до Node.js.
Следовательно, правильные cache headers внутри Node-сервера не решали проблему для ресурсов, которые Nginx отдавал самостоятельно.
Это хороший пример ошибки на границе компонентов.
Каждый компонент по отдельности может выглядеть правильно, но реальное поведение определяется тем, кто фактически обслуживает запрос.
Почему PWA делает ситуацию ещё интереснее
У Ritm есть Service Worker.
Он хранит набор основных ресурсов приложения:
/app.html
/assets/app.css
/js/app.mjs
/js/store.mjs
/js/views/plan.mjs
/js/views/journal.mjs
...Для версии 3.0.2 cache получил отдельный идентификатор:
const CACHE =
'ritm-v3.0.2-plan-journal-cachefix-20260913';При активации новой версии старые ritm-* cache удаляются.
Это уже защищает от части проблем.
Кроме того, стратегия для основных ресурсов фактически построена как network-first:
fetch(event.request)
.then(response => {
// успешный ответ обновляет Cache Storage
})
.catch(() => caches.match(event.request))То есть при наличии сети Service Worker сначала пытается получить свежий ресурс, а cache используется как fallback.
Но здесь есть тонкость.
fetch() внутри Service Worker всё равно существует внутри браузерного HTTP-стека.
Если HTTP-кеш считает файл достаточно свежим, само наличие network-first Service Worker ещё не означает, что физически будет скачана новая копия файла с VPS.
Поэтому необходимо было правильно настроить оба уровня.
Решение №1: executable frontend должен перепроверяться
Для файлов со стабильными именами мы убрали часовую свежесть.
В production Nginx появились отдельные правила:
location = /app.html {
try_files $uri @ritm_node;
expires -1;
}
location = /runtime-config.js {
try_files $uri @ritm_node;
expires -1;
}
location = /sw.js {
try_files $uri @ritm_node;
expires -1;
}
location ~* \.(?:css|js|mjs)$ {
try_files $uri @ritm_node;
expires -1;
}Но мы не стали полностью отключать нормальное кеширование всей статики.
Изображения, favicon и manifest могут спокойно жить с более длительным cache policy:
location ~* \.(?:svg|png|ico|webmanifest)$ {
try_files $uri @ritm_node;
expires 1h;
}Здесь важно различать две категории ресурсов.
Иконка приложения редко меняет поведение программы.
Старый plan.mjs — меняет.
Поэтому универсальное правило «кешировать всю статику одинаково» оказалось слишком грубым.
Решение №2: entry points получили версию release
В app.html основные entry-файлы версии получили cache-busting параметр:
<link
rel="stylesheet"
href="/assets/app.css?v=3.0.2-plan-journal"
>и:
<script
type="module"
src="/js/app.mjs?v=3.0.2-plan-journal">
</script>При изменении release браузер получает уже другой URL.
Это особенно полезно для главной точки входа приложения.
При этом внутренние ES-модули всё ещё имеют стабильные имена. Поэтому правильная политика revalidation для .mjs остаётся необходимой.
Мы не стали пытаться решить проблему только query-параметром.
Использовались оба механизма:
release version в entry URL
+
revalidation для стабильных JS/MJS/CSSРешение №3: Service Worker также имеет release identity
Service Worker получил новый cache ID.
Во время activate он удаляет предыдущие caches приложения:
caches.keys().then(keys =>
Promise.all(
keys
.filter(
key =>
key.startsWith('ritm-') &&
key !== CACHE
)
.map(key => caches.delete(key))
)
)После этого вызывается:
self.clients.claim()Новая версия начинает управлять клиентами, а старый Cache Storage не продолжает бесконечно жить рядом.
И ещё одна небольшая деталь: запросы с query string Service Worker намеренно не кеширует этим механизмом.
Таким образом versioned entry module не попадает в конфликт со старой записью CORE cache.
Но исправить cache policy недостаточно
Одна из вещей, которые мы в итоге зафиксировали в release-процессе: недостаточно доказать, что файл изменился на VPS.
Нужно доказать, что новый файл возвращает публичный URL.
Это разные проверки.
Плохая проверка:
cat /opt/ritm/current/public/js/views/plan.mjsОна доказывает только то, что на диске находится новый файл.
Гораздо полезнее:
curl -fsS \
"https://ritm-on.ru/js/views/plan.mjs?v=$STAMP"и проверить конкретный marker нового release.
Таким же способом проверяется journal.mjs и сам app.html.
Это проходит уже через реальную production-цепочку:
Internet
↓
DNS
↓
HTTPS
↓
Nginx
↓
production public root
↓
конкретный releaseИменно такой тест отвечает на вопрос:
«Что сервер действительно отдаёт пользователю?»
Мы отдельно проверяем cache headers
После deployment недостаточно получить HTTP 200.
Нужно посмотреть заголовки:
curl -sI \
"https://ritm-on.ru/js/views/plan.mjs?v=$STAMP"Нас интересует не только статус, но и:
Cache-Control
ExpiresИначе можно выполнить правильный deployment и тут же снова создать ту же проблему конфигурацией proxy.
Deployment мы не делаем поверх текущего каталога
Ещё один важный элемент схемы — release не копируется напрямую поверх:
/opt/ritm/currentСначала создаётся новый timestamped каталог:
/opt/ritm/releases/3.0.2-<timestamp>Внутри него выполняются проверки.
Затем новый release переключается атомарно:
current.new
↓
mv
↓
currentПредыдущая версия сохраняется как:
previousЕсли deployment после переключения падает, предусмотрен rollback на предыдущий release.
Так мы избегаем состояния, когда половина файлов уже от новой версии, а половина ещё от предыдущей.
Это особенно важно для ES Modules: смешение несовместимых версий модулей может создавать ошибки, которые очень трудно воспроизвести.
API-процессы тоже обновляются не одновременно
После переключения release сначала перезапускается первый API-процесс.
Затем deployment ожидает успешный:
http://127.0.0.1:4173/api/readyТолько после этого обновляется второй:
4174И затем worker.
Readiness здесь важнее обычного process alive.
/api/ready проверяет не только существование Node-процесса, но и production dependencies:
PostgreSQL
Redis
S3То есть новый API считается готовым не тогда, когда процесс просто запустился, а когда он способен работать со своей инфраструктурой.
Почему мы не заменяем Nginx-конфиг целиком
Во время исправления кеширования можно было просто скопировать новый nginx.conf поверх production-файла.
Мы специально этого не делаем.
Live-конфигурация может содержать:
TLS
Certbot
security headers
proxy settings
server-specific pathsПоэтому безопасный путь — изменить только необходимые cache locations.
Перед reload:
nginx -tИ только при успешной проверке:
systemctl reload nginxИначе исправление кеша вполне способно закончиться поломанным HTTPS.
Это ещё один общий урок production-разработки: исправлять нужно минимальную поверхность системы, необходимую для конкретной проблемы.
Как мы превратили исправление в regression test
Самое полезное после устранения подобной ошибки — сделать так, чтобы она не вернулась через три релиза.
В проекте появился отдельный regression check для План/Дневник.
Тест проверяет не только функциональность интерфейса.
Он проверяет наличие release version в app.html:
app.mjs?v=3.0.2-plan-journalи анализирует Nginx-конфигурацию, убеждаясь, что:
.js
.mjs
.cssиспользуют revalidation, а app.html не получает старую часовую cache policy.
То есть production-инцидент превратился в автоматизированный архитектурный инвариант.
Для нас это важнее самого исправления.
Пока проблема хранится только в памяти разработчика, она может вернуться.
Когда она становится тестом, следующий разработчик уже не обязан знать всю историю релиза, чтобы случайно не повторить ошибку.
Что показал этот случай
До этой задачи мы могли рассматривать deployment примерно так:
Новый код
↓
сервер обновлён
↓
deployment завершёнПосле — схема стала длиннее:
Новый код
↓
tests
↓
production preflight
↓
atomic release switch
↓
readiness
↓
Nginx
↓
HTTP cache
↓
Service Worker
↓
браузер
↓
пользователь действительно получил новую версиюИ только последняя точка является настоящим результатом.
Не весь кеш плохой
После подобных проблем легко принять противоположное решение:
Отключим кеширование вообще.
Мы не считаем это правильным.
Кеширование остаётся важным механизмом производительности и PWA.
Проблема была не в существовании кеша.
Проблема заключалась в том, что стабильные имена исполняемых файлов имели политику, которая не соответствовала механизму deployment.
Статические изображения можно продолжать кешировать.
Offline-копия PWA тоже нужна.
Но исполняемый frontend должен иметь понятную стратегию обновления.
Что бы мы проектировали сразу в следующем PWA
После этого кейса мы рассматриваем release delivery как отдельную часть архитектуры приложения.
Не как последний этап после разработки.
Для стабильных имён ресурсов заранее определяется revalidation.
Entry points получают release identity.
Service Worker имеет версию cache.
Production-проверка идёт через публичный URL.
Deployment сохраняет предыдущую рабочую версию.
Readiness проверяет инфраструктуру до завершения переключения.
А исправленные production-проблемы превращаются в regression tests.
Такой подход требует немного больше работы в начале.
Но он значительно дешевле ситуации, когда после каждого deployment приходится отвечать на вопрос:
«Почему сервер уже обновился, а у меня всё ещё старая версия?»
Вместо вывода
У этой проблемы не было сложного алгоритма.
Мы не переписывали backend и не меняли базу данных.
Причиной стала граница нескольких вполне обычных механизмов:
Nginx
HTTP cache
Service Worker
ES Modules
PWA
release deploymentКаждый из них по отдельности работал ожидаемо.
Ошибка появилась в их взаимодействии.
Именно поэтому production-разработка часто отличается от разработки функции.
Функция может быть полностью корректной.
Но пользователь получает её только после прохождения всей цепочки доставки.
С тех пор для нас успешный deployment означает не только:
новый код находится на сервере.
Он означает:
публичный production URL действительно отдаёт новую версию, её зависимости готовы, браузеру дана корректная политика обновления, а предыдущий release можно безопасно восстановить.
На практике именно это и есть доставка программного продукта до пользователя.