Выкат: образ, миграции, домен и первый инцидент
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Проект отвечает на запросы у вас на машине: договор написан, домен собран, вход, владение и роли работают. Осталось одно: чтобы на него можно было зайти по адресу. Новых механизмов здесь не будет — образ, стек, джоба миграций и прокси разобраны главой о выкладке, и проект только складывает их вокруг себя. Зато будет то, чего до первого выката не бывает: файл, который не доехал, потому что попал под правило исключений; конфигурация, которую никто не проверил; проверка выката, проходящая при лежащей базе; и журнал, в котором половина отказов — вовсе не беда.
Выкат — это не одна команда, а четыре части, и у каждой свой способ подвести. Образ решает, что из монорепозитория вообще попадёт в контейнер. Одноразовая джоба решает, когда схема базы догонит код. Стек решает, кто кого ждёт при старте. Прокси решает, по какому адресу и с каким сертификатом всё это видно снаружи. В проекте ни одна из этих частей не меняется: меняются имена сервисов и список переменных.
| Часть выпуска | Что в ней у проекта | Где разобрана |
|---|---|---|
| Образ приложения | срез монорепозитория с вашим пакетом и его зависимостями, без dev-зависимостей | Образ из монорепо: слои как кеш |
| Джоба миграций | те же файлы шагов, что применяет ваш раннер, и свой образ: инструмент миграций лежит в dev-зависимостях | Кто применяет миграцию |
| Стек и порядок старта | база, джоба, приложение, прокси — и условия, по которым приложение ждёт джобу | Запущен, готов, отработал |
| Дверь наружу | один опубликованный процесс, сертификат и граница между сайтом и процедурами по путям | Одна дверь, граница по путям |
Пятая часть в этом списке не значится, хотя без неё ничего не соберётся: сами исходники. В выкате учебника образы собираются прямо на сервере, поэтому перед сборкой туда уезжает рабочая копия репозитория — обычным копированием по ssh, со списком исключений. Список этот выглядит скучно ровно до того дня, когда из-за него падает выкат.
Правил в нём два вида: запреты и возвраты, и работают они не как фильтр, а как таблица маршрутизации — путь проверяется правилами по порядку, и решает первое совпавшее. Образец без косой черты сверяется с любым звеном пути, а не только с последним: запрет .env отсекает файл на любой глубине, и это именно то, чего от него хотят — локальные .env с ключами разработчика на сервер не едут. Беда в том, что несколько файлов .env лежат в самом репозитории: это окружение записанных прогонов, без которого не собирается сайт.
Прогноз: какие из шести путей доедут до сервера при первом порядке правил и какие — при втором?
Порядок решает всё. Пока возвраты стоят после запретов, до них не доходит очередь: путь уже совпал с .env и остался дома — вместе с ним остаётся и .env.prod.example, шаблон, по которому на сервере заполняют боевое окружение. Стоит поднять возвраты выше запретов — и оба файла едут, а личный apps/api/.env по-прежнему остаётся: его путь не совпал ни с одним возвратом. Последняя строка показывает границу точности: возврат /.env*.example привязан к корню передачи, и такой же файл в глубине дерева он не возвращает.
#!/bin/sh
# Тот же rsync, что в deploy/deploy.sh, только вхолостую (-n) и в локальный
# каталог: спрашиваем не «скопируй», а «что бы уехало».
set -eu
repo=${1:-.}
probe() {
title=$1
shift
list=$(rsync -azn --delete --out-format='%n' \
--exclude '.git' --exclude 'node_modules' --exclude '.next' --exclude 'dist' \
--exclude '.turbo' --exclude '.expo' \
"$@" \
--exclude '.env' --exclude '.env.*' \
--exclude 'deploy/backups' \
"$repo/" /tmp/jsp-deploy-dry/)
echo "$title"
for file in .env.prod.example \
packages/content/runs/boot-fails-fast/.env \
packages/content/runs/boot-config-accepted/.env \
.env
do
if printf '%s\n' "$list" | grep -qx "$file"; then
echo " едет: $file"
else
echo " остался: $file"
fi
done
}
probe 'правила выката до 15.09.2026:'
probe 'правила выката после 15.09.2026:' \
--include 'packages/content/runs/*/.env' \
--include '/.env*.example'правила выката до 15.09.2026: остался: .env.prod.example остался: packages/content/runs/boot-fails-fast/.env остался: packages/content/runs/boot-config-accepted/.env остался: .env правила выката после 15.09.2026: едет: .env.prod.example едет: packages/content/runs/boot-fails-fast/.env едет: packages/content/runs/boot-config-accepted/.env остался: .env
Настоящее копирование отвечает так же: до правки возвратов в списке не было вовсе, а это тот же исход, что и у возвратов, стоящих позади запретов. Важно, что возвраты повторяют не «здравый смысл», а то, что разрешает сам репозиторий: файлы окружения прогонов открыты в нём ровно на один уровень вложенности и без точечных расширений. Шире — значит возить на сервер то, чего в репозитории нет и быть не должно.
Копирование с удалением лишнего на той стороне — это ещё и способ стереть данные. Каталог с копиями базы стоит в запретах именно поэтому: он лежит внутри дерева, в репозитории его нет, и без запрета первый же выкат вычистил бы его с сервера.
Процесс, которому не хватает переменной, обычно узнаёт об этом не на старте, а через неделю — на первом письме или первом входе. Лекарство вы уже написали: разобрать окружение один раз и отдать либо готовую конфигурацию, либо список всего, что чинить. В проекте у этого приёма появляется вторая работа.
Переменных у проекта немного, но перечислены они в трёх местах сразу: в проверке старта, в шаблоне окружения для сервера и в описании сервиса внутри стека. Три списка расходятся всегда — ровно как разошлись бы схема входа и договор процедур, если бы их было два. Поэтому список пишется значением, а проверка старта из него выводится, а не повторяет его. Шаблон и описание сервиса из кода не вывести, зато с этим списком их можно сверять — и расхождение всплывёт раньше выката, а не на боевой машине.
Прогноз: сколько бед насчитает проверка в каждом из четырёх окружений и какие?
Первое окружение — чистая машина, где не задано ничего; второе — знакомая картина «половина доехала»: пробелы вместо секрета выглядят как значение, но значением не являются. Третье интереснее: секрет из черновика и порт, который кто-то вписал словом. Обе беды печатаются вместе — иначе сервер поднимают по одной переменной за перезапуск, а каждый перезапуск на боевой машине стоит минуты простоя.
Есть и вторая проверка, которую стоит написать в тот же день. Форма значения ничего не говорит о том, куда оно ведёт: адрес базы может быть разобран без единого замечания и указывать на localhost — то есть, внутри контейнера, на сам контейнер. Эта опечатка переживает все проверки формы и всплывает первым же запросом.
Такая проверка не отказывает старту, а предупреждает: на стенде разработчика localhost совершенно законен, и отказ по нему сломал бы локальный запуск. В скрипте выката учебника ровно это и стоит — адрес почтового сервера сверяется со списком домашних имён, и выкат печатает предупреждение, потому что почта ломается тихо: на просьбу восстановить пароль API отвечает «ок» в любом случае, а письмо при этом может никуда не уходить.
Проверка формы доказывает форму. Достижимость доказывает только запрос: соединение с базой, письмо самому себе, запрос к хранилищу файлов. Ставить это на старт дорого — но одна такая проверка в смоуке после выката стоит дёшево.
Дальше всё это надо запустить. Прогон ниже делает это трижды на настоящем PostgreSQL и без docker: сначала с неполным окружением, потом с полным, но на пустой базе, потом — той же командой после миграции. Приложение в нём урезано до двух процедур договора и репозитория проекта на Drizzle: всё остальное к выкату отношения не имеет.
Прогноз: что ответит приложение во втором старте — и что попадёт при этом в журнал?
/* * Договор с сервером: что приложение обязано узнать до первого запроса. * Читается один раз при старте — и либо разбирается целиком, либо старт * не состоится вовсе. */ export type Config = { databaseUrl: string; secret: string; port: number }; const непусто = (value: string | undefined): string | null => { const trimmed = (value ?? '').trim(); return trimmed === '' ? null : trimmed; }; export function readConfig(env: Record<string, string | undefined>): Config { const беды: string[] = []; const databaseUrl = непусто(env.DATABASE_URL); if (databaseUrl === null) беды.push('DATABASE_URL: адрес базы не задан'); const secret = непусто(env.SECRET); if (secret === null) беды.push('SECRET: секрет подписи не задан'); else if (secret.length < 32) беды.push(`SECRET: короче 32 символов (${secret.length})`); const port = Number(env.PORT ?? '0'); if (!Number.isInteger(port)) беды.push(`PORT: ожидалось целое число, пришло ${env.PORT}`); // все беды сразу: иначе сервер поднимают по одной переменной за перезапуск if (беды.length > 0) throw new Error(беды.join('; ')); return { databaseUrl: databaseUrl!, secret: secret!, port }; }
база: postgres://postgres@127.0.0.1:<порт-базы>/app
--- старт 1: переменной нет
SECRET: секрет подписи не задан
--- старт 2: конфигурация полная, схемы в базе ещё нет
адрес: http://127.0.0.1:<порт>
notes.list: 500 {"code":"INTERNAL"}
в журнале: notes.list 42P01 relation "notes" does not exist
--- миграция
drizzle-kit generate --name init: выполнено
drizzle-kit migrate: выполнено
--- старт 3: та же команда, та же конфигурация
адрес: http://127.0.0.1:<порт>
notes.list: 200 Идеи на лето | Хлеб и молоко
notes.create без токена: 401 {"code":"NOT_AUTHENTICATED"}
notes.mine (в договоре нет): 404 {"code":"NO_SUCH_ROUTE"}
в журнале: 0 строкПервый старт не состоялся вовсе, и это лучший из возможных исходов: процесс не занял порт, не открыл соединение и назвал причину одной строкой. Читатель этой строки — не программист, а тот, кто выкатывает, и поэтому в ней имя переменной, а не стек.
Второй старт — тот самый случай, ради которого пишут журналы. Окружение полное, процесс поднялся, порт слушает, адрес отвечает — и отвечает пятисоткой с кодом INTERNAL. Наружу больше ничего и не должно уезжать: чужое исключение клиенту не выносится. Вся разница между «непонятно» и «понятно» — в журнальной строке рядом: в ней код базы и имя отношения, которого нет.
Третий старт отличается от второго ровно одним: между ними прошла миграция. Команда та же, конфигурация та же, образ был бы тот же — сменилось состояние базы, а его помнит сама база, а не репозиторий. Заодно видно, что закрытая процедура без токена отвечает 401, а имени, которого нет в договоре, — 404: договор остаётся единственным списком процедур и после выката.
Отсюда и порядок в стеке. Джобу миграций запускает та же команда, что поднимает всё остальное, а приложение объявляет, что ждёт её успешного завершения, — не «запуска», а именно завершения с нулём. Без этого условия выкат выглядит удачным, а второй старт из прогона случается на боевой машине: приложение поднялось раньше схемы и честно отвечает пятисотками, пока кто-нибудь не догадается посмотреть, отработала ли джоба.
Здесь нет docker: база — временный кластер, поднятый рядом, а миграции запускает сам пример, а не отдельный контейнер. Приложение слушает случайный свободный порт и отвечает на сыром сервере из главы о своём HTTP, а не под каркасом.
Совпадает то, ради чего прогон снят: порядок шагов и то, как выглядит каждый промах. Конфигурация читается до соединения, соединение — до первого запроса, схема — отдельным шагом, а не побочным действием старта.
Проверка конфигурации в файлах прогона набрана короче справочника из раздела выше: переменных три, и таблица правил под них — лишняя работа. Справочник окупается на десятке переменных, когда список начинают переписывать в шаблон окружения и в описание сервиса.
Стек поднят, команда вернулась без ошибок. Теперь надо решить, выкатилось оно или нет, — и решить это одним-двумя запросами, потому что проверка, которая идёт десять минут, не выполняется никогда. Вопрос в том, что именно доказывает каждый ответ.
Ниже проект отвечает на четыре запроса, и рядом с каждым ответом печатается число походов в таблицы — то, чего в настоящем ответе не видно, а знать надо. Прогноз: у каких запросов это число окажется нулём?
Три ответа из четырёх получены, не заглянув в базу ни разу. Имя, которого нет в договоре, отсеивает сам договор. Закрытая процедура без токена упирается в уровень доступа. Чужая подпись не проходит проверку ключом — и до перепроверки личности по строке в таблице дело не доходит. Все три ответа — доказательство того, что прокси довёл запрос, процесс жив и транспорт работает. И ни одного слова о базе.
В скрипте выката учебника проверка устроена именно так, и разобрана она главой о выкладке: две страницы отвечают 200, закрытая процедура без токена — 401. Здесь важно, до чего доходит каждый из трёх запросов. Первые два спрашивают статику и о приложении не говорят ничего — всё, что проверка о нём знает, она узнаёт из третьего ответа.
Процедура здесь взята существующая, и это не придирка. Пока в проверке стояло имя процедуры, которой в приложении нет, ответом был 404 — и выкат падал каждый раз, хотя выкатывалось всё правильно. Ошибка ровно та же, что у первого запроса в примере выше: имя, которого нет в договоре, отвечает одинаково и на живом приложении, и на мёртвом.
Проверка, которая всегда красная, выключается через неделю. Проверка, которая всегда зелёная, живёт годами и не ловит ничего. Хорошая проверка обязана краснеть ровно тогда, когда сломано то, что она называет.
Рано или поздно в журнале проекта появится строка, ради которой придётся всё бросить. Собирать по ней картину вы умеете: сквозной номер сшивает строки одного запроса, а разбор падения идёт тремя вопросами по порядку — что сломано, с какого момента, что менялось. У проекта к этому добавляется одна вещь, которой в общем случае нет.
Каждый отказ проекта приезжает в журнал с доменным кодом, и код сразу говорит, чей это отказ. NOT_AUTHENTICATED, NO_SUCH_ROUTE, NOT_FOUND, TOO_MANY_REQUESTS — это не инцидент, а работающее приложение: договор ответил сам, ровно так, как написано. Разбирать надо строки с INTERNAL: это единственный код, который значит «мы не знаем, что случилось».
Прогноз: сколько в этом журнале отказов клиенту, а сколько наших?
Строки сведены в три кучки, и разбирать надо одну. Причина наших отказов стоит не в них самих: ответ несёт код INTERNAL и больше ничего, а настоящая беда лежит строкой рядом — у того же запроса, тем же номером. Это и есть цена перевода отказа в ответ: клиент не узнаёт ничего лишнего, зато в журнал обязаны попасть оба уровня.
Последняя строка разбора — та же, что в любом инциденте: ближайший старт до первой ошибки и версия в нём. Что с этой парой делать, вы уже разбирали, но у первого выката проекта она бессильна: изменилось всё разом, и сужать поиск нечем. Версия в строке старта начинает приносить пользу со второго выката, а строку эту надо писать с первого.
И самое неприятное совпадение этой статьи: ответы, которыми проверяют выкат, в этом журнале присутствуют полностью. 401 на закрытую процедуру, 404 на неизвестное имя — всё на месте, всё правильное. Проверка из одних только таких ответов показала бы зелёное, пока приложение отвечало пятисотками на всё, что ходит в базу.
Ответ и его причина — две разные строки с одним номером запроса, и код стоит только в первой. Журнал, отфильтрованный по коду, оставляет одни ответы: причина в такую выборку не попадает вовсе.
- Выкат проекта состоит из четырёх частей главы 13 и пятой, которой в ней нет: копирования исходников. Правила копирования проверяются по порядку, и возврат, стоящий после запрета, не работает вовсе.
- Список переменных проекта — такое же значение, как договор процедур: из него выводится проверка старта, и по нему же пишут шаблон окружения и описание сервиса. Три отдельных списка расходятся всегда.
- Проверка конфигурации доказывает форму значения, а не достижимость адреса. Хост, который есть только на машине разработчика, ловится списком имён — и это предупреждение, а не отказ.
- Джоба миграций обязана отработать до приложения, а не рядом с ним: приложение, поднявшееся раньше схемы, отвечает пятисотками, а сам выкат при этом выглядит удачным.
- Ответы 401 и 404 доказывают, что запрос дошёл и транспорт работает, — и ничего не говорят о базе: они складываются до первого похода в таблицу. Проверка выката обязана содержать вопрос, на который без базы не ответить.
- В журнале проекта доменный код значит «приложение отработало по правилам», а INTERNAL — «разбирать здесь». Причина строки с INTERNAL лежит не в ней, а рядом, у того же номера запроса.
1/4В списке правил копирования стоят запрет .env и возврат packages/content/runs/*/.env. Когда файл окружения прогона уедет на сервер?
2/4Проверка конфигурации на старте разобрала DATABASE_URL без замечаний. Что из этого следует?
3/4После выката закрытая процедура без токена ответила 401. Что доказано?
4/4В журнале проекта за час 40 отказов: 36 с доменными кодами и 4 с кодом INTERNAL. С чего начинать разбор?
Проверка выката, которая всегда зелёная
Смоук написали в день выката: одна процедура, одна проверка. В примере ниже база проекта сломана так же, как ломает её невыполненная миграция, — и смоук этого не замечает. Добавьте в него вопрос, на который нельзя ответить без базы, и учтите ответ в итоге.
Спрашивать надо то, что ходит в таблицу. Гостевой список подходит лучше всего: он открыт без токена, значит, проверке не нужен ни пароль, ни выдача токена, — и при этом он читает строки.
Теперь смоук краснеет, а в выводе появляется и журнальная строка с настоящей причиной: перевод отказа в ответ прячет её от клиента, но не от журнала.
Сводка, которая сложила две беды в одну
Первые минуты после выката: наружу у всех отказов один код, и сводка честно его повторяет — одна беда, одним куском. На самом деле бед две, и лечатся они по-разному. Сделайте так, чтобы сводка их разделила, а окно у каждой считалось своё.
Первого слова причины достаточно: код базы и имя системной ошибки стоят в её начале, а дальше идут подробности, у каждой строки свои.
Группировать надо по причине, а не по коду ответа: код наружу у всех один — в этом и был смысл перевода отказа в ответ. Ключ группы — первое слово причины.
Выходит два окна вместо одного: секундное в самом начале и другое — тремя минутами позже. Первое — база не отвечала вовсе, второе — база отвечала, но нужного отношения в ней не было. Это разные аварии, и «перезапустили» вылечит только одну из них.
Секрет со значением по умолчанию
Умолчания у переменных поставили, чтобы локально ничего не настраивать. До контейнера доехала одна переменная из двух, и проверка старта не нашла ни одной беды. Посмотрите, чем это кончается, и решите, у каких переменных умолчание допустимо, а у каких нет.
Умолчание у адреса базы безобидно: промах виден первым же запросом, и никому от него хуже не станет. Умолчание у секрета подписи — это ключ, опубликованный вместе с исходниками: токен с любой ролью выпишет каждый, кто их открывал. Значит, в списке умолчаний секрета быть не должно, а незаданная переменная обязана становиться бедой.
После правки старт не состоится и назовёт переменную — то есть беда найдётся на выкате, а не через неделю и не чужими руками. Проверять длину секрета всё равно стоит: заданный, но короткий секрет проверку умолчаний проходит.