В этом руководстве мы напишем небольшой API заметок на Node.js, создадим для него Postgres, подключим к нему второй сервис через приватную сеть и настроим миграции базы, которым можно доверять в продакшене. Код сервисов и шаги развёртывания ниже выполнены на платформе ровно в таком виде; вывод команд сокращён. По ходу дела объясняем, почему сделано именно так: каким должен быть пул соединений, что проверять при проверке работоспособности, зачем миграции запускаются до того, как сервер начнёт слушать порт, и как менять схему без простоя.
Что получится#
Три приложения в одном проекте. Из интернета доступен только API, всё остальное общается через приватную сеть рабочего пространства.
- notes-db — Postgres из шаблона: постоянный том и приватный TCP-эндпоинт.
- notes-api — сервис на Node.js. Он отвечает за схему, запускает миграции и отдаёт
/notesи/healthz. - notes-summary — приватный вспомогательный сервис: API обращается к нему по HTTP, чтобы получить краткое содержание заметки. На нём видно, как сервисы находят и вызывают друг друга и как пережить ситуацию, когда один из них лежит.
1. Создаём базу#
Начнём с базы: сервису нужен её адрес. Проект собирает все три сервиса на одном холсте в консоли:
# A project groups the services on one canvas inquir projects create notes # Postgres 16 from the template: a persistent volume, a generated password and # a private TCP endpoint. Nothing is exposed to the internet. inquir apps create notes-db --template postgres --project notes # Created application notes-db # template: postgres — first release 1ce3b082 is starting # — serving (29s): serving production traffic — # connection string: inquir apps connection-url notes-db # A minute later the private address is verified inquir apps status notes-db # private: notes-db-7ax8nt.apps.internal:5432 (postgres) # volume: data → /var/lib/postgresql/data
Шаблон запускает Postgres 16 с 512 МБ памяти и половиной vCPU. Пользователь — inquir, база называется по имени приложения (notes-db превращается в notes_db), пароль генерируется и хранится в зашифрованном виде. Данные лежат на томе data. Postgres слушает только приватный адрес notes-db-<suffix>.apps.internal:5432. Суффикс случайный и не меняется никогда, даже при переименовании приложения. Публичного порта у базы нет, а трафик в приватной сети не заворачивается в TLS.
2. Пишем сервис#
Создайте папку notes-api с такой структурой: package.json, db.js, migrate.js, server.js, папка migrations, Dockerfile и .dockerignore. Единственная зависимость — pg, стандартный драйвер Postgres: выполните npm install pg, заодно появится package-lock.json, который нужен Dockerfile. Сервис написан на голом node:http, чтобы ничто не заслоняло работу с базой. С Fastify или Express маршруты выглядят иначе, но db.js, migrate.js и порядок запуска остаются точно такими же.
{ "name": "notes-api", "version": "1.0.0", "private": true, "type": "module", "engines": { "node": ">=22" }, "scripts": { "start": "node server.js", "migrate": "node migrate.js" }, "dependencies": { "pg": "^8.23.1" } }
db.js отвечает за пул соединений: один пул на процесс, создаётся один раз. В продакшене важны именно его настройки: небольшой max; тайм-аут подключения, чтобы запрос завершался ошибкой, а не ждал вечно; statement_timeout, чтобы один медленный запрос не держал соединение; и обработчик error. Без этого обработчика соединение, оборвавшееся, пока простаивало в пуле (например, во время развёртывания самой базы), роняет весь процесс. waitForDatabase повторяет попытки до минуты: при первом развёртывании база может ещё запускаться.
import pg from 'pg'; if (!process.env.DATABASE_URL) { // Fail fast with a hint instead of a confusing ECONNREFUSED on localhost. throw new Error('DATABASE_URL is not set. Run: inquir apps connect <database> <app>'); } // One pool per process. Every replica opens up to `max` connections, so keep // replicas x max well under the database's max_connections (100 by default). export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: Number(process.env.PG_POOL_MAX ?? 5), idleTimeoutMillis: 30_000, // close connections nobody used for 30 s connectionTimeoutMillis: 5_000, // fail a request instead of queueing forever statement_timeout: 10_000, // no query may hold a connection longer than 10 s application_name: 'notes-api', // shows up in pg_stat_activity }); // A connection that dies while idle in the pool (database restarted, network // blip) emits 'error' on the pool. Without a listener Node crashes the process. pool.on('error', (err) => console.error('postgres: idle client error:', err.message)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** * The database may still be starting (first deploy) or restarting (its own * deploy). Retry with backoff for up to a minute before giving up. */ export async function waitForDatabase({ timeoutMs = 60_000 } = {}) { const deadline = Date.now() + timeoutMs; for (let attempt = 1; ; attempt++) { try { await pool.query('SELECT 1'); return; } catch (err) { if (Date.now() >= deadline) throw err; const delay = Math.min(500 * 2 ** attempt, 5_000); console.log(`postgres: not ready (${err.code ?? err.message}), retrying in ${delay} ms`); await sleep(delay); } } }
server.js запускается в строгом порядке: дождаться базы, применить миграции и только потом слушать порт. Платформа направляет трафик только после того, как пройдёт проверка работоспособности, а она не пройдёт, пока сервер не слушает порт, поэтому ни один запрос не попадёт на старую схему. По SIGTERM сервер перестаёт принимать соединения, даёт текущим запросам завершиться и закрывает пул. Обработчики сигналов регистрируются до медленной части запуска, так что остановка посреди миграций тоже обрабатывается корректно.
import http from 'node:http'; import { pool, waitForDatabase } from './db.js'; import { migrate } from './migrate.js'; const port = Number(process.env.PORT ?? 3000); let draining = false; // --- the other service, reached over the private network ------------------- // SUMMARY_URL is http://<slug>.apps.internal of the summary app. A slow or // missing dependency must not take this API down: short timeout, no throw. async function summarize(text) { if (!process.env.SUMMARY_URL) return null; try { const res = await fetch(new URL('/summarize', process.env.SUMMARY_URL), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text }), signal: AbortSignal.timeout(2_000), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()).summary ?? null; } catch (err) { console.warn(`summary service unavailable: ${err.message}`); return null; } } // --- tiny HTTP helpers ------------------------------------------------------- function send(res, status, body) { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); } async function readJson(req, limit = 64 * 1024) { let raw = ''; req.setEncoding('utf8'); for await (const chunk of req) { raw += chunk; if (raw.length > limit) throw Object.assign(new Error('body too large'), { status: 413 }); } try { return raw ? JSON.parse(raw) : {}; } catch { throw Object.assign(new Error('invalid JSON'), { status: 400 }); } } // --- routes ------------------------------------------------------------------ async function handle(req, res) { const url = new URL(req.url, 'http://localhost'); const noteId = url.pathname.match(/^\/notes\/(\d{1,18})$/)?.[1]; if (url.pathname === '/healthz') { // Ready only when we can actually serve: not draining, database reachable. if (draining) return send(res, 503, { status: 'draining' }); await pool.query('SELECT 1'); return send(res, 200, { status: 'ok' }); } if (url.pathname === '/notes' && req.method === 'GET') { const { rows } = await pool.query( 'SELECT id, title, body, summary, created_at FROM notes ORDER BY id DESC LIMIT 50', ); return send(res, 200, rows); } if (url.pathname === '/notes' && req.method === 'POST') { const { title, body = '' } = await readJson(req); if (typeof title !== 'string' || !title.trim()) return send(res, 400, { error: 'title is required' }); if (typeof body !== 'string') return send(res, 400, { error: 'body must be a string' }); const summary = await summarize(`${title}. ${body}`); // Always parameterised queries ($1, $2...), never string concatenation. const { rows } = await pool.query( 'INSERT INTO notes (title, body, summary) VALUES ($1, $2, $3) RETURNING *', [title.trim(), body, summary], ); return send(res, 201, rows[0]); } if (noteId && req.method === 'GET') { const { rows } = await pool.query('SELECT * FROM notes WHERE id = $1', [noteId]); return rows[0] ? send(res, 200, rows[0]) : send(res, 404, { error: 'not found' }); } if (noteId && req.method === 'DELETE') { const { rowCount } = await pool.query('DELETE FROM notes WHERE id = $1', [noteId]); if (!rowCount) return send(res, 404, { error: 'not found' }); return res.writeHead(204).end(); } return send(res, 404, { error: 'not found' }); } const server = http.createServer((req, res) => { handle(req, res).catch((err) => { if (!err.status) console.error(`${req.method} ${req.url} failed:`, err.message); if (!res.headersSent) send(res, err.status ?? 500, { error: err.status ? err.message : 'internal error' }); }); }); // --- stop: finish in-flight requests, then close the pool --------------------- // Registered before the slow startup below, so a stop during startup is clean too. async function shutdown(signal) { if (draining) return; draining = true; console.log(`${signal} received, draining`); // Hard deadline below the platform's drain grace (--drain-grace, 10 s by // default), in case a client hangs on. setTimeout(() => process.exit(1), 8_000).unref(); server.close(async () => { await pool.end(); console.log('drained, bye'); process.exit(0); }); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); // --- start: database first, then migrations, then traffic --------------------- await waitForDatabase(); await migrate(); if (!draining) server.listen(port, '0.0.0.0', () => console.log(`notes-api listening on ${port}`));
Две детали в ответах: id приходит строкой, потому что pg отдаёт bigint строкой, чтобы не терять точность; и все запросы параметризованы ($1, $2) — никакой склейки строк.
3. Миграции#
Миграция — это пронумерованный SQL-файл в migrations/. Файлы применяются по порядку имён, каждый ровно один раз, а имя каждого применённого файла записывается в таблицу schema_migrations. Для начала их две: сама таблица и колонка, добавленная позже.
CREATE TABLE notes ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title text NOT NULL, body text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now() );
-- Expand step: a new nullable column. Old code ignores it, new code fills it. -- Adding a nullable column without a default is instant even on a big table. ALTER TABLE notes ADD COLUMN summary text;
Программа миграций — около шестидесяти строк, которые можно прочитать целиком. Она открывает собственное соединение, отдельно от пула, и берёт advisory lock Postgres: если запускаются несколько реплик сразу, миграции применяет одна, а остальные ждут и затем видят, что делать уже нечего. Она задаёт lock_timeout, поэтому DDL, которому пришлось ждать занятую таблицу, сдаётся через пять секунд, а не выстраивает за собой в очередь все запросы. Обычный файл выполняется в транзакции: если он падает, из него не применяется ничего и он не записывается. Файл, первая строка которого — -- migrate:no-transaction, содержит одну команду и выполняется вне транзакции и без lock_timeout; это нужно для CREATE INDEX CONCURRENTLY, который по своей природе ждёт завершения текущих транзакций.
import { readdir, readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; // Any constant unique to this application. Every replica that starts takes // the same advisory lock, so only one of them applies migrations at a time; // the others wait, then find nothing left to do. const LOCK_KEY = 4_242_001; const DIR = new URL('./migrations/', import.meta.url); export async function migrate() { if (!process.env.DATABASE_URL) { throw new Error('DATABASE_URL is not set. Run: inquir apps connect <database> <app>'); } // A dedicated connection, not the pool: no statement_timeout here, and the // advisory lock lives exactly as long as this session. const client = new pg.Client({ connectionString: process.env.DATABASE_URL, application_name: 'notes-api:migrate', }); await client.connect(); try { await client.query('SELECT pg_advisory_lock($1)', [LOCK_KEY]); // DDL that has to wait for a busy table gives up after 5 s instead of // queueing every query behind it. Retry the deploy later rather than // freezing production. await client.query("SET lock_timeout = '5s'"); await client.query(` CREATE TABLE IF NOT EXISTS schema_migrations ( name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now() )`); const { rows } = await client.query('SELECT name FROM schema_migrations'); const applied = new Set(rows.map((row) => row.name)); const files = (await readdir(DIR)).filter((f) => f.endsWith('.sql')).sort(); for (const file of files) { if (applied.has(file)) continue; const sql = await readFile(new URL(file, DIR), 'utf8'); // CREATE INDEX CONCURRENTLY and friends refuse to run in a transaction. // Such a file holds exactly one statement and says so on its first line. const inTransaction = !/^\uFEFF?--\s*migrate:no-transaction\b/.test(sql); console.log(`migrate: applying ${file}`); try { if (inTransaction) { await client.query('BEGIN'); } else { // A concurrent index build waits for running transactions by design. await client.query('SET lock_timeout = 0'); } await client.query(sql); await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]); if (inTransaction) await client.query('COMMIT'); } catch (err) { if (inTransaction) await client.query('ROLLBACK'); throw new Error(`migration ${file} failed: ${err.message}`); } finally { if (!inTransaction) await client.query("SET lock_timeout = '5s'"); } } console.log(`migrate: up to date (${files.length} migrations)`); } finally { await client.end(); // closing the session releases the advisory lock } } // `node migrate.js` runs the migrations on their own, e.g. from your laptop. if (process.argv[1] === fileURLToPath(import.meta.url)) { migrate().catch((err) => { console.error(err.message); process.exit(1); }); }
Почему миграции запускаются при старте? У приложений пока нет команды релиза (шага перед развёртыванием), поэтому единственное место, где код выполняется до трафика, — сам контейнер. Если миграция падает, процесс завершается, новый релиз так и не проходит проверку работоспособности, а продакшен продолжает обслуживать предыдущий релиз. Запустить миграции вручную тоже можно: npm run migrate против локальной базы или любой базы, к которой у вас есть доступ.
4. Dockerfile#
Обычный образ для продакшена: установить ровно то, что записано в lock-файле, без dev-зависимостей, скопировать код и запустить от непривилегированного пользователя node. node server.js — главный процесс, поэтому SIGTERM попадает прямо в обработчик; обёртка вроде sh -c его бы проглотила.
FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY . . # Run as the unprivileged user the node image ships with. USER node EXPOSE 3000 CMD ["node", "server.js"]
inquir apps deploy отправляет папку на сборку. Он пропускает node_modules, .git, dist и папки, имя которых начинается с точки, а начиная с CLI 0.4.1 применяет к самой загрузке .dockerignore контекста сборки (и .inquirignore с тем же синтаксисом). Кроме того, он никогда не загружает файлы .env, .envrc и .env.* (кроме .env.example, .env.sample и .env.template), приватные SSH-ключи (id_rsa, id_ed25519, …), хранилища ключей .p12/.pfx, .netrc, .git-credentials и .pgpass, файлы .pem, .key, .p8 и JSON размером до 1 МиБ, в которых есть приватный ключ (например, ключ сервисного аккаунта облака; файлы крупнее не проверяются), а также .npmrc/.pypirc с учётными данными, записанными прямо в файле, и называет каждый пропущенный такой файл. Никакой ignore-файл не вернёт их в загрузку; значения для сборки передавайте через --build-arg. Старые версии CLI загружают .env; выполните inquir upgrade. Настоящие секреты лучше вообще не держать в папке проекта, а задавать переменными.
node_modules npm-debug.log .env .env.* .git
5. Создаём, подключаем, деплоим#
Создайте приложение, подключите к нему базу и разверните его. connect записывает DATABASE_URL в переменные сервиса; переменная попадает в контейнер только при запуске нового релиза, поэтому порядок такой: сначала connect, потом deploy.
cd notes-api # HTTP on port 3000, health-checked at /healthz inquir apps create notes-api --port 3000 --health-path /healthz --project notes # DATABASE_URL lands in notes-api's variables; the password never passes # through your terminal inquir apps connect notes-db notes-api # DATABASE_URL set on notes-api # Applies on the next release — deploy it: inquir apps deploy notes-api # Build the Dockerfile on the platform and start a release next to production inquir apps deploy notes-api # Build succeeded in 13956ms (sha256:93fe5775df17) # Release 4cf0e868 queued for notes-api (build 06e85ac3) # — preview live (4s): serving its preview URL — # send production traffic to it: inquir apps promote 4cf0e868-ae7b-… # Did the migrations run? Logs of this release; they follow until Ctrl-C inquir apps logs 4cf0e868 --tail 20 # migrate: applying 001_create_notes.sql # migrate: applying 002_add_note_summary.sql # migrate: up to date (2 migrations) # notes-api listening on 3000 # Production, then a public HTTPS address inquir apps promote 4cf0e868 inquir apps update notes-api --ingress public inquir apps status notes-api # production: https://notes-api-2fzzux.apps.inquir.org
deploy без --promote запускает новый релиз рядом с продакшеном и ждёт проверки работоспособности. Новые приложения приватные, поэтому у этого первого релиза нет URL: посмотрите в логах, как прошли миграции, затем переведите релиз в продакшен и откройте публичный доступ. Когда приложение публичное, каждое развёртывание печатает собственный адрес предварительной версии, который можно проверить до перевода в продакшен.
Проверьте:
URL=https://notes-api-2fzzux.apps.inquir.org # yours is in: inquir apps status notes-api curl $URL/healthz # {"status":"ok"} curl -X POST $URL/notes -H 'content-type: application/json' \ -d '{"title":"First note","body":"Hello from the tutorial"}' # {"id":"1","title":"First note","body":"Hello from the tutorial","created_at":"2026-10-06T15:16:29.313Z","summary":null} curl $URL/notes # [{"id":"1","title":"First note",…}]
6. Как сервис общается с Postgres#
- Строка подключения.
connectзаписываетpostgresql://inquir:<password>@notes-db-<suffix>.apps.internal:5432/notes_db. Она указывает на приватный адрес, иsslmodeне нужен. Если у сервиса уже естьDATABASE_URL,connectоткажется его перезаписывать; чтобы заменить, добавьте--overwrite.inquir apps connection-url notes-dbпечатает URL, если он нужен где-то ещё на платформе. - Размер пула. По умолчанию Postgres разрешает 100 соединений. Каждая реплика открывает до
maxиз них, а реплик у приложения не больше 8. Во время развёртывания рядом со старыми репликами запускается полный набор новых, поэтому в худшем случае выходит 16 × 5 = 80 соединений, плюс одно для программы миграций и небольшой запас для вас. На базе с 512 МБ пул побольше не ускоряет запросы: он лишь позволяет большему их числу бороться за тот же CPU. ПоднимайтеPG_POOL_MAX, только когда метрики показывают, что запросы ждут соединения, и каждый раз пересчитывайте эту сумму. - Таймауты.
connectionTimeoutMillisроняет запрос через 5 с, если пул исчерпан или база недоступна, аstatement_timeoutостанавливает затянувшийся запрос через 10 с. Видимая ошибка лучше запроса, который висит, пока клиенту не надоест. - Перезапуски базы. База с томом деплоится так: старый контейнер останавливается до старта нового, поэтому во время её собственных деплоев она пропадает на несколько секунд. Пул сам заменяет оборванные соединения, а обработчик
errorне даёт процессу упасть, пока это происходит. - Проверка работоспособности затрагивает базу. После каждого развёртывания платформа опрашивает
/healthz(работоспособным считается любой статус ниже 500, 3 с на попытку, 120 с на то, чтобы пройти) и переключает трафик на новый релиз только после успеха. Раз/healthzвыполняетSELECT 1, релиз, который не видит базу, никогда не станет продакшеном. Держите проверку такой же дешёвой: никаких сканов таблиц и вызовов других сервисов. Помните о цене: если платформа по этой проверке ещё и заменяет неработоспособные реплики, при недоступной базе падают все реплики разом, и их перезапуск базу не вернёт; а под большой нагрузкой проверка, ждущая свободного соединения из пула, может не уложиться в 3 с. Если это для вас важнее, чем защита перевода в продакшен, направьте проверку на путь, который не обращается к базе. - Корректное завершение (graceful shutdown). Когда релиз заменяют, он сначала перестаёт получать новый трафик, а позже останавливается сигналом
SIGTERM, за которым следуетSIGKILL, когда истечёт время на дренаж (--drain-grace, по умолчанию 10 с). Обработчик дожидается текущих запросов, закрывает пул, чтобы Postgres не ждал мёртвых сессий, и сдаётся через 8 с — меньше этого времени. Если увеличите--drain-grace, можно увеличить и этот срок.
7. Добавляем второй сервис в приватной сети#
Теперь интеграция между сервисами. notes-summary — вспомогательный сервис без базы и без публичного адреса. Создайте рядом папку notes-summary с таким server.js, Dockerfile и package.json, в котором достаточно {"name": "notes-summary", "private": true, "type": "module"}:
import http from 'node:http'; // A private helper service: no database, no public address. The notes API // calls it at http://<slug>.apps.internal/summarize. const port = Number(process.env.PORT ?? 3000); function summarize(text) { const words = text.trim().split(/\s+/).filter(Boolean); const summary = words.slice(0, 12).join(' ') + (words.length > 12 ? '…' : ''); return { words: words.length, summary }; } const server = http.createServer(async (req, res) => { if (req.url === '/healthz') { res.writeHead(200).end('ok'); return; } if (req.url === '/summarize' && req.method === 'POST') { let raw = ''; req.setEncoding('utf8'); for await (const chunk of req) { raw += chunk; if (raw.length > 64 * 1024) { res.writeHead(413).end('body too large'); return; } } try { const { text = '' } = JSON.parse(raw || '{}'); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(summarize(String(text)))); } catch { res.writeHead(400).end('invalid JSON'); } return; } res.writeHead(404).end(); }); server.listen(port, '0.0.0.0', () => console.log(`notes-summary listening on ${port}`)); process.on('SIGTERM', () => server.close(() => process.exit(0)));
FROM node:22-alpine WORKDIR /app COPY . . USER node EXPOSE 3000 CMD ["node", "server.js"]
Разверните его, узнайте приватный адрес и передайте адрес API через переменную. redeploy запускает новый релиз из образа, который уже работает в продакшене, с текущими переменными и переводит его в продакшен, как только он пройдёт проверку работоспособности: пересобирать ничего не нужно.
cd ../notes-summary inquir apps create notes-summary --port 3000 --health-path /healthz --project notes inquir apps deploy notes-summary --promote inquir apps status notes-summary # private: http://notes-summary-v4sjsk.apps.internal # Tell the API where it lives, then roll a release with the new variable. # redeploy reuses the image production runs: no rebuild. inquir apps update notes-api --set SUMMARY_URL=http://notes-summary-v4sjsk.apps.internal inquir apps redeploy notes-api # Release e79756d2 queued for notes-api (redeploy of 4cf0e868) # Production now serves e79756d2 curl -X POST $URL/notes -H 'content-type: application/json' \ -d '{"title":"Second note","body":"This one goes through the private summary service and back"}' # {"id":"2",…,"summary":"Second note. This one goes through the private summary service and back"}
- Адреса. У каждого приложения есть приватный адрес
http://<name>-<suffix>.apps.internal, его показываетinquir apps status. Для HTTP-сервиса порт 80 на этом адресе ведёт на HTTP-порт приложения, поэтому порт в URL не нужен; TCP-порты сохраняют свой номер, как Postgres на 5432. Короткого имени без суффикса нет, так что копируйте адрес целиком. - Приватно по умолчанию.
--ingress publicпонадобился только API. Ingress определяет, кто может обратиться к приложению из интернета; база и вспомогательный сервис остаются доступны только внутри рабочего пространства. - Рассчитывайте, что другая сторона может лежать. API вызывает вспомогательный сервис с таймаутом 2 с, а если вызов не удался, сохраняет заметку без краткого содержания. Медленный вспомогательный сервис не должен ронять API.
- Конфигурация, а не код. Адрес хранится в
SUMMARY_URL, поэтому тот же образ можно направить на другой вспомогательный сервис без пересборки. Задайте его черезinquir apps update --set, затем выполнитеredeploy.
8. Миграции в продакшене#
Всё в этом разделе следует из одного факта. Новый релиз выполняет миграции, пока предыдущий ещё обслуживает трафик, а откат запускает предыдущий код на новой схеме. Значит, каждая версия схемы должна работать с двумя версиями кода: той, что была до миграции, и той, что идёт после.
- Только вперёд. Никогда не правьте миграцию, которая уже где-то выполнилась; исправляйте новым файлом. Обратные (down) миграции не пишите: настоящий откат схемы — это новая миграция вперёд, а down-миграция, которая удаляет колонку, теряет данные.
- Маленькие и быстрые. Одно изменение на файл. Миграции должны уложиться в те 120 с, за которые релиз должен пройти проверку работоспособности, поэтому долгие бэкфиллы больших таблиц — в отдельный скрипт, который работает пачками, а не при старте.
- Помните о блокировках. Большинство
ALTER TABLEненадолго блокируют таблицу;lock_timeoutне даёт им заморозить продакшен, пока они ждут своей очереди. Индексы на работающих таблицах стройте черезCREATE INDEX CONCURRENTLY, одной командой в файле с пометкойno-transaction:
-- migrate:no-transaction CREATE INDEX CONCURRENTLY notes_created_at_idx ON notes (created_at DESC);
Если построение индекса с CONCURRENTLY упало, Postgres оставляет невалидный индекс, а файл не записывается, поэтому следующее развёртывание запустит его снова и остановится на already exists. Раз этот файл ни разу не выполнился успешно, его можно изменить: замените команду на DROP INDEX CONCURRENTLY IF EXISTS notes_created_at_idx;, разверните, а затем создайте индекс заново в новом файле.
- Ничего разрушительного в том же развёртывании. Удаление или переименование колонки, которой ещё пользуется работающий код, ломает продакшен в момент запуска нового релиза.
Подход expand/contract#
Изменения, которые не сводятся к добавлению, раскладываются на несколько развёртываний. Переименование title в heading занимает четыре, и на каждом шаге со схемой работают оба релиза: тот, что в продакшене, и тот, на который можно откатиться:
-- Renaming notes.title to notes.heading without downtime: four deploys. -- Deploy 1, expand. Code writes title AND heading, reads COALESCE(heading, title). -- migrations/004_add_heading.sql ALTER TABLE notes ADD COLUMN heading text; -- Deploy 2, backfill. Code reads heading, still writes both columns. -- migrations/005_backfill_heading.sql UPDATE notes SET heading = title WHERE heading IS NULL; -- Deploy 3. Code no longer touches title at all. -- migrations/006_title_nullable.sql ALTER TABLE notes ALTER COLUMN title DROP NOT NULL; -- Deploy 4, contract. Nothing running reads or writes title any more. -- migrations/007_drop_title.sql ALTER TABLE notes DROP COLUMN title;
Комментарий к каждому шагу описывает код, который выкатывается вместе с этой миграцией. Между третьим и четвёртым деплоем подождите, пока не будете уверены, что откатываться дальше третьего не придётся. Добавить колонку, таблицу или индекс — это expand в один шаг, без contract.
Откат#
inquir apps rollback notes-api возвращает продакшен на предыдущий релиз без всякой пересборки. Откатывается код, а не схема, и это безопасно именно благодаря правилу выше. Программа миграций пропускает миграции, которые записаны в базе, но которых нет в старом коде, поэтому старый релиз запускается как обычно. Если ошибочной оказалась сама миграция, напишите новую, которая её исправляет, и разверните её.
Бэкапы перед рискованными изменениями#
inquir apps backups notes-db create делает снимок тома базы, а restore возвращает снимок обратно. Кроме того, платформа сама копирует том по расписанию — сейчас каждые 6 часов, — так что свежая точка восстановления обычно уже есть; и всё же перед рискованным изменением сделайте снимок вручную. Приложения, тома которых вместе занимают больше 2 ГБ, платформа пока не копирует. Запустить pg_dump вручную пока нельзя: консоль (inquir apps exec) и публичные TCP-порты для приложений на рабочих серверах недоступны. Если нужны логические дампы, пусть другое приложение рабочего пространства запускает pg_dump "$DATABASE_URL" из своего кода (адрес ему выдаст inquir apps connect notes-db dumper, в образе нужен клиент Postgres 16) и отправляет дамп за пределы платформы.
Снимок восстанавливает том целиком, и всё, что записано после него, пропадает. Для отдельной миграции точнее страхует подход expand/contract: до шага contract никакие данные не пропадают, а сам шаг contract может сохранить копию того, что удаляет, прямо в той же базе:
-- The contract step, with a safety copy. Drop the copy in a later -- migration, once you are sure you will not need it. CREATE TABLE notes_title_backup AS SELECT id, title FROM notes; ALTER TABLE notes DROP COLUMN title;
Секреты и конфигурация#
DATABASE_URL приходит из connect, остальные секреты задаются так же: inquir apps update notes-api --set API_KEY=…, а затем inquir apps redeploy notes-api. Значения хранятся зашифрованными и не попадают в логи сборки. Никогда не кладите секреты в Dockerfile (ENV и ARG оседают в образе) и в папку проекта.
Локальная разработка#
Запустите локально Postgres той же мажорной версии через Docker Compose, направьте на него DATABASE_URL и запустите сервис. Те же миграции выполняются при каждом старте, поэтому локальная схема всегда совпадает с тем, что получит продакшен при следующем развёртывании.
# The same Postgres major version as the template services: db: image: postgres:16-alpine environment: POSTGRES_USER: notes POSTGRES_PASSWORD: notes POSTGRES_DB: notes ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data volumes: pgdata:
# In the notes-api folder, next to compose.yaml docker compose up -d export DATABASE_URL=postgresql://notes:notes@localhost:5432/notes npm install npm start # waits for Postgres, migrates, listens on :3000 npm run migrate # or only the migrations
docker compose down -v удаляет локальные данные, когда нужно прогнать все миграции с нуля, — хорошая проверка перед деплоем.
Когда появятся release-команды#
Release-команда (шаг перед деплоем) для приложений запланирована; когда она появится, node migrate.js переедет туда и будет выполняться один раз на деплой до старта любой реплики, а все правила с этой страницы останутся прежними.
9. Эксплуатируем#
Команды, которые пригодятся каждый день:
inquir apps status notes-api # releases, private and public addresses inquir apps logs notes-api --tail 100 # live output; follows until Ctrl-C inquir apps log-history notes-api --since 1h # persisted logs, older releases too inquir apps metrics notes-api # CPU, RAM, network per replica inquir apps scale notes-api always --replicas 2 inquir apps rollback notes-api # previous release, no rebuild
Если что-то пошло не так#
runtime.healthcheck.path: Invalid input: must start with "/"в Windows. Git Bash превращает/healthzв путь Windows ещё до того, как его увидит CLI. Начиная с CLI 0.4.1 CLI распознаёт такой путь, использует/healthzи предупреждает об этом. Со старой версией добавьте перед командойMSYS_NO_PATHCONV=1или запустите её из PowerShell.connectговорит, что у базы ещё нет проверенного приватного адреса. Платформа проверяет приватный маршрут, когда база начинает обслуживать трафик, и перепроверяет примерно каждые 20 секунд. Подождите минуту; когдаinquir apps status notes-dbпокажет адрес в строкеprivate:, запуститеconnectещё раз.- Сервис падает с
DATABASE_URL is not set. Переменные попадают только в релизы, запущенные после изменения. Выполнитеconnect, затемdeployилиredeploy. - Первое развёртывание печатает
no reachable endpointи не даёт URL. Приложение приватное. Посмотритеinquir apps logs, переведите релиз в продакшен, затем выполнитеinquir apps update notes-api --ingress public. inquir apps logsне возвращает управление. Команда печатает последние строки и дальше следит за потоком, пока вы не нажмёте Ctrl-C. Добавьте--no-follow(при желании с--tail 100), чтобы напечатать и завершиться.inquir apps log-history notes-api --since 30mпечатает сохранённые логи с фильтрами;--sinceпринимает любую длительность вроде30m,2hили1h30m, а также метку времени ISO.- После изменения схемы релиз не проходит проверку работоспособности. Ищите
migration … failedв логах этого релиза:inquir apps logs <releaseId>илиinquir apps log-history notes-api --release <full-release-id>(простоinquir apps logs notes-apiпоказывает продакшен). Продакшен при этом остаётся на предыдущем релизе. Обычный файл откатился и не записан, так что исправьте его и разверните снова; про упавший файл сno-transactionсм. заметку об индексах сCONCURRENTLYв разделе 8.canceling statement due to lock timeoutозначает, что таблица занята: разверните ещё раз в более спокойный момент. inquir apps execотвечаетConsole for applications running on a remote worker is not available yet. Консоль пока не работает для приложений на рабочих серверах, поэтому разовые задачи внутри контейнера сейчас не запустить. Это ещё одна причина, по которой миграции выполняются при старте. Обратите внимание:execпринимает имя приложения, а не id релиза.sorry, too many clients already. Число реплик, умноженное наPG_POOL_MAX, превышает то, что разрешает Postgres. Уменьшите пул или число реплик.
Очистка#
Сначала удалите сервисы, которые пользуются базой: база не даст себя удалить, пока к ней подключены другие приложения. delete сразу останавливает все релизы, а само приложение вместе с томом окончательно удаляется через сутки. Опустевший проект заархивируйте в консоли.
# The apps that use the database go first inquir apps delete notes-api --yes inquir apps delete notes-summary --yes inquir apps delete notes-db --yes
Куда дальше#
Справочник по приложениям описывает порты, тома, масштабирование и API за каждой командой, а Переменные окружения и секреты подробнее разбирают переменные. Домены и предварительные версии в консоли — в руководстве Развёртывание приложения; все флаги — в справочнике CLI.