本文是“Phorge 现代化改造实战”系列第一篇。这个系列从一个可运行的容器基线出发,依次处理运行质量、入口信任和外部服务拆分,记录一个持续跟进上游的 Phorge fork 如何逐步变得更容易部署、验证和维护。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整;
  4. 拆分 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 的官方源码仓库仍未提供 Dockerfiledocker-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 应用,而是至少包含三个部分:

  1. Apache 或 Nginx 加 PHP,负责 Web 页面和接口;
  2. MySQL 或 MariaDB,保存业务数据;
  3. 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 扩展包括 mysqligdcurlmbstringiconvpcntlposixopcache。其中,pcntlposix 会被后台守护进程使用,缺少它们时 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_filesizepost_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/configbin/storagebin/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_filesizepost_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。只有确实需要从宿主机调试数据库时,才临时增加端口映射。

入口脚本做了什么

应用容器启动后,入口脚本依次完成这些工作:

  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,需要把升级动作改成独立、一次性的部署步骤。

把应用跑起来

构建镜像并启动服务:

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