Files
creator-hub/docs/deployment.md
T

439 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CreatorHub 部署
本文档适用于当前阶段 A:在一台 Linux 主机上通过 Docker Compose 部署。控制面使用单用户 HTTP Basic Auth;当前不提供 RBAC 或多租户隔离。
## 部署内容
`compose.yaml` 会启动以下服务:
- `creator-hub`:Go 1.26 控制面,同时提供 React 19/Vite 8 构建的静态页面;
- `docker-gateway`:受限 Docker API 网关,是唯一挂载 `/var/run/docker.sock` 的服务;
- `postgres`PostgreSQL 17,数据保存在 `creatorhub_postgres` 命名卷;
- 浏览器容器:由网关按需创建,镜像引用由平台「镜像版本」页配置(缺失时网关自动拉取),Profile 保存在 `creatorhub-profile-<别名>` 命名卷。
控制面发布到宿主机所有网卡,局域网内可直接访问;浏览器容器可访问外网。
`creator-hub` 会等待 `docker-gateway` 健康检查通过后再启动。
## 前置条件
- Linux 主机;
- Docker Engine 26 或兼容版本;
- Docker Compose v2
- 当前用户可访问 Docker daemon
- 可访问镜像仓库(如 `git.ipao.vip`),并已完成登录(如仓库要求认证);镜像也可不在宿主机预拉取,网关会在缺失时按引用自动拉取;
- `curl``jq``openssl` 和 GNU `stat`,用于启动与业务验证命令;
- 若验证真实浏览器运行环境:Linux x86_64、一个可访问的固定 HTTP/HTTPS/SOCKS 出口,以及可被 Docker daemon 拉取的 `fingerprint-chromium` 镜像。
在仓库根目录执行预检:
```bash
test -S /var/run/docker.sock
docker info >/dev/null
docker compose version
: "${CONTROL_PLANE_USERNAME:?export CONTROL_PLANE_USERNAME in this shell}"
: "${CONTROL_PLANE_PASSWORD:?export CONTROL_PLANE_PASSWORD in this shell}"
docker compose config --quiet
```
## 首次部署
以下命令应在同一个 shell、仓库根目录执行。本说明采用显式导出模式:Compose 启动和手工验证必须读取当前 shell 中导出的同一组变量;验证脚本不会自行读取 `.env`,也不会使用 Compose 默认值。若变量只写在 `.env` 中,请先将相同值 export 到当前 shell。`DOCKER_GID` 必须与宿主机 Docker socket 的组一致;`CREATORHUB_PORT` 控制控制面的宿主机端口。
```bash
export DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)"
export CREATORHUB_PORT=8080
export GATEWAY_TOKEN="$(openssl rand -hex 24)"
export CONTROL_PLANE_USERNAME=creatorhub
export CONTROL_PLANE_PASSWORD="$(openssl rand -hex 24)"
docker compose config --quiet
docker compose up --detach --build
```
控制面启动时会连接 PostgreSQL,并在事务和 advisory lock 保护下自动执行前向迁移。迁移失败时控制面会退出,由 Compose 按 `restart: unless-stopped` 重启;先检查日志,不要删除数据卷。
### 首次配置
服务起来后打开 <http://127.0.0.1:${CREATORHUB_PORT}>
1. 「网关管理」页注册网关:名称如 `gw-main`Endpoint `http://docker-gateway:8081`,令牌填 `GATEWAY_TOKEN` 的值(即 `openssl rand -hex 24` 生成的值)。
2. 「镜像版本」页添加可用镜像,如版本 `148.0.7778.215`、引用 `git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215`(或 `@sha256:` 摘要引用)。
3. 「运行环境」页创建环境:中文名 + 小写别名 + 指纹参数,容器名 `creatorhub-browser-<别名>`Profile 卷 `creatorhub-profile-<别名>`
## 部署验证
```bash
curl --fail --silent --show-error \
--retry 30 --retry-delay 2 --retry-connrefused \
--output /dev/null "http://127.0.0.1:${CREATORHUB_PORT}/healthz"
curl --fail --silent --show-error \
--retry 30 --retry-delay 2 --retry-connrefused \
--user "${CONTROL_PLANE_USERNAME}:${CONTROL_PLANE_PASSWORD}" \
"http://127.0.0.1:${CREATORHUB_PORT}/api/browsers" >/dev/null
docker compose exec -T postgres \
psql -U creatorhub -d creatorhub -tAc \
'SELECT 1 FROM schema_migration WHERE version = 12;' \
| grep -qx 1
docker compose ps
```
健康检查应成功,浏览器列表接口应返回 JSON,迁移查询当前应输出 `1`,三个 Compose 服务应为运行状态。然后访问 <http://127.0.0.1:8080>;修改过 `CREATORHUB_PORT` 时使用对应端口。
排障时读取结构化服务日志:
```bash
docker compose logs --tail=200 creator-hub docker-gateway postgres
```
## 手工业务验证(阶段 A Mock)
当前阶段 A 是单用户、虚拟平台 mock 的离线闭环,不连接真实社交平台。最小业务路径是:账号 → 草稿 → 显式确认 → 任务入队 → Mock 执行 → 审计回溯。
页面路径如下:
1. 登录后在「网关管理」注册 <http://docker-gateway:8081,令牌必须等于> GATEWAY_TOKEN。
2. 在「镜像版本」添加并启用一个可拉取的 fingerprint-chromium 镜像。
3. 可选:在「网络出口」创建出口并点击「检测」,健康状态必须为「健康」;这里只填写凭据引用 ID,不填写密码、Cookie 或 token。留空则使用网关所在机器的网络出口直连。
4. 在「社媒账号」创建平台为 mock 的账号;创建后默认暂停。
5. 在「运行环境」选择该账号、镜像和可选的健康出口,使用正整数 Fingerprint Seed 创建环境;随后在账号详情点击「恢复账号」,再在「运行环境」点击「启动」。
6. 在账号详情创建文本草稿,点击「核对草稿」,勾选“我已核对当前账号、草稿内容、运行环境和固定出口”,依次执行「确认当前快照」→「保存确认」→「加入队列」。
7. Mock 执行器没有独立页面,使用下面的 POST /api/phase-a/mock/execute,再到「任务中心」和「审计」核对结果。
以下命令可在已经启动的仓库根目录直接执行,并且必须在启动 Compose 的同一个 shell 中执行。开始前确认 `CREATORHUB_PORT``GATEWAY_TOKEN``CONTROL_PLANE_USERNAME``CONTROL_PLANE_PASSWORD` 都已 export;脚本不会读取未 export 的 `.env` 值或 Compose 默认值。出口参数必须替换成已批准且可从 creator-hub 容器访问的无认证代理;若代理需要认证,先按部署规范准备 credential_reference,不要把秘密值写进命令或仓库。镜像引用也可改成已发布的 registry 引用;本地 smoke test 可以使用宿主机上已有的镜像标签。
~~~bash
set -Eeuo pipefail
: "${CREATORHUB_PORT:?export CREATORHUB_PORT (same value used by Compose)}"
: "${CONTROL_PLANE_USERNAME:?set CONTROL_PLANE_USERNAME}"
: "${CONTROL_PLANE_PASSWORD:?set CONTROL_PLANE_PASSWORD}"
: "${GATEWAY_TOKEN:?export GATEWAY_TOKEN (same value registered in the gateway)}"
: "${PROXY_HOST:?set PROXY_HOST to an approved fixed proxy host}"
: "${PROXY_PORT:?set PROXY_PORT to an approved fixed proxy port}"
BASE_URL="http://127.0.0.1:${CREATORHUB_PORT}"
api() {
curl --fail-with-body --silent --show-error \
--user "${CONTROL_PLANE_USERNAME}:${CONTROL_PLANE_PASSWORD}" \
-H 'Content-Type: application/json' "$@"
}
[[ "$PROXY_PORT" =~ ^[0-9]+$ ]] && (( PROXY_PORT >= 1 && PROXY_PORT <= 65535 ))
run_id="manual-$(date +%s)"
gateway_name="gw-${run_id}"
image_version="${IMAGE_VERSION:-148.0.7778.215}"
image_ref="${IMAGE_REF:-git.ipao.vip/rogee/fingerprint-chromium:${image_version}}"
exit_protocol="${PROXY_PROTOCOL:-http}"
account_id="${run_id}-account"
account_key="${run_id}-platform"
credential_id="${run_id}-credential"
credential_key="manual/${account_id}"
env_alias="${run_id}-env"
gateway_json="$(api -X POST "$BASE_URL/api/gateways" --data "$(jq -n \
--arg name "$gateway_name" --arg token "$GATEWAY_TOKEN" \
'{name:$name,endpoint:"http://docker-gateway:8081",token:$token}')")"
jq -e --arg name "$gateway_name" '.name == $name' <<<"$gateway_json" >/dev/null
image_json="$(api -X POST "$BASE_URL/api/browser-images" --data "$(jq -n \
--arg version "$image_version" --arg ref "$image_ref" \
'{version:$version,image_ref:$ref,note:"manual validation",enabled:true}')")"
jq -e --arg version "$image_version" '.version == $version and .enabled == true' <<<"$image_json" >/dev/null
exit_json="$(api -X POST "$BASE_URL/api/network-exits" --data "$(jq -n \
--arg protocol "$exit_protocol" --arg host "$PROXY_HOST" --argjson port "$PROXY_PORT" \
'{protocol:$protocol,host:$host,port:$port}')")"
exit_id="$(jq -er '.id' <<<"$exit_json")"
checked_exit="$(api -X POST "$BASE_URL/api/network-exits/$exit_id/check")"
jq -e '.health_status == "healthy"' <<<"$checked_exit" >/dev/null
account_json="$(api -X POST "$BASE_URL/api/phase-a/accounts" --data "$(jq -n \
--arg id "$account_id" --arg key "$account_key" --arg credential_id "$credential_id" --arg credential_key "$credential_key" \
'{id:$id,platform:"mock",platform_account_key:$key,authorization_kind:"owned",credential_reference:{id:$credential_id,provider:"os_keyring",key:$credential_key}}')")"
jq -e --arg id "$account_id" '.id == $id and .authorization_status == "authorized" and .runtime_status == "paused"' <<<"$account_json" >/dev/null
created_env="$(api -X POST "$BASE_URL/api/browsers" --data "$(jq -n \
--arg alias "$env_alias" --arg name "手工验证环境" --arg gateway "$gateway_name" \
--arg version "$image_version" --arg account "$account_id" --arg exit "$exit_id" \
'{alias:$alias,name:$name,gateway:$gateway,image_version:$version,account_id:$account,network_exit_id:$exit,fingerprint:{seed:1000}}')")"
jq -e --arg alias "$env_alias" '.alias == $alias' <<<"$created_env" >/dev/null
api -X POST "$BASE_URL/api/phase-a/accounts/$account_id/resume" >/dev/null
api -X POST "$BASE_URL/api/browsers/$env_alias/start" >/dev/null
for _ in $(seq 1 30); do
runtime_json="$(api "$BASE_URL/api/browsers/$env_alias")"
if jq -e '.runtime_id != "" and .runtime_instance_id != ""' <<<"$runtime_json" >/dev/null; then
break
fi
sleep 1
done
jq -e '.runtime_id != "" and .runtime_instance_id != ""' <<<"$runtime_json" >/dev/null
account_json="$(api "$BASE_URL/api/phase-a/accounts/$account_id")"
account_version="$(jq -er '.version' <<<"$account_json")"
draft_json="$(api -X POST "$BASE_URL/api/phase-a/drafts" --data "$(jq -n \
--arg account "$account_id" '{account_id:$account,content:"阶段 A 手工验证内容"}')")"
draft_id="$(jq -er '.id' <<<"$draft_json")"
draft_version="$(jq -er '.version' <<<"$draft_json")"
confirmation_json="$(api -X POST "$BASE_URL/api/phase-a/confirmations" --data "$(jq -n \
--arg draft "$draft_id" --argjson account_version "$account_version" --argjson draft_version "$draft_version" \
'{draft_id:$draft,account_version:$account_version,draft_version:$draft_version}')")"
confirmation_id="$(jq -er '.id' <<<"$confirmation_json")"
task_json="$(api -X POST "$BASE_URL/api/phase-a/tasks" --data "$(jq -n --arg confirmation "$confirmation_id" '{confirmation_id:$confirmation}')")"
task_id="$(jq -er '.id' <<<"$task_json")"
jq -e '.state == "queued"' <<<"$task_json" >/dev/null
duplicate_task_json="$(api -X POST "$BASE_URL/api/phase-a/tasks" --data "$(jq -n --arg confirmation "$confirmation_id" '{confirmation_id:$confirmation}')")"
jq -e --arg task_id "$task_id" '.id == $task_id' <<<"$duplicate_task_json" >/dev/null
execution_json="$(api -X POST "$BASE_URL/api/phase-a/mock/execute" --data \
'{"worker_id":"manual-worker","outcome":"succeeded"}')"
jq -e '.was_claimed == true and .state == "succeeded"' <<<"$execution_json" >/dev/null
task_detail="$(api "$BASE_URL/api/phase-a/tasks/$task_id")"
jq -e '(.state == "succeeded") and ((.attempts | length) >= 1) and (.attempts[0].evidence.mock_outcome == "succeeded")' <<<"$task_detail" >/dev/null
audit_json="$(api "$BASE_URL/api/phase-a/audit?task_id=$task_id&page_size=100")"
for event_type in task_queued task_claimed task_finished; do
jq -e --arg event_type "$event_type" '.data | any(.[]; .event_type == $event_type)' <<<"$audit_json" >/dev/null
done
printf 'PASS account=%s environment=%s task=%s state=succeeded audit=task_queued,task_claimed,task_finished\n' \
"$account_id" "$env_alias" "$task_id"
~~~
通过标准:/healthz 返回 204;受保护的 /api/browsers 返回 JSON;网关、控制面、PostgreSQL 均为运行状态;运行环境有 runtime_id 和 runtime_instance_id;任务最终为 succeeded,且执行尝试的脱敏证据为 mock_outcome=succeeded;审计至少包含 task_queued、task_claimed、task_finished。重复提交同一确认时应返回同一任务 ID,不应产生第二条任务。
验证完成后的安全清理(不删除 PostgreSQL 或 Profile 卷):
~~~bash
api() {
curl --fail-with-body --silent --show-error \
--user "${CONTROL_PLANE_USERNAME}:${CONTROL_PLANE_PASSWORD}" \
-H 'Content-Type: application/json' "$@"
}
api -X POST "$BASE_URL/api/browsers/$env_alias/stop" >/dev/null
api -X DELETE "$BASE_URL/api/browsers/$env_alias" >/dev/null
docker compose stop
~~~
## 常见故障排查
| 现象 | 先检查 | 处理 |
| --- | --- | --- |
| docker compose config 报 required | CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否在当前 shell 非空 | 重新 export 两个变量;用户名不能含冒号,密码至少 6 字节 |
| 手工验证脚本在 `:?` 处退出 | CREATORHUB_PORT、GATEWAY_TOKEN、CONTROL_PLANE_USERNAME、CONTROL_PLANE_PASSWORD 是否都已 export | 在启动 Compose 的同一个 shell 中 export 完整变量集;不要只依赖 `.env` 或 Compose 默认值 |
| creator-hub 未启动 | docker compose ps、docker compose logs --tail=200 postgres docker-gateway creator-hub | 先确认 PostgreSQL 与网关 health 为 healthy;网关需能访问 /var/run/docker.sockDOCKER_GID 使用 stat -c '%g' /var/run/docker.sock 的实际值 |
| API 返回 401 | curl 是否带 --user CONTROL_PLANE_USERNAME:CONTROL_PLANE_PASSWORD | /healthz 不需要认证,其余 /api/* 需要控制面 Basic Auth |
| /api/browsers 返回 503 或网关不可用 | 网关注册的 Endpoint、令牌与 Compose 的 GATEWAY_TOKEN | Endpoint 在 Compose 网络内应为 <http://docker-gateway:8081;重新注册时令牌必须完全一致> |
| 出口一直是 unchecked/unhealthy | 出口协议、主机、端口;控制面容器到代理的连通性;last_check_reason | 先用无认证代理完成最小验证;有认证时只提供已配置的凭据引用,不把认证值放到请求、日志或文档 |
| 创建环境时报 image_unavailable 或拉取超时 | image_ref 格式、镜像架构、Docker daemon 的 registry 登录和网络 | 版本表中的镜像必须可被 Docker daemon 拉取;缺失镜像会由网关按引用拉取,最长约 10 分钟 |
| 恢复/入队返回 503 | readiness、GET /api/browsers/<alias>、GET /api/network-exits/<id> | binding_missing、network_exit_unhealthy、runtime_missing 表示固定资源未就绪;先修复出口并启动原环境,不要换出口重试 |
| Mock 执行没有领取任务 | GET /api/phase-a/tasks/<id> 的 state、hold_reason | POST /api/phase-a/mock/execute 只领取满足账号、确认、健康出口、活动 runtime 和租约条件的 queued 任务;policy_hold/needs_confirmation 不会自动重试 |
| 停止 Compose 后浏览器仍在运行 | docker ps --filter 'name=^creatorhub-browser-' | 动态浏览器不由 Compose 管理;先通过「运行环境」停止/回收,再 docker compose stop,不要误删 Profile 卷 |
不要执行 docker compose down --volumes 作为普通排障手段;它会删除 PostgreSQL 数据卷。不要把控制面密码、网关令牌、代理密码、Cookie 或 token 写入仓库、截图、日志或审计查询。
## 配置
Compose 部署时通常只需设置以下宿主机变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `CREATORHUB_PORT` | `8080` | 控制面宿主机端口,局域网可访问 |
| `DOCKER_GID` | `999` | Docker socket 的宿主机组 ID;必须按实际值设置 |
| `GATEWAY_TOKEN` | `dev-creatorhub-gateway-token` | Compose 未提供变量时的默认值;本说明要求显式 export 随机值,并同步填入网关注册表单 |
| `CONTROL_PLANE_USERNAME` | 无(必填) | 控制面唯一用户;不能包含冒号 |
| `CONTROL_PLANE_PASSWORD` | 无(必填) | 控制面密码,至少 6 字节;使用随机值 |
服务本身支持并校验以下环境变量;`compose.yaml` 会在控制面凭据缺失或为空时拒绝渲染。下表中的 Compose 默认值不由手工验证脚本隐式读取;手工验证沿用上文的显式 export 要求:
| 服务 | 变量 | 当前 Compose 值 |
| --- | --- | --- |
| `creator-hub` | `LISTEN_ADDR` | 默认 `:8080` |
| `creator-hub` | `WEB_DIR` | 镜像内固定为 `/app/web` |
| `creator-hub` | `DATABASE_URL` | `postgres://creatorhub@postgres/creatorhub?sslmode=disable` |
| `creator-hub` | `LOG_LEVEL` | 默认 `info` |
| `creator-hub` | `CONTROL_PLANE_USERNAME` | 必填;HTTP Basic Auth 用户名 |
| `creator-hub` | `CONTROL_PLANE_PASSWORD` | 必填且至少 6 字节;不会写入日志或响应 |
| `docker-gateway` | `LISTEN_ADDR` | 默认 `:8081` |
| `docker-gateway` | `DOCKER_SOCKET` | 默认值和 Compose 挂载均固定为 `/var/run/docker.sock`;不能只覆盖环境变量 |
| `docker-gateway` | `BROWSER_NETWORK` | `creatorhub_browser` |
| `docker-gateway` | `GATEWAY_TOKEN` | 与平台注册值一致,长度 ≥16`/v1` 全部接口校验 Bearer 令牌 |
| `docker-gateway` | `LOG_LEVEL` | 默认 `info` |
不要把凭据写入仓库或 Compose 文件。
### P0-lite 停机通知
当前只使用 `creator-hub` 的专用 Logrus JSON logger 作为通知渠道;它固定输出警告,不继承业务 `LOG_LEVEL`。选择它是因为 Compose 已可靠收集服务日志,不需要新增外部账号、凭据、网络重试或通知依赖。仅 `policy_hold``needs_confirmation` 会产生 `operator attention required`,字段限定为 `event_type``reason_code`、账号/任务 ID;不会包含请求头、Secret 引用或凭据值。运维可用下列命令接入现有日志采集或人工查看:
```bash
docker compose logs creator-hub | grep 'operator attention required'
```
该渠道是单实例 P0-lite 能力,不保证外部送达、升级或确认回执;只有出现明确的多渠道/送达需求时才增加 webhook 或消息平台。
## 更新与回滚
更新前记录当前版本并备份数据库:
```bash
set -Eeuo pipefail
git rev-parse HEAD
umask 077
export BACKUP_FILE="$(pwd)/creatorhub-$(date +%Y%m%d-%H%M%S).dump"
docker compose exec -T postgres \
pg_dump -U creatorhub -d creatorhub --format=custom \
> "$BACKUP_FILE"
test -s "$BACKUP_FILE"
docker compose exec -T postgres pg_restore --list \
< "$BACKUP_FILE" >/dev/null
```
拉取已审核版本后,重新执行部署和验证:
```bash
set -Eeuo pipefail
git pull --ff-only
export DOCKER_GID="$(stat -c '%g' /var/run/docker.sock)"
export CREATORHUB_PORT=8080
docker compose config --quiet
docker compose up --detach --build
```
镜像版本升级在页面「运行环境 → 升级」完成:平台会停止并删除旧容器(保留 Profile 卷),用新镜像引用与原指纹参数重建后启动;失败时直接重试即可,无需回滚。
数据库迁移只支持安全前进,不提供自动破坏性回滚。需要同时恢复旧代码和更新前数据库时,修改下面两个变量后**整块执行一次**;不要逐行或拆块执行。预检、恢复演练、动态容器停止、停服、主库恢复、提交切换和启动都位于同一个 fail-fast subshell 中。
```bash
(
set -Eeuo pipefail
BACKUP_FILE=/absolute/path/to/creatorhub-YYYYmmdd-HHMMSS.dump
RESTORE_REV=PREVIOUS_REVIEWED_COMMIT_SHA
: "${BACKUP_FILE:?set BACKUP_FILE to the absolute archive path}"
: "${RESTORE_REV:?set RESTORE_REV to the previous reviewed commit SHA}"
test -r "$BACKUP_FILE"
test -s "$BACKUP_FILE"
git cat-file -e "${RESTORE_REV}^{commit}"
docker compose exec -T postgres pg_restore --list \
< "$BACKUP_FILE" >/dev/null
docker compose exec -T postgres \
dropdb --if-exists --force -U creatorhub creatorhub_restore_check
docker compose exec -T postgres \
createdb -U creatorhub creatorhub_restore_check
docker compose exec -T postgres \
pg_restore -U creatorhub -d creatorhub_restore_check \
--exit-on-error --no-owner --no-privileges \
< "$BACKUP_FILE"
docker compose exec -T postgres \
psql -U creatorhub -d creatorhub_restore_check -v ON_ERROR_STOP=1 -tAc \
'SELECT 1 FROM schema_migration WHERE version = 12;' \
| grep -qx 1
docker compose exec -T postgres \
dropdb --force -U creatorhub creatorhub_restore_check
list_running_browsers() {
docker ps --quiet \
--filter 'name=^creatorhub-browser-' \
--filter 'label=io.creatorhub.managed=true' \
--filter 'label=io.creatorhub.runtime-id'
}
browser_ids="$(list_running_browsers)" || exit 1
for browser_id in $browser_ids; do
if ! docker stop "$browser_id"; then
docker rm --force "$browser_id"
fi
done
browser_ids="$(list_running_browsers)" || exit 1
test -z "$browser_ids"
docker compose stop creator-hub docker-gateway
umask 077
PRE_ROLLBACK_BACKUP="$(pwd)/creatorhub-pre-rollback-$(date +%Y%m%d-%H%M%S).dump"
docker compose exec -T postgres \
pg_dump -U creatorhub -d creatorhub --format=custom \
> "$PRE_ROLLBACK_BACKUP"
test -s "$PRE_ROLLBACK_BACKUP"
docker compose exec -T postgres pg_restore --list \
< "$PRE_ROLLBACK_BACKUP" >/dev/null
test -r "$BACKUP_FILE"
test -s "$BACKUP_FILE"
git cat-file -e "${RESTORE_REV}^{commit}"
docker compose exec -T postgres pg_restore --list \
< "$BACKUP_FILE" >/dev/null
docker compose exec -T postgres \
dropdb --if-exists --force -U creatorhub creatorhub
docker compose exec -T postgres createdb -U creatorhub creatorhub
docker compose exec -T postgres \
pg_restore -U creatorhub -d creatorhub \
--exit-on-error --no-owner --no-privileges \
< "$BACKUP_FILE"
docker compose exec -T postgres \
psql -U creatorhub -d creatorhub -v ON_ERROR_STOP=1 -tAc \
'SELECT 1 FROM schema_migration WHERE version = 12;' \
| grep -qx 1
git switch --detach "$RESTORE_REV"
if ! docker compose up --detach --build; then
docker compose stop creator-hub docker-gateway || true
exit 1
fi
)
```
任何命令失败时 subshell 立即退出;若失败发生在主库 `dropdb` 之后,应用保持停止。`docker compose up` 自身失败时也会显式停回应用服务。成功后重新执行“部署验证”。不要直接删除数据卷或手工改写迁移记录。
## 停止与数据保留
`docker compose stop/down` 不管理网关动态创建的浏览器容器;直接执行会留下运行中的浏览器和活动会话。先严格按固定容器名前缀及两个管理标签筛选并停止;只有单个容器停止失败或超时时才强制删除。命令不带 `--volumes`,因此 Profile 卷仍会保留:
```bash
set -Eeuo pipefail
list_running_browsers() {
docker ps --quiet \
--filter 'name=^creatorhub-browser-' \
--filter 'label=io.creatorhub.managed=true' \
--filter 'label=io.creatorhub.runtime-id'
}
browser_ids="$(list_running_browsers)" || exit 1
for browser_id in $browser_ids; do
if ! docker stop "$browser_id"; then
docker rm --force "$browser_id"
fi
done
browser_ids="$(list_running_browsers)" || exit 1
test -z "$browser_ids"
docker compose stop
```
恢复服务使用 `docker compose up --detach`。需要删除服务容器和网络但保留数据时使用:
```bash
docker compose down
```
不要执行 `docker compose down --volumes`:它会删除 PostgreSQL 数据卷。浏览器 Profile 卷不属于 Compose 声明卷,删除浏览器容器或执行 `docker compose down` 时仍会保留;可用以下命令核对:
```bash
docker volume ls --filter name=creatorhub-profile-
```
`docker.sock` 即使以只读文件方式挂载,也仍允许 Docker API 写操作,等价于宿主机 root 权限。完整安全边界、API 契约和失败语义见[《浏览器容器控制面》](architecture/container-control.md)。