Для AI-агентов: markdown этой страницы — /docs-content/recipes/db-dump-restore.md индекс документации — /llms.txt

Загрузка дампа БД на сервер

Сложность: средний | Скоупы: vibe:infra | Стек: cURL / JavaScript, psql / pg_restore

Восстанавливаем дамп PostgreSQL (или другой СУБД) в базу, доступную с BLACKHOLE-сервера. Дамп — файл с выгрузкой содержимого базы, его создаёт pg_dump. BLACKHOLE — сервер за защищённым туннелем, к которому идут запросы exec, upload и logs.

Что понадобится

  • API-ключ Вайбкод со скоупом vibe:infra
  • BLACKHOLE-сервер, с которого доступна целевая база
  • Файл дампа на рабочей машине
  • python3 для примеров на cURL или Node.js 18+ для примеров на JavaScript — ими кодируется тело запроса на загрузку
  • Клиент СУБД той же старшей версии, что и целевая база (ставится на сервер шагом 3)
  • Доступ к базе: хост, пользователь, имя базы и пароль

Во всех примерах подставьте свои значения: $VIBE_URL — базовый URL https://vibecode.bitrix24.tech, $SERVER_ID — идентификатор сервера, $VIBE_API_KEY — ваш API-ключ. Плейсхолдеры подключения к БД — DB_HOST, DB_USER, DB_NAME.

Как устроено решение

  1. Готовим каталог для данных вне /opt/app — тот очищается при чистом деплое.
  2. Кладём дамп на сервер через /upload, минуя лимит длины команды.
  3. Ставим клиент той же старшей версии, что и целевая база.
  4. Восстанавливаем фоновой задачей с остановкой приложения — одной транзакцией.
  5. Сверяем счётчики строк.

Шаг 1. Каталог для данных — вне /opt/app

Каталог /opt/app очищается при чистом деплое. Так ведёт себя деплой, когда содержимое приложения передаётся прямо в теле JSON: поле cleanDeploy там по умолчанию true. Если архив отправляется отдельным файлом, умолчание обратное. Каталог /opt/data и установленные среды выполнения повторный деплой переживают, поэтому дамп и рабочие файлы кладите в /opt/data:

cURL

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "mkdir -p /opt/data"}'

JavaScript

javascript
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
const SERVER_ID = process.env.SERVER_ID
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ command: 'mkdir -p /opt/data' }),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'каталог не создан')

Флаг --fail-with-body у curl обязателен: без него отказ вроде EXEC_BUSY печатается в вывод, но код возврата остаётся нулевым, и следующий шаг сценария запускается вслепую.

Шаг 2. Загрузить дамп через /upload

Мегабайты base64 не помещаются в аргумент команды оболочки из-за ограничения ARG_MAX — соберите тело запроса в файл и отправьте его через --data-binary. Скрипт кодирует db.dump в base64 и складывает в upload.json, затем curl отправляет этот файл:

Terminal
python3 - <<'EOF'
import base64, json
data = base64.b64encode(open('db.dump', 'rb').read()).decode()
json.dump({'content': data, 'path': '/opt/data/db.dump', 'mode': '0644'}, open('upload.json', 'w'))
EOF

curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/upload" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  --data-binary @upload.json

JavaScript

javascript
import { readFile } from 'node:fs/promises'

