本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月08日 统计字数: 17322字 阅读时间: 35分钟阅读 本文链接: https://soulteary.com/2026/09/08/phorge-modernization-part-13-integrate-six-external-services.html ----- # Phorge 现代化改造实战(十三):联调六项外部服务,容器在运行,不代表业务已经切换 本文是“Phorge 现代化改造实战”系列第十三篇。上一篇解决了 Elasticsearch 跨版本兼容,让搜索服务能够在真实后端上建索引、写文档和查询;这一篇继续往外走一层,完成 Phorge、六个 Gorge 服务及其数据库、搜索和邮件后端的整体联通。 ## 系列导航 1. [从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线](https://soulteary.com/2026/09/06/phorge-modernization-part-1-from-no-official-image-to-docker-compose.html); 2. [改进容器化的七个细节:补齐权限、持久化、依赖和探活](https://soulteary.com/2026/09/06/phorge-modernization-part-2-seven-containerization-details.html); 3. [接入 Stargate:把 Forward Auth 的信任边界做完整](https://soulteary.com/2026/09/06/phorge-modernization-part-3-stargate-forward-auth-trust-boundary.html); 4. [拆分模块到 Gorge:无侵入改造不等于不碰文件](https://soulteary.com/2026/09/06/phorge-modernization-part-4-split-modules-to-gorge.html); 5. [替换 diff 子进程:兼容不等于逐字一致](https://soulteary.com/2026/09/07/phorge-modernization-part-5-replace-diff-subprocess.html); 6. [替换实时通知服务:为什么 HTTP 501 反而表示正常](https://soulteary.com/2026/09/07/phorge-modernization-part-6-replace-realtime-notification-service.html); 7. [迁移邮件服务:先分清哪些失败不该重试](https://soulteary.com/2026/09/07/phorge-modernization-part-7-migrate-mail-service.html); 8. [迁移搜索服务:写进索引不等于搜得到](https://soulteary.com/2026/09/07/phorge-modernization-part-8-migrate-search-service.html); 9. [迁移文件存储:写得进去也要读得回来](https://soulteary.com/2026/09/07/phorge-modernization-part-9-migrate-file-storage.html); 10. [迁移 Webhook 投递服务:先解决重复投递](https://soulteary.com/2026/09/07/phorge-modernization-part-10-migrate-webhook-delivery.html); 11. [升级 Gorge 的 HTTP 框架:接口没变,行为也不能变](https://soulteary.com/2026/09/08/phorge-modernization-part-11-upgrade-http-framework.html); 12. [兼容 Elasticsearch 5、6、7:版本配置决定整个索引结构](https://soulteary.com/2026/09/08/phorge-modernization-part-12-elasticsearch-version-compatibility.html); 13. **联调六项外部服务:容器在运行,不代表业务已经切换;** 14. [为 Go 服务建立统一的 Phorge API 入口:网关可以换框架,Conduit 协议不能变](https://soulteary.com/2026/09/08/phorge-modernization-part-14-unified-conduit-api-gateway.html); 15. [用 Go 接管 Phorge 工作队列:如何避免新旧消费者互相抢任务](https://soulteary.com/2026/09/08/phorge-modernization-part-15-take-over-work-queue-with-go.html)。 ## 写在前面 前面几篇文章一次只处理一个功能:高亮、通知、邮件、搜索、文件存储或者 Webhook。每个服务单独看都不算难启动,有端口、有探针,也有一组环境变量。 等六个服务放到一起,判断状态就复杂了。 `docker compose ps` 里所有容器都是 `Up`,只能说明进程已经启动;`/healthz` 返回 200,只能说明 HTTP 栈还活着;即使 `/readyz` 也正常,仍然不能证明 Phorge 已经把业务交给了这个服务。 这次联调中,我见到的几种状态都很有迷惑性: - `gorge-render` 已经 healthy,页面却仍在使用内置高亮器; - `gorge-file-storage` 已经被发现,新上传的文件却继续落进原生 MySQL 引擎; - `gorge-mailer` 和 `gorge-search` 容器健康,Config 页面却报告 Not Ready; - `gorge-webhook` 服务与 Phorge 配置没有一起切换,Webhook 可能无人投递,也可能被重复投递; - 通知服务的 admin 接口在容器里可达,浏览器却连不上 WebSocket。 这些现象来自六项功能各不相同的接入机制。 本文会在启动命令之外,依次梳理五个阶段:**运行、可达、就绪、被选中和业务生效。** 本文使用的是 [Gorge `2026.09.08-r1`](https://github.com/soulteary/gorge/releases/tag/2026.09.08-r1) 与 [Phorge `2026.09.08-r1`](https://github.com/soulteary/phorge/releases/tag/2026.09.08-r1) 这一组版本。前者提供六个 Go 服务和独立 demo,后者提供 PHP 适配器、配置下发、setup check 与叠加编排。 ## 先分清三套看起来很像的联调环境 我的本地目录里同时有 `gorge/` 与 `phorge-fork/`。后者是 clone `soulteary/phorge` 时使用的目录名。两个仓库一起提供了三种运行方式: | 环境 | Phorge 在哪里 | Gorge 在哪里 | 外部后端 | 主要用途 | | ---------- | ---------- | ---------- | ----------- | -------------------------------- | | Gorge demo | 不启动 | Compose 容器 | demo 一并启动 | 验 search、mailer、webhook 三条真实后端链路 | | 全容器叠加 | Compose 容器 | Compose 容器 | 邮件与搜索需要自行提供 | 验 Phorge 到六个服务的完整接入 | | 宿主裸跑 | Compose 容器 | 宿主机 Go 进程 | 按服务自行配置 | 改 Gorge 代码时快速重启和调试 | 这三套环境不能互相替代。 Gorge demo 能证明 Go 服务与 Elasticsearch、Meilisearch、OwlMail、MySQL 和 Webhook 接收端之间的协议成立,但里面没有 Phorge。它无法证明 `cluster.search` 是否已经切换,也不知道 PHP 端是否正确解析了 `{data, error}` 信封。 全容器叠加最接近实际部署。Phorge 与六个 Gorge 服务共享 Compose 网络,服务之间用容器服务名通信,只有浏览器需要访问的端口才映射到宿主。 宿主裸跑则是开发路径。MySQL 与 Phorge 仍在容器里,Gorge 直接由 `go run` 启动。改完 Go 代码只需要重启一个进程,不必反复构建六个镜像。 选择哪一套,要看要验证哪一段边界。 ### 两个 Compose 文件名决定了两条完全不同的路径 Phorge 仓库里与本文有关的文件有三个: ```text docker-compose.yml docker-compose.gorge.yml docker-compose.override.yml ``` `docker-compose.yml` 是基础编排,只定义 MySQL、一次性的 `db-init` 和 Phorge。 [`docker-compose.gorge.yml`](https://github.com/soulteary/phorge/blob/main/docker-compose.gorge.yml) 是显式叠加文件,增加六个 Gorge 服务,并给 Phorge 注入容器网络里的服务地址。 [`docker-compose.override.yml`](https://github.com/soulteary/phorge/blob/main/docker-compose.override.yml) 则专门服务于“Gorge 在宿主裸跑”的开发路径。按照 Docker 官方的[多文件合并规则](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/),Compose 在不带 `-f` 时会自动读取同目录下的基础文件与 override 文件;一旦显式写出 `-f` 列表,就只合并列出的文件。这条规则来自 Compose 本身,项目没有额外脚本。 因此,下面两条命令虽然只差几个参数,得到的拓扑完全不同。 全容器叠加: ```bash docker compose \ -f docker-compose.yml \ -f docker-compose.gorge.yml \ up -d --build ``` 后面的全容器命令都要使用同一组 Compose 文件。为了避免反复写完整前缀,可以在当前终端定义一个小函数: ```bash dcg() { docker compose \ -f docker-compose.yml \ -f docker-compose.gorge.yml \ "$@" } ``` 这样 `dcg exec gorge-search ...` 与前面的启动命令使用的是同一份最终模型,不会在排障时意外切回自动加载的开发 override。 宿主裸跑 Gorge: ```bash docker compose up -d --build ``` 第二条命令会自动合并 `docker-compose.override.yml`。它只创建基础的三个服务,却会把 Phorge 访问 Gorge 的地址全部改成 `host.docker.internal`。 因此,在当前仓库里裸写 `docker compose up`,仍会使用面向 Gorge 本地开发的 override 文件。如果宿主上没有对应的 Gorge 进程,Config 页面会出现一组服务不可达提示;Webhook 配置的影响更大,后面会单独说明。 每次启动前,我现在都会先看一次合并结果: ```bash # 宿主裸跑路径:应只有 mysql、db-init、phorge docker compose config --services # 全容器路径:在上面三个之外,还应出现六个 gorge-* 服务 docker compose \ -f docker-compose.yml \ -f docker-compose.gorge.yml \ config --services ``` `docker compose config` 比直接阅读三个 YAML 更可靠,因为它展示的是变量替换和文件合并后的最终模型。排查“为什么 Phorge 连向了错误地址”时,先看最终模型,通常比进容器改配置更快。 ## 路径一:把 Phorge 与六个 Gorge 服务全部放进容器 两个仓库可以并列放置,但启动命令在 `phorge-fork/` 中执行: ```bash cd phorge-fork cp .env.example .env docker compose \ -f docker-compose.yml \ -f docker-compose.gorge.yml \ up -d --build ``` Compose 文件已经为大部分变量提供默认值,首次启动可以不复制 `.env`;完整联调仍需填写邮件和搜索后端。 这里的 `--build` 不能省。 Phorge 的 PHP 源码和 `docker/entrypoint.sh` 都会被烤进本地镜像。如果机器上已经存在旧镜像,不重新构建就可能继续运行旧 entrypoint。结果是六个 Gorge 容器已经启动,Phorge 镜像里的启动脚本却根本不知道后来增加的配置项。 这种失败尤其麻烦,因为它不一定让容器退出。最明显的线索只是启动日志里缺少下面这些配置下发记录: ```text [entrypoint] 下发 Gorge ... 配置 ``` 修改 `.env` 后,还要确认容器里的启动脚本会读取这些变量。先确认镜像包含当前 entrypoint,再检查配置值。 ### entrypoint 只负责下发配置 叠加编排会把六组变量注入 Phorge。`entrypoint.sh` 再用 `bin/config set` 把它们写进 `conf/local/local.json`: | 服务 | Phorge 侧配置 | | ------------ | -------------------------------------------------------------- | | render | `gorge.render.uri`、`gorge.render.token` | | notification | `notification.servers` | | mailer | `cluster.mailers` 中的一条 `type: gorge` 记录 | | search | `cluster.search` 中的一条 `type: gorge` 记录,以及 `gorge.search.token` | | file-storage | `gorge.file.uri`、`gorge.file.token` | | webhook | `gorge.webhook.uri`、`gorge.webhook.token` | 这些项被放在初次生成 `local.json` 的守卫之外,所以修改 `.env` 后重启 Phorge,新的部署地址会再次幂等下发,不必删除整个配置文件。 但“幂等写入”有一个容易误解的边界:**变量留空时,entrypoint 通常选择跳过,不会擅自删除已经存在的本地配置。** 这是为了保护使用者在 Web 界面或命令行里维护的值。代价是回滚时不能只把 `.env` 中一行删掉,还要把已经写进 `local.json` 的配置显式恢复。 表中六项的作用各不相同,有些只把连接信息交给 Phorge,各业务域仍通过原有扩展机制选择实际实现。 ### 六个服务其实有四种接管方式 把六个服务按“怎样开始承接业务”分类,会比逐个背命令清楚得多: | 接管方式 | 服务 | 配置写入后的状态 | | ----------- | ------------------- | -------------------------------- | | 还要手工选择引擎 | render、file-storage | 服务可达,但不一定收到业务 | | 配置写入立即生效 | notification、mailer | Phorge 随即开始使用;mailer 仍可能没有投递后端 | | 配置生效后还需准备数据 | search | 查询已经转向 Gorge,但索引可能尚不存在或为空 | | 配置本身就是交接开关 | webhook | Phorge 停止安排原 worker,由 Go 服务接管队列表 | 这就是为什么不能用一条统一的“服务已启用”检查覆盖所有域。 ### 高亮:服务健康以后,还要切引擎和清缓存 `gorge.render.uri` 写入以后,Phorge 只知道外部高亮服务在哪里,默认使用的仍然是内置引擎。 切换需要两条命令: ```bash dcg exec phorge /opt/phorge/phorge/bin/config set \ syntax-highlighter.engine PhabricatorGorgeSyntaxHighlighterEngine dcg exec phorge /opt/phorge/phorge/bin/cache purge --all ``` 第二条不能省。Phorge 缓存的是已经高亮过的 HTML;切换引擎不会自动重新渲染源码。只改引擎时,新打开的内容可能走 Gorge,已经看过的 Paste 和 diff 却继续使用旧缓存,看起来像同一个配置时好时坏。 所以高亮验收要同时确认: ```bash dcg exec phorge /opt/phorge/phorge/bin/config get \ syntax-highlighter.engine dcg exec phorge /opt/phorge/phorge/bin/config get \ gorge.render.uri ``` 然后重新打开一个包含 Phorge 内置引擎不支持语言的 Paste 或 diff。只有页面实际出现新高亮,接管才算完成。 ### 文件存储:还要确认引擎优先级 文件存储是六个服务里最容易“配置全对但业务不走”的一个。 `gorge.file.uri` 写入以后,`PhabricatorGorgeFileStorageEngine` 会被自动发现。不过 Phorge 选择写入引擎时会按 priority 排序,原生 MySQL blob 引擎仍然排在 Gorge 前面。 此时通常不会报错:原生引擎继续接走小文件,分块引擎与 Gorge 又可能处理另一部分文件。站点看起来能够上传,数据却散落在不同实现里。 完整切换还需要关闭原生可写后端: ```bash dcg exec phorge /opt/phorge/phorge/bin/config set \ storage.mysql-engine.max-size 0 # 下面两项默认未配置;以前设置过才需要删除本地覆盖值。 dcg exec phorge /opt/phorge/phorge/bin/config delete \ storage.local-disk.path dcg exec phorge /opt/phorge/phorge/bin/config delete \ storage.s3.bucket ``` 这里不要写成 `bin/config set ... null`。这两个配置项的类型是字符串,命令行参数 `null` 会被保存成字面量;恢复默认状态应使用 `bin/config delete`。如果某一项原本就没有本地配置,`delete` 会提示未设置,此时无需继续处理。 再看引擎顺序: ```bash dcg exec phorge /opt/phorge/phorge/bin/files engines ``` Web 界面的 Applications → Files → Storage Engines 更直观:可写引擎会被高亮,Gorge 应当成为第一个可写项。随后分别上传一个小文件和一个大文件,到文件详情页检查实际的 Storage Engine。 这个切换是增量的。旧文件仍然按照数据库中保存的 engine identifier 交给原引擎读取,不要求在切换当天把存量数据全部迁走。 但它也带来一个回滚边界:一旦有文件写入 Gorge,就不能简单删除 `gorge-file-storage` 容器或清掉 `gorge.file.uri`。那样新文件也许重新写回 MySQL,已经落在 Gorge 的文件却会失去读取路径。 ### 邮件与搜索:还要检查服务端后端 `gorge-mailer` 与 `gorge-search` 的配置都分成两半: | 配置层 | 邮件 | 搜索 | | ----------------- | --------------------------------- | ---------------------- | | Phorge 如何找到 Gorge | `GORGE_MAILER_*` | `GORGE_SEARCH_*` | | Gorge 如何找到实际后端 | `MAILER_TYPE`、`SMTP_*`、`MAILER_*` | `ES_*`、`MEILI_*` 或后端列表 | 前一半在叠加编排里有默认值,后一半没有。 这是一种刻意允许的中间状态:服务先启动,配置稍后补齐。于是 `/healthz` 与 `/readyz` 必须表达不同事实: ```text /healthz 进程活着,HTTP 栈能够响应 /readyz 至少有一个能够工作的后端 ``` Compose 对 mailer 与 search 的健康检查使用 `/healthz`,因为没有配置邮件或搜索后端,不应该把整个 Phorge 站点堵在启动阶段。Phorge 的 setup check 再访问 `/readyz`,把“服务活着但不能工作”显示成 Not Ready。 #### 邮件的最小联调配置 只想验证 Phorge 到 Gorge 的调用链,可以使用丢弃邮件的 test 后端: ```env MAILER_TYPE=test ``` 它能让 `/readyz` 通过,但不会真的投递,不能用于生产验收。 要验证完整邮件链路,至少配一个 SMTP 或 provider 后端。例如: ```env MAILER_TYPE=smtp SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=phorge@example.com SMTP_PASSWORD=... ``` 这里以 587 端口的普通 SMTP/STARTTLS 为例,所以不设置 `SMTP_PROTOCOL`:Go 的 `net/smtp.SendMail` 会在服务端声明 STARTTLS 时自动升级连接。只有使用 465 端口的隐式 TLS 时,才应设置 `SMTP_PROTOCOL=tls` 或 `ssl`。具体端口和协议仍要以邮件服务商给出的配置为准。 重启以后,先看就绪状态,再发一封定向测试邮件: ```bash dcg exec gorge-mailer \ wget -qO- http://127.0.0.1:8110/readyz echo 'test body' | dcg exec -T phorge \ /opt/phorge/phorge/bin/mail send-test \ --to you@example.com \ --mailer gorge-mailer \ --subject 'gorge test' ``` 这里的 `--mailer gorge-mailer` 指向 `cluster.mailers` 条目的 key;服务端 SMTP 后端属于另一层配置。 #### 搜索不仅需要后端,还需要索引 以 Elasticsearch 7 为例: ```env GORGE_SEARCH_ENGINE=elasticsearch ES_HOST=es.example.com:9200 ES_PROTOCOL=http ES_VERSION=7 ``` `ES_VERSION` 必须与集群真实主版本一致。上一篇已经解释过,它决定 mapping、写入路径和类型过滤,不能当作日志展示字段。 服务就绪以后,还要创建并回填索引: ```bash dcg exec gorge-search \ wget -qO- http://127.0.0.1:8120/readyz dcg exec phorge \ /opt/phorge/phorge/bin/search init dcg exec phorge \ /opt/phorge/phorge/bin/search index --all --force ``` 不建索引时,错误通常很明显;只建索引但不执行全量回填时,查询可能正常返回空结果。后者更像业务数据本来不存在,因此更容易误判。 `init` 会删除并重建索引,不能对生产索引当作无害探针执行。大库的全量 `index` 也可能持续很久,需要单独安排重建窗口。 另外,`GORGE_SEARCH_KEEP_MYSQL=1` 可以保留 MySQL 搜索作为回退,最终列表是 `[gorge, mysql]`。它适合迁移期,但也会让一次失败悄悄退回质量不同的 MySQL 结果。联调时如果想明确验证 Gorge,默认保持 `0` 更容易判断。 ### 为什么 file-storage 与 webhook 不能等待 healthy `/readyz` 比 `/healthz` 更接近可用状态,看起来所有 `depends_on` 都该使用 `service_healthy`。然而,全新数据卷上的实际依赖顺序会让这种配置锁死系统。 `gorge-file-storage` 的 MySQL blob 后端需要连接 `phabricator_file`,`gorge-webhook` 需要连接 `phabricator_herald`。这两个库由 Phorge 启动时执行的 `bin/storage upgrade` 创建;MySQL 镜像和 `db-init` 只给普通账号授予 `phabricator_%` 的权限。 如果让 Phorge 等这两个服务 ready,依赖就会变成: ```text Phorge 等 Gorge ready ↓ Gorge 等 phabricator_file / phabricator_herald 存在 ↓ 两个库又在等 Phorge 执行 storage upgrade ``` 没有任何一方能够继续。 所以叠加编排对 render、notification、mailer 与 search 使用 `service_healthy`,对 file-storage 和 webhook 只使用 `service_started`。先让进程存在,再允许 Phorge 建库;库出现后,两项服务的 `/readyz` 会自行恢复。 首次启动时,它们短暂显示 unhealthy 是预期的中间状态。文件服务还可以把 blob 写入失败下沉到本地磁盘,上传并不会因此中断。这里保留 `/readyz` 的严格语义,同时在编排层避免循环依赖,比把探针改成一个永远返回 200 的形式更有用。 ### 通知:同一个服务,要从两个网络视角看 `gorge-notification` 一个进程监听两个端口: | 入口 | 消费者 | 默认地址 | | --------------- | --------------- | ----------------------------------- | | admin `:22281` | Phorge 容器中的 PHP | Compose 服务名或 `host.docker.internal` | | client `:22280` | 用户浏览器 | 浏览器实际能够解析和访问的主机名 | 全容器路径中,admin 可以写 `gorge-notification:22281`。宿主裸跑路径中,PHP 通过 `host.docker.internal:22281` 回到宿主。 client 完全不同。这个地址会被 Phorge 拼成 WebSocket URI 交给浏览器,浏览器既不在 Compose 网络里,也不会把 `host.docker.internal` 当成项目内部 DNS。默认本机联调应该使用 `127.0.0.1:22280`。 如果站点由其他机器访问,就要填写用户浏览器使用的域名,并开放相应绑定;站点走 HTTPS 时,WebSocket 也要经过 TLS,不能让 HTTPS 页面去连接明文 `ws://`。 这就是为什么“Phorge 能访问 admin”与“页面能收到实时通知”必须分别验证。前者看 Config 中的通知状态,后者要在浏览器开发者工具里检查 WebSocket 握手和消息。 还有一个反直觉的兼容细节:client 端口上的普通 `GET /` 按 Aphlict 约定返回 501,Phorge 正是把这个状态当作服务存在。不要为了让探针看起来更顺眼,把它改成 200。 ### Webhook:它的接管方向与其他服务相反 前五个服务都是 Phorge 主动发 HTTP 请求。`gorge-webhook` 不一样,它直接轮询 `phabricator_herald.herald_webhookrequest` 队列表。 因此,`gorge.webhook.uri` 主要承担交接开关的作用。值非空时,Phorge 停止为新请求安排 `HeraldWebhookWorker`,由 Go 服务从数据库取走 queued 记录。 服务状态与配置状态必须同进同退: | gorge-webhook | `gorge.webhook.uri` | 结果 | | ------------- | ------------------- | ------------------------- | | 运行 | 已写入 | Go 服务投递,正确状态 | | 停止 | 已写入 | 没有投递者,请求停在 queued | | 运行 | 未写入 | Phorge 与 Go 都可能投递,接收端收到两次 | | 停止 | 未写入 | Phorge 原生 worker 投递 | 所以 Webhook 不能只做“接口可达”验收。至少还要确认配置已经写入: ```bash dcg exec phorge /opt/phorge/phorge/bin/config get \ gorge.webhook.uri dcg exec gorge-webhook \ wget -qO- http://127.0.0.1:8160/readyz ``` 然后在 Herald 中创建一个指向可观测接收端的 Webhook,修改一次任务标题,确认接收端只收到一份 payload。这一步才是接管是否正确的最终证据。 回滚时顺序同样重要:先清掉交接配置,让 Phorge 恢复为新请求安排 worker,再停 Go 服务。如果先停服务,停止到清配置之间创建的请求没有 PHP 任务,Go 也不再轮询,会长期留在 queued。 ## 路径二:Phorge 在容器里,Gorge 在宿主机裸跑 开发某个 Gorge 服务时,我更常用第二条路径。 在 `phorge-fork/` 中直接运行: ```bash cd phorge-fork docker compose up -d --build ``` 因为 `docker-compose.override.yml` 会自动合并,它做了两件事: 1. 把 MySQL 发布到宿主回环的 `127.0.0.1:3306`,供 file-storage 与 webhook 连接; 2. 给 Phorge 添加 `host.docker.internal:host-gateway`,并把六个 Gorge 地址指向宿主机。 对应端口是: | 服务 | 宿主监听端口 | | --------------------------- | ------------: | | file-storage | 8100 | | mailer | 8110 | | search | 8120 | | render(同时承载 diff) | 8140 | | webhook | 8160 | | notification client / admin | 22280 / 22281 | Gorge 根目录的 `Makefile` 通过 `SERVICE` 选择一次启动哪个二进制: ```bash cd ../gorge SERVICE=gorge-render make run ``` 通知服务默认就监听两个约定端口: ```bash SERVICE=gorge-notification make run ``` 其余服务至少需要一个后端。只做本地调用链调试时,可以给 mailer 与 search 使用内存或丢弃型测试后端: ```bash GORGE_MAILER_CONFIG='[{"key":"test","type":"test"}]' \ SERVICE=gorge-mailer make run GORGE_SEARCH_BACKENDS='[{"type":"test","roles":["read","write"]}]' \ SERVICE=gorge-search make run ``` 测试后端只能说明 Phorge 与 Gorge 之间的协议可用。它不验证 SMTP、Elasticsearch 或 Meilisearch,服务重启以后内存索引也会消失。 文件服务可以用一个独立的本地目录: ```bash GORGE_FILE_LOCAL_DISK_PATH=/tmp/gorge-files-dev \ SERVICE=gorge-file-storage make run ``` 如果要同时验证 MySQL blob 后端,再补宿主数据库连接和正确的命名空间: ```bash GORGE_FILE_MYSQL_HOST=127.0.0.1 \ GORGE_FILE_MYSQL_USER=phorge \ GORGE_FILE_MYSQL_PASS=phorge \ GORGE_FILE_NAMESPACE=phabricator \ GORGE_FILE_LOCAL_DISK_PATH=/tmp/gorge-files-dev \ SERVICE=gorge-file-storage make run ``` Webhook 没有可替代的测试后端,它必须连接 Phorge 的 Herald 数据库: ```bash GORGE_WEBHOOK_MYSQL_HOST=127.0.0.1 \ GORGE_WEBHOOK_MYSQL_USER=phorge \ GORGE_WEBHOOK_MYSQL_PASS=phorge \ GORGE_WEBHOOK_NAMESPACE=phabricator \ SERVICE=gorge-webhook make run ``` 上面的账号密码是仓库示例配置的本地默认值。如果已经修改过 `phorge-fork/.env`,这里必须使用同一组值。 这条路径最大的便利是可以只启动当前正在改的服务,但 override 默认会把六个地址全部写给 Phorge。没有启动的服务会在 Config 页面显示 Unreachable;如果暂时不调 Webhook,尤其要同时移除 override 中的 `GORGE_WEBHOOK_URI`,不能让 Phorge 把投递交给一个不存在的进程。 ### 只验证 Gorge 自己时,不要把 Phorge 也拖进来 如果目标只是确认真实 Elasticsearch、Meilisearch、SMTP 和 Webhook 接收端能否工作,Gorge 自己的 demo 更快: ```bash cd gorge/deploy/compose/demo cp .env.demo .env docker compose -f docker-compose.demo.yml up -d --build ``` 这套编排只选择依赖外部系统的三个 Gorge 服务: | Gorge 服务 | demo 后端 | | -------- | -------------------------------------- | | search | Meilisearch + Elasticsearch 7,双写并提供读回退 | | mailer | OwlMail SMTP 与 Web UI | | webhook | MySQL 队列与 Webhook 接收端 | render、notification 与 file-storage 不在这套 demo 里。前两者没有必须额外拉起的外部后端,文件服务也可以直接用本地磁盘完成验证。 先检查 `/readyz`: ```bash for port in 8120 8110 8160; do printf '%s -> ' "$port" curl -s "http://127.0.0.1:${port}/readyz" echo done ``` 再分别运行三条真实链路脚本: ```bash BASE_URL=http://127.0.0.1:8120 bash tests/e2e/search.sh BASE_URL=http://127.0.0.1:8110 bash tests/e2e/mailer.sh BASE_URL=http://127.0.0.1:8160 bash tests/e2e/webhook.sh ``` 当前预期结果是 search 18/18、mailer 7/7、webhook 9/9。Webhook 脚本只覆盖诊断接口,真实后台投递还要在日志里确认出现: ```text WEBHOOK_DELIVERED ... status=200 ``` `search.sh` 会调用初始化接口删除并重建索引,只能对一次性 demo 环境运行,不能把 `BASE_URL` 指向装有真实索引的 Gorge 实例。 完整的场景、期望结果和首次启动竞态记录在 Gorge 仓库的 [`INTEGRATION-TESTING.md`](https://github.com/soulteary/gorge/blob/main/deploy/compose/demo/INTEGRATION-TESTING.md) 中。README 负责把环境拉起来,这份手册负责判断它是否真的工作。 ### 联调默认值不能原样搬进生产 本地环境把大部分 Gorge HTTP 端口留在 Compose 内网,服务 token 默认允许为空,目的是减少第一次联调的变量。这些接口依然不适合直接暴露。 邮件接口可以借你的域名对外发信,搜索接口能够读写整个索引,文件接口可以读写删除站点文件,notification admin 端口为了保持 Aphlict 线协议本身不带鉴权。生产环境至少要做到三件事: - 给支持服务 token 的两端配置同一个非空随机值; - 不把只供 Phorge 调用的内部端口发布到公网,notification admin 尤其不能暴露; - 把 `GORGE_IMAGE_TAG` 固定到已验证版本,不让一次普通的 `pull` 悄悄改变六个服务的实现。 如果站点经过 Traefik 或 Nginx,还要单独处理 notification client 的 WebSocket 路由与 TLS。不能因为其他五个 HTTP 服务都能走内部地址,就把浏览器入口也藏进 Compose 网络。 ### 如何读 Config 页面上的三类提示 接好以后,Phorge 的 Config 页面比单独看容器状态更接近使用者视角。Gorge 相关提示大致可以分成三类: | 提示 | 通常表示什么 | 优先检查 | | ----------- | -------------------- | ---------------------------------------- | | Unreachable | Phorge 连不到服务 | URI、容器网络、`host.docker.internal`、端口、token | | Not Ready | 服务活着,但没有可用后端 | `/readyz`、SMTP/ES/Meili/MySQL 配置、服务日志 | | Not In Use | 服务可达,但业务尚未切换或被其他状态挡住 | 高亮引擎、存储 priority、全局 silent 等 | 这三类名字刻意没有合并。 Unreachable 属于拓扑问题;Not Ready 属于服务依赖问题;Not In Use 属于业务选择问题。把它们都叫“服务异常”,会让排障从一开始就跑错方向。 另外,还有两条常见提示与 Gorge 没有关系: - `No Authentication Providers Configured`:全新 Phorge 尚未配置登录方式; - `Alternate File Domain Not Configured`:生产环境建议把用户上传内容放到独立域名,降低同源 XSS 风险。 本地联调可以暂时保留第二条,第一条则需要创建管理员并配置至少一种认证方式。不要因为它们与 Gorge 提示出现在同一页,就继续修改 Gorge 的 Compose 文件。 ## 用五个层面判断联调状态 一套联调环境是否成立,我最后会按下面五层依次检查: ### 第一层:拓扑正确 ```bash docker compose config --services docker compose ps ``` 确认这次需要的服务确实位于最终 Compose 模型中,没有意外混入另一套 override,也没有停在 `Created`。 ### 第二层:进程存活 访问 `/healthz`。这一层只判断进程和 HTTP 栈,不推断后端状态。 ### 第三层:依赖就绪 访问 `/readyz`。对于 mailer、search、file-storage 和 webhook,它回答实际后端是否已经接通。 ### 第四层:Phorge 已经选择它 检查 `syntax-highlighter.engine`、`notification.servers`、`cluster.mailers`、`cluster.search`、文件引擎顺序和 `gorge.webhook.uri`。 ### 第五层:产生一次真实业务结果 分别打开一段新高亮、观察一条 WebSocket 消息、发送一封邮件、查询一份已回填文档、上传后再下载一个文件,以及确认一个 Webhook 只到达一次。 前四层更适合快速定位错误,第五层才是最终验收。因为很多兼容问题都发生在状态之间:配置看起来存在,实际选择了另一个引擎;请求已经到达,结果却被缓存掩盖;队列已经写入,两个消费者却同时处理。 ## 回滚也不能只有一条 `docker compose stop` 服务拆出去以后,回滚还要处理 Phorge 中已经持久化的选择状态。 高亮要先切回内置引擎并清缓存;搜索既要恢复 `cluster.search`,也要停止 `gorge-search`;文件存储要先恢复原生可写引擎,并保留 Gorge 对已写文件的读取能力;Webhook 要先取消交接,再停止服务。 因此,`docker-compose.gorge.yml` 没有让所有开关随容器启动便不可逆地切换。服务部署和业务接管被刻意拆开,方便切流前验证依赖,也方便在出现问题时退回原实现。 不过这种设计不会自动替使用者维护顺序。特别是文件与 Webhook,一个牵涉存量数据的读取路径,一个牵涉队列的唯一消费者,删容器之前都要先看清当前状态。 ## 最后 六个 Gorge 服务全部运行,只解决了最表层的服务地址问题。后面还有多组状态需要核对:高亮涉及引擎选择与缓存,文件存储涉及引擎优先级,邮件和搜索还依赖服务端后端,通知横跨 PHP 与浏览器两个网络,Webhook 配置则直接决定队列归谁消费。 这些差异很难被一条统一的部署状态概括。 容器状态说明进程是否存在,探针说明服务能否工作,Phorge 配置说明流量可能去哪;最终接管状态以真实业务结果为准。 前面几篇把六个功能逐个从 PHP 或 Node.js 中拆出来;这一次,再把这些服务接回一个能够运行的 Phorge 系统。模块化带来的价值,是每条边界都能独立验证、独立切换,并且具备清晰的回退路径。 --EOF