Миграции при выкате: одноразовая джоба и обратная совместимость
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Джоба миграций — самый скучный сервис стека и самое частое место, где выкат перестаёт быть безопасным. Раннер у вас уже есть: свой раннер миграций собран целиком, с журналом в базе и контрольной суммой шага. Дальше начинаются два вопроса, на которые он не отвечает. Первый: кто его запускает — старт приложения, рука в ssh или отдельная одноразовая джоба, и почему из трёх ответов годится один. Второй неприятнее: миграция всегда применяется раньше, чем начинает работать новый код, поэтому на новой схеме какое-то время живёт старая версия приложения. Шаг, который кажется очевидным, — переименовать колонку — в этот момент кладёт прод целиком. Разберём, как разбить такой шаг на выпуски, какие ещё шаги не переживают момент, почему у джобы миграций свой образ и что происходит, когда она падает.
Раннер миграций — это библиотека: функция, которой дают подключение и список шагов. Вопрос выката в другом — где её позвать. Ответов три, и они не равноценны: рука в ssh работает ровно до первого выката, который делает не автор, и до первой ночи, когда команду забыли; старт приложения выглядит красивее всего; одноразовая джоба требует отдельного сервиса в compose и отдельного образа.
Старт приложения соблазняет тем, что ничего не надо заводить: пара строк перед listen, и схема всегда свежая. Беда в том, что приложение бывает не одно. Копий бывает две — потому что одного процесса стало мало; бывает три — потому что обновление идёт без простоя; бывает одна, но запущенная дважды: docker compose run api … добавляет к живому контейнеру ещё один, с тем же кодом и той же базой. А раннер читает журнал и ставит отметку двумя отдельными запросами — между ними есть щель.
import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const подключить = async () => { const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); return client; }; const ИМЯ = '0001_notes'; const ШАГ = 'CREATE TABLE notes (id serial PRIMARY KEY, body text NOT NULL)'; /* * Раннер главы 9: прочитать журнал, применить непомеченное, поставить отметку * в одной транзакции с шагом. Разница одна — таких раннеров здесь два, как их * получается два, когда миграцию применяет старт приложения. */ async function выкатить(имя, client, { замок, старт, перед } = {}) { if (старт) await старт(); await client.query('BEGIN'); // общий замок базы: число — просто ключ, важно, что он один у всех копий if (замок) await client.query('SELECT pg_advisory_xact_lock(4242)'); const { rowCount } = await client.query('SELECT 1 FROM __migrations WHERE name = $1', [ИМЯ]); console.log(` ${имя}: отметок про ${ИМЯ} в журнале — ${rowCount}`); if (перед) await перед(); if (rowCount) { await client.query('ROLLBACK'); console.log(` ${имя}: применять нечего`); return; } try { await client.query(ШАГ); await client.query('INSERT INTO __migrations (name) VALUES ($1)', [ИМЯ]); await client.query('COMMIT'); console.log(` ${имя}: применил ${ИМЯ}`); } catch (err) { await client.query('ROLLBACK'); console.log(` ${имя}: ${err.code} ${err.message}`); } } /** Веха: одна сторона её отмечает, другая на ней ждёт. */ const веха = () => { let отметить; const пройдена = new Promise((готово) => (отметить = готово)); return { пройдена, отметить }; }; const служебный = await подключить(); const A = await подключить(); const B = await подключить(); // журнал раннер заводит сам при первом выкате (глава 9); спор идёт не о нём await служебный.query('CREATE TABLE __migrations (name text PRIMARY KEY, at timestamptz NOT NULL DEFAULT now())'); const состояние = async () => { const { rows: отметки } = await служебный.query('SELECT name FROM __migrations ORDER BY name'); const { rows: таблицы } = await служебный.query( "SELECT table_name AS имя FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1", ); console.log(' журнал:', отметки.map((строка) => строка.name).join(', ') || 'пусто'); console.log(' таблицы:', таблицы.map((строка) => строка.имя).join(', ')); }; /* * Вехами задан порядок: в проде его решает случай, а записанный вывод обязан * совпадать при каждом прогоне. Обе копии успевают прочитать журнал прежде, * чем первая применит шаг, — и только это здесь от случая и требуется. */ console.log('--- два раннера, у каждого своя транзакция'); const прочитанA = веха(); const дошлаA = веха(); const первая = выкатить('A', A, { перед: прочитанA.отметить }); const вторая = выкатить('B', B, { старт: () => прочитанA.пройдена, перед: () => дошлаA.пройдена }); await первая; дошлаA.отметить(); await вторая; await состояние(); // вернём базу в то состояние, в котором её застали оба раннера await служебный.query('DROP TABLE notes'); await служебный.query('DELETE FROM __migrations'); console.log('--- те же два раннера под общим замком базы'); const замокВзят = веха(); await Promise.all([ выкатить('A', A, { замок: true, перед: замокВзят.отметить }), выкатить('B', B, { замок: true, старт: () => замокВзят.пройдена }), ]); await состояние(); await служебный.end(); await A.end(); await B.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app --- два раннера, у каждого своя транзакция A: отметок про 0001_notes в журнале — 0 B: отметок про 0001_notes в журнале — 0 A: применил 0001_notes B: 42P07 relation "notes" already exists журнал: 0001_notes таблицы: __migrations, notes --- те же два раннера под общим замком базы A: отметок про 0001_notes в журнале — 0 A: применил 0001_notes B: отметок про 0001_notes в журнале — 1 B: применять нечего журнал: 0001_notes таблицы: __migrations, notes
В первой половине обе копии увидели пустой журнал и обе пошли применять первый шаг. Второй досталось 42P07 relation "notes" already exists: имя таблицы занял тот, кто зафиксировался первым. В журнале при этом ровно одна отметка — база не дала испортить схему, потому что шаг оказался CREATE TABLE. Если бы шагом был UPDATE, вторая копия применила бы его второй раз, и никакая база этому не помешала бы.
Во второй половине тот же раннер берёт общий замок базы: pg_advisory_xact_lock(4242). Ключ — просто число, важно, что оно одно у всех копий. Вторая копия на нём ждёт — ожидание блокировки читатель уже видел на строке под правкой, а дождавшись, читает журнал заново и находит там отметку — «применять нечего». Замок держится транзакцией и отпускается фиксацией, поэтому забыть его снять нельзя даже при падении процесса.
Первое: отказ миграции должен остановить выкат, а не превратиться в цикл рестартов. Контейнер, упавший на миграции, docker поднимет снова, и дальше одно из двух: дело было в гонке — вторая попытка увидит отметку соседа и приложение стартует как надо; дело в самой миграции — контейнер будет падать и подниматься, пока выкат не остановят руками. Одноразовая джоба выходит с ненулевым кодом, и API, который ждёт её по условию завершения успехом, не стартует вовсе.
Второе: инструмента миграций в образе приложения нет — об этом отдельный раздел ниже. Третье: миграция под замком задерживает старт каждой копии, и её отказ выглядит в журнале как «приложение не поднимается», а не как «схема не поехала». Разбираться придётся дважды: сначала почему не поднялось, потом почему не поехала.
Поэтому у автора миграции — отдельный сервис, который поднимается один раз, делает своё дело и выходит. Порядок и готовность ему задают условия, разобранные в прошлой статье; здесь важно только то, что сервис один и запускается он однажды.
Джоба и приложение ходят в базу одной учётной записью: права менять схему есть у неё только потому, что запись одна на всех. Развести роли — первое, что стоит сделать, когда база перестаёт быть одноразовой.
Условие «стартуй после успешной джобы» читается как обещание: приложение увидит смигрированную схему. У него есть и обратная сторона, менее приятная: новый код физически не может начать работать раньше миграции. Значит, между концом джобы и первым запросом к новому контейнеру схема уже новая, а запросы обслуживает либо старый контейнер, либо никто. Второе — это отказ прокси на каждый запрос: за адресом никого нет. Первое — это старый код на новой схеме.
И это ещё короткое окно, секунды или десятки секунд. Длинное открывает обратный выкат: образ откатывают одной командой, схему — нет. Старый код живёт на новой схеме столько, сколько длится разбор, а ночью разбор длится до утра.
Что при этом происходит, видно на модели. Таблица здесь — набор колонок и массив строк; обращение к колонке, которой в схеме нет, кончается тем же, чем в настоящей базе: отказом с кодом.
Первый момент — прод до выката: один старый код, одна колонка, всё работает. Второй — джоба уже переименовала колонку, а контейнер со старым кодом ещё отвечает: он получает 42703 и на чтении, и на записи, то есть любой запрос к заметкам кончается ошибкой. Третий — старый контейнер заменён новым, и всё снова работает. Прод отвечает отказами ровно столько, сколько длится второй момент.
Момент кончается не по таймеру. Если новый контейнер не поднялся — не собрался образ, не хватило памяти, не прошла проба, — старый код живёт на новой схеме до конца разбора.
Старый контейнер, кстати, умирает не мгновенно: ему приходит сигнал завершения, и он доживает начатые запросы — сколько именно и почему это не бесплатно, разбирает остановка по сигналу. Для схемы это значит, что старых запросов в новой схеме будет чуть больше, чем кажется по журналу выката.
Правило, которое закрывает момент, простое: схема меняется так, чтобы в любой её точке работали и старый код, и новый. Переименование колонки этому правилу не подчиняется, потому что это два шага сразу — колонка исчезла и колонка появилась. Его разбивают на три выпуска: сперва схему только расширяют, потом переводят код, и лишь потом сужают. Отсюда и название — expand и contract.
| выпуск | шаг в схеме | что делает код |
|---|---|---|
| 1 · расширение | ADD COLUMN body, перенос значений, ALTER COLUMN text DROP NOT NULL | пишет и в text, и в body, читает ту, где что-то есть |
| 2 · переход | догоняющий перенос: UPDATE … WHERE body IS NULL | пишет только в body, читает body |
| 3 · сужение | DROP COLUMN text | ничего не меняется: про text он уже не знает |
Каждый выпуск проверяется одним и тем же вопросом: какие версии кода сейчас живы и обе ли они работают на этой схеме. В модели это буквально цикл.
Две строки вывода здесь важнее остальных. «Перенесено строк 1» во втором выпуске — это заметка, которую старый код успел записать между выпусками: догоняющий перенос забирает именно её, и без него она осталась бы с пустым body навсегда. А «пустых 0» у каждой версии в каждом выпуске — это и есть проверка, ради которой модель написана: ни одна живая версия ни в один момент не увидела пустой клетки.
На настоящей базе те же шаги дают ещё три ответа, которых у модели быть не может.
import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); /* * Три версии одного обработчика заметок. Старая знает колонку text; переходная * пишет в обе и читает ту, в которой что-то есть; новая знает только body. * В момент выката на одной схеме работают две версии из трёх. */ const версии = { 'старый код': { читает: 'SELECT text AS видно FROM notes ORDER BY id', пишет: 'INSERT INTO notes (text) VALUES ($1)', }, 'переходный код': { читает: 'SELECT COALESCE(body, text) AS видно FROM notes ORDER BY id', пишет: 'INSERT INTO notes (text, body) VALUES ($1, $1)', }, 'новый код': { читает: 'SELECT body AS видно FROM notes ORDER BY id', пишет: 'INSERT INTO notes (body) VALUES ($1)', }, }; // каждый запрос сам себе транзакция: отказ одного не мешает следующему const попытка = async (запрос, значения) => { try { const { rows } = await client.query(запрос, значения); return { ok: true, rows }; } catch (err) { return { ok: false, код: err.code, сообщение: err.message }; } }; /** Шаг миграции: удачный молчит, неудачный называет код SQLSTATE. */ const шаг = async (запрос) => { const итог = await попытка(запрос); if (!итог.ok) console.log(` миграция: ${итог.код} ${итог.сообщение}`); }; /** Что видит и что может записать версия кода на нынешней схеме. */ async function проверить(имя, метка) { const { читает, пишет } = версии[имя]; const чтение = await попытка(читает); const запись = await попытка(пишет, [метка]); if (чтение.ok) { const пустых = чтение.rows.filter((строка) => строка.видно === null).length; console.log(` ${имя} читает: строк ${чтение.rows.length}, пустых ${пустых}`); } else { console.log(` ${имя} читает: ${чтение.код} ${чтение.сообщение}`); } console.log(` ${имя} пишет: ${запись.ok ? 'записал' : `${запись.код} ${запись.сообщение}`}`); } const колонкиИСтроки = async () => { const { rows } = await client.query( `SELECT attname AS имя FROM pg_attribute WHERE attrelid = 'notes'::regclass AND attnum > 0 AND NOT attisdropped ORDER BY attnum`, ); const колонки = rows.map((строка) => строка.имя); const { rows: данные } = await client.query(`SELECT ${колонки.join(', ')} FROM notes ORDER BY id`); console.log(` колонки: ${колонки.join(', ')}`); for (const строка of данные) console.log(` ${колонки.map((к) => `${к}=${строка[к] ?? 'null'}`).join(' ')}`); }; const сНачала = async () => { await шаг('DROP TABLE IF EXISTS notes'); await шаг('CREATE TABLE notes (id serial PRIMARY KEY, text text NOT NULL)'); await шаг("INSERT INTO notes (text) VALUES ('была до выката')"); }; await сНачала(); console.log('--- схема, которая в проде сейчас'); await проверить('старый код', 'старый до выката'); await проверить('новый код', 'новый до выката'); console.log('--- в лоб, попытка 1: ALTER TABLE notes ADD COLUMN body text NOT NULL'); await шаг('ALTER TABLE notes ADD COLUMN body text NOT NULL'); console.log('--- в лоб, попытка 2: ALTER TABLE notes RENAME COLUMN text TO body'); await шаг('ALTER TABLE notes RENAME COLUMN text TO body'); await проверить('старый код', 'старый после переименования'); await проверить('новый код', 'новый после переименования'); await сНачала(); console.log('--- выкат 1, расширение начато: ADD COLUMN body и перенос значений'); await шаг('ALTER TABLE notes ADD COLUMN body text'); await шаг('UPDATE notes SET body = text WHERE body IS NULL'); await проверить('новый код', 'новый на половине расширения'); console.log('--- расширение доведено: ALTER TABLE notes ALTER COLUMN text DROP NOT NULL'); await шаг('ALTER TABLE notes ALTER COLUMN text DROP NOT NULL'); await проверить('старый код', 'старый в выкат 1'); await проверить('переходный код', 'переходный в выкат 1'); await проверить('новый код', 'новый в выкат 1'); await колонкиИСтроки(); console.log('--- выкат 2: догоняющий перенос, дальше пишет только body'); await шаг('UPDATE notes SET body = text WHERE body IS NULL'); await проверить('переходный код', 'переходный в выкат 2'); await проверить('новый код', 'новый в выкат 2'); console.log('--- выкат 3, сужение: ALTER TABLE notes DROP COLUMN text'); await шаг('ALTER TABLE notes DROP COLUMN text'); await проверить('новый код', 'новый в выкат 3'); await проверить('старый код', 'старый в выкат 3'); console.log('--- другой способ расширения: ADD COLUMN tag text NOT NULL DEFAULT \'\' — и что легло в строки'); await шаг("ALTER TABLE notes ADD COLUMN tag text NOT NULL DEFAULT ''"); const { rows: описание } = await client.query( `SELECT atthasmissing, attmissingval::text AS значение FROM pg_attribute WHERE attrelid = 'notes'::regclass AND attname = 'tag'`, ); console.log(` pg_attribute про tag: atthasmissing ${описание[0].atthasmissing}, attmissingval ${описание[0].значение}`); await проверить('новый код', 'новый не знает про tag'); console.log('--- колонка, которую считает сама база, и попытка кода в неё записать'); await шаг('ALTER TABLE notes ADD COLUMN body_mirror text GENERATED ALWAYS AS (body) STORED'); const { rows: зеркало } = await client.query('SELECT count(*)::int AS заполнено FROM notes WHERE body_mirror IS NOT NULL'); console.log(` строк с непустым body_mirror: ${зеркало[0].заполнено}`); await шаг("UPDATE notes SET body_mirror = 'своё'"); await client.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app
--- схема, которая в проде сейчас
старый код читает: строк 1, пустых 0
старый код пишет: записал
новый код читает: 42703 column "body" does not exist
новый код пишет: 42703 column "body" of relation "notes" does not exist
--- в лоб, попытка 1: ALTER TABLE notes ADD COLUMN body text NOT NULL
миграция: 23502 column "body" of relation "notes" contains null values
--- в лоб, попытка 2: ALTER TABLE notes RENAME COLUMN text TO body
старый код читает: 42703 column "text" does not exist
старый код пишет: 42703 column "text" of relation "notes" does not exist
новый код читает: строк 2, пустых 0
новый код пишет: записал
--- выкат 1, расширение начато: ADD COLUMN body и перенос значений
новый код читает: строк 1, пустых 0
новый код пишет: 23502 null value in column "text" of relation "notes" violates not-null constraint
--- расширение доведено: ALTER TABLE notes ALTER COLUMN text DROP NOT NULL
старый код читает: строк 1, пустых 0
старый код пишет: записал
переходный код читает: строк 2, пустых 0
переходный код пишет: записал
новый код читает: строк 3, пустых 1
новый код пишет: записал
колонки: id, text, body
id=1 text=была до выката body=была до выката
id=3 text=старый в выкат 1 body=null
id=4 text=переходный в выкат 1 body=переходный в выкат 1
id=5 text=null body=новый в выкат 1
--- выкат 2: догоняющий перенос, дальше пишет только body
переходный код читает: строк 4, пустых 0
переходный код пишет: записал
новый код читает: строк 5, пустых 0
новый код пишет: записал
--- выкат 3, сужение: ALTER TABLE notes DROP COLUMN text
новый код читает: строк 6, пустых 0
новый код пишет: записал
старый код читает: 42703 column "text" does not exist
старый код пишет: 42703 column "text" of relation "notes" does not exist
--- другой способ расширения: ADD COLUMN tag text NOT NULL DEFAULT '' — и что легло в строки
pg_attribute про tag: atthasmissing true, attmissingval {""}
новый код читает: строк 7, пустых 0
новый код пишет: записал
--- колонка, которую считает сама база, и попытка кода в неё записать
строк с непустым body_mirror: 8
миграция: 428C9 column "body_mirror" can only be updated to DEFAULTОтвет первый: шаг ADD COLUMN body text NOT NULL на непустой таблице не проходит вовсе — 23502 column "body" of relation "notes" contains null values. Обязательная колонка без значения по умолчанию — не «опасный шаг», а невыполнимый; джоба на нём просто упадёт.
Ответ второй: расширение, не доведённое до конца, ломает как раз новый код. Пока text остаётся обязательным, новый код читает нормально, а вставить не может: 23502 null value in column "text" … violates not-null constraint. Он про text не знает и ничего в него не пишет, а база требует.
Ответ третий — самый тихий. Строка «новый код читает: строк 3, пустых 1» не сопровождается никакой ошибкой: код выхода ноль, в логах чисто, а одна заметка приехала клиенту пустой. Это строка, которую записал старый код, знающий только text. Именно поэтому в первом выпуске живёт переходная версия с COALESCE — тем же «первым непустым», что ?? в модели, — а не сразу конечная: одна ошибка видна сразу, а потерянное поле находят через неделю по жалобе.
Заодно в дампе строк видно, что номера идут 1, 3, 4, 5. Отклонённая вставка успела взять номер из последовательности и не вернула его: «серийный» номер обещает возрастание, но не отсутствие дыр.
Значением по умолчанию: ADD COLUMN tag text NOT NULL DEFAULT '' проходит на непустой таблице и не переписывает её. В том же прогоне это видно прямо в описании колонки: atthasmissing у неё true, а attmissingval — {""}. Значение лежит в каталоге и подставляется при чтении тех строк, которые о колонке не знают; старый код продолжает вставлять строки без неё.
Колонкой, которую считает сама база: ADD COLUMN body_mirror text GENERATED ALWAYS AS (body) STORED заполняет все строки без единого UPDATE. Но писать в неё нельзя никому — 428C9 column "body_mirror" can only be updated to DEFAULT, — поэтому такая колонка годится для зеркала и индекса, а не для переезда: новый код в неё ничего не запишет.
Своим переносом — если значения считаются не одним выражением. На боевой таблице его делают порциями: один UPDATE на все строки держит замок до конца и оставляет после себя столько же мёртвых версий строк, сколько было живых.
Переименование — самый наглядный, но не единственный шаг, который нельзя выкатывать одним куском. Все они узнаются одним и тем же вопросом: что будет делать код, который об этом шаге не знает.
| шаг | что с ним не так | как делают |
|---|---|---|
ADD COLUMN необязательную | ничего: старый код её не видит и не мешает | одним шагом |
ADD COLUMN … NOT NULL без значения | 23502 на непустой таблице — шаг не выполнится | колонка, перенос значений, SET NOT NULL — и последнее лишь тогда, когда колонку заполняет весь живой код |
RENAME COLUMN | старый код получает 42703 на каждом запросе | расширение и сужение |
DROP COLUMN | то же, но незаметно до первого отката | отдельным выпуском, когда старого кода нет ни в контейнерах, ни в образах |
CREATE UNIQUE INDEX | 23505, если дубли уже есть; а дальше старый код начнёт получать отказы на том, что раньше проходило | чистка дублей, потом код, который их не делает, и только потом индекс |
сужение типа, varchar(64) вместо text | старый код пишет длинное и получает отказ | проверка в коде раньше, чем ограничение в схеме |
ALTER TABLE, меняющий колонки | берёт замок, несовместимый даже с чтением | lock_timeout и повтор, а не «подождём, оно быстро» |
Последняя строка — самая дорогая, потому что она про доступность, а не про совместимость. И видно её не на глаз, а только под нагрузкой.
import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); // роль соединения в обе стороны: в выводе имена, а не pid этой машины const кто = new Map(); const pidРоли = new Map(); const подключить = async (роль) => { const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); const { rows } = await client.query('SELECT pg_backend_pid() AS pid'); кто.set(rows[0].pid, роль); pidРоли.set(роль, rows[0].pid); return client; }; const наблюдатель = await подключить('наблюдатель'); const отчёт = await подключить('отчёт'); const миграция = await подключить('миграция'); const читатель = await подключить('читатель'); await наблюдатель.query('CREATE TABLE notes (id serial PRIMARY KEY, body text NOT NULL, slug text)'); await наблюдатель.query("INSERT INTO notes (body, slug) VALUES ('первая', 'odna'), ('вторая', 'odna')"); // шаг миграции: удачный молчит, неудачный называет код SQLSTATE. Подробности // отказа драйвер кладёт отдельным полем — печатаем и его, если оно есть const шаг = async (client, запрос) => { try { await client.query(запрос); } catch (err) { const ещё = err.detail ? ` | detail: ${err.detail}` : ''; console.log(` миграция: ${err.code} ${err.message}${ещё}`); } }; const пауза = (мс) => new Promise((готово) => setTimeout(готово, мс)); /* * Запрос уходит на сервер не мгновенно, а вывод обязан совпадать при каждом * прогоне: прежде чем идти дальше, ждём, пока база сама скажет, что этот * сеанс чего-то ждёт. */ const дождатьсяСеанса = async (роль) => { for (;;) { const { rows } = await отчёт.query('SELECT wait_event_type AS чего FROM pg_stat_activity WHERE pid = $1', [ pidРоли.get(роль), ]); if (rows[0]?.чего === 'Lock') return; await пауза(20); } }; const замки = async () => { const { rows } = await отчёт.query( `SELECT pid, mode, granted FROM pg_locks WHERE relation = 'notes'::regclass ORDER BY granted DESC, pid`, ); for (const строка of rows) { console.log(` ${кто.get(строка.pid) ?? 'кто-то ещё'}: ${строка.mode} ${строка.granted ? 'выдан' : 'ждёт'}`); } }; console.log('--- долгий отчёт открыл транзакцию и читает таблицу'); await отчёт.query('BEGIN'); await отчёт.query('SELECT count(*) FROM notes'); console.log('--- миграция с lock_timeout = 250ms'); await миграция.query("SET lock_timeout = '250ms'"); try { await миграция.query('ALTER TABLE notes ADD COLUMN tag text'); console.log(' миграция: применена'); } catch (err) { console.log(` миграция: ${err.code} ${err.message}`); } console.log('--- та же миграция без таймаута, а сразу за ней — обычное чтение'); await миграция.query('SET lock_timeout = 0'); const ждётМиграция = миграция .query('ALTER TABLE notes ADD COLUMN tag text') .then(() => 'применена') .catch((err) => `${err.code} ${err.message}`); await дождатьсяСеанса('миграция'); const ждётЧитатель = читатель .query('SELECT count(*) FROM notes') .then(() => 'прочитал') .catch((err) => `${err.code} ${err.message}`); await дождатьсяСеанса('читатель'); await замки(); console.log('--- отчёт закрыл транзакцию'); await отчёт.query('COMMIT'); console.log(` миграция: ${await ждётМиграция}`); console.log(` читатель: ${await ждётЧитатель}`); console.log('--- CREATE INDEX CONCURRENTLY внутри транзакции, как её открывает раннер'); await миграция.query('BEGIN'); await шаг(миграция, 'CREATE INDEX CONCURRENTLY notes_slug_idx ON notes (slug)'); await миграция.query('ROLLBACK'); console.log('--- уникальный индекс на колонке, куда старый код успел положить дубли'); await шаг(миграция, 'CREATE UNIQUE INDEX notes_slug_key ON notes (slug)'); await наблюдатель.end(); await отчёт.end(); await миграция.end(); await читатель.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app --- долгий отчёт открыл транзакцию и читает таблицу --- миграция с lock_timeout = 250ms миграция: 55P03 canceling statement due to lock timeout --- та же миграция без таймаута, а сразу за ней — обычное чтение отчёт: AccessShareLock выдан миграция: AccessExclusiveLock ждёт читатель: AccessShareLock ждёт --- отчёт закрыл транзакцию миграция: применена читатель: прочитал --- CREATE INDEX CONCURRENTLY внутри транзакции, как её открывает раннер миграция: 25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block --- уникальный индекс на колонке, куда старый код успел положить дубли миграция: 23505 could not create unique index "notes_slug_key" | detail: Key (slug)=(odna) is duplicated.
Отчёт держит открытую транзакцию и с ней AccessShareLock — обычный замок чтения. Миграция просит AccessExclusiveLock, они несовместимы, и она ждёт. Самое важное дальше: обычный SELECT, пришедший после неё, тоже ждёт. С отчётом он не конфликтует ни на копейку, но встаёт в очередь за миграцией. Одна незакрытая транзакция плюс один ALTER TABLE — и таблица недоступна всем, кто пришёл позже. Открытые транзакции читатель уже встречал у версий строк; здесь у них другая цена.
Отсюда lock_timeout: миграция, которая не смогла взять замок за четверть секунды, отваливается с 55P03 canceling statement due to lock timeout, и это лучший из исходов. Упавшую джобу перезапускают; таблицу, недоступную три минуты в час пик, не отменишь.
Два последних шага прогона база не даёт сделать вовсе. CREATE INDEX CONCURRENTLY — тот самый способ построить индекс, не блокируя запись, — не работает внутри транзакции: 25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block. А раннер миграций транзакцию как раз открывает. И CREATE UNIQUE INDEX на колонке с дублями даёт 23505 could not create unique index: в подробностях отказа база называет одно повторившееся значение и ни одной строки. Сколько всего дублей и где они, из отказа не узнать — их ищут запросом заранее. Все эти ответы — коды SQLSTATE, и ветвиться в коде надо по ним, а не по тексту: код на проводе одинаков на любом языке сервера.
Отдельным шагом вне транзакции: своей джобой с командой psql -c или раннером, который для этой миграции транзакцию не открывает. У drizzle-kit второго пути нет, поэтому такой индекс у автора не поедет его миграцией вовсе.
И проверкой после. Построение CONCURRENTLY может упасть на середине — например, на дубле, — и тогда в схеме остаётся индекс, помеченный негодным: indisvalid у него false. Такой индекс не используется планировщиком, но занимает место и мешает создать одноимённый. Его удаляют и строят заново, а не «пробуют ещё раз».
Остался вопрос, почему джобе нужен свой образ, а не образ API с другой командой. Ответ короткий: в образе API нет drizzle-kit. Файлы миграций там есть — пакет @jsp/db попадает в срез вместе с каталогом migrations, — а сам инструмент нет, потому что он и tsx объявлены в devDependencies этого пакета, а рантайм собирается срезом боевых зависимостей. Проверено в собранном образе 16.09.2026: /app/node_modules/@jsp/db/migrations/0000_fantastic_puck.sql на месте, drizzle-kit отсутствует.
Три детали этого файла стоит прочитать внимательно. Установка идёт без --prod — ровно наоборот к образу API, и это не оплошность: нужное лежит именно в dev-зависимостях. Версии не перечисляются в Dockerfile, а берутся из pnpm-lock.yaml: прежний вариант с npm install без lock ставил drizzle-kit 0.31.4 при 0.31.10 в lock, и транзитивные зависимости плавали от сборки к сборке. А WORKDIR указан на пакет базы, потому что drizzle.config.ts называет схему и каталог миграций относительными путями — из корня образа он их не найдёт.
Чего этот образ не делает, так это не выходит «лёгким». Холодная сборка 16.09.2026 дала 392 359 352 байта против 379 545 107 у образа API: джоба миграций тяжелее приложения. Кроме drizzle-kit и tsx установка тянет все остальные dev-зависимости пакета базы, а корневые turbo и prettier pnpm ставит при любом --filter. Это нормальная цена: образ собирается один раз на выкат и живёт секунды. Но обещать «маленький образ только с инструментом миграций» не стоит — такого здесь нет.
Сид — вторая одноразовая джоба на том же образе: tsx в нём уже есть, а команда переопределяется в compose. Профиль держит её в стороне от обычного выката: сервис с profiles не стартует вместе с остальными и запускается отдельной командой.
Одна тонкость на будущее: обеим командам нужен записываемый /tmp — tsx кладёт туда свой каталог. Образ с файловой системой только для чтения и без tmpfs на /tmp роняет и сид, и миграцию.
Порядок внутри выпуска один: расширение схемы едет вместе с кодом или раньше него, сужение — следующим выпуском. Вопрос «через сколько можно сужать» имеет точный ответ: когда старого кода не осталось ни в одном работающем контейнере и ни в одном образе, на который можно откатиться. Пока прежний образ можно собрать или он ещё лежит на машине, DROP COLUMN отнимает возможность откатиться.
Второй вопрос выката — что происходит, когда джоба падает. Посмотрим на случай, который разобран выше: во второй миграции стоит CREATE INDEX CONCURRENTLY, и в транзакции он невозможен.
import { execFileSync } from 'node:child_process'; import { readFileSync, rmSync, writeFileSync } from 'node:fs'; import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL!); url.password = ''; console.log('база:', url.href); const client = new pg.Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); // так одноразовая джоба и запускается: CMD образа, код выхода — приговор выкату const джоба = () => { try { execFileSync('drizzle-kit', ['migrate'], { stdio: 'inherit' }); // drizzle-kit не переводит строку после последнего сообщения console.log(); console.log(' код выхода 0'); } catch (err) { console.log(); console.log(` код выхода ${(err as { status?: number }).status}`); } }; const состояние = async () => { const { rows: таблицы } = await client.query( `SELECT table_schema || '.' || table_name AS имя FROM information_schema.tables WHERE table_schema IN ('public', 'drizzle') ORDER BY 1`, ); console.log(' таблицы базы:', таблицы.map((строка) => строка.имя).join(', ') || 'ни одной'); const { rows: журнал } = await client.query( `SELECT to_regclass('drizzle.__drizzle_migrations') IS NOT NULL AS есть`, ); if (журнал[0].есть) { const { rows: отметки } = await client.query('SELECT count(*)::int AS сколько FROM drizzle.__drizzle_migrations'); console.log(' отметок в журнале drizzle:', отметки[0].сколько); } else { console.log(' журнала drizzle в базе нет'); } const { rows: индексы } = await client.query( `SELECT indexname AS имя FROM pg_indexes WHERE tablename = 'notes' ORDER BY 1`, ); console.log(' индексы notes:', индексы.map((строка) => строка.имя).join(', ') || 'таблицы нет'); }; console.log('--- две непримененные миграции, во второй CREATE INDEX CONCURRENTLY'); джоба(); await состояние(); console.log('--- второй шаг убран из каталога миграций'); rmSync('drizzle/0001_notes_slug_idx.sql'); const путь = 'drizzle/meta/_journal.json'; const журнал = JSON.parse(readFileSync(путь, 'utf8')); журнал.entries = журнал.entries.filter((запись: { tag: string }) => запись.tag !== '0001_notes_slug_idx'); writeFileSync(путь, JSON.stringify(журнал, null, 2)); джоба(); await состояние(); await client.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app --- две непримененные миграции, во второй CREATE INDEX CONCURRENTLY No config path provided, using default 'drizzle.config.ts' Reading config file '<пример>/drizzle.config.ts' Using 'pg' driver for database querying [~] applying migrations... [~] applying migrations... код выхода 1 таблицы базы: drizzle.__drizzle_migrations отметок в журнале drizzle: 0 индексы notes: таблицы нет --- второй шаг убран из каталога миграций No config path provided, using default 'drizzle.config.ts' Reading config file '<пример>/drizzle.config.ts' Using 'pg' driver for database querying [~] applying migrations... [✓] migrations applied successfully! код выхода 0 таблицы базы: drizzle.__drizzle_migrations, public.notes отметок в журнале drizzle: 1 индексы notes: notes_pkey
Код выхода 1 — и ни слова о причине: drizzle-kit migrate печатает крутилку и молча уходит. Дальше интереснее: журнал в базе заведён, отметок в нём ноль, а таблицы notes нет вовсе — хотя первый шаг был безобиден и должен был пройти. Так и задумано: drizzle-kit migrate зовёт миграционную функцию drizzle-orm, а та гоняет все непримененные шаги одной транзакцией (session.transaction в pg-core/dialect 0.45.2), поэтому падение второго откатывает первый. Для выката это хорошо — половины схемы не бывает. Для разбора плохо: причину придётся искать в журнале базы или повторяя шаг руками.
А что при этом с сайтом? API ждёт джобу по условию успешного завершения и не стартует — это правильно: лучше отказ прокси, чем приложение на схеме, которой оно не знает. Сайт же у автора от API не зависит вовсе, и зависимость эта убрана намеренно.
Проверка после выката спрашивает не контейнеры, а адреса: страницу сайта и ручку API. То, что джоба вышла с нулём, ещё не значит, что схема получилась та, которую ждёт код.
Обратных шагов drizzle-kit не генерирует, и это не упущение. Расширение откатывать не нужно: старый код на расширенной схеме работает, лишняя колонка ему не мешает. Сужение откатить нельзя: данных в удалённой колонке уже нет, и шаг «создать колонку обратно» вернёт её пустой. Всё, что остаётся, — ещё один шаг вперёд, написанный руками под конкретную аварию.
Поэтому порядок «расширение — переход — сужение» ценен не только тем, что переживает момент. Первые два выпуска откатываются откатом кода, без единого запроса к базе; необратим только третий, и он едет последним, когда откатывать уже нечего.
Схема и код теперь едут согласованно. Осталось то, что видно снаружи: обратный прокси и сертификат, а потом разбор настоящего инцидента по журналам — эксплуатация.
- Миграцию применяет одноразовая джоба, а не старт приложения: копий приложения бывает больше одной, а чтение журнала и отметка о применении — разные запросы. Общий замок базы гонку закрывает, но отказ миграции всё равно должен останавливать выкат, а не превращаться в цикл рестартов.
- Новый код не может начать работать раньше миграции, поэтому на новой схеме всегда есть окно со старым кодом: короткое при обычном выкате и сколь угодно длинное при обратном.
- Схему меняют так, чтобы её понимали обе живые версии кода: расширение, переход, сужение. Переименование и удаление колонки этому правилу не подчиняются и разбиваются на три выпуска.
- Во втором выпуске обязателен догоняющий перенос: строки, записанные старым кодом после первого переноса, иначе останутся пустыми. Это отказ без ошибки — самый дорогой из возможных.
- Обязательная колонка без значения по умолчанию на непустой таблице не добавляется вовсе, уникальный индекс не создаётся при дублях, а
CREATE INDEX CONCURRENTLYне работает внутри транзакции, которую открывает раннер. ALTER TABLE, меняющий колонки, берёт замок, несовместимый даже с чтением, и запросы, пришедшие после него, ждут вместе с ним. Поэтому у миграции ставятlock_timeout: упавшую джобу перезапускают, а недоступную таблицу не отменяют.
1/4Почему миграцию не применяют в старте приложения?
2/4Джоба переименовала колонку, а контейнер со старым кодом ещё отвечает. Что он получает?
3/4Зачем во втором выпуске нужен догоняющий перенос UPDATE … WHERE body IS NULL?
4/4Миграция ждёт замок за долгой читающей транзакцией. Что происходит с обычным SELECT, который пришёл позже?
Переживёт ли шаг момент
Шаг миграции описан объектом: вид, подробности и подпись для вывода. Напишите переживёт так, чтобы она возвращала true ровно для тех шагов, которые можно выкатить одним куском — то есть которые не ломают код, ничего о них не знающий. Список шагов и печать не трогайте.
Правил всего два, и оба про код, который о шаге не знает. Необязательная колонка ему не мешает; обязательная — мешает, если база не умеет подставить значение сама, то есть если у колонки нет DEFAULT. Обычный индекс не меняет того, что код может записать; уникальный меняет, поэтому он едет только после чистки дублей и кода, который их не делает. Всё остальное — переименование и удаление — отнимает у старого кода имя, которое он называет.
Вывод: переживут три шага из семи — необязательная колонка, обязательная со значением по умолчанию и обычный индекс. Обратите внимание на порядок проверок в первой строке: поУмолчанию: "" — пустая строка, и проверять её на правдивость нельзя, сравнение идёт с undefined.
Два раннера по одному
Две копии раннера стартуют одновременно — так бывает, когда миграцию применяет старт приложения. Сейчас завестиЗамок возвращает функцию, которая просто зовёт дело, и обе копии применяют шаг. Сделайте так, чтобы дела шли по одному: следующий начинается только после того, как закончился предыдущий. Переменная очередь для этого уже объявлена; остальной код не меняйте.
Замок здесь — это цепочка обещаний: каждое новое дело пристраивается к хвосту очереди, а хвостом становится оно само. Отказ одного дела не должен рвать очередь, поэтому в хвост уходит вариант с перехваченной ошибкой, а наружу — исходное обещание, чтобы вызывающий увидел и результат, и отказ.
Теперь вторая копия читает журнал уже после того, как первая поставила отметку, и видит единицу вместо нуля — «применять нечего». Ровно это делает в настоящей базе pg_advisory_xact_lock, только очередь там одна на все процессы, а не на один.
Перенос порциями
Догоняющий перенос на боевой таблице делают порциями. Напишите порциями: она получает строки и размер порции, а возвращает массив порций — массивов идентификаторов тех строк, где новая колонка ещё пуста. Строки, которые заполнены, в порции попадать не должны.
Сначала отбираются нужные строки — те, где новая колонка пуста, — и от них остаются только идентификаторы: обновлять база будет по ним. Потом список режется на куски заданной длины.
Выходит две порции: 2, 3 и 5. В настоящем переносе к этому добавляется пауза между порциями и повтор всего прохода до тех пор, пока порций не останется: пока идёт перенос, старый код успевает записать новые пустые строки.