本文是“Phorge 现代化改造实战”系列第十三篇。上一篇解决了 Elasticsearch 跨版本兼容,让搜索服务能够在真实后端上建索引、写文档和查询;这一篇继续往外走一层,完成 Phorge、六个 Gorge 服务及其数据库、搜索和邮件后端的整体联通。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整
  4. 拆分模块到 Gorge:无侵入改造不等于不碰文件
  5. 替换 diff 子进程:兼容不等于逐字一致
  6. 替换实时通知服务:为什么 HTTP 501 反而表示正常
  7. 迁移邮件服务:先分清哪些失败不该重试
  8. 迁移搜索服务:写进索引不等于搜得到
  9. 迁移文件存储:写得进去也要读得回来
  10. 迁移 Webhook 投递服务:先解决重复投递
  11. 升级 Gorge 的 HTTP 框架:接口没变,行为也不能变
  12. 兼容 Elasticsearch 5、6、7:版本配置决定整个索引结构
  13. 联调六项外部服务:容器在运行,不代表业务已经切换;
  14. 为 Go 服务建立统一的 Phorge API 入口:网关可以换框架,Conduit 协议不能变
  15. 用 Go 接管 Phorge 工作队列:如何避免新旧消费者互相抢任务

写在前面

前面几篇文章一次只处理一个功能:高亮、通知、邮件、搜索、文件存储或者 Webhook。每个服务单独看都不算难启动,有端口、有探针,也有一组环境变量。

等六个服务放到一起,判断状态就复杂了。

docker compose ps 里所有容器都是 Up,只能说明进程已经启动;/healthz 返回 200,只能说明 HTTP 栈还活着;即使 /readyz 也正常,仍然不能证明 Phorge 已经把业务交给了这个服务。

这次联调中,我见到的几种状态都很有迷惑性:

  • gorge-render 已经 healthy,页面却仍在使用内置高亮器;
  • gorge-file-storage 已经被发现,新上传的文件却继续落进原生 MySQL 引擎;
  • gorge-mailergorge-search 容器健康,Config 页面却报告 Not Ready;
  • gorge-webhook 服务与 Phorge 配置没有一起切换,Webhook 可能无人投递,也可能被重复投递;
  • 通知服务的 admin 接口在容器里可达,浏览器却连不上 WebSocket。

这些现象来自六项功能各不相同的接入机制。

本文会在启动命令之外,依次梳理五个阶段:运行、可达、就绪、被选中和业务生效。

本文使用的是 Gorge 2026.09.08-r1Phorge 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 仓库里与本文有关的文件有三个:

docker-compose.yml
docker-compose.gorge.yml
docker-compose.override.yml

docker-compose.yml 是基础编排,只定义 MySQL、一次性的 db-init 和 Phorge。

docker-compose.gorge.yml 是显式叠加文件,增加六个 Gorge 服务,并给 Phorge 注入容器网络里的服务地址。

docker-compose.override.yml 则专门服务于“Gorge 在宿主裸跑”的开发路径。按照 Docker 官方的多文件合并规则,Compose 在不带 -f 时会自动读取同目录下的基础文件与 override 文件;一旦显式写出 -f 列表,就只合并列出的文件。这条规则来自 Compose 本身,项目没有额外脚本。

因此,下面两条命令虽然只差几个参数,得到的拓扑完全不同。

全容器叠加:

docker compose \
  -f docker-compose.yml \
  -f docker-compose.gorge.yml \
  up -d --build

后面的全容器命令都要使用同一组 Compose 文件。为了避免反复写完整前缀,可以在当前终端定义一个小函数:

dcg() {
  docker compose \
    -f docker-compose.yml \
    -f docker-compose.gorge.yml \
    "$@"
}

这样 dcg exec gorge-search ... 与前面的启动命令使用的是同一份最终模型,不会在排障时意外切回自动加载的开发 override。

宿主裸跑 Gorge:

docker compose up -d --build

第二条命令会自动合并 docker-compose.override.yml。它只创建基础的三个服务,却会把 Phorge 访问 Gorge 的地址全部改成 host.docker.internal

因此,在当前仓库里裸写 docker compose up,仍会使用面向 Gorge 本地开发的 override 文件。如果宿主上没有对应的 Gorge 进程,Config 页面会出现一组服务不可达提示;Webhook 配置的影响更大,后面会单独说明。

每次启动前,我现在都会先看一次合并结果:

# 宿主裸跑路径:应只有 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/ 中执行:

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 镜像里的启动脚本却根本不知道后来增加的配置项。

这种失败尤其麻烦,因为它不一定让容器退出。最明显的线索只是启动日志里缺少下面这些配置下发记录:

[entrypoint] 下发 Gorge ... 配置

修改 .env 后,还要确认容器里的启动脚本会读取这些变量。先确认镜像包含当前 entrypoint,再检查配置值。

entrypoint 只负责下发配置

