Перейти к содержимому
Документация

Туториал: Node.js-сервис с Postgres и миграциями

В этом туториале мы напишем небольшой API заметок на Node.js, поднимем для него Postgres, подключим к нему второй сервис через приватную сеть и настроим миграции базы, которым можно доверять в production. Код сервисов и шаги деплоя ниже прогнаны на платформе ровно в таком виде; вывод команд сокращён. По ходу дела объясняем, почему сделано именно так: каким должен быть пул соединений, что проверять в health-check, зачем миграции запускаются до того, как сервер начнёт слушать порт, и как менять схему без простоя.

Что получится#

Три приложения в одном проекте. Из интернета доступен только API, всё остальное общается через приватную сеть воркспейса.

  • notes-db — Postgres из шаблона: постоянный том и приватный TCP-эндпоинт.
  • notes-api — сервис на Node.js. Он отвечает за схему, запускает миграции и отдаёт /notes и /healthz.
  • notes-summary — приватный вспомогательный сервис: API обращается к нему по HTTP, чтобы получить краткое содержание заметки. На нём видно, как сервисы находят и вызывают друг друга и как пережить ситуацию, когда один из них лежит.

1. Создаём базу#

Начнём с базы: сервису нужен её адрес. Проект собирает все три сервиса на одном канвасе в дашборде:

terminalbash
# 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 и порядок запуска остаются точно такими же.

package.jsonjson
{
  "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 повторяет попытки до минуты: при первом деплое база может ещё стартовать.

db.jsjs
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 сервер перестаёт принимать соединения, даёт текущим запросам завершиться и закрывает пул. Обработчики сигналов регистрируются до медленной части запуска, так что остановка посреди миграций тоже обрабатывается корректно.

server.jsjs
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. Для начала их две: сама таблица и колонка, добавленная позже.

migrations/001_create_notes.sqlbash
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()
);
migrations/002_add_note_summary.sqlbash
-- 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, который по своей природе ждёт завершения текущих транзакций.

migrate.jsjs
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 его бы проглотила.

Dockerfilebash
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 не пустит его в образ. Настоящие секреты лучше вообще не держать в папке проекта.

.dockerignorebash
node_modules
npm-debug.log
.env
.env.*
.git

5. Создаём, подключаем, деплоим#

Создайте приложение, подключите к нему базу и задеплойте. connect записывает DATABASE_URL в переменные сервиса; переменная попадает в контейнер только при старте нового релиза, поэтому порядок такой: сначала connect, потом deploy.

terminalbash
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, который можно проверить до промоута.

Проверьте:

terminalbash
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"}:

notes-summary/server.jsjs
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)));
notes-summary/Dockerfilebash
FROM node:22-alpine
WORKDIR /app
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]

Задеплойте его, узнайте приватный адрес и передайте адрес API через переменную. redeploy запускает новый релиз из образа, который уже работает в production, с текущими переменными, и промоутит его, как только тот здоров: пересобирать ничего не нужно.

terminalbash
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:
migrations/003_index_notes_created_at.sqlbash
-- 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, и тот, на который можно откатиться:

expand / contractbash
-- 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 может сохранить копию того, что удаляет, прямо в той же базе:

migrations/007_drop_title.sqlbash
-- 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 при следующем деплое.

compose.yamlbash
# 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:
terminalbash
# 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. Эксплуатируем#

Команды, которые пригодятся каждый день:

terminalbash
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 сразу останавливает все релизы, а само приложение вместе с томом окончательно удаляется через сутки. Опустевший проект заархивируйте в дашборде.

terminalbash
# 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.