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

Быстрый цикл выпуска

Скоуп: vibe:infra

Для отдельной виртуальной машины с переданным runtime полный деплой через POST /v1/infra/servers/:id/deploy начинается в порядке runtime → stop_existing → clean → download → install: платформа устанавливает рантайм до остановки сервиса, очистки дерева, скачивания архива и установки зависимостей. Затем она записывает .env, выполняет команды preStart, создаёт systemd-юнит, запускает сервис и проверяет его работоспособность. Без runtime первым шагом на сервере остаётся stop_existing. Спящий сервер деплой будит сам. Это правильный путь для первого выпуска и для смены рантайма, но на каждой мелкой правке кода прогонять полный конвейер долго. Ниже — четыре приёма, которые сокращают цикл: убрать раздувание тела запроса, не переустанавливать тяжёлые зависимости, обновлять один изменившийся файл вместо всего дерева, держать данные отдельно от кода.

Если runtime завершается ошибкой, деплой не отправляет команду остановки, не очищает и не заменяет дерево приложения и не читает или перезаписывает .env. Скрипт рантайма выполняется с правами root и сам может изменить системные зависимости сервера, поэтому непрерывная работа прежней версии приложения не гарантируется. После успешного runtime последующие ошибки сохраняют прежнее поведение: сервис уже может быть остановлен, а дерево приложения заменено. Нового отката нет.

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

  • API-ключ Вайбкод со скоупом vibe:infra
  • BLACKHOLE-сервер с уже выпущенным приложением — приёмы ниже сокращают повторный выпуск, а не первый
  • Архив сборки на рабочей машине
  • Node.js 18 или новее для примеров на JavaScript

Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $SERVER_ID — идентификатор BLACKHOLE-сервера, $VIBE_API_KEY — ваш API-ключ.

Какой приём когда применять

Приёмы независимы — берите тот, что отвечает вашей задержке, а не все подряд.

Что мешает Приём Что даёт
Тело запроса раздувается, выгрузка идёт долго Multipart вместо base64 Минус ~33 % объёма, merge-режим по умолчанию
Каждый деплой переустанавливает тяжёлые инструменты Тяжёлые зависимости — вне install-скрипта Шаг install перестаёт упираться в свои 300 секунд
Изменился один файл, а прогоняется полный конвейер Точечные обновления через /upload Обновление без деплоя вообще
Данные лежат в /opt/app и стираются Данные отдельно от кода cleanDeploy перестаёт быть опасным

Полный деплой остаётся правильным путём для первого выпуска, смены рантайма и правки start-команды.

Примеры ниже показывают отдельные вызовы и опираются на переменные VIBE_URL, VIBE_API_KEY и SERVER_ID, объявленные в первом блоке JavaScript.

Multipart вместо base64

Встроенный source.content — это base64-строка архива, а base64 увеличивает объём примерно на 33 %: архив 30 МБ уходит по проводу как 40 МБ. Multipart-режим передаёт архив файлом-как-есть, без этой надбавки. Второе отличие — cleanDeploy в multipart-режиме по умолчанию "false" (merge-деплой: новые файлы кладутся поверх существующего дерева), тогда как inline-деплой из JSON по умолчанию cleanDeploy: true и стирает /opt/app перед распаковкой. Для итеративной правки merge-режим быстрее — не нужно каждый раз выкладывать полное дерево.

cURL

Terminal
tar -czf app.tar.gz -C ./my-app .
curl -sS --fail-with-body -X POST "$VIBE_URL/v1/infra/servers/$SERVER_ID/deploy" \
  -H "X-Api-Key: $VIBE_API_KEY" \
  -F "file=@app.tar.gz" \
  -F "start=cd /opt/app && node server.js" \
  -F "port=3000"

JavaScript

javascript
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 form = new FormData()
form.append('file', new Blob([await readFile('app.tar.gz')]), 'app.tar.gz')
form.append('start', 'cd /opt/app && node server.js')
form.append('port', '3000')

