部署指南
本页讲怎样长期运行一套 HybridInference:会启动哪些服务,怎样放到公网上或者不对外暴露,怎样创建第一个管理员,日常怎么运维,以及重置时怎样既不丢数据、也不误留数据。staging 或临时实例用的是同一套服务栈,对它们最要紧的是「不对外暴露」和「第一个管理员账号」这两节。
首次搭建——克隆仓库、填写 .env、第一次 make up——见安装。本页假定整套服务栈已经能起来。
服务栈的组成
make up 会按 deploy/docker/docker-compose.yml 启动三个容器:
服务 |
镜像 / 构建 |
发布地址 |
|---|---|---|
|
由 |
|
|
由 |
|
|
|
|
三个发布端口默认都绑定在回环地址上,同一台机器上的反向代理可以通过 127.0.0.1:3001 访问控制台。三个容器还会加入同一个 Compose 文件里定义的桥接网络,后端在这个网络里用 postgres:5432 连接数据库。在数据库地址相关的设置里,.env 只有 DB_PORT 会传进这个文件,而且只用作端口映射的宿主机一侧(127.0.0.1:${DB_PORT:-5432}:5432);宿主机一侧的绑定地址写死为回环地址。DB_HOST 在 Compose 文件里固定为 postgres,所以在 Compose 下设置它不起作用,只有直接从源码启动后端时它才有意义。
同一个文件里还有一个服务 pgadmin(profile 为 admin),只有指定这个 profile 时才会启动。
frontend 依赖 backend 时用的是 condition: service_started,而不是 service_healthy。这是有意为之:后端可能因为数据库日志写不进去而报告 unhealthy,这时不应该连控制台也起不来。
把它放到公网上
服务栈里没有任何组件负责 TLS 终止,仓库也没有现成的反向代理配置可以直接套用:证书和两个发布端口前面的代理都要你自己准备。跑在宿主机上的代理,访问控制台用 127.0.0.1:3001,直接访问网关用 127.0.0.1:8080;和服务栈在同一个 Docker 网络里的代理容器,则用 frontend:3001 或 backend:8080。
如果代理跑在另一台机器上,就在 .env 里把 FRONTEND_HOST 设成代理能访问到的宿主机网卡地址;设成 0.0.0.0 会绑定所有 IPv4 网卡。然后限制只让你的代理访问,再运行 make up 重建端口映射。控制台除了提供页面,还会转发 API 路由,所以暴露它的端口也就暴露了这些路由。只有需要直接访问网关时,才另外设置 BACKEND_HOST。
在默认改为回环地址之前,旧版本会把控制台绑定到 0.0.0.0。如果你的部署依赖这个行为,升级前要显式设置 FRONTEND_HOST;见发布与升级。
至于哪些公开路径由控制台自己处理、哪些转发给后端,那是另一个问题,由控制台自己的 next.config.js 决定,和代理配置无关。见公开路径表。
代理仍然要负责的事
有三项防护通常交给反向代理来做,这套服务栈内部并不提供。如果部署不经过代理就直接暴露已发布的端口——比如让隧道守护进程直接连到 127.0.0.1:3001——这三项防护就一项都没有,而且不会有任何提示:
防护项 |
前置代理通常做什么 |
|---|---|
Next.js Server Action 防护 |
拒绝带 |
|
限制补全请求的请求体大小(常见配置是 |
|
覆盖客户端自带的转发链,让网关只看到代理自己加上的那几跳 |
网关只信任你授权过的代理发来的转发头(见可信代理与客户端 IP),但从不改写这些头。如果前面没有负责改写的代理,X-Forwarded-For 最左边的那一项就是调用方随便填的值——所以前面有 Cloudflare 时,优先用 CF-Connecting-IP。
不对外暴露:SSH 隧道
staging 或临时实例,以及任何你根本不想放到网络上的网关,都可以保留默认的回环绑定,然后在自己的工作机上转发这两个端口来访问:
ssh -L 3001:127.0.0.1:3001 -L 8080:127.0.0.1:8080 <user>@<your-server>
然后打开 http://localhost:3001。本地这一端只能用 localhost 或 127.0.0.1:存放 refresh token 的 cookie 默认带 Secure 标志(COOKIE_SECURE),而浏览器只在 HTTPS 或回环地址的 origin 下才接受 Secure cookie。
控制台访问 API 的地址,是镜像构建时编译进去的 NEXT_PUBLIC_API_BASE(默认 http://localhost:8080),所以隧道还要转发 8080。改这个值需要重新构建前端,光重启没用。
VS Code 系列的编辑器可以在自带的端口面板里管理同样的转发。
检查服务栈是否正常响应:
curl -s http://localhost:8080/health
curl -s http://localhost:8080/v1/models
CORS_ALLOWED_ORIGINS 的默认值已经覆盖 localhost 和 127.0.0.1 上的 3000、3001、3002 端口,外加 8443 端口上的 HTTPS,所以走隧道访问的实例不需要再加 CORS 条目。只有当控制台由别的 origin 提供时才需要加。
日常运维
以下命令都在仓库根目录执行:
make up # start everything
make down # stop everything (data survives; see below)
make restart # restart everything
make restart s=backend # restart one service
make ps # services and health status
make logs # tail all logs
make logs s=backend # tail one service
make build # rebuild images and restart
make build s=frontend # rebuild one service
make up 和 make build 会先执行 docker-volumes 这个 target,外部卷 hybridinference_postgres_data 不存在时由它创建。
要启动可选的 profile,在 make 命令行上传入即可。命令行上设置的变量会导出到 recipe 的环境里,而在 Compose 中,shell 变量比任何 --env-file 都优先:
make up COMPOSE_PROFILES=admin
COMPOSE_PROFILES 是逗号分隔的列表,所以可以一次指定多个 profile。它也可以写在 .env 里(.env.example 有说明),但想确定哪些 profile 真的生效时,优先用命令行这种写法。
改动之后到底要做什么
答案有三种,选错了,看起来就像改动没生效:
你改了什么 |
该怎么做 |
|---|---|
|
|
模型注册表或路由 YAML |
|
发行版的品牌 YAML |
|
挂载的 site-assets 目录中的文件 |
不用重建镜像,直接替换部署 overlay 里的文件 |
|
|
真正只在构建时生效的 |
|
后端或前端的源码 |
|
控制台在运行时从后端的 /site-config 获取名称和品牌,从自己的运行时环境获取 /agents 的目标地址,所以两者都不需要新的前端镜像:改了品牌文件就重启后端,改了控制台的环境变量就运行 make up。
拿不到有效的 /site-config 响应,控制台就不会渲染正常页面。如果后端连不上、太慢(超过三秒)或者返回了无效内容,控制台会显示一个带重试按钮的配置错误,而不会自己去猜。先查看前端日志,确认后端是否在运行,然后重试。示例部署不需要为此做任何额外设置。
为了兼容现有的构建流水线,还保留了几个旧的构建期选项:旧的品牌变量(Compose 仍然接受这些构建参数),以及两个 Compose 已经不再设置的 agent 构建参数。标准构建用不到它们。真正的 NEXT_PUBLIC_* 构建值会编译进浏览器 bundle,改了就要运行 make build s=frontend;见公开路径表。
配置
环境变量
所有配置都在仓库根目录的 .env 里,.env.example 是带注释的清单。调用 Compose 时会带上 --env-file .env,后端服务也通过 env_file 加载它。Compose 本身要求必填的变量,以及两个不该留空的密钥,见安装。
配置文件的解析顺序
网关按以下顺序选取每个配置文件:先是显式设置的 MODELS_CONFIG_PATH / ROUTING_CONFIG_PATH / ALERTS_CONFIG_PATH,然后是已启用的发行版 manifest,最后是 config/examples/ 下的示例;见网关如何找到自己的配置。
注意,Compose 文件是显式把它们透传进去的:
ROUTING_CONFIG_PATH: ${ROUTING_CONFIG_PATH-}
MODELS_CONFIG_PATH: ${MODELS_CONFIG_PATH-}
DISTRIBUTION_CONFIG_PATH: ${DISTRIBUTION_CONFIG_PATH-}
光有 --env-file 并不能把变量放进容器的环境里;真正把它带进去的是这几行。没有它们,网关就会悄悄回退到默认文件。
本地推理服务器
后端容器通过 host.docker.internal 访问宿主机上的服务器,这个主机名由 Compose 文件里的 extra_hosts: host.docker.internal:host-gateway 配置。在模型注册表里要显式写出这个地址:
route:
- kind: openai_compat
base_url: http://host.docker.internal:8001/v1
如果后端直接跑在宿主机上,就改用 localhost。网关从不改写 provider 的 URL。见添加新的本地模型。
健康检查
curl -s http://localhost:8080/health
{
"status": "healthy",
"routes_configured": 3,
"database_configured": true,
"database_connected": true,
"stores": {
"operational_store": {"status": "ok", "backend": "postgres", "cache": "in_memory"},
"log_store": {"status": "ok", "backend": "postgres"}
}
}
routes_configured统计的是已发布的路由条目——生效的注册表里每个模型 id 一条,每个别名再加一条。它完全取决于你自己的注册表定义了什么。database_configured区分的是「这个部署本来就不要数据库」和「数据库挂了」:DB_ENABLED=false时它是false,状态仍然是healthy;而配置了数据库、启动时却连不上时,/health返回 503,并带上"reason": "database_unavailable_at_startup"。已配置的两个存储中,如果一个挂了、另一个还能用,
status会变成degraded,但 HTTP 状态码仍是 200。这是有意这样设计的:容器的HEALTHCHECK用的是curl -f /health,如果部分降级也返回 503,还在正常响应请求的后端就会被下线。
需要严格的就绪探针(readiness probe)时,用 /health/ready:它要求已配置的存储全部正常,有任何一个没起来就返回 503。/health/deep 还会额外报告每个端点的健康状况。
给监控流量打标记
有些监控会真的发起推理:按计划请求 /v1/chat/completions,端到端地测量某个后端,而不只是轮询 /health。不做标记的话,这类请求会写进 api_logs,把仪表盘的数据带偏,还会被 RouteWise 的在线学习当成真实的用户需求。给这些请求加上 X-Probe: synthetic 就能把它们排除在外:带标记的请求不写入 api_logs(被拒绝时也不进拒绝日志),不记录路由观测,并且响应里会带一个 X-Provider 头,写明是哪个后端回答的,方便监控确认自己测的是哪条路由。
这个标记只认已通过鉴权的 internal 或 admin 角色 API key——free/pro key 不行,agent 沙箱的 grant 不行,在 USER_AUTH_ENABLED=0 的部署上,匿名调用方尤其不行(关掉鉴权后,每个调用方都会拿到 admin 角色,但这不等于持有监控身份)。其他调用方带这个头会被忽略,请求照普通流量记录。如果部署不开鉴权又需要探测,应该用显式的机制,比如共享密钥或来源白名单,而不是这个头。
这个标记只管噪声,不管访问权限。监控自己的认证失败照样可能触发针对反复认证失败的黑名单,因为是否封禁是在读取请求带的 key 之前就判断的——见监控或服务账号突然开始收到 429。
标记唯一不影响的是计费:无论走哪个接口、调用方是否可信,成本和配额都会照常累加,所以没法用探测标记来获得免计费的推理。如果你还是想让带标记的流量写进 api_logs,比如想在请求仪表盘里看到监控的真实延迟和花费,就打开 log_synthetic_probes 这个运行时设置;路由观测和 X-Provider 的行为不受影响。
告警
后端内置了一个进程内的告警引擎,会把告警推送到 Slack webhook。它默认关闭,需要手动打开:
ALERTS_ENABLED=true
SLACK_ALERTS_WEBHOOK_URL=https://hooks.slack.com/services/...
SLACK_ALERTS_WEBHOOK_URL 为空时会改用 SLACK_WEBHOOK_URL,所以两条代码路径可以共用一个 webhook。
规则和阈值由各个部署自己决定,本仓库不带告警文件。把 ALERTS_CONFIG_PATH 指向你自己的文件;不设置的话就用内置阈值。规则类型和求值逻辑在 apps/backend/serving/observability/ 里。
有两条认证规则要分清楚,它们的默认值是故意设成不一样的:
rules.auth_failure_spike默认关闭。错误的 key 在互联网上就是背景噪声,光看数量指不出该处理什么。不管这条规则开不开,auth_failure日志都照常输出。rules.auth_ip_blocked默认开启。它在黑名单开始拒绝某个来源时触发:这是一个明确的判断,阈值高得多,而且指向具体地址。默认开启是因为被拒的来源有时就是部署自己的调用方;见下面的故障排查条目。
认证告警里有什么
认证失败的告警卡片会列出失败的来源,能查到的话还会给出背后的账号:来源地址、不同地址的个数、所出示 key 的前几个字符、每次失败的原因,以及被访问的路径。恢复卡片给出的也是刚结束的这次事件的完整情况,而不只是规则名:等尖峰恢复时,当初越过阈值的那个时间窗口里已经没有数据了,所以这些数字只能来自整个事件期间持续累计的统计。
其中有两行值得仔细读:
Known accounts(已知账号)。大多数认证失败天生就是匿名的——正因为没有人通过认证,才叫认证失败。出现账号,说明有人出示了本部署确实签发过的 key 并被拒绝,
credential_state会说明原因(revoked、expired、user_suspended)。这才是需要处理的情况:某个监控、CI 任务或服务账号的凭据已经失效。查出持有者,每次认证失败要多做一次走索引的查询;这些查询和其他拒绝请求的信息补全共用一份预算,遇到流量洪峰会立即放弃。设置AUTH_FAILURE_IDENTIFY_CALLER=false就没有这笔开销,但也看不到这一行了。看到账号可以当作证据;看不到账号只能算未知,不能当作流量来自外部的证据:查询恰恰会在你正在排查的洪峰期间被放弃,而且查询超时、查询失败或这个设置关闭时,它都不会给出结果。Arrived via peers(实际经由的对端)。只有上报的地址不是直接取自 socket 时,才会出现这一行。这时这些地址是否可信,完全取决于写入它们的那层代理;而伪造
X-Forwarded-For,正是一个来源把自己的失败次数分散到黑名单各个分桶里的手段。这一行列出这些请求实际是从哪些 套接字对端进来的。
带 (capped) 标记的计数是下限,不是总数:某个来源换地址的速度一旦超过统计能跟踪的范围,超出的部分就不再计入,而不是任由统计无限增长。不要根据封顶的数字估计事件规模。
在仪表盘中静音告警
管理仪表盘的 Settings 标签页里有两个开关,两者都不会改动你的告警文件:
Slack Alerts 可以暂停所有告警,最长七天。
Alert Types 可以静音某一类告警,例如
auth_ip_blocked或circuit_open,时长为一小时、一天、一周,或直到手动取消静音。其他类型照常发送。
静音保存在数据库里,所以重启后依然有效,几秒内就会同步到所有网关进程。某类告警被静音期间,它的任何消息都不会发出;静音期间开始的事件在关闭时也不会发送恢复消息。静音之前已经发出的事件仍会收到恢复消息;静音解除时如果越限仍在持续,会在下一次评估时立即告警。静音适合那些你以后还想恢复的告警;要彻底停用某条规则,应该在告警文件里把它关掉。
第一个管理员账号
每个部署一开始都需要一个管理员。创建方式要慎重选择:
警告
在别人能访问到的实例上,不要同时使用 SIGNUP_ENABLED=1、SIGNUP_REQUIRE_EMAIL_VERIFICATION=0 和 ADMIN_EMAILS。三者组合起来就是一条现成的提权路径:
关闭验证后,
POST /auth/signup会把任何地址都标记为已验证,却根本不往这个地址发邮件;登录时以及每一次 token 刷新时,后端都会把地址列在
ADMIN_EMAILS里的账号从free提升为admin,并不检查注册的人是否真的拥有那个地址。
于是,陌生人只要猜到或看到了你的 ADMIN_EMAILS,用那个地址注册,第一次登录就是管理员。
改用下面两种做法之一。
推荐做法——绕开注册流程单独创建管理员,并且不设置 ADMIN_EMAILS。ops/admin/create_admin.py 直接往数据库写记录:新建一个 role='admin'、status='active'、email_verified=TRUE 的账号;如果同一地址的账号已经存在,就把它提升为管理员。后端至少启动过一次之后(表结构由后端创建),在仓库根目录运行:
python ops/admin/create_admin.py --email [email protected]
要在项目环境里运行它(make setup-dev 之后执行 source .venv/bin/activate),这样才能 import serving 包。它从 .env 读取 DB_HOST、DB_PORT、DB_NAME、DB_USER 和 DB_PASSWORD,而 Postgres 发布在 127.0.0.1:5432,所以直接在宿主机的 shell 里就能运行。不带 --password 时它会提示你输入密码,这样密码就不会留在 shell 历史里。有了这个管理员,实例就可以关闭注册运行:
USER_AUTH_ENABLED=1
SIGNUP_ENABLED=0
备选做法——保持注册开放,但不要关闭邮箱验证。SIGNUP_REQUIRE_EMAIL_VERIFICATION 默认为 true,开启时,账号必须先点击发送到该地址的验证链接才能登录,这就补上了 ADMIN_EMAILS 自己不做的地址归属检查。这需要一套可用的 SMTP;没有 SMTP,谁都无法完成注册。
ADMIN_EMAILS 还决定注册审批邮件的默认收件人。如果只想缩小通知名单、不想改变谁是管理员,就设置 SIGNUP_NOTIFY_EMAILS(逗号分隔);它为空时,通知仍发给 ADMIN_EMAILS。
SIGNUP_NOTIFY_EMAILS=[email protected]
signup_enabled 和 signup_require_email_verification 也可以在运行时通过 settings store 修改,而且运行时的值优先于环境变量。.env 里写的只是初始值,不能保证一直生效。
数据库
PostgreSQL 16 跑在 postgres 服务里,数据放在 Docker 卷 hybridinference_postgres_data 中。开一个 psql shell:
docker exec -it hybridinference-postgres psql -U "${DB_USER}" -d "${DB_NAME}"
表结构细节见数据库。
pgAdmin(可选)
make up COMPOSE_PROFILES=admin
之后 pgAdmin 会监听 127.0.0.1:5050,并设置了 SCRIPT_NAME=/pgadmin,所以开一条到这个端口的 SSH 隧道就能访问。控制台也可以在 /pgadmin/ 下代理它,由 apps/frontend/src/app/pgadmin/[[...path]]/route.ts 检查是否为管理员会话;遇到任何意外情况它都会拒绝访问,包括连不上后端的时候。
有一点要注意:pgAdmin 是否还要求登录它自己的账号,由 PGADMIN_CONFIG_SERVER_MODE 决定,而两处的默认值不一致。Compose 服务在未设置时取 False,pgAdmin 完全不需要登录;.env.example 建议设为 True,即在控制台这道关卡之后,再加一道 pgAdmin 自己的登录。True 更安全。
故障排查
某个服务起不来
make logs s=backend
make ps
required variable DB_NAME is missing a value: DB_NAME must be set in .env file——Compose 在变量插值阶段就停下了,什么都还没启动。DB_NAME、DB_USER和DB_PASSWORD是用${VAR:?message}形式声明为必填的,所以冒号之后那半句是 Compose 文件自己写的文案,也是这一行里最好 grep 的部分。端口已被占用——换一个
BACKEND_PORT、FRONTEND_PORT或DB_PORT。数据库连接失败——用
make ps查看postgres的健康状态。
监控或服务账号突然开始收到 429
Too many authentication failures from this IP. Temporarily blocked. 是网关自己的防滥用机制,既不是 provider 报的错,也不是配额限制。一个来源在 AUTH_FAILURE_BLOCK_WINDOW_SEC 内累计 AUTH_FAILURE_BLOCK_THRESHOLD 次认证失败(默认是一天 200 次)后,接下来的 AUTH_FAILURE_BLOCK_DURATION_SEC(一天)内都会被拒绝。
麻烦的是你自己的调用方——状态监控、CI 任务、服务账号——它的 key 被轮换或吊销了,或者压根没配置到它的运行环境里。它按计划不断重试,越过阈值,然后在检查 key 之前就被拒绝。这会带来两个后果:
修好凭据并不会解除封禁。黑名单在读取请求带的 key 之前就做了判断,所以在封禁到期之前,换上正确的 key 也还是 429。
429 掩盖了原始错误。封禁生效之后,调用方报什么都说明不了底层那个 401/403 究竟修好了没有。
到底是哪个调用方?打开 log_rejected_requests 这个管理设置,被拒绝的请求就会以 ip_blocked 行的形式出现在 Recent Requests 里。每一行都会写出调用方所带 key 背后的账号,包括已吊销或已过期的 key(卡住的监控带的正是这种),并在用户旁边标出凭据状态(revoked、expired、user_suspended)。没有用户的行只是没查出来,不能证明是陌生人:可能这个 key 根本不是本部署签发的,也可能查询被放弃了。这个查询的预算很紧,而封禁正在挡的那种流量洪峰,恰恰最容易让它放弃。
要恢复,先修好凭据,再解除封禁:
# Which sources is this worker refusing?
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:8080/admin/auth-blocks
# Lift one. `ip` takes a raw address, or a bucket key exactly as listed
# (IPv6 sources are bucketed to their /64).
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"ip": "203.0.113.7"}' \
http://localhost:8080/admin/auth-blocks/clear
cleared: false 表示没有需要解除的封禁:要么已经到期,要么那个分桶从来没被封过。解除封禁不等于豁免:调用方如果还在用错误的 key,越过阈值后会再次被封。对于永远不该被封的来源,应该把它加进 AUTH_FAILURE_BLOCK_EXEMPT_IPS(逗号分隔的地址或 CIDR)。
黑名单是进程内的内存状态。在默认的单进程部署下,这两个接口给出的都是准确结果,而且重启也会清掉所有封禁。跑多个 worker 时,每个 worker 各记一份计数,所以列表只反映应答的那个 worker,解除也可能需要调用多次。
重置这套服务栈
停止再启动,保留数据:
make down && make up
销毁数据库、从干净状态开始。docker compose down -v 做不到这件事。postgres_data 在 deploy/docker/docker-compose.yml 里声明为 external: true,而 Compose 从不删除外部卷——down -v 会返回成功,却把卷原封不动地留着,所以 make up 之后还是原来那份数据。要按名字删除这个卷:
make down
docker volume rm hybridinference_postgres_data
make up # docker-volumes recreates it empty; Postgres re-initialises
警告
docker volume rm 不可逆,所有账号、API key 和请求日志都会随之删除。只要其中有任何数据还有用,先做一次 pg_dump。
pgAdmin 自己的卷(hybridinference_pgadmin_data)是普通的本地卷,make down 不会动它。要清掉 pgAdmin 保存的状态,参照重置 pgAdmin,按名字删除这个卷。
改动代码后重新构建
make build # all images
make build s=backend # one service