В этом туториале мы напишем небольшой API заметок на Node.js, поднимем для него Postgres, подключим к нему второй сервис через приватную сеть и настроим миграции базы, которым можно доверять в production. Код сервисов и шаги деплоя ниже прогнаны на платформе ровно в таком виде; вывод команд сокращён. По ходу дела объясняем, почему сделано именно так: каким должен быть пул соединений, что проверять в health-check, зачем миграции запускаются до того, как сервер начнёт слушать порт, и как менять схему без простоя.
Что получится#
Три приложения в одном проекте. Из интернета доступен только 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 отвечает за пул соединений: один пул на процесс, создаётся один раз. В production важны именно его настройки: небольшой 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 стартует в строгом порядке: дождаться базы, применить миграции и только потом слушать порт. Платформа пускает трафик только после того, как пройдёт health-check, а он не пройдёт, пока сервер не слушает порт, поэтому ни один запрос не попадёт на старую схему. По 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); }); }
Почему миграции запускаются при старте? У приложений пока нет release-команды (шага перед деплоем), поэтому единственное место, где код выполняется до трафика, — сам контейнер. Если миграция падает, процесс завершается, новый релиз так и не проходит health-check, а production продолжает обслуживать предыдущий релиз. Запустить миграции вручную тоже можно: npm run migrate против локальной базы или любой базы, к которой у вас есть доступ.
4. Dockerfile#
Обычный production-образ: поставить ровно то, что записано в 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 и папки, имя которых начинается с точки, но .dockerignore не читает. Файл .env из папки будет загружен, и только .dockerignore не пустит его в образ. Настоящие секреты лучше вообще не держать в папке проекта.
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 запускает новый релиз рядом с production и ждёт его health-check. Новые приложения приватные, поэтому у этого первого релиза нет URL: посмотрите в логах, как прошли миграции, затем промоутните его и откройте публичный доступ. Когда приложение публичное, каждый деплой печатает собственный превью-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не даёт процессу упасть, пока это происходит. - Health-check проверяет базу. После каждого деплоя платформа опрашивает
/healthz(здоровым считается любой статус ниже 500, 3 с на попытку, 120 с на то, чтобы пройти) и переключает трафик на новый релиз только после успеха. Раз/healthzвыполняетSELECT 1, релиз, который не видит базу, никогда не станет production. Держите проверку такой же дешёвой: никаких сканов таблиц и вызовов других сервисов. Помните о цене: если платформа по health-check ещё и заменяет нездоровые реплики, при недоступной базе падают все реплики разом, и их перезапуск базу не вернёт; а под большой нагрузкой проверка, ждущая свободного соединения из пула, может не уложиться в 3 с. Если это для вас важнее, чем защита промоута, направьте health-check на путь, который не трогает базу. - Корректное завершение (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 запускает новый релиз из образа, который уже работает в production, с текущими переменными, и промоутит его, как только тот здоров: пересобирать ничего не нужно.
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. Миграции в production#
Всё в этом разделе следует из одного факта. Новый релиз выполняет миграции, пока предыдущий ещё обслуживает трафик, а откат запускает предыдущий код на новой схеме. Значит, каждая версия схемы должна работать с двумя версиями кода: той, что была до миграции, и той, что идёт после.
- Только вперёд. Никогда не правьте миграцию, которая уже где-то выполнилась; исправляйте новым файлом. Обратные (down) миграции не пишите: настоящий откат схемы — это новая миграция вперёд, а down-миграция, которая удаляет колонку, теряет данные.
- Маленькие и быстрые. Одно изменение на файл. Миграции должны уложиться в те 120 с, за которые релиз должен стать здоровым, поэтому долгие бэкфиллы больших таблиц — в отдельный скрипт, который работает пачками, а не при старте.
- Помните о блокировках. Большинство
ALTER TABLEненадолго блокируют таблицу;lock_timeoutне даёт им заморозить production, пока они ждут своей очереди. Индексы на живых таблицах стройте через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;, задеплойте, а затем создайте индекс заново в новом файле.
- Ничего разрушительного в том же деплое. Удаление или переименование колонки, которой ещё пользуется работающий код, ломает production в момент старта нового релиза.
Подход expand/contract#
Изменения, которые не сводятся к добавлению, раскладываются на несколько деплоев. Переименование title в heading занимает четыре, и на каждом шаге со схемой работают оба релиза: тот, что в production, и тот, на который можно откатиться:
-- 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 возвращает production на предыдущий релиз без всякой пересборки. Откатывается код, а не схема, и это безопасно именно благодаря правилу выше. Раннер пропускает миграции, которые записаны в базе, но которых нет в старом коде, поэтому старый релиз стартует как обычно. Если ошибочной оказалась сама миграция, напишите новую, которая её исправляет, и задеплойте её.
Бэкапы перед рискованными изменениями#
inquir apps backups notes-db create делает снимок тома базы, а restore возвращает снимок обратно. Учтите текущее ограничение: если том базы живёт на воркер-раннере — а именно туда сейчас попадают новые базы из шаблона на inquir.org, — команда отвечает, что бэкапы там пока не поддерживаются. Консоль (inquir apps exec) и публичные TCP-порты для таких баз тоже недоступны, так что запустить pg_dump для такой базы извне сейчас тоже не получится.
Пока управляемые бэкапы не дошли до таких баз, пусть вас страхует подход 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 и запустите сервис. Те же миграции выполняются при каждом старте, поэтому локальная схема всегда совпадает с тем, что получит production при следующем деплое.
# 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. Добавьте перед командой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.inquir apps log-history notes-api --since 1hпечатает сохранённые логи и завершается;--sinceпринимает1h,6h,1d,7dили30d.- После изменения схемы релиз не становится здоровым. Ищите
migration … failedв логах этого релиза:inquir apps logs <releaseId>илиinquir apps log-history notes-api --release <full-release-id>(простоinquir apps logs notes-apiпоказывает production). Production при этом остаётся на предыдущем релизе. Обычный файл откатился и не записан, так что исправьте его и задеплойте снова; про упавший файл с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.