| 开发 | 本机 Windows | dev.ps1 → encore run :4000;本地 Postgres 由 encore 经 Docker Desktop 管理 | bun(encore.app 的 bun-runtime 实验位) | 可用(R0 起) |
| 测试 | 同上 | dev.ps1 test → encore test → bun --bun vitest run | bun | 可用(R-BUN 起) |
| 预发 | 130 服务器 | docker compose(deploy/)。可选环境:有需要时先在 130 发版验证,不是发生产的前置(所有者裁定 2026-09-03);SHA 允许落后于生产 | bun(oven/bun:1.4.0-slim 基座) | 可用(R9,2026-09-01 全链路实测通过;http://192.168.100.130,当前 7cc17fe / 迁移 7) |
| 生产 | 境内轻量服务器 | docker compose;若经过 130 验证则原样提升同一个镜像(SHA 相同,不重新构建) | bun | 已投产(R11,2026-09-02,https://www.kzgai.cloud/);逐次发版见 releases.md |
运行时与包管理器是两件事: bun 只做 JS 运行时与脚本执行器;依赖安装仍走 npm ci + package-lock.json,不切 bun install。理由见 rounds/round-bun/round-bun.md「包管理器为何不切」——pi SDK 自带 npm-shrinkwrap.json 锁定传递依赖,bun 不读它,切过去等于丢掉这层供应链锁定,而收益为零。
镜像是不可变制品:同一个 git SHA 构建一次;经 130 预发验证是可选的(所有者裁定 2026-09-03),验过就原样提升同一个镜像、不重新构建,没验就直接发生产。每次生产发版记入 releases.md。服务器上不装 encore CLI、不装 node/bun 工具链、不留 git 工作区,只有 docker + compose 加四个部署资产:docker-compose.yml、Caddyfile、migrate.sh、.env(由 .env.example 复制)。这四个文件首次部署时随镜像一起 scp 上去,之后仅在其有变更的发布中重传——migrate.sh 是部署序列的必经步骤,漏传就无法完成迁移。
本机构建(Windows,CLAUDE.md 规则 1/10):
该命令做四件事:拒绝在脏工作区构建、并核对 skills 清单生成物与 runner/skills 一致(node tools/skills-manifest/generate.mjs --check)→ encore build docker --config deploy/infra-config.json --base oven/bun:1.4.0-slim --services agent,trace,notes,mcp,metrics,about,system,site,skills,source 出 api 镜像 → docker build apps/web 出 web 镜像 → docker build runner 出 runner 镜像(R-SKILLS-2 的执行容器 xray-runner:<sha>;Python 基座按 digest 钉在 runner/Dockerfile,不是 JS 运行时;R-WEBFETCH 起它要从 PyPI 装 requirements.txt 里全部带 hash 的依赖,本机直连慢就设 $env:PIP_INDEX_URL 指向镜像站,hash 照核)。三个 tag 都是 git 短 SHA,ship 三镜像一起 save;egress 实例复用 runner 镜像,不是第四个镜像。服务白名单以 dev.ps1 的 $hostedServices 为准,新增服务必须补进去,漏补的表现是镜像与健康检查都正常、该服务端点静默 404(冒烟清单第 1 条就是为它设的)。
⚠️ 构建前先 docker pull python:3.12-slim@<digest>(digest 在 runner/Dockerfile 里)—— 与下面 bun 基座同一个理由,境内网络下让 daemon 侧的 mirror 去拉。
⚠️ --base 不能省。Encore 开启 bun-runtime 后会把镜像 ENTRYPOINT 改成 bun run …,却仍按默认基座 node:slim 打包,产出的镜像里没有 bun,docker run 直接报 exec: "bun": executable file not found in $PATH。encore.app 里的 build.docker.base_image 对本地构建无效(只对 Encore 自家 CI/CD 生效)。已固化进 dev.ps1 build,不要手敲 encore build docker。
⚠️ 构建前先 docker pull oven/bun:1.4.0-slim。encore 发现本地没有基座时会自己直连 Docker Hub 拉取,不走 docker daemon 的 registry mirror——境内网络下会卡住几十分钟(实测 2026-08-31);daemon 侧 docker pull 正常,拉好后 encore 直接用本地镜像。
镜像传输:走文件,不走管道——Windows PowerShell 5.1 的管道按文本重编码,docker save … | ssh … docker load 会把二进制 tar 破坏掉(远端 load 报 unexpected EOF 之类):
上面三步(外加建 ~/deploy、传五件部署资产(R-WEBFETCH 起含 egress-filter.sh)、给 migrate.sh / egress-filter.sh 补执行位、load 完删远端 tar) 已固化成一条命令,平时用它:
它刻意不传 .env —— .env 按环境独立、含密钥,永不出本机。
传输量比想象的小:docker save 对两个镜像共享的 bun 基座层去重,api 600 MB + web 355 MB 打成的 tar 只有 155 MB(R11 实测,传到境内云 99 秒)——别按镜像大小之和去估带宽,也不必为此改走压缩或 registry。
生产按网络情况用同法或私有 registry(registry 流程在 ./migrate.sh 前先 docker pull 对应 api 镜像,迁移要从镜像里取 SQL)。任何环境都不用 latest tag:compose 里 ${IMAGE_TAG:?} 挡空值,migrate.sh 进一步硬校验 tag 必须是 git SHA。部署资产(docker-compose.yml / Caddyfile / migrate.sh / .env.example)首次与变更时随包一起 scp。
服务器部署 —— 先迁移,后起服务:
顺序不能颠倒。 /health 不触库,所以「先 up -d 起全部、再迁移」会留下一段中间状态:Caddy 已经对外放流量、容器 healthy、健康检查全绿,而真实业务接口全部 500。这段窗口靠监控发现不了,只能靠部署顺序消除。升级时同理:新镜像若带了新迁移,也要先停在这个顺序上。
数据库迁移 —— 必须显式执行一次,不会自动跑:
⚠️ 经 ssh 远程跑时,一条命令一个 ssh,或给它 < /dev/null。migrate.sh 内部用 docker compose exec -T, 而 -T 只是不分配 TTY,stdin 照样 attach —— 把它放进 ssh host bash -s <<'EOF' 的多行脚本里, 它会把 heredoc 剩下的行当成自己的输入吃掉,后面的命令静默不执行。R11 生产首发就是这么 「--status 通了、实际迁移一行没跑、表数 0」的,日志里看不出任何异常。
为什么需要这一步(实测 2026-08-29):Encore 的自托管镜像不含迁移执行逻辑。本地 encore run 时是 encore CLI 把 SQL 灌进库的(日志里的 "Running database migrations"),而生产镜像里没有 CLI,Encore 运行时本身也没有迁移代码。镜像虽打包了 agent/migrations/*.up.sql,但容器启动不会应用。空库直起的表现极具迷惑性:
deploy/migrate.sh(所有者裁定 2026-08-29 方案一)的设计:
升级顺序:先停旧服务,再迁移,再起新版。「先迁移、后起服务」在升级场景有个陷阱:只对 postgres up -d --wait 时,,迁移是在它脚下改 schema——旧二进制遇到不兼容的新 schema 会出错甚至写坏数据。V1 用短暂停机换确定性:
首次部署必做:经 MCP 配置 LLM provider(R6 起没有引导密钥,docs/security.md §3)。
在这一步之前,站点三个 Tab 都能开,但 /agent/ask 会回 503 对话服务尚未配置模型 —— 这是设计如此,不是故障。运行期 LLM 凭据的唯一来源是 llm_config 表,只能经管理面写入:
内容(文章与配图)同理:库是空的,由所有者经 MCP 发布,或从别的环境 pg_dump/pg_restore 搬过来。镜像里不含任何 notes 内容。
可选:开联网搜索(R-WEBSEARCH)。web_search 是唯一的外呼工具,种子行默认是关的,要两步才生效——先配 provider,再开开关。少哪一步都是「工具不出现」而不是报错:
可选:开生图(R-IMAGEGEN)。generate_image 是第二个外呼工具,同样两步、同样默认关。协议形态由 apiStyle 决定:
验证 —— 冒烟清单(R9 在 130 上逐项跑过,留证在 rounds/round-09/smoke.md):
| # | 检查 | 期望 |
|---|---|---|
| 1 | 服务白名单逐项可达 | agent / trace / notes / mcp / metrics / about / system / site / skills / source 各取一个正式端点(site 是 GET /api/site/tabs,skills 是 GET /api/skills,source 是 GET /api/source —— 首次发版在 source-publish 之前回 404 是预期,之后必须 200),全部非 404 |
| 2 | 已删服务 | /api/spike/* 与 /admin 全部 404 |
| 3 | 五 Tab | /、/notes、/skills、/source、/about 均 200 且渲染真实数据;/skills/<name>.zip 经 Caddy 回 application/zip(R-SKILLS 的扩展名分流) |
| 4 | MCP 管理面 | 无 token / 错 token / 格式不对的 token / GET 一律 401,且;带 token 且带齐 2026-07-28 逐请求契约的 回 , 回全部工具(以 的 计数为准,R-WEBSEARCH 后是 28、R-IMAGEGEN 后是 32、R-TABS 后 34、R-SKILLS 后 42、R-SKILLS-2 后 46、R-SOURCE 后 ;)(R10 修准:带 token 的 GET 是 —— 认证闸在方法校验之前,别按 401 去核) |
回滚:镜像即回滚单元。把 .env 的 IMAGE_TAG 换回上一个 SHA,docker compose up -d。涉及不可逆迁移时先恢复备份(R10 衔接)。
环境间内容迁移(预发 → 生产,R11 实做过一次):Notes 与 About 走库级拷贝,不走 MCP 逐篇重发——103 张配图的 base64 过一遍对话会把上下文炸掉,而库拷贝 20 MB、几秒钟、逐字节一致。
四条硬约束,每条都有实测依据:
拷完检查一遍 About:文案里可能带着源环境的话(R11 从 130 拷来的 intro 结尾是「这里是 130 预发环境」,上了公开生产站才发现)。
不停机升级(跳过第 1 步)仅当确认本次迁移与在跑旧版完全后向兼容时才允许;V1 默认不做这个保证。
imagegen_provider_upsert {
"provider": "cliproxy-dmit",
"apiKey": "<明文 key,加密入库,读回只给掩码>",
"baseUrl": "https://<生图网关>/v1", // host 必须在**生图**白名单内(与搜索白名单是两份)
"modelId": "gpt-image-2",
"apiStyle": "images", // gpt-image-* 用 images;gemini-*-image 经兼容网关用 chat
"imageSize": "1024x1024", // 只对 images 形态生效;省略 = 上游默认
"dailyImageLimit": 50 // 0 = 不限;每张都是真金白银
}
tool_config_set { "name": "generate_image", "enabled": true }
| 5 | 正文配图路由 | /notes/<系列>/<哈希>.webp → 200 + ETag;带 If-None-Match 复请求 → 304;同形的文章页地址不被图片路由劫走 |
| 6 | RSS | /rss.xml 与 /rss/<分类>.xml 200,条目里的绝对链接用的是 SITE_ORIGIN;未知分类 404 |
| 7 | SSE ×2 | POST /agent/ask 经 Caddy 流式出字;GET /trace/stream 有 15s 心跳、afterSeq 断线重连能精确回放;docker compose stop api 时客户端在停机同刻拿到确定的终止而非挂到超时(R10 修准:别钉死退出码——R9 见 curl 18、R10 见 0,差别只在断开落在响应分块的哪个位置;要判的是「+0s 就结束」) |
| 8 | SSE 脱敏(值级,R-LEAK 2026-09-08 改) | ① 字面词:两条流的原始字节里搜不到明文 key、Authorization、api-key;before_provider_headers 帧只剩 type ② 值级:先从生产库取当前生效的配置值 —— llm_providers_list / websearch_providers_list 回的 provider / baseUrl 的 host / modelId(key 只回掩码,不用它)——再拿这几个值去两条流的原始字节里 grep,期望 0 命中(泄的从来不是 baseUrl 这四个字母,而是它的值:R-LEAK 修的三条通道分别是 model_select 派生字段、web_search 的 request 阶段文案、web_search 结果的 details)。跑法:抓一轮含联网搜索的对话(curl -N 收 /agent/ask 与 /trace/stream 两份原始字节存文件),对每个值 grep -c;命中即为回归 |
| 9 | 配额 | 把 dailyTokenLimit / maxTurnsPerSession 调小 → 429 + code(daily_tokens / turn_limit),恢复配置后立即可用 |
| 10 | agent_ro 沙箱 | SET LOCAL ROLE agent_ro 后写 notes 三张表全部 permission denied;读这三张表成功;读其余任何表 permission denied |
| 11 | 容器安全约束 | docker inspect:api/web 为 10001:10001、ReadonlyRootfs=true、CapDrop=[ALL]、PidsLimit、Memory、no-new-privileges、tmpfs noexec |
| 12 | 最终运行镜像无 Node.js | 见下面的「怎么查才是对的」 |
| 13 | 网络分段 | 从 web / caddy 容器连不上也解析不到 postgres;api 可以 |
| 14 | 打点与统计 | POST /t → 204;visits 里只有哈希、无原始 IP;MCP 的 traffic_* 结果与打点逐项对得上 |
| 15 | migrate.sh 守卫 | IMAGE_TAG=latest 被拒;未知参数(如 --stats)被拒 |
| 17 | HTTP/3(R11) | docker run --rm ymuski/curl-http3 curl --http3-only -sS -o /dev/null -w '%{http_version} %{http_code}' https://<域名>/ 回 3 200。本机 curl 是 Schannel 构建、不支持 h3,验不了这条。三处缺一不可:compose 的 443:443/udp(简写 "443:443" 只映射 tcp)、ufw 443/udp、云控制台 UDP 443。漏了的表现不是报错,是 Caddy 照样广告 Alt-Svc: h3 而访客首访白等一次超时 |
| 18 | 80 无响应 + 规范跳转(仅生产,R11) | http://<域名>/ 连不上或空回复,不得出现 30x;非规范主机名(SITE_REDIRECT_FROM)→ 301 到 SITE_ORIGIN,路径与 query 原样带过;规范主机名自身不被跳转 |
| 16 | 联网搜索(R-WEBSEARCH,配了才查) | ① 只配 provider 不开 tool_config → 工具不出现;两步都做 → 下一轮出现 ② 问一个知识截止后的问题,答案带来源链接 ③ 右栏 Timeline 出现 tool_execution_update · web_search ×N,Lifecycle 的 tool_call/tool_execution/tool_result 三节点点亮 ④ websearch_provider_upsert 传一个白名单外的 baseUrl 被拒 ⑤ websearch_providers_list 只回掩码 ⑥ /trace/stream 原始字节里搜不到搜索 key |
| 17 | 生图(R-IMAGEGEN,配了才查) | ① 两步都做后说「画一张…」,助手回复里渲染出图片(不是一行地址);② Timeline 出现 tool_execution_update · generate_image ×N(等图期间每 10s 一条「生成中」);③ 换一个没有该访客 cookie 的浏览器直接打开图片地址 → 404;④ imagegen_provider_upsert 传搜索白名单里、生图白名单外的 baseUrl 被拒;⑤ imagegen_providers_list 只回掩码;⑥ /agent/tools 与 /trace/stream 里搜不到 key / baseUrl / model;⑦ 删掉那个会话后图片地址 404 |
| 19 | skill-runner 隔离形态(R-SKILLS-2,所有者裁定 7:在生产冒烟验收,skill_load / skill_run 双闸关闭状态下跑,四条都过了才按任务卡「运维」段的顺序打开) | ① docker compose ps 里 skill-runner 为 healthy(healthcheck = runner.py --health 走同一个 unix socket);② 容器内出不了网:docker compose exec skill-runner /opt/venv/bin/python -I -c "import socket; socket.create_connection(('1.1.1.1',53),2)" 抛 OSError,且 cat /proc/net/route 只有表头(没有任何路由);③ 只读:docker compose exec skill-runner sh -c 'touch /opt/skills/x' 失败(Read-only file system);④ 清单闸:在 api 容器里对 socket 发一个不在清单里的脚本名 → 404 {"error":"unknown_script"}:docker compose exec api bun -e 'fetch("http://skill-runner/run",{method:"POST",unix:"/run/runner/runner.sock",headers:{"content-type":"application/json"},body:JSON.stringify({skill:"text-tools",script:"rm.py",sha256:"0".repeat(64),input:{}})}).then(async r=>console.log(r.status,await r.text()))'。另核 docker inspect:NetworkMode=none、ReadonlyRootfs=true、CapDrop=[ALL]、PidsLimit=64、Memory=384m、tmpfs /run/work 带 noexec |
| 20 | agent 使用 skills 端到端(R-SKILLS-2,第 19 条过了并按顺序打开之后) | ① skills_agent_status 里目标 skill consistency: ok、available: true;② 对 agent 说「用 text-tools 统计这段话的词频:…」→ Timeline 出现 before_agent_start(详情卡 EXTENSION RETURNED · xray-skills 列出可用 skills)→ tool_call · skill_load → tool_call · skill_run → tool_execution_update · skill_run ×N → tool_execution_end,回复里有脚本算出的词频;③ 再说「用 text-tools 跑 scripts/rm.py」→ tool_call · skill_run 行带红色 blocked 徽标与「└ xray-guard returned {block: true}」注记,tool_execution_end 为 isError;④ /api/agent/tools 与 /api/trace/stream 原始流里搜不到 /run/runner、unix:、超时与限额数字;⑤ tool_config_set skill_run false 后下一轮工具消失;skills_agent_set text-tools false 后该 skill 不再出现在 before_agent_start 的 skills 里 |
| 21 | egress 执行容器 + web-fetch(R-WEBFETCH;①②③在 web-fetch 未打开时就能验,④要 skills_agent_set web-fetch true 之后) | ① docker compose ps 里 skill-runner-egress 为 healthy;docker inspect:Networks 只有 deploy_egress、ReadonlyRootfs=true、CapDrop=[ALL]、PidsLimit=64、Memory=256m(268435456)、挂载只有 runner_egress_sock 与 tmpfs /run/work(noexec);② 网络边界:docker compose exec skill-runner-egress /opt/venv/bin/python -I -c "import socket; socket.getaddrinfo('postgres',5432)" 与 getaddrinfo('api',4000) 都抛 gaierror,socket.create_connection(('1.1.1.1',443),3) 成功,socket.create_connection(('169.254.169.254',80),3) 失败(宿主 sudo ./egress-filter.sh --status 六条 ok,systemctl is-enabled xray-egress-filter 为 enabled);③ 档次闸(在 api 容器里,sha256 取 runner/manifest.json 里 web-fetch.scripts["fetch.py"]):对 none 档 socket(/run/runner/runner.sock)发 {skill:"web-fetch",script:"fetch.py",…} → 403 {"error":"network_mismatch"};对 egress 档 socket(unix:"/run/runner-egress/runner.sock")发 text-tools → 同样 403;对 egress socket 发 web-fetch + {"url":"https://169.254.169.254/"} → 200 且 exitCode:2, stdout:"E_BAD_URL\n"(纯数字 TLD 在 narrow_url 的 TLD_RE 就被拒,到不了地址校验那层;2026-09-04 生产实测订正,原记 E_UNFETCHABLE 有误);发 {"url":"http://example.com/"} → E_BAD_URL;发 {"url":"https://www.kzgai.cloud/about"} → exitCode:0、stdout 是以 # 开头的 markdown;④ 端到端:skills_upsert 上传 runner/skills/web-fetch/ 的 3 个文件(LF)→ skills_agent_status 里 web-fetch consistency: ok → skills_agent_set web-fetch true → 对 agent 说「读一下 https://en.wikipedia.org/wiki/Server-side_request_forgery 并用三句话总结」→ Timeline tool_call · skill_run(web-fetch / fetch.py)→ tool_execution_update ×N → tool_result,回复的三句来自正文;说「读 https://169.254.169.254/latest/meta-data/」→ 模型读过 SKILL.md 后通常在调用前就自己拒了(2026-09-04 生产实测),不进 skill_run;要看工具侧的短码用上面 ③ 的直连 socket 用例。真进了工具则以「脚本运行失败…(E_BAD_URL)」失败(isError);/api/trace/stream 与 /api/agent/tools 原始流里 grep 不到 IPv4 / IPv6 字面量、Location、/run/runner-egress;docker compose stop skill-runner-egress 后 web-fetch 以「执行容器当前不可用」失败、text-tools 照常;skills_agent_set web-fetch false 后下一轮 <available_skills> 里没有它 |
| 22 | 源码快照 + Source tab(R-SOURCE;首次发版要先 source-publish) | ① GET /api/source 200 且 snapshot.shortSha == .env 的 IMAGE_TAG(展示的 = 正在跑的,所有者裁定 5);② /source 200 渲染 README、/source/apps/api/agent/tools.ts 200 带行号代码、/source/apps/web/app/(site)/notes/[series]/page.tsx(括号与方括号的路径)200、/source/apps/api(目录地址)与 /source/nope.ts 走 2k-B 找不到;③ dev.ps1 ship 末尾打印 commit current(或首次发版在 compose up 后 dev.ps1 source-publish <host> <sha> 成功),MCP source_snapshots_list 只有一行 current、pending: 0;④ agent:问「这个站的工具注册表在哪个文件」→ Timeline 出现 tool_call · source_search / source_read,回答带路径与行号;⑤ site_tab_set source false 后导航五格变四格、/source 404、/api/source 仍 200,再 true 恢复 |
预检必须走 compose 起容器,别用 docker run 手工凑。 R11 上线前用 docker run -p 443:443/udp … 做过一次 访问层预检,HTTP/3 是通的;正式 docker compose up 之后却不通 —— compose 里根本没写 udp 映射, udp 是预检时手敲在命令行上的。「预检用的启动方式和生产不是同一条」这类差异只能靠跑真实部署路径消除。
--services 是维护热点,必须纳入冒烟。 打进镜像的服务由 dev.ps1 build 里的 $hostedServices(当前 agent,trace,notes,mcp,metrics,about,system,site,skills,source)白名单决定。新增服务时必须同步在那里补上服务名,否则表现是:镜像构建成功、容器 healthy、/health 200,而该服务的所有端点静默 404 —— 没有任何一处会报错。
因此冒烟不能只看 /health,要逐个确认当前已落地的正式 service 端点都可达(表里第 1 条)。本项目不引入自动服务发现,这条靠清单与冒烟兜住。
「镜像里没有 node」怎么查才是对的(R9 实测):command -v node 在 api 与 web 镜像里都能查到一个 /usr/local/bun-node-fallback-bin/node —— 它是 oven/bun 基座自带的软链,指向 /usr/local/bin/bun, 存在的意义是让带 #!/usr/bin/env node shebang 的脚本落到 bun 上。用它当判据会得出「镜像里有 node」的错误结论。 正确的判据是问那个 node 自己是谁:
R9 实测三项结论:node = 指向 bun 的软链、真实的 node/nodejs 二进制不存在、nodejs 包未安装。 所以「最终运行镜像里没有 Node.js 运行时」这句成立,只是不能用 command -v node 去证。