const dump = await readFile('db.dump')
const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/upload`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    content: dump.toString('base64'),
    path: '/opt/data/db.dump',
    mode: '0644',
  }),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'дамп не загружен')
console.log(`загружено ${body.data.size} байт в ${body.data.path}`)

Ответ подтверждает записанный путь и размер:

JSON
{
  "success": true,
  "data": {
    "path": "/opt/data/db.dump",
    "size": 48317204,
    "extracted": false
  }
}

Поле size — размер файла на сервере в байтах, extracted показывает, распаковал ли агент архив. Сверьте size с размером локального файла: расхождение означает обрыв загрузки.

Альтернатива — поле url вместо content: агент скачает файл по ссылке сам, без base64.

Шаг 3. Инструменты той же старшей версии, что целевая БД

Восстановление падает, если старшая версия клиента не совпадает с версией целевой базы. Дамп из pg_dump версии 17 в базе версии 16 даёт ошибку класса unsupported version: формат архива 1.16 не читается, а внутри дампа встречаются метакоманды SET transaction_timeout и \restrict, которых нет в 16-й версии. Установите клиент нужной старшей версии один раз — каталог /usr переживает деплой, повторная установка при следующих деплоях не нужна:

cURL

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "apt-get install -y postgresql-client-17"}'

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ command: 'apt-get install -y postgresql-client-17' }),
})
const body = await res.json()
if (!body.success) throw new Error(body.error?.message ?? 'клиент не установлен')
// Ненулевой код возврата — это НЕ отказ маршрута: success остаётся true.
if (body.data.exitCode !== 0) throw new Error(body.data.stderr || 'apt-get завершился с ошибкой')

Ответ маршрута успешен и тогда, когда сама команда упала. Признак успеха команды — data.exitCode, а не success.

Если установка тяжёлая и выходит за лимит timeout (максимум 600 секунд), запустите её фоновой задачей через systemd-run. Такая задача живёт в собственной группе процессов и не обрывается, когда время вызова exec истекает:

Terminal
curl -sS -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "systemctl reset-failed install-pg 2>/dev/null; systemd-run --unit=install-pg /bin/bash -c \"apt-get install -y postgresql-client-17\"", "timeout": 30}'

curl -sS -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/infra/servers/$SERVER_ID/logs?service=install-pg&lines=50"

Шаг 4. Восстановление — фоновой задачей, с остановкой приложения

Восстановление большого дампа занимает минуты, а когда время вызова exec истекает, агент снимает всю группу процессов сигналом SIGKILL, не давая ей завершиться штатно. Поэтому восстановление всегда идёт фоновой задачей. Оформите его отдельным скриптом и загрузите через /upload с правами mode: "0755" — так же, как дамп в шаге 2:

Terminal
#!/bin/bash
set -euo pipefail
set -a; source /opt/data/restore.env; set +a   # файл с PGPASSWORD=…, права 0600.
                                              # set -a экспортирует переменную дочернему psql
PSQL=(psql -h DB_HOST -p 5432 -U DB_USER -d DB_NAME)

systemctl stop app               # запросы приложения держат блокировки — TRUNCATE/COPY встанут в очередь
trap 'rm -f /opt/data/restore.env; systemctl start app' EXIT  # приложение поднимется
                                 # даже если восстановление сорвётся, файл с паролем не останется

# Очистка и загрузка идут ОДНОЙ транзакцией: при любой ошибке откатывается всё
# вместе с TRUNCATE, и старые данные остаются на месте. Отдельный TRUNCATE до
# pg_restore означал бы, что сорвавшаяся загрузка оставляет базу пустой.
{
  echo 'TRUNCATE TABLE t1, t2 RESTART IDENTITY CASCADE;'
  pg_restore --data-only --no-owner --no-privileges /opt/data/db.dump
} | "${PSQL[@]}" --single-transaction -v ON_ERROR_STOP=1

echo "RESTORE COMPLETE"

Пароль базы скрипт читает из файла /opt/data/restore.env с правами 0600 — этот файл кладётся тем же /upload, что и дамп. Годится и ~/.pgpass. Через текст команды пароль не передаётся никогда — агент журналирует первые ~200 символов команды, и секрет попал бы в журнал. Приложение останавливается на время восстановления: его запросы держат блокировки, и без остановки TRUNCATE и COPY встанут в очередь.

Два нюанса pg_restore --data-only, которые всплывают на связанных таблицах:

  • Порядок таблиц — алфавитный, не по зависимостям. Данные льются в алфавитном порядке имён таблиц, поэтому дочерняя таблица может пойти раньше родительской и упасть на нарушении внешнего ключа. Решение — переупорядоченный список объектов: pg_restore --list db.dump > toc.list, переставьте строки данных так, чтобы родительские таблицы шли первыми, и восстанавливайте с pg_restore -L toc.list ….
  • Схему миграций исключите из восстановления. Инструменты работы с базой из кода — Drizzle, Prisma и подобные — держат собственную таблицу миграций в отдельной схеме, например drizzle.__drizzle_migrations. Дамп несёт её данные, а приложение при старте могло уже вставить туда свежую строку, и тогда COPY упадёт на дубликате ключа. Исключайте схему целиком: pg_restore -N drizzle …. Таблицей миграций управляет само приложение.

Остановка systemctl stop app в скрипте выше закрывает ещё одну ловушку: служба с Restart=on-failure перезапустила бы упавшее приложение в промежутке между TRUNCATE и стартом COPY, и оно успело бы записать свои строки — одной секунды достаточно. Если во время долгого восстановления возможен внешний запуск приложения (например, параллельный /deploy), усильте остановку до systemctl mask app. Тогда и снятие маски переносится в trap, иначе замаскированная служба не запустится: trap 'systemctl unmask app; systemctl start app' EXIT.

Запуск и контроль. systemd-run помещает задачу в собственную группу процессов, поэтому она переживает истечение времени exec. Перед повторным запуском под тем же именем сбросьте прежнее состояние службы через systemctl reset-failed:

cURL

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "systemctl reset-failed restore-db 2>/dev/null; systemd-run --unit=restore-db /bin/bash /opt/data/restore.sh", "timeout": 30}'

curl -sS --fail-with-body -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/infra/servers/$SERVER_ID/logs?service=restore-db&lines=50"

JavaScript

javascript
const start = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    command: 'systemctl reset-failed restore-db 2>/dev/null; systemd-run --unit=restore-db /bin/bash /opt/data/restore.sh',
    timeout: 30,
  }),
}).then(r => r.json())
if (!start.success) throw new Error(start.error?.message ?? 'задача не запущена')

// Задача идёт в фоне — за ходом следим по журналу юнита.
const logs = await fetch(
  `${VIBE_URL}/v1/infra/servers/${SERVER_ID}/logs?service=restore-db&lines=50`,
  { headers: { 'X-Api-Key': VIBE_API_KEY } },
).then(r => r.json())
console.log(logs.data.logs)

Скрипт выше подаёт TRUNCATE и вывод pg_restore одним потоком в psql --single-transaction -v ON_ERROR_STOP=1, поэтому очистка и загрузка составляют одну транзакцию. Это защищает от двух исходов сразу. Первый — прерванная загрузка на уже очищенных таблицах: несовпадение старшей версии клиента из шага 3 обрывает pg_restore, и при отдельном TRUNCATE база осталась бы пустой без возможности отката. Второй — «задача выполнялась часами, а загрузилось 0 строк»: без ON_ERROR_STOP загрузка продолжается после первой ошибки и завершается с виду успешно.

Строка trap 'systemctl start app' EXIT возвращает приложение при любом выходе из скрипта, включая аварийный. Без неё сорвавшееся восстановление оставляет приложение остановленным.

Шаг 5. Проверить счётчики

После восстановления сверьте количество строк в таблицах. Пароль базы здесь передаётся через поле env, а не в тексте команды:

cURL

Terminal
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/exec" \
  -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  -d '{"command": "psql -h DB_HOST -U DB_USER -d DB_NAME -tAc \"SELECT count(*) FROM t1\"", "env": {"PGPASSWORD": "…"}, "timeout": 60}'

JavaScript

javascript
const check = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    command: 'psql -h DB_HOST -U DB_USER -d DB_NAME -tAc "SELECT count(*) FROM t1"',
    env: { PGPASSWORD: process.env.DB_PASSWORD },
    timeout: 60,
  }),
}).then(r => r.json())
if (!check.success) throw new Error(check.error?.message ?? 'счётчик не прочитан')
if (check.data.exitCode !== 0) throw new Error(check.data.stderr)
console.log(`строк в t1: ${check.data.stdout.trim()}`)

Ограничения

Один ответ на всю команду. Синхронный вызов /exec возвращает код возврата и оба потока вывода одним ответом.

JSON
{
  "success": true,
  "data": {
    "exitCode": 0,
    "stdout": "RESTORE COMPLETE\n",
    "stderr": "",
    "duration": 4213,
    "truncated": false
  }
}

Канал занят одной командой. Пока предыдущая синхронная команда не завершилась, канал занят.

JSON
{
  "success": false,
  "error": {
    "code": "EXEC_BUSY",
    "message": "Another operation is running on this server",
    "retryable": true,
    "retryAfter": 10,
    "hint": {
      "reason": "A 'deploy' operation currently holds the lock on this server.",
      "recovery": "If the previous operation crashed or its deploy task is stuck (e.g. the backend restarted, or the server was deleted and recreated), force-release the lock and retry.",
      "recoveryAction": "DELETE /v1/infra/servers/:id/lock",
      "autoExpiresInSeconds": 42,
      "note": "The backend lock auto-expires after ~15 minutes. The Black Hole agent also holds its own exec mutex (\u226410 min) that releases when the running command finishes or times out. If force-releasing the backend lock STILL yields EXEC_BUSY, the agent exec mutex has leaked (a detached background process is holding it open) — call POST /v1/infra/servers/:id/unstick to force-release the lock AND bounce the agent tunnel (its reconnect handler group-kills the stuck exec, freeing the mutex) with no VM reboot."
    }
  }
}

Поля retryable и retryAfter — машинный сигнал: повтор уместен, пауза в секундах (не больше 10 — это интервал опроса, а не срок жизни блокировки), она же приходит заголовком Retry-After. Блокировка снимается сама примерно через 15 минут, а зависшая снимается вручную через DELETE /v1/infra/servers/:id/lock.

Полный перечень кодов — Ошибки.

Большие файлы — через /upload. Мегабайты base64 не помещаются в аргумент команды из-за лимита ARG_MAX, а команда длиннее 10000 символов отклоняется отказом COMMAND_TOO_LONG. Поэтому дамп и любые большие файлы идут через /upload, а не через exec. Сам маршрут /upload принимает до 500 МБ на файл.

Долгая работа — в фоновую задачу. Потолок timeout у exec — 600 секунд. По его истечении агент снимает всю группу процессов сигналом SIGKILL, не давая ей завершиться штатно. Команда, не уложившаяся в timeout, приходит отказом EXEC_TIMEOUT. Поэтому всё, что дольше, — включая само восстановление, — запускается фоновой задачей через systemd-run.

Данные — вне /opt/app. Каталог /opt/app очищается при чистом деплое, поэтому дамп и рабочие файлы живут в /opt/data. Установленные среды выполнения в /usr повторный деплой переживают, повторно ставить клиент не нужно.

Не объявляйте /opt/data каталогом данных приложения. Поле dataDirs в теле деплоя передаёт каталог учётной записи, под которой работает приложение, а владелец каталога может удалить или заменить в нём любой файл — включая restore.env с паролем и сам restore.sh, который вы запускаете от root. Для этого рецепта это прямой путь к подмене исполняемого скрипта. Нужен приложению собственный каталог состояния — объявите отдельный, например /opt/data/state. По этой же причине dataDirsRecursive для /opt/data не принимается.

Пароль — не в тексте команды. Агент журналирует первые ~200 символов команды. Пароль базы поэтому передаётся переменной окружения PGPASSWORD, файлом ~/.pgpass или полем env, но никогда текстом команды.

Откат при ошибке. Восстановление идёт одной транзакцией: при ошибке откатывается вместе с очисткой таблиц. Отдельная очистка до загрузки оставила бы базу пустой на сорвавшемся восстановлении.

Полный код

Скрипт проходит все пять шагов: готовит каталог, кладёт дамп и сценарий восстановления, запускает фоновую задачу и ждёт её завершения по журналу. Это единственный запускаемый артефакт страницы — примеры шагов выше показывают отдельные вызовы.

Скрипт очищает таблицы целевой базы. Перед первым запуском убедитесь, что DB_NAME указывает на ту базу, которую вы намерены перезаписать, и что у вас есть отдельная резервная копия.

javascript
// restore.mjs — загрузка дампа на BLACKHOLE-сервер и восстановление базы
// Расширение .mjs обязательно: скрипт использует await на верхнем уровне,
// а файл .js без "type": "module" Node читает как CommonJS и падает на разборе.
import { readFile } from 'node:fs/promises'

const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
const SERVER_ID = process.env.SERVER_ID
const DB_PASSWORD = process.env.DB_PASSWORD
if (!VIBE_API_KEY || !SERVER_ID || !DB_PASSWORD) {
  throw new Error('задайте VIBE_API_KEY, SERVER_ID и DB_PASSWORD')
}

const BASE = `${VIBE_URL}/v1/infra/servers/${SERVER_ID}`
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

// Маршрут отвечает успехом и на команду, завершившуюся с ошибкой: признак
// успеха самой команды — exitCode, а не success. Проверяются оба.
async function call(path, init) {
  const res = await fetch(`${BASE}${path}`, init)
  const body = await res.json().catch(() => null)
  if (!body?.success) throw new Error(body?.error?.message ?? `запрос отклонён (${res.status})`)
  return body.data
}

async function exec(command, timeout = 60) {
  const data = await call('/exec', {
    method: 'POST',
    headers,
    body: JSON.stringify({ command, timeout }),
  })
  if (data.exitCode !== 0) throw new Error(data.stderr || `команда завершилась кодом ${data.exitCode}`)
  return data.stdout
}

async function upload(localPath, remotePath, mode) {
  const bytes = await readFile(localPath)
  const data = await call('/upload', {
    method: 'POST',
    headers,
    body: JSON.stringify({ content: bytes.toString('base64'), path: remotePath, mode }),
  })
  // Размер на сервере сверяется с локальным: расхождение означает обрыв загрузки.
  if (data.size !== bytes.length) {
    throw new Error(`${remotePath}: загружено ${data.size} из ${bytes.length} байт`)
  }
  return data
}

// 1. Каталог вне /opt/app — тот очищается при чистом деплое
await exec('mkdir -p /opt/data')

// 2-3. Дамп, сценарий восстановления и файл с паролем. Пароль уходит
// отдельным файлом с правами 0600, а не текстом команды: агент журналирует
// первые ~200 символов команды, и секрет попал бы в журнал.
await upload('./db.dump', '/opt/data/db.dump', '0644')
await upload('./restore.sh', '/opt/data/restore.sh', '0755')
await call('/upload', {
  method: 'POST',
  headers,
  body: JSON.stringify({
    content: Buffer.from(`PGPASSWORD='${DB_PASSWORD.replace(/'/g, `'\\''`)}'\n`).toString('base64'),
    path: '/opt/data/restore.env',
    mode: '0600',
  }),
})