叠加编排会把六组变量注入 Phorge。entrypoint.sh 再用 bin/config set 把它们写进 conf/local/local.json

服务 Phorge 侧配置
render gorge.render.urigorge.render.token
notification notification.servers
mailer cluster.mailers 中的一条 type: gorge 记录
search cluster.search 中的一条 type: gorge 记录,以及 gorge.search.token
file-storage gorge.file.urigorge.file.token
webhook gorge.webhook.urigorge.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 只知道外部高亮服务在哪里,默认使用的仍然是内置引擎。

切换需要两条命令:

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 却继续使用旧缓存,看起来像同一个配置时好时坏。

所以高亮验收要同时确认:

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 又可能处理另一部分文件。站点看起来能够上传,数据却散落在不同实现里。

完整切换还需要关闭原生可写后端:

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 会提示未设置,此时无需继续处理。

再看引擎顺序:

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-mailergorge-search 的配置都分成两半:

配置层 邮件 搜索
Phorge 如何找到 Gorge GORGE_MAILER_* GORGE_SEARCH_*
Gorge 如何找到实际后端 MAILER_TYPESMTP_*MAILER_* ES_*MEILI_* 或后端列表

前一半在叠加编排里有默认值,后一半没有。

这是一种刻意允许的中间状态:服务先启动,配置稍后补齐。于是 /healthz/readyz 必须表达不同事实:

/healthz  进程活着,HTTP 栈能够响应
/readyz   至少有一个能够工作的后端

Compose 对 mailer 与 search 的健康检查使用 /healthz,因为没有配置邮件或搜索后端,不应该把整个 Phorge 站点堵在启动阶段。Phorge 的 setup check 再访问 /readyz,把“服务活着但不能工作”显示成 Not Ready。

邮件的最小联调配置

只想验证 Phorge 到 Gorge 的调用链,可以使用丢弃邮件的 test 后端:

MAILER_TYPE=test

它能让 /readyz 通过,但不会真的投递,不能用于生产验收。

要验证完整邮件链路,至少配一个 SMTP 或 provider 后端。例如:

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=tlsssl。具体端口和协议仍要以邮件服务商给出的配置为准。

重启以后,先看就绪状态,再发一封定向测试邮件:

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 为例:

GORGE_SEARCH_ENGINE=elasticsearch
ES_HOST=es.example.com:9200
ES_PROTOCOL=http
ES_VERSION=7

ES_VERSION 必须与集群真实主版本一致。上一篇已经解释过,它决定 mapping、写入路径和类型过滤,不能当作日志展示字段。

服务就绪以后,还要创建并回填索引:

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_filegorge-webhook 需要连接 phabricator_herald。这两个库由 Phorge 启动时执行的 bin/storage upgrade 创建;MySQL 镜像和 db-init 只给普通账号授予 phabricator_% 的权限。

如果让 Phorge 等这两个服务 ready,依赖就会变成:

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 不能只做“接口可达”验收。至少还要确认配置已经写入:

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/ 中直接运行:

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 选择一次启动哪个二进制:

cd ../gorge
SERVICE=gorge-render make run

通知服务默认就监听两个约定端口:

SERVICE=gorge-notification make run

其余服务至少需要一个后端。只做本地调用链调试时,可以给 mailer 与 search 使用内存或丢弃型测试后端:

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,服务重启以后内存索引也会消失。

文件服务可以用一个独立的本地目录:

GORGE_FILE_LOCAL_DISK_PATH=/tmp/gorge-files-dev \
  SERVICE=gorge-file-storage make run

如果要同时验证 MySQL blob 后端,再补宿主数据库连接和正确的命名空间:

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 数据库:

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 更快:

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

for port in 8120 8110 8160; do
  printf '%s -> ' "$port"
  curl -s "http://127.0.0.1:${port}/readyz"
  echo
done

再分别运行三条真实链路脚本:

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 脚本只覆盖诊断接口,真实后台投递还要在日志里确认出现:

WEBHOOK_DELIVERED ... status=200

search.sh 会调用初始化接口删除并重建索引,只能对一次性 demo 环境运行,不能把 BASE_URL 指向装有真实索引的 Gorge 实例。

完整的场景、期望结果和首次启动竞态记录在 Gorge 仓库的 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 文件。

用五个层面判断联调状态

一套联调环境是否成立,我最后会按下面五层依次检查:

第一层:拓扑正确

docker compose config --services
docker compose ps

确认这次需要的服务确实位于最终 Compose 模型中,没有意外混入另一套 override,也没有停在 Created

第二层:进程存活

访问 /healthz。这一层只判断进程和 HTTP 栈,不推断后端状态。

第三层:依赖就绪

访问 /readyz。对于 mailer、search、file-storage 和 webhook,它回答实际后端是否已经接通。

第四层:Phorge 已经选择它

检查 syntax-highlighter.enginenotification.serverscluster.mailerscluster.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