const res = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/deploy`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY },
  body: form,
})
const body = await res.json()
if (!body.success) {
  // Отказ шага деплоя приходит кодом DEPLOY_FAILED, а имя шага — полем step.
  throw new Error(`${body.error?.step ?? 'deploy'}: ${body.error?.message}`)
}

Ответ перечисляет пройденные шаги:

JSON
{
  "success": true,
  "data": {
    "steps": [
      { "step": "stop_existing", "status": "ok", "duration": 320 },
      { "step": "clean", "status": "ok", "duration": 180 },
      { "step": "install", "status": "ok", "duration": 21350 },
      { "step": "start", "status": "ok", "duration": 900 }
    ],
    "serviceName": "app",
    "status": "running",
    "appUrl": "https://app-b7c1e2a4f9d0.vibecode.bitrix24.tech"
  }
}

У шага четыре возможных состояния: running, ok, warning и error. Шаг со статусом warning деплой не прерывает — пояснение приходит в stdout этого шага.

Порт в multipart-режиме передавать обязательно — строкой ("3000"). Чтобы вместо merge сделать чистый деплой, добавьте -F "cleanDeploy=true" (в JavaScript — form.append('cleanDeploy', 'true')).

Тяжёлые зависимости — вне install-скрипта

У шага install таймаут 300 секунд, и запускается он на каждом деплое. Долгая установка набора инструментов сборки на этом шаге либо упирается в таймаут, либо отнимает минуты у каждого деплоя. Файловая система виртуальной машины помогает этого избежать: cleanDeploy стирает только extractTo (по умолчанию /opt/app), а установленные рантаймы, каталог /usr и /opt/data переживают редеплой. Поэтому системный инструмент ставится один раз и остаётся на месте.

Установите тяжёлый инструмент однажды и в дальнейшем проверяйте его наличие идемпотентной строкой — она запускает установочный скрипт, только если инструмента ещё нет:

Terminal
command -v pg_restore >/dev/null || bash /opt/data/install-tools.sh

Строку помещают в preStart. При первом деплое pg_restore не найден — скрипт отработает. На следующих деплоях инструмент уже на месте, проверка проходит мгновенно, установка не повторяется. Так каждый деплой не платит за переустановку того, что уже стоит на диске.

Точечные обновления через /upload

Когда изменился один файл или один пакет сборки, полный деплой избыточен. Загрузите изменившийся архив через POST /v1/infra/servers/:id/upload с extract: true — агент распакует его прямо в рабочую директорию, минуя полный конвейер деплоя. Затем перезапустите сервис через /exec:

Terminal
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" \
  -d '{
    "url": "https://example.com/dist.tar.gz",
    "path": "/opt/app/dist.tar.gz",
    "extract": true,
    "extractTo": "/opt/app"
  }'

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 restart app"}'

JavaScript

javascript
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

const uploaded = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/upload`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    url: 'https://example.com/dist.tar.gz',
    path: '/opt/app/dist.tar.gz',
    extract: true,
    extractTo: '/opt/app',
  }),
}).then(r => r.json())
// Перезапуск вслепую, без этой проверки, поднимет прежнюю сборку и создаст
// впечатление, что правка не применилась.
if (!uploaded.success) throw new Error(uploaded.error?.message ?? 'файл не загружен')

const restarted = await fetch(`${VIBE_URL}/v1/infra/servers/${SERVER_ID}/exec`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ command: 'systemctl restart app' }),
}).then(r => r.json())
if (!restarted.success) throw new Error(restarted.error?.message ?? 'сервис не перезапущен')
if (restarted.data.exitCode !== 0) throw new Error(restarted.data.stderr)

Ответ /upload подтверждает путь, размер и факт распаковки:

JSON
{ "success": true, "data": { "path": "/opt/app/dist.tar.gz", "size": 4193280, "extracted": true } }

Если предыдущая команда на сервере ещё выполняется, перезапуск через /exec отклоняется:

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), call POST /v1/infra/servers/:id/unstick and retry: it refuses with 409 OPERATION_IN_PROGRESS whenever a lock is still OBSERVED — a live one, or one whose owner cannot be proven dead. That guard covers the PLATFORM lock only. With no lock observed the call proceeds, and it works by bouncing the agent tunnel: the reconnect handler group-kills whatever the agent is running, including a detached background job. That is precisely why it cures a leaked agent mutex, and precisely why it is not safe to fire while your own long command may still be alive. It clears an observed lock on its own only when that lock is an exec mutex that outlived its own TTL. DELETE /v1/infra/servers/:id/lock releases the backend lock UNCONDITIONALLY and is a deliberate SECOND step. An ABSENT error.hint.holder does not authorize it: that read is fail-open, so a missing holder is also what you get when the platform could not read one. Absence buys a normal retry after Retry-After — nothing more. Force-release only after you have confirmed the server is idle INDEPENDENTLY (GET /v1/infra/servers/:id plus the logs); otherwise keep retrying or escalate.",
      "recoveryAction": "POST /v1/infra/servers/:id/unstick",
      "autoExpiresInSeconds": 42,
      "note": "The backend lock auto-expires after ~15 minutes. The Black Hole agent also holds its own synchronous exec mutex (≤10 min), or a durable exec admission gate for the whole job. First read GET /v1/infra/servers/:id/operations?kind=exec and reconcile durable operations. While a durable operation is running, poll its operationId; an unknown outcome needs support and positive proof that the job stopped. POST /v1/infra/servers/:id/unstick does not clear a durable admission gate. Only when no durable job remains active and a leaked synchronous mutex is independently confirmed should you use that guarded recovery. The order lives in hint.recovery: unstick comes BEFORE any unconditional release, never after it."
    }
  }
}

Сначала прочитайте GET /v1/infra/servers/:id/operations?kind=exec и исход предыдущей операции. На STANDALONE работающая длительная задача занимает канал после освобождения платформенного лока с 202. Опрашивайте её операцию; при неизвестном исходе нужны поддержка и независимое подтверждение полной остановки. Отсутствие autoExpiresInSeconds не доказывает утечку мьютекса. Восстановление туннеля и удаление лока не снимают ограничение приёма длительной задачи. В Galaxy команды выполняются синхронно с пределом 600 секунд и общим каналом для всех приложений и выкладок; задача соседнего приложения может не попасть в ваш список операций, поэтому пустой список не доказывает простой хоста.

Поля retryable и retryAfter — машинный сигнал: повтор уместен, пауза в секундах (не больше 10 — это интервал опроса, а не срок жизни лока), она же приходит заголовком Retry-After. Лок снимается сам примерно через 15 минут, и для зависшего серверного лока это ожидание и есть путь: гардированный POST /v1/infra/servers/:id/unstick, пока замок наблюдается, без force отвечает 409, а сам собой снимает только exec-мьютекс, переживший собственный TTL. Безусловное DELETE /v1/infra/servers/:id/lock срезает ожидание только на реплике-ДЕРЖАТЕЛЕ: рассылка щадит живую чужую выкладку — оно остаётся последним шагом и только после независимо подтверждённого простоя.

Перезапуск запускайте только после успешной загрузки: если /upload вернул отказ, а systemctl restart уже ушёл, сервис поднимется на старом коде и это будет выглядеть как «деплой не применился».

Имя сервиса в команде перезапуска — то, под которым приложение задеплоено. По умолчанию это app, поэтому и systemctl restart app. Встроенным content проходит файл примерно до 72 МБ — с ним проходит и целый бандл, и отдельный файл, а более крупный передаётся полем url.

Данные — никогда в install-шаге

Шаг install (и preStart) выполняется на каждом деплое. Если положить в него загрузку данных — вставку строк в базу, импорт справочника, накат начальных данных — эта загрузка повторится при каждом деплое, и записи задвоятся. Данные грузят в /opt/data один раз и под идемпотентной защитой, чтобы повторный запуск ничего не добавлял:

Terminal
test -f /opt/data/.seeded || { bash /opt/data/load-seed.sh && touch /opt/data/.seeded; }

Каталог /opt/data переживает редеплой, поэтому файл-маркер .seeded остаётся на месте между деплоями. Первый деплой загрузит данные и поставит маркер, все следующие увидят маркер и пропустят загрузку. Команды-шаги деплоя держите для установки кода и зависимостей, а разовую загрузку данных выносите под такую защиту.

Известные особенности

Умолчание зависит от режима. Умолчание cleanDeploy зависит от режима: у inline-деплоя из JSON это true и /opt/app стирается перед распаковкой, у multipart — false, то есть файлы кладутся поверх дерева. Режимы не взаимозаменяемы по последствиям.

Потолок шага install. Шаг install запускается на каждом деплое и ограничен 300 секундами. Тяжёлая установка либо упирается в таймаут, либо отнимает минуты у каждого выката.

/opt/app не переживает чистый деплой. Каталог /opt/app живёт до следующего чистого деплоя. Данные, которые должны пережить выкат, кладутся в /opt/data, а не в дерево приложения.

В /opt/data пишет root, а не приложение — пока каталог не объявлен. Все примеры выше выполняются на шагах install и preStart, то есть от root, и это работает как написано. Само приложение systemd запускает под непривилегированной учётной записью, которой платформа отдаёт только extractTo, — поэтому его собственная запись в /opt/data завершится ошибкой доступа. Чтобы приложение писало туда само, объявите каталог в теле деплоя:

JSON
{ "dataDirs": ["/opt/data/state"] }

Платформа создаст каталог и передаст его учётной записи приложения на каждом деплое.

Объявляйте отдельный подкаталог, а не сам /opt/data. Владелец каталога может удалить или заменить в нём любой файл — даже те, что положил root и читать которые не может. В /opt/data по примерам выше лежат install-tools.sh и load-seed.sh, которые запускаются от root на каждом деплое: отдав приложению весь /opt/data, вы дали бы ему возможность подменить эти скрипты. Приложению — свой подкаталог (/opt/data/state), служебным скриптам и файлам с паролями — сам /opt/data, который вы не объявляете. Передаётся при этом сам каталог, а не его содержимое: файлы, положенные root раньше, владельца не меняют. Подробности — «Деплой приложения».

Потолок загрузки зависит от способа. Встроенное base64-содержимое content маршрут /upload принимает до 96 МБ тела, то есть около 72 МБ самого файла, и тело крупнее отклоняет кодом 413 INLINE_SOURCE_TOO_LARGE. Файл, переданный полем url, агент скачивает сам — там потолок 500 МБ.

Отказы зависят от того, чей это вызов. У прямого вызова /exec: EXEC_TIMEOUT — команда не уложилась в свой timeout, COMMAND_TOO_LONG — команда длиннее 10000 символов, EXEC_BUSY — на сервере уже идёт операция.

У деплоя ответ другой. Шаг, не уложившийся в свои 300 секунд, приходит как DEPLOY_FAILED с полем step — например "step": "install", — а текст таймаута лежит в message. Ветвиться на EXEC_TIMEOUT после деплоя бесполезно, этот код там не приходит.

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

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