// 4. Фоновая задача: собственная cgroup переживает таймаут exec.
// reset-failed обязателен — иначе повторный запуск под тем же именем откажет.
await call('/exec', {
  method: 'POST',
  headers,
  body: JSON.stringify({
    command: 'systemctl reset-failed restore-db 2>/dev/null; '
      + 'systemd-run --unit=restore-db /bin/bash /opt/data/restore.sh',
    timeout: 30,
  }),
})

// 5. Ждём маркер завершения в журнале юнита
for (let i = 0; i < 120; i++) {
  await sleep(15_000)
  const { logs } = await call('/logs?service=restore-db&lines=50', {
    headers: { 'X-Api-Key': VIBE_API_KEY },
  })
  // Маршрут отдаёт logs МАССИВОМ строк, а journalctl предваряет каждую
  // отметкой времени и именем юнита. Поэтому маркер ищется вхождением
  // в склеенный текст, а не равенством элемента массива.
  const text = logs.join('\n')
  if (text.includes('RESTORE COMPLETE')) {
    console.log('восстановление завершено')
    break
  }
  if (/^.*(FATAL|ERROR:)/m.test(text)) throw new Error(`восстановление сорвалось:\n${text}`)
  if (i === 119) throw new Error('маркер завершения не появился за 30 минут')
}

const rows = await exec('psql -h DB_HOST -U DB_USER -d DB_NAME -tAc "SELECT count(*) FROM t1"')
console.log(`строк в t1: ${rows.trim()}`)

Смотрите также