本文是“Phorge 现代化改造实战”系列第一篇。这个系列从一个可运行的容器基线出发,依次处理运行质量、入口信任和外部服务拆分,记录一个持续跟进上游的 Phorge fork 如何逐步变得更容易部署、验证和维护。
系列导航
- 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
- 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
- 接入 Stargate:把 Forward Auth 的信任边界做完整;
- 拆分 Gorge:无侵入不等于不碰文件。
写在前面
上一次在博客里提及 Phabricator,还是 2020 和 2021 年。
Phabricator 在 2021 年停止维护后,社区 fork 出来的 Phorge 接过了后续维护工作。代码审查、仓库托管、缺陷跟踪、项目管理,这些熟悉的功能都还在,只是项目已经从原来的官方开发模式,转入社区维护阶段。
最近,我准备升级一套从今年三月持续运行至今的服务。第一步自然是先看看新版本里有什么变化,再找一个现成的官方 Docker 镜像,把服务跑起来。
结果是:没有找到。
继续翻阅官方任务、社区方案,以及自己几个月前留下的改造笔记后,我发现真正的问题不只是“补一个 Dockerfile”,而是如何把 Web、数据库和后台守护进程组成的传统部署结构,转换成一套可以重复构建、启动和排查的容器基线。
这篇文章先完成第一步:做出一个能在本地通过 Docker Compose 跑起来的最小版本。它还不是生产级方案,进程拆分、优雅退出、凭据管理、邮件和实时通知等问题,后面再逐步处理。
本次改造的代码基线保存在 soulteary/phorge 2026.09.06-r1,感兴趣可以自取。正文中的配置片段还包含了整理文章时复查出来的少量修正,后续会一并归入新的修订版本。
为什么会缺少官方镜像
Phorge 的任务库里有过明确讨论:T15928 讨论的是在 AWS 上进行简单的 IaC 安装,Q148 则直接询问是否存在容器化的 Phorge。
讨论里的结论大致相同:确实有人需要,但目前没有由项目维护的标准方案,也欢迎社区参与实现和维护。
截至 2026 年 9 月,Phorge 的官方源码仓库仍未提供 Dockerfile、docker-compose.yml 或其他容器编排文件,也没有随项目正式发布的容器镜像。
所以,“找一个官方镜像直接升级”这条路暂时走不通。
社区里有哪些方案
社区并非完全没有可用的镜像。
wetherc/phorge-docker 基于 Ubuntu 24.04,最后一次提交停留在 2025 年 3 月。它派生自更早的 RedpointArchive/phabricator,后者是 Phabricator 仍在维护时出现的一套容器方案。
在此基础上,recaptime-dev/infra-docker-phorge 将基础系统换成了 Alpine,并配置了每日构建,镜像发布在 GitHub Container Registry。
Docker Hub 上还有 buddyspencer/phorge,不过 latest 已经超过两年没有更新。
如果目标只是“跑一个 Phorge 看看”,这些第三方镜像已经足够。但我的情况稍有不同:需要运行自己 fork 并持续调整的源码,还希望后续把邮件、Webhook 和其他服务逐步拆出去。因此,比起继续给第三方镜像打补丁,直接从当前源码构建更容易控制变化。
先理解它原本是怎么运行的
写 Dockerfile 之前,得先弄清楚这个应用到底由什么组成。
项目源码里的 scripts/install/update_phorge.sh 是一个很好的说明书。它是官方提供的升级脚本示例,从执行顺序里可以反推出 Phorge 的基本运行拓扑:
# 停止守护进程
$ROOT/$NAME_MAIN/bin/phd stop
# 停止 Web Server(Apache/Nginx/php-fpm)
sudo /etc/init.d/httpd stop
# 升级数据库 Schema
$ROOT/$NAME_MAIN/bin/storage upgrade
# 依次启动回来
sudo /etc/init.d/httpd start
$ROOT/$NAME_MAIN/bin/phd start
它并不是一个只有 HTTP 进程的普通 PHP 应用,而是至少包含三个部分:
- Apache 或 Nginx 加 PHP,负责 Web 页面和接口;
- MySQL 或 MariaDB,保存业务数据;
phd后台守护进程,执行仓库拉取、任务队列、定时触发等异步工作。
此外,还有一个可选的 aphlict,用于通过 WebSocket 提供实时通知。
这套结构原本更接近“在一台长期运行的服务器上安装一组服务”。要把它放进容器,首先得决定哪些东西暂时放在一起,哪些东西从一开始就应该独立。
这一版先采取最简单的方式:MySQL 独立运行,Apache、PHP 和 phd 暂时放在同一个应用容器里。这样可以先验证代码、数据库和基本功能,再继续拆分。
升级脚本开头还有一段容易忽略的说明:
This script assumes you are running it from a directory which contains
arcanist/ and phorge/.
这句话是后面第一个坑的伏笔。
先构建一个能运行的镜像
基础镜像先选相对保守的 php:8.3-apache。它已经把 Apache 和 PHP 组合好了,可以减少一部分无关配置。
Phorge Web 进程和 phd 需要的主要 PHP 扩展包括 mysqli、gd、curl、mbstring、iconv、pcntl、posix 和 opcache。其中,pcntl 与 posix 会被后台守护进程使用,缺少它们时 daemon 无法正常工作。
FROM php:8.3-apache
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
git mariadb-client procps \
libpng-dev libjpeg-dev libfreetype6-dev \
libcurl4-openssl-dev libonig-dev libzip-dev \
default-libmysqlclient-dev; \
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install -j"$(nproc)" \
mysqli gd curl mbstring pcntl posix opcache iconv; \
rm -rf /var/lib/apt/lists/*
PHP 的运行参数也需要调整,否则 Phorge 的 Setup 页面会给出一串提示:
RUN { \
echo "opcache.enable=1"; \
echo "opcache.validate_timestamps=0"; \
echo "upload_max_filesize=32M"; \
echo "post_max_size=40M"; \
echo "memory_limit=512M"; \
echo "max_execution_time=0"; \
echo "date.timezone=UTC"; \
} > /usr/local/etc/php/conf.d/phorge.ini
这里需要同时设置 upload_max_filesize 和 post_max_size。只提高后者并不能放开文件上传限制,而且 post_max_size 应略大于单文件上限,给表单里的其他内容留出空间。
opcache.validate_timestamps=0 适合源码随镜像一起发布的场景。如果在开发环境里把源码目录直接挂载进容器,则需要重新开启时间戳检查,否则修改代码后不会立即生效。
配置 Apache 路由
Phorge 的 Web 根目录是 webroot/,请求需要交给 index.php 处理,并通过 __path__ 传递原始路径:
<VirtualHost *:80>
DocumentRoot /opt/phorge/phorge/webroot
RewriteEngine on
RewriteRule ^(.*)$ /index.php?__path__=$1 [B,L,QSA]
<Directory "/opt/phorge/phorge/webroot">
Require all granted
Options FollowSymLinks
AllowOverride None
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
这里的 [B] flag 不能随手省略。它会对 rewrite 的反向引用进行转义,缺少它时,包含特殊字符的 URL 可能被错误解析。
第一次折腾 Apache 的 .htaccess 已经是十九年前了,没想到多年后还会因为一个 rewrite flag 再翻文档。
三个真正花时间的坑
镜像的基本结构并不复杂,真正花时间的是下面三个问题。
一、Arcanist 是运行依赖,但不在 Phorge 仓库里
前面提到的“假设目录中同时存在 arcanist/ 和 phorge/”并不是一个可选建议。
Phorge 的类加载体系依赖 Arcanist 提供的基础库。原先独立的 libphutil 能力后来已经合并进 Arcanist;如果缺少这个相邻目录,bin/config、bin/storage、bin/phd 等命令都无法正常启动。
但 Arcanist 仍然是一个独立仓库,没有以 Git submodule 或 Composer 依赖的形式包含在 Phorge 源码中。
所以,构建镜像时需要主动把它放到约定位置:
WORKDIR /opt/phorge
RUN git clone --depth 1 \
https://we.phorge.it/source/arcanist.git arcanist \
|| git clone --depth 1 \
https://github.com/phorgeit/arcanist.git arcanist
COPY . /opt/phorge/phorge
RUN chown -R www-data:www-data /opt/phorge
最终目录结构必须是:
/opt/phorge/
├── arcanist/
└── phorge/
这里使用 Phorge 的主站作为首选地址,GitHub 镜像作为 fallback。
当前版本为了先跑起来,仍然直接拉取 Arcanist 的最新代码。这意味着同一个 Phorge release 在不同时间构建时,可能取得不同的 Arcanist 版本。后续需要把 Arcanist 固定到经过验证的 commit,并把基础镜像也固定到 digest,才能真正做到可重复构建。
二、base-uri 不接受裸 localhost
第二个问题的表现是:容器刚起来就退出,然后又被 Docker 的 restart policy 拉起来,陷入反复重启。
日志中有这么一段信息:
Usage Exception: Config option "phabricator.base-uri" is invalid.
The URI must contain a dot ("."), like "https://example.com/",
not just a bare name like "https://example/".
Some web browsers will not set cookies on domains with no TLD.
Phorge 会拒绝 http://localhost:8088/ 这种不包含点号的主机名。原因也写在错误信息里:部分浏览器不会为没有 TLD 的域名正常设置 Cookie,登录状态可能无法保持。
这是一个合理的生产约束,但对本地启动不太友好。
它之所以会表现成“容器反复重启”,是因为入口脚本使用了:
set -euo pipefail
当 bin/config set phabricator.base-uri 返回非零状态时,入口脚本会立即退出;随后 restart: unless-stopped 再次拉起容器,于是相同的错误日志不断重复出现。
通常看到这样的日志模式,应该先想到“启动失败后被自动重启”,而不是运行期间偶发了同一个错误。
本地环境可以直接使用 127.0.0.1。它包含点号,能够通过 Phorge 的校验:
$ bin/config set phabricator.base-uri "http://127.0.0.1:8088/"
DONE Wrote configuration key "phabricator.base-uri" to local storage
所以,Compose 中使用下面的地址作为本地默认值:
PHORGE_BASE_URI: "http://127.0.0.1:8088/"
正式部署时仍然应该换成真实域名,并在反向代理层配置 HTTPS。
三、数据库明明健康,入口脚本却一直说“未就绪”
应用启动前需要等待数据库可连接,然后才能运行 storage upgrade。
最初我使用容器中的 MariaDB 命令行客户端做探活:
mariadb \
-h "$MYSQL_HOST" \
-u "$MYSQL_USER" \
-p"$MYSQL_PASS" \
-e "SELECT 1"
结果入口脚本连续重试了三十多次,始终提示数据库未就绪;另一边,MySQL 容器的 healthcheck 明明已经是 healthy。
手动进入应用容器执行同一条命令,才看到被探活脚本隐藏起来的真实错误:
ERROR 2026 (HY000): TLS/SSL error: self-signed certificate in certificate chain
在当时使用的基础镜像和客户端版本组合中,MySQL 8 提供的自动生成证书无法通过 MariaDB 客户端的校验。显式加上 --skip-ssl 后,命令行客户端能够连接,但这个参数会直接关闭 TLS,并不只是“忽略证书校验”。因此它只适合作为隔离开发网络里的临时排查手段,不能当作生产环境的长期修复。
有意思的是,同一个容器里,Phorge 实际使用的 PHP mysqli 可以正常建立连接:
$ php -r '$c=mysqli_connect("mysql","root","phorge",null,3306);
echo mysqli_get_server_info($c);'
8.0.46
也就是说,应用可以连接数据库,但负责判断“应用是否可以连接数据库”的工具却连接失败了。
这让我重新确认了一条很实用的原则:健康检查最好尽量使用与应用相同的协议栈和驱动。 否则检查到的可能不是应用依赖是否可用,而是另一个客户端是否能以自己的默认参数连接。
最后,入口脚本改成使用 PHP mysqli 探测:
db_ready() {
php -r '
$c = @mysqli_connect(
$argv[1],
$argv[3],
$argv[4],
null,
(int)$argv[2]
);
exit($c ? 0 : 1);
' \
"$MYSQL_HOST" \
"$MYSQL_PORT" \
"$MYSQL_USER" \
"$MYSQL_PASS" \
>/dev/null 2>&1
}
for i in $(seq 1 60); do
if db_ready; then
echo "[entrypoint] 数据库已就绪。"
break
fi
echo " ... 数据库未就绪,重试 ($i/60)"
sleep 3
if [ "$i" -eq 60 ]; then
echo "[entrypoint] 数据库连接超时,退出。" >&2
exit 1
fi
done
如果生产环境要求数据库链路使用 TLS,正确做法仍然是配置可信 CA,并让 mysqli 和命令行客户端采用一致的验证策略,而不是永久关闭加密。
使用 Docker Compose 编排
数据库中还有几个 Phorge 会检查或实际使用的参数。例如,max_allowed_packet 过小时,较大的数据库请求可能失败。
需要注意,它只控制 MySQL 能接收的数据包大小,不等于 Web 上传上限;后者还受到 PHP 的 upload_max_filesize 和 post_max_size 约束。
下面是一份用于本地验证的 Compose 配置:
services:
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: phorge
command:
- --sql-mode=STRICT_ALL_TABLES
- --max-allowed-packet=134217728
- --innodb-buffer-pool-size=1600M
- --local-infile=0
- --ft-min-word-len=3
volumes:
- phorge-db:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pphorge"]
interval: 5s
timeout: 5s
retries: 20
phorge:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
environment:
MYSQL_HOST: mysql
MYSQL_PORT: "3306"
MYSQL_USER: root
MYSQL_PASS: phorge
PHORGE_BASE_URI: "http://127.0.0.1:8088/"
PHORGE_TIMEZONE: "UTC"
ports:
- "8088:80"
volumes:
- phorge-repo:/var/repo
volumes:
phorge-db:
phorge-repo:
这里的 root 账号、固定密码和资源参数都是为了让示例足够短,不能原样用于生产环境。正式部署至少应该改为独立数据库账号、Secret 或文件形式的凭据,并按照实际内存调整 InnoDB Buffer Pool。
数据库也没有必要对宿主机开放 3306;应用可以直接通过 Compose 内部网络访问 mysql:3306。只有确实需要从宿主机调试数据库时,才临时增加端口映射。
入口脚本做了什么
应用容器启动后,入口脚本依次完成这些工作:
- 根据环境变量写入 Phorge 本地配置;
- 等待数据库可以通过 mysqli 连接;
- 执行
storage upgrade --force; - 启动
phd; - 通过
exec启动 Apache。
最后一步使用 exec "$@",可以让 Apache 成为容器的 PID 1,从而直接接收 Docker 发出的停止信号。
不过,这只解决了 Apache 的信号传递。phd start 会在前面启动后台进程,它们仍然没有被一个统一的前台进程完整管理。因此,这一版还不能算优雅地处理了整个容器的生命周期。
当前 release 中,phd 启动失败时入口脚本还会记录警告并继续启动 Web 服务。这便于先看到页面、继续调试,但也意味着“页面能打开”不等于后台任务一定健康。后续拆分进程时,这部分会调整为独立服务和独立健康状态。
另外,启动时自动执行数据库升级适合单实例和本地环境。如果将来横向启动多个 Web 容器,不能让所有实例同时执行 Schema Migration,需要把升级动作改成独立、一次性的部署步骤。
把应用跑起来
构建镜像并启动服务:
docker compose up -d --build
首次启动时,storage upgrade 会初始化数据库。当前版本的日志里会分析大约 500 张表,因此需要等待一会儿:
[entrypoint] 升级/初始化数据库 schema ...
Loading quickstart template onto "mysql:3306"...
Storage is up to date.
ANALYZED Analyzed 500 tables.
[entrypoint] 启动 Phorge 守护进程 (phd) ...
(Pool: 1) PhabricatorRepositoryPullLocalDaemon
(Pool: 1) PhabricatorTriggerDaemon
(Pool: 1) PhabricatorFactDaemon
(Pool: 4) PhabricatorTaskmasterDaemon
[entrypoint] 启动 Web 服务: apache2-foreground
先查看首次请求的响应:
$ curl -sI http://127.0.0.1:8088/
HTTP/1.1 302 Found
Location: /auth/register/
再跟随跳转,确认最终页面可访问:
$ curl -sL -o /dev/null \
-w "%{http_code} %{url_effective}\n" \
http://127.0.0.1:8088/
200 http://127.0.0.1:8088/auth/register/
首次启动时跳转到 /auth/register/ 是正确行为。Phorge 检测到系统中还没有账号,会引导创建第一个管理员。浏览器打开页面后,可以看到标题 Welcome to Phorge。
到这里,一个最小可运行的容器基线就完成了。
其他:这一版还缺什么
“能够启动”和“适合长期运行”之间,还有一段不短的距离。
这一版本的核心目标是,先把自己维护的 Phorge 源码稳定地跑起来,并把真正的依赖关系和故障暴露出来。
目前这套方案至少还有下面这些问题:容器内进程的生命周期和健康状态还不完美;aphlict 尚未启用,页面没有实时通知;邮件发送还没有接入独立服务;Arcanist、PHP 和 MySQL 版本没有全部固定到不可变引用,维护还存在一些风险;数据库凭据仍以普通环境变量传入;Web 与 daemon 缺少各自独立的健康检查;源码目录权限仍然偏宽,需要区分只读代码与运行时可写目录;数据库迁移仍然与每次应用启动绑定;还没有覆盖备份、恢复和跨版本升级测试…
正是有上面这些问题,我们后续折腾才更有意义,也才有可靠的参照物。
最后
回头看,Phorge 缺少官方镜像反而是一个不错的切入点。
它迫使我们重新理解这个项目:为什么源码旁边必须有 Arcanist,为什么一个看似普通的 PHP 应用还需要一组长期运行的 daemon,为什么数据库健康检查会和应用实际行为不一致,以及为什么把 Apache 放到 PID 1 并不代表整个容器已经拥有正确的生命周期。
这一篇文章里,我们解决的是“从源码到可运行容器”的问题。下一篇文章里,我们暂时不急着拆进程,而是先修正这套最小方案里的权限、持久化、依赖回收和探活问题,把“能够启动”的基线推进到“适合继续演进”的状态。
–EOF