# Agent X-Ray 单机部署(预发 130 / 生产境内轻量服务器共用)
#
# 镜像是**不可变制品**:本机构建 → 传输 → 服务器只 pull/load + up,服务器上不构建、
# 不保留任何构建工具链(CLAUDE.md 规则 10)。构建命令见 `dev.ps1 build`。
#
# 用法(首次部署按这个顺序,不要一把 `up -d` 起全部):
# cp .env.example .env && chmod 600 .env # 首次;IMAGE_TAG 写进 .env
# docker compose up -d --wait postgres # 1) 只起库,等到 healthy
# ./migrate.sh # 2) schema 就位
# docker compose up -d # 3) 再起 api / web / caddy
#
# 升级时在最前面多一步 `docker compose stop api web`:旧版 api 在迁移期间继续
# 对外服务会形成「旧二进制 × 新 schema」的混版本窗口;V1 用短暂停机换确定性
# (详见 docs/deploy-environments.md「升级顺序」)。
#
# ⚠️ 顺序不能颠倒。Encore 自托管镜像**不含迁移执行逻辑**,而 /health 不触库——
# 先起 api 再迁移会出现一段「Caddy 已对外放流量 + 健康检查全绿 + 业务接口 500
# (relation "sessions" does not exist)」的中间状态,监控发现不了。
# 先迁移后起服务可以整段消除这个窗口。migrate.sh 幂等,重复执行是空操作;
# 设计说明见 docs/deploy-environments.md「数据库迁移」。
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
# HTTP/3(QUIC)走 **UDP** 443。R11 上线冒烟实测:少了这一行,Caddy 照样在响应里
# 广告 `Alt-Svc: h3=":443"`,浏览器于是去试 QUIC —— 而宿主上 udp/443 根本没人听,
# 表现是**首访多一次超时再回落 TCP**,功能不坏但每个访客都白等一次。
# compose 的 `"443:443"` 简写默认**只映射 tcp**,udp 必须单独写(实测:
# `docker port` 只列 `443/tcp`,`ss -ulnp` 无 udp/443)。
# 防火墙侧两层也要放行 udp/443:ufw(已加)与腾讯云控制台(所有者已加)。
- "443:443/udp"
environment:
# Caddyfile 站点地址(R11)。留空 = `:80`,与本项加入前的行为一字不差(130 预发就留空);
# 生产填域名(如 kzgai.cloud)→ Caddy 自动签发 TLS 并把 80 跳到 443。
# ⚠️ 它和 SITE_ORIGIN 是两件事:这个决定 Caddy **听**什么,那个是 RSS 里写出去的**对外地址**。
# 两者必须指向同一个站,否则订阅链接指向一个没在听的端点。
- SITE_ADDRESS=${SITE_ADDRESS:-:80}
# 规范主机名重定向(R11):把 SITE_REDIRECT_FROM 列的主机名 301 到 SITE_ORIGIN。
# 两者都不设 = 这条规则是死的(130 预发就是这样)。Caddy 拿 SITE_ORIGIN 只为当跳转目标,
# 与 web/api 拿它写 RSS 绝对链接是同一个值 —— 共用一个变量,配不歪。
# ⚠️ 默认值必须是哨兵而不是空串:Caddy 的 `{$VAR:default}` 只在变量**不存在**时
# 取默认值,变量存在但为空时取空 —— 那会让 matcher 变成 `host `(缺参数),
# Caddyfile 解析失败、caddy 起不来。给一个永不匹配的主机名最安全。
- SITE_REDIRECT_FROM=${SITE_REDIRECT_FROM:-__unset__}
- SITE_ORIGIN=${SITE_ORIGIN:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks: [front]
depends_on:
- web
- api
mem_limit: 128m
pids_limit: 128
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
# 仅保留绑定 80/443 所需的一项能力
cap_add: [NET_BIND_SERVICE]
web:
# Next.js standalone + bun 运行时(apps/web/Dockerfile)
image: ${IMAGE_REGISTRY:-local}/xray-web:${IMAGE_TAG:?IMAGE_TAG 必须显式指定(git SHA),禁止 latest}
restart: unless-stopped
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=32m
environment:
- NODE_ENV=production
- PORT=3000
- HOSTNAME=0.0.0.0
# Notes 三级页是 Server Component,取数发自 web 进程 → 直连内网的 api,
# 不经 caddy(本机开发默认 127.0.0.1:4000)。R5
- API_INTERNAL_URL=http://api:4000
# 订阅弹层里展示与复制的 RSS 地址。R5
- SITE_ORIGIN=${SITE_ORIGIN:?SITE_ORIGIN 必须填对外访问地址(含 scheme,必要时含端口)}
# ICP 备案号(R8)。**只有生产填**:填了才在页面底部渲染一条链到工信部的
# 底栏,留空则整块不渲染(130 预发不备案,保持留空)。
# 不能改成构建期注入:镜像是不可变制品,预发与生产共用同一个 SHA(规则 10)。
- ICP_BEIAN=${ICP_BEIAN:-}
# 公安联网备案号(2026-09-10)。与 ICP 同一条约束、同一套注入方式:**只有生产填**,
# 填了才在底栏渲染「备案编号图标 + 号码」并链到公安部查询页,留空则那一半不渲染。
- MPS_BEIAN=${MPS_BEIAN:-}
expose:
- "3000"
networks: [front]
mem_limit: 384m
pids_limit: 128
user: "10001:10001"
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
api:
# Encore.ts + pi SDK in-process,bun 运行时(encore build docker --base oven/bun:…)
image: ${IMAGE_REGISTRY:-local}/xray-api:${IMAGE_TAG:?IMAGE_TAG 必须显式指定(git SHA),禁止 latest}
restart: unless-stopped
# 安全约束(docs/security.md 第 3 层):非 root、只读根 FS、无特权、无能力、限内存/进程数
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
user: "10001:10001"
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
# 1g 是**初始生产上限**,不是「已证明足够所有真实负载」的结论。
# 选它的三个依据:Bun 口径实测基座 ~162MB、事件缓冲的结构性上限
# (每会话最坏 ~56MB,见 events.ts/runtime.ts 的常数)、以及 3.6GiB 主机的
# 总内存预算。**真实活跃会话的内存增量目前没有数据**(R1/R-BUN 只测了空闲
# 会话,不能用它推容量),要等 R3/R4 后用真实使用数据重新标定。
# 调整依据见 docs/deploy-cn-lightweight.md §0 的容量公式,不要拍脑袋改。
mem_limit: 1g
pids_limit: 256
environment:
# 镜像默认监听 8080;这里显式对齐 Caddyfile 的 api:4000(二选一,已写死)
- PORT=4000
# 下列变量由 deploy/infra-config.json 以 {"$env": …} 引用,构建时已绑定
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?}
# MCP 管理面(R6)。**注意这里放的不是可直接使用的凭据**:
# MCP_AUTH_TOKEN_HASH = bearer token 的 sha256(hex);原 token 只在所有者手里
# CONFIG_ENCRYPTION_KEY = llm_config 里 LLM key 的 AES-256-GCM 密钥(32 字节 base64)
# 换 CONFIG_ENCRYPTION_KEY 会让库里既有的密文全部解不开 —— agent 直接停摆,
# 必须经 MCP 重新写一遍各 provider 的 key。生成方式见 deploy/.env.example。
- MCP_AUTH_TOKEN_HASH=${MCP_AUTH_TOKEN_HASH:?}
- CONFIG_ENCRYPTION_KEY=${CONFIG_ENCRYPTION_KEY:?}
# 访问统计(R8)的访客标识盐。`:?` 是刻意的:漏配时**启动就失败**,
# 而不是留到运行期变成「打点静默停用、统计永远是 0」——后者要靠翻日志
# 才发现。docs/security.md §6「IP 加盐哈希后落库,不存原始 IP」。
- METRICS_IP_SALT=${METRICS_IP_SALT:?}
# RSS 条目里的绝对链接。不配就会回落到请求头里的 Host —— 那等于让任何人
# 换个 Host 头就能左右订阅源里的链接指向。
# **必须含 scheme**:备案通过前 130 预发只有明文 HTTP,写死 https 会让整份
# 订阅源指向一个没在听的端点(codex review 2026-08-31 P2)。R5
- SITE_ORIGIN=${SITE_ORIGIN:?}
# R7 的 agent_ro 只读角色**不需要任何变量**:它是 NOLOGIN 角色,由应用连接在
# 事务里 SET LOCAL ROLE 临时降权(docs/security.md §1 第 2 层的落地补记),
# 没有第二套连接串、也没有第二份口令。角色与授权由迁移 006 建,
# 因此 deploy/migrate.sh 必须在起 api 之前跑完(本来就是既定顺序)。
#
# 高危工具双闸之一,**默认不设**(设了也只是开了第一闸,还要 tool_config 那半):
# XRAY_UNLOCK_DANGEROUS_TOOLS=1
# R-SKILLS-2 起它有了用武之地:`skill_run`(在独立的 skill-runner 容器里跑 skill 自带的 Python 脚本)
# 是注册表里第一个 dangerous=TRUE 的工具。打开顺序见 rounds/round-skills/round-skills-2.md「运维」段
# (发版 → 生产冒烟 4 条 → skill_load → 逐 skill agent_enabled → 这里设 1 并 `up -d api` 重建 → skill_run)。
# 关掉只需 `tool_config_set skill_run false`(不用动这里);要回到「注册表里没有 dangerous 实现」的状态
# 才把这一行去掉并重建 api。
- XRAY_UNLOCK_DANGEROUS_TOOLS=${XRAY_UNLOCK_DANGEROUS_TOOLS:-}
# 执行容器地址**不在这里配**:api 走命名卷里的 unix socket,每档一条(代码默认值):
# none 档 runner_sock → unix:/run/runner/runner.sock (skill-runner,无网络)
# egress 档 runner_egress_sock → unix:/run/runner-egress/runner.sock (skill-runner-egress,只出公网;R-WEBFETCH)
# `XRAY_SKILL_RUNNER_URL` / `XRAY_SKILL_RUNNER_EGRESS_URL` 是本机开发专用的 TCP 覆盖项(代码级闭集,只认
# http://127.0.0.1:<port>),生产不设。需要新增变量时,先在 infra-config.json 的 secrets 段增声明再在此处补。
#
# 联网搜索的目标域白名单**追加**项(R-WEBSEARCH),默认空。
# 内置白名单在代码里(shared/websearch-hosts.ts),这个变量只能加不能减 ——
# 所以留空是安全的默认,不用 `:?`。websearch 的 API key **不在这里**:
# 它经 MCP 加密入库,与 LLM key 共用 CONFIG_ENCRYPTION_KEY。
- XRAY_WEBSEARCH_EXTRA_HOSTS=${XRAY_WEBSEARCH_EXTRA_HOSTS:-}
# 生图的目标域白名单**追加**项(R-IMAGEGEN),同上;**另一份清单**
# (shared/imagegen-hosts.ts),所有者的网关域名两个变量都要写。
- XRAY_IMAGEGEN_EXTRA_HOSTS=${XRAY_IMAGEGEN_EXTRA_HOSTS:-}
expose:
- "4000"
networks: [front, back]
volumes:
# api ↔ skill-runner 的唯一通道:命名卷里的 unix socket(R-SKILLS-2;docs/security.md §1 R-SKILLS-2 补记)。
# 两个容器同 uid 10001,socket 0660。api 这边只读挂载没有意义(连接 socket 不需要写目录),
# 但也不需要写:目录里只会有 runner 创建的那一个 socket 文件。
- runner_sock:/run/runner
# 第二条 socket(R-WEBFETCH):egress 实例的。api 按 skill 清单里的 network 档次选用哪一条
- runner_egress_sock:/run/runner-egress
depends_on:
postgres:
condition: service_healthy
# 只保证启动顺序(socket 目录先由 runner 建好),**不是**硬依赖:
# `docker compose stop skill-runner` 之后 api 照常服务,skill_run 以固定文案失败(止损路径)。
skill-runner:
condition: service_started
# 同上:`docker compose stop skill-runner-egress` 之后只有 egress 档的 skill(web-fetch)以固定文案失败,其余照常
skill-runner-egress:
condition: service_started
# 必须 ≥ infra-config.json 的 graceful_shutdown.total(30s)+ 余量,
# 否则 SSE 长连接会被 SIGKILL 硬断而不是走优雅关闭
stop_grace_period: 40s
skill-runner:
# agent `skill_run` 的执行容器(R-SKILLS-2,所有者裁定 2 / 4):Python venv + 镜像里预置的 skill 脚本
# (runner/,本机 `dev.ps1 build` 出第三个镜像 xray-runner:<sha>)。**不是 JS 运行时**,规则 11 不涉及。
image: ${IMAGE_REGISTRY:-local}/xray-runner:${IMAGE_TAG:?IMAGE_TAG 必须显式指定(git SHA),禁止 latest}
restart: unless-stopped
# 【硬约束】没有任何网络 —— 连 DNS 都没有,因而也回打不了 api / postgres(共网方案的硬伤,research.md §2.4)。
# api 经上面那个命名卷里的 unix socket 单向找它,反向无通道。R-WEBFETCH 的 egress 实例是下面**另一个** service
# (skill-runner-egress),这一行对它不适用;本实例的 network_mode: none 一字不改。
network_mode: none
# 【必须】docker 自带的 tini 当 PID 1:脚本 fork 出来的孙进程被 killpg 之后由它收养并 reap。
# runner.py 刻意**不**自己 waitpid(-1) —— 并发两次运行时那会把另一次还没 wait 的子进程先收走、退出码丢成 0
# (codex 首轮 P1)。少了这一行的表现是 runner 日志一行 WARNING + 僵尸逐渐占满 pids_limit。
init: true
# 比 api 再收紧三处(docs/security.md §1 第 3 层「执行容器」):只读根 FS + noexec 的 tmpfs 工作目录 + cpus 限 1。
read_only: true
tmpfs:
# 每次运行一个 /run/work/<uuid>,结束即删;noexec 让脚本落盘的东西不可执行;64m 是容量上限(rlimit FSIZE 16M 是单文件)
- /run/work:rw,noexec,nosuid,nodev,size=64m,uid=10001,gid=10001
volumes:
- runner_sock:/run/runner
user: "10001:10001"
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
mem_limit: 384m
pids_limit: 64
cpus: 1.0
environment:
# 出网档次(缺省 none):清单里该 skill 的 network 与这里不等即拒(R-WEBFETCH C6 提前进本轮)。
# egress 实例(R-WEBFETCH)会把它设成 egress 并挂在专用网络上;本实例永远是 none。
- RUNNER_NETWORK=none
# 走同一个 socket 的 GET /health;冒烟清单第 1 条「skill-runner healthy」看的就是它
healthcheck:
test: ["CMD", "/opt/venv/bin/python", "-I", "/opt/runner.py", "--health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
skill-runner-egress:
# egress 档执行容器(R-WEBFETCH,所有者裁定 C1 / C7):**同一镜像**的第二个实例,只跑 xray.json 声明 `network: egress`
# 的 skill(首个是 web-fetch:访客给公网 https 网址 → 脚本抓取抽正文)。与上面的 skill-runner 只有四处不同:
# 网络(专用 egress 网络而不是 none)、RUNNER_NETWORK、socket 卷、并发 1 + mem_limit 256m。其余收紧项逐字相同。
image: ${IMAGE_REGISTRY:-local}/xray-runner:${IMAGE_TAG:?IMAGE_TAG 必须显式指定(git SHA),禁止 latest}
restart: unless-stopped
# 【硬约束】只在 egress 这一个网络里,**不在 front / back**:docker 内嵌 DNS 只解析同网络的容器名,api / postgres /
# web / caddy 在它眼里不存在;跨 bridge 的流量被 docker 的 isolation 链挡住。它能到的是公网(经宿主 NAT)与宿主上
# 绑定 0.0.0.0 的端口(也就是本站自己);宿主的私网邻居与云元数据由脚本的地址段校验 + 宿主 DOCKER-USER 规则
# (deploy/egress-filter.sh)两道挡。
networks: [egress]
init: true
read_only: true
tmpfs:
- /run/work:rw,noexec,nosuid,nodev,size=64m,uid=10001,gid=10001
volumes:
# 自己的 socket 卷;容器内路径与默认实例相同(镜像 ENV RUNNER_LISTEN 不用改),api 那边挂到 /run/runner-egress
- runner_egress_sock:/run/runner
user: "10001:10001"
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
# 所有者裁定 C7:256m + 并发 1(抓取是 I/O 型且低频;主机预算紧)。子进程 rlimit AS 256 MB 与默认实例相同
mem_limit: 256m
pids_limit: 64
cpus: 1.0
environment:
- RUNNER_NETWORK=egress
- RUNNER_CONCURRENCY=1
healthcheck:
test: ["CMD", "/opt/venv/bin/python", "-I", "/opt/runner.py", "--health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
# 库名必须是 agent:Encore infra-config 的 databases key 即物理库名
- POSTGRES_DB=agent
- POSTGRES_USER=app
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?}
command:
- postgres
- -c
- shared_buffers=256MB
volumes:
- pg_data:/var/lib/postgresql/data
expose:
- "5432"
networks: [back]
mem_limit: 768m
pids_limit: 256
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d agent"]
interval: 5s
timeout: 3s
retries: 12
start_period: 20s
networks:
front:
back:
# 数据库只在内部网段可达:caddy/web 无法直接连 postgres
internal: true
egress:
# skill-runner-egress 的专用出网网络(R-WEBFETCH):只有它一个成员,能出公网、不 internal。
# **网段固定**,因为宿主的 DOCKER-USER 过滤规则(deploy/egress-filter.sh)按源网段匹配;改这里要连脚本的
# EGRESS_SUBNET 一起改。172.30.0.0/24 在 docker 默认地址池(172.17–172.31)之内但由 compose 显式占用,
# 后建的自动分配网络会绕开它;若与宿主 VPC 网段重叠(腾讯云轻量默认是 10.x,不重叠)再换。
driver: bridge
ipam:
config:
- subnet: 172.30.0.0/24
volumes:
caddy_data:
caddy_config:
pg_data:
# api ↔ skill-runner 的 unix socket 所在(R-SKILLS-2)。只有一个 socket 文件,不持久化任何数据
runner_sock:
# api ↔ skill-runner-egress 的 unix socket 所在(R-WEBFETCH),同上
runner_egress_sock: