本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月06日 统计字数: 3965字 阅读时间: 8分钟阅读 本文链接: https://soulteary.com/2026/09/06/phorge-modernization-part-1-from-no-official-image-to-docker-compose.html ----- # Phorge 现代化改造实战(一):从没有官方镜像到 Docker Compose 跑起来 本文是“Phorge 现代化改造实战”系列第一篇。这个系列从一个可运行的容器基线出发,依次处理运行质量、入口信任和外部服务拆分,记录一个持续跟进上游的 Phorge fork 如何逐步变得更容易部署、验证和维护。 ## 系列导航 1. **从没有官方镜像到 Docker Compose 跑起来**:建立最小可运行基线; 2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活; 3. 接入 Stargate:把 Forward Auth 的信任边界做完整; 4. 拆分 Gorge:无侵入不等于不碰文件。 ## 写在前面 上一次在博客里提及 Phabricator,还是 [2020 和 2021 年](https://soulteary.com/tags/phabricator.html)。 Phabricator 在 2021 年停止维护后,社区 fork 出来的 Phorge 接过了后续维护工作。代码审查、仓库托管、缺陷跟踪、项目管理,这些熟悉的功能都还在,只是项目已经从原来的官方开发模式,转入社区维护阶段。 最近,我准备升级一套从今年三月持续运行至今的服务。第一步自然是先看看新版本里有什么变化,再找一个现成的官方 Docker 镜像,把服务跑起来。 结果是:**没有找到。** 继续翻阅官方任务、社区方案,以及自己几个月前留下的改造笔记后,我发现真正的问题不只是“补一个 Dockerfile”,而是如何把 Web、数据库和后台守护进程组成的传统部署结构,转换成一套可以重复构建、启动和排查的容器基线。 这篇文章先完成第一步:做出一个能在本地通过 Docker Compose 跑起来的最小版本。它还不是生产级方案,进程拆分、优雅退出、凭据管理、邮件和实时通知等问题,后面再逐步处理。 本次改造的代码基线保存在 [soulteary/phorge 2026.09.06-r1](https://github.com/soulteary/phorge/releases/tag/2026.09.06-r1),感兴趣可以自取。正文中的配置片段还包含了整理文章时复查出来的少量修正,后续会一并归入新的修订版本。 ## 为什么会缺少官方镜像 Phorge 的任务库里有过明确讨论:[T15928](https://we.phorge.it/T15928) 讨论的是在 AWS 上进行简单的 IaC 安装,[Q148](https://we.phorge.it/Q148) 则直接询问是否存在容器化的 Phorge。 讨论里的结论大致相同:确实有人需要,但目前没有由项目维护的标准方案,也欢迎社区参与实现和维护。 截至 2026 年 9 月,Phorge 的官方源码仓库仍未提供 `Dockerfile`、`docker-compose.yml` 或其他容器编排文件,也没有随项目正式发布的容器镜像。 所以,“找一个官方镜像直接升级”这条路暂时走不通。 ### 社区里有哪些方案 社区并非完全没有可用的镜像。 [wetherc/phorge-docker](https://github.com/wetherc/phorge-docker) 基于 Ubuntu 24.04,最后一次提交停留在 2025 年 3 月。它派生自更早的 [RedpointArchive/phabricator](https://github.com/RedpointArchive/phabricator),后者是 Phabricator 仍在维护时出现的一套容器方案。 在此基础上,[recaptime-dev/infra-docker-phorge](https://github.com/recaptime-dev/infra-docker-phorge) 将基础系统换成了 Alpine,并配置了每日构建,镜像发布在 [GitHub Container Registry](https://github.com/recaptime-dev/infra-docker-phorge/pkgs/container/infra%2Fdocker%2Fphorge)。 Docker Hub 上还有 [buddyspencer/phorge](https://hub.docker.com/r/buddyspencer/phorge/tags),不过 `latest` 已经超过两年没有更新。 如果目标只是“跑一个 Phorge 看看”,这些第三方镜像已经足够。但我的情况稍有不同:需要运行自己 fork 并持续调整的源码,还希望后续把邮件、Webhook 和其他服务逐步拆出去。因此,比起继续给第三方镜像打补丁,直接从当前源码构建更容易控制变化。 ## 先理解它原本是怎么运行的 写 Dockerfile 之前,得先弄清楚这个应用到底由什么组成。 项目源码里的 `scripts/install/update_phorge.sh` 是一个很好的说明书。它是官方提供的升级脚本示例,从执行顺序里可以反推出 Phorge 的基本运行拓扑: ```bash # 停止守护进程 $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 应用,而是至少包含三个部分: 1. Apache 或 Nginx 加 PHP,负责 Web 页面和接口; 2. MySQL 或 MariaDB,保存业务数据; 3. `phd` 后台守护进程,执行仓库拉取、任务队列、定时触发等异步工作。 此外,还有一个可选的 `aphlict`,用于通过 WebSocket 提供实时通知。 这套结构原本更接近“在一台长期运行的服务器上安装一组服务”。要把它放进容器,首先得决定哪些东西暂时放在一起,哪些东西从一开始就应该独立。 这一版先采取最简单的方式:MySQL 独立运行,Apache、PHP 和 `phd` 暂时放在同一个应用容器里。这样可以先验证代码、数据库和基本功能,再继续拆分。 升级脚本开头还有一段容易忽略的说明: ```text 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 无法正常工作。 ```dockerfile 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 页面会给出一串提示: ```dockerfile 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__` 传递原始路径: ```apache DocumentRoot /opt/phorge/phorge/webroot RewriteEngine on RewriteRule ^(.*)$ /index.php?__path__=$1 [B,L,QSA] Require all granted Options FollowSymLinks AllowOverride None ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined ``` 这里的 `[B]` flag 不能随手省略。它会对 rewrite 的反向引用进行转义,缺少它时,包含特殊字符的 URL 可能被错误解析。 第一次折腾 Apache 的 `.htaccess` 已经是[十九年前](https://soulteary.com/2007/08/27/htaccess.html)了,没想到多年后还会因为一个 rewrite flag 再翻文档。 ## 三个真正花时间的坑 镜像的基本结构并不复杂,真正花时间的是下面三个问题。 ### 一、Arcanist 是运行依赖,但不在 Phorge 仓库里 前面提到的“假设目录中同时存在 `arcanist/` 和 `phorge/`”并不是一个可选建议。 Phorge 的类加载体系依赖 Arcanist 提供的基础库。原先独立的 `libphutil` 能力后来已经合并进 Arcanist;如果缺少这个相邻目录,`bin/config`、`bin/storage`、`bin/phd` 等命令都无法正常启动。 但 Arcanist 仍然是一个独立仓库,没有以 Git submodule 或 Composer 依赖的形式包含在 Phorge 源码中。 所以,构建镜像时需要主动把它放到约定位置: ```dockerfile 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 ``` 最终目录结构必须是: ```text /opt/phorge/ ├── arcanist/ └── phorge/ ``` 这里使用 Phorge 的主站作为首选地址,GitHub 镜像作为 fallback。 当前版本为了先跑起来,仍然直接拉取 Arcanist 的最新代码。这意味着同一个 Phorge release 在不同时间构建时,可能取得不同的 Arcanist 版本。后续需要把 Arcanist 固定到经过验证的 commit,并把基础镜像也固定到 digest,才能真正做到可重复构建。 ### 二、`base-uri` 不接受裸 `localhost` 第二个问题的表现是:容器刚起来就退出,然后又被 Docker 的 restart policy 拉起来,陷入反复重启。 日志中有这么一段信息: ```text 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,登录状态可能无法保持。 这是一个合理的生产约束,但对本地启动不太友好。 它之所以会表现成“容器反复重启”,是因为入口脚本使用了: ```bash set -euo pipefail ``` 当 `bin/config set phabricator.base-uri` 返回非零状态时,入口脚本会立即退出;随后 `restart: unless-stopped` 再次拉起容器,于是相同的错误日志不断重复出现。 通常看到这样的日志模式,应该先想到“启动失败后被自动重启”,而不是运行期间偶发了同一个错误。 本地环境可以直接使用 `127.0.0.1`。它包含点号,能够通过 Phorge 的校验: ```bash $ bin/config set phabricator.base-uri "http://127.0.0.1:8088/" DONE Wrote configuration key "phabricator.base-uri" to local storage ``` 所以,Compose 中使用下面的地址作为本地默认值: ```yaml PHORGE_BASE_URI: "http://127.0.0.1:8088/" ``` 正式部署时仍然应该换成真实域名,并在反向代理层配置 HTTPS。 ### 三、数据库明明健康,入口脚本却一直说“未就绪” 应用启动前需要等待数据库可连接,然后才能运行 `storage upgrade`。 最初我使用容器中的 MariaDB 命令行客户端做探活: ```bash mariadb \ -h "$MYSQL_HOST" \ -u "$MYSQL_USER" \ -p"$MYSQL_PASS" \ -e "SELECT 1" ``` 结果入口脚本连续重试了三十多次,始终提示数据库未就绪;另一边,MySQL 容器的 healthcheck 明明已经是 `healthy`。 手动进入应用容器执行同一条命令,才看到被探活脚本隐藏起来的真实错误: ```text ERROR 2026 (HY000): TLS/SSL error: self-signed certificate in certificate chain ``` 在当时使用的基础镜像和客户端版本组合中,MySQL 8 提供的自动生成证书无法通过 MariaDB 客户端的校验。显式加上 `--skip-ssl` 后,命令行客户端能够连接,但这个参数会直接关闭 TLS,并不只是“忽略证书校验”。因此它只适合作为隔离开发网络里的临时排查手段,不能当作生产环境的长期修复。 有意思的是,同一个容器里,Phorge 实际使用的 PHP mysqli 可以正常建立连接: ```bash $ php -r '$c=mysqli_connect("mysql","root","phorge",null,3306); echo mysqli_get_server_info($c);' 8.0.46 ``` 也就是说,应用可以连接数据库,但负责判断“应用是否可以连接数据库”的工具却连接失败了。 这让我重新确认了一条很实用的原则:**健康检查最好尽量使用与应用相同的协议栈和驱动。** 否则检查到的可能不是应用依赖是否可用,而是另一个客户端是否能以自己的默认参数连接。 最后,入口脚本改成使用 PHP mysqli 探测: ```bash 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 配置: ```yaml 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`。只有确实需要从宿主机调试数据库时,才临时增加端口映射。 ## 入口脚本做了什么 应用容器启动后,入口脚本依次完成这些工作: 1. 根据环境变量写入 Phorge 本地配置; 2. 等待数据库可以通过 mysqli 连接; 3. 执行 `storage upgrade --force`; 4. 启动 `phd`; 5. 通过 `exec` 启动 Apache。 最后一步使用 `exec "$@"`,可以让 Apache 成为容器的 PID 1,从而直接接收 Docker 发出的停止信号。 不过,这只解决了 Apache 的信号传递。`phd start` 会在前面启动后台进程,它们仍然没有被一个统一的前台进程完整管理。因此,这一版还不能算优雅地处理了整个容器的生命周期。 当前 release 中,`phd` 启动失败时入口脚本还会记录警告并继续启动 Web 服务。这便于先看到页面、继续调试,但也意味着“页面能打开”不等于后台任务一定健康。后续拆分进程时,这部分会调整为独立服务和独立健康状态。 另外,启动时自动执行数据库升级适合单实例和本地环境。如果将来横向启动多个 Web 容器,不能让所有实例同时执行 Schema Migration,需要把升级动作改成独立、一次性的部署步骤。 ## 把应用跑起来 构建镜像并启动服务: ```bash docker compose up -d --build ``` 首次启动时,`storage upgrade` 会初始化数据库。当前版本的日志里会分析大约 500 张表,因此需要等待一会儿: ```text [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 ``` 先查看首次请求的响应: ```bash $ curl -sI http://127.0.0.1:8088/ HTTP/1.1 302 Found Location: /auth/register/ ``` 再跟随跳转,确认最终页面可访问: ```bash $ 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