本文是“Phorge 现代化改造实战”系列第二篇。在第一篇建立可运行基线之后,这一篇先补齐容器内部的工程细节,为后续处理入口认证和外部服务拆分准备一个可信的运行底座。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整;
  4. 拆分 Gorge:无侵入不等于不碰文件。

写在前面

上一篇文章里,我们从一份刚 fork 下来的 Phorge 源码开始,做出了一个能够运行的最小容器:Apache 提供 Web 服务,phd 在后台处理任务,MySQL 保存数据,执行一次 docker compose up 就能进入初始化页面。

那一版解决的核心是“能不能跑”的问题。这一版的主要工作聚焦在把容器方案从“能运行”改进到“可配置、可持久化、可观测”的程度。

相比较上一个版本,2026.09.06-r2 这个版本只多了一个提交,改动了 12 个文件。完整改动差异可以在 r1…r2 中查看。

这次改动主要补上了这些东西:

方面 r1 r2
配置 每次启动逐项覆盖 首次原子生成,后续保留
数据库 应用直接使用 root 独立普通账号,一次性授权任务
运行权限 phd 继承 root phdwww-data 运行
持久化 只挂仓库目录 配置和仓库分别持久化
镜像 构建依赖全部保留 按动态链接关系回收无用依赖
可观测性 只有数据库探活 应用增加 HTTP Healthcheck
配置入口 参数散落在 Compose 中 增加 .env.exampleDOCKER.md

本文记录的七个细节,并不只属于 Phorge。任何需要把传统应用装进容器的项目,大概率都会遇到其中几个。

本文中引用的实现分别位于 r2 的 Dockerfiledocker/entrypoint.shdocker-compose.yml

一、命名卷会放大目录属主问题

这是最普适、也最容易在“本机正常,换台机器就坏”时浪费时间的坑。

Phorge 默认把仓库工作副本放在 /var/repo/,把守护进程日志放在 /var/tmp/phd/log/

最小方案自然会把命名卷挂到这些路径上:

services:
  phorge:
    volumes:
      - phorge-repo:/var/repo

volumes:
  phorge-repo:

问题是,在 r1 镜像里并不存在 /var/repo

当 Docker 第一次把空命名卷挂到容器里时,会用镜像中已有的挂载点内容初始化这个卷;如果目标路径原本不存在,最终得到的目录通常由 root 创建。Apache 工作进程和准备降权后的 phd 都以 www-data 运行,也自然写不进去。

更麻烦的是,这个错误未必会立即出现。

r1 中的 phd 由 root 启动,所以仓库照样能够拉取,只是卷里逐渐留下了一批 root 属主的文件。等到进程降权,或者另一个 www-data 进程需要读写这些文件时,问题才会出现。

r2 在构建镜像时预先创建运行目录并设置属主:

RUN set -eux; \
    mkdir -p \
        /var/repo \
        /var/tmp/phd/log \
        /var/tmp/phd/pid \
        /opt/phorge/phorge/conf/local \
    ; \
    chown -R www-data:www-data \
        /var/repo \
        /var/tmp/phd \
        /opt/phorge/phorge/conf

这些路径是哪里来的呢?

repository.default-local-path 的默认值来自 PhabricatorRepositoryConfigOptions.phpphd.log-directory 则来自 PhabricatorPHDConfigOptions.php。当前版本已经不再暴露旧的 phd.pid-directory 配置,保留 /var/tmp/phd/pid 主要是为了兼容旧版本和可能的降级场景。

此外,执行顺序也很重要。RUN 放在 COPY --chown=www-data:www-data . /opt/phorge/phorge 之后,可以避免随后复制进来的 conf/ 内容重新改变已经整理好的属主。

不过,这个修改只影响第一次初始化的新卷。已经被 root 文件污染的旧卷,不会因为重新构建镜像自动修好,仍然需要执行一次显式的 chown,或者在确认数据可丢弃后重建卷。

凡是准备挂卷、又需要非 root 进程写入的路径,都应该在镜像里提前 mkdirchown;同时把旧卷迁移列入升级步骤,而不是只验证全新安装。

二、Compose 里的 $$,放进 Dockerfile 会变成进程号

第二个坑来自过去改动过的项目,而且已经静默存在了很久。参考改动的 Dockerfile 里有一段“替换 Debian 软件源”的代码:

RUN set -eux; \
    for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \
        if [ -f "$$f" ]; then \
            # ...
        fi; \
    done

$$VAR 是 Compose 文件里的常见写法:Compose 不对它做变量插值,而是把字面的 $VAR 交给容器内的 shell。

但这里是 Dockerfile 的 shell 形式 RUN。命令最终由 /bin/sh 解释,shell 会把 $$ 展开为当前进程 PID,于是 "$$f" 得到的不是变量 f,而是类似 "12345f" 的字符串。[ -f "12345f" ] 永远为假,替换软件源的 sed 自然一次都没有执行。

如果想这个功能生效,这里不需要任何特殊转义,直接使用普通的 shell 变量即可:

if [ -f "$f" ]; then

这段坏代码之所以长期没被发现,是因为它不会让构建失败。镜像照样能够构建,只是软件源一直是 deb.debian.org

这里的小经验是:一是 Compose、Dockerfile 和 shell 分别有自己的变量展开阶段,跨文件复制脚本时必须重新检查 $;二是所有“加速”“缓存”“优化”类代码都需要可观测的验证,例如从构建日志确认真正访问了哪个域名。否则很难区分“已经生效但收益不明显”和“根本没生效”。

三、入口脚本不应该在每次启动时覆盖配置

r1 的入口脚本会在每次启动时依次执行:

"$CONFIG_BIN" set mysql.host "$MYSQL_HOST"
"$CONFIG_BIN" set mysql.port "$MYSQL_PORT"
"$CONFIG_BIN" set mysql.user "$MYSQL_USER"
"$CONFIG_BIN" set mysql.pass "$MYSQL_PASS"
"$CONFIG_BIN" set phabricator.base-uri "$PHORGE_BASE_URI"

这个实现有三个问题。

第一,每次启动都会覆盖同名本地配置,用户手工维护的 local.json 无法稳定保留。第二,逐项调用只能方便地处理标量,将来遇到数组或对象配置时会变得很笨拙。第三,脚本开启了 set -euo pipefail,任何一条命令失败都会立即退出,而且前面已经成功写入的几项不会回滚,最终留下半成品配置。

r2 改为只在文件不存在或为空时生成:

if [ -s "$CONF_FILE" ]; then
    echo "[entrypoint] 已存在 $CONF_FILE,保留现有配置。"
else
    echo "[entrypoint] 生成 Phorge 本地配置 $CONF_FILE ..."
    # 使用 PHP 一次生成完整 JSON,先写临时文件,再 rename 到目标位置。
fi

生成过程使用 json_encode 一次写出完整对象,再通过 rename 原子替换目标文件。这样即使容器在写入中途被终止,也不会留下半截 JSON。conf/local 同时被命名卷持久化,因此重启容器不会重新生成配置;如果希望重新按环境变量生成,删除 local.json 后再启动即可。用户直接挂载自己维护的配置文件也自然成立,不需要额外开关。

这里还有一个需要说清楚的取舍。r2 中,配置生成、Schema 升级和 phd 启动失败时都会记录警告并继续拉起 Apache;数据库等待超时则仍然是致命错误。

对于方便体验和现场排障的镜像,这个策略有价值:容器至少能启动,用户可以进入容器检查配置,而不是只看到一轮又一轮 CrashLoop。但它不是所有生产环境下的标准答案:

  • 配置生成失败,可能进入未配置状态;
  • Schema 升级失败,Web 虽然启动,业务请求仍可能报错;
  • phd 启动失败,页面可能可用,但仓库拉取和异步任务已经停止。

因此,“进程还活着”不能等同于“应用已经完全就绪”。生产部署更适合把数据库迁移做成独立任务,并根据需要增加严格模式,让关键步骤失败时直接退出。

四、单容器里的守护进程也必须降权

r1 把 Web 服务和后台守护进程放在同一个容器里,入口脚本又以 root 运行,于是直接执行 phd start 时,后台进程也继承了 root 权限。

这会带来两个现实问题:一是 /var/repo 下产生 root 属主的仓库文件,与 www-data 运行的 Apache 发生权限冲突;二是权限边界被无谓放大。如果同时配置了 phd.user=www-data,Phorge 的 Setup Check 也能够发现实际运行用户不匹配。

r2 用系统自带的 su 启动守护进程:

if [ "$PHORGE_START_PHD" = "1" ]; then
    echo "[entrypoint] 以 www-data 启动 Phorge 守护进程 (phd) ..."
    su -s /bin/sh www-data -c "$PHD_BIN start" ||
        echo "[entrypoint] 警告: phd 启动失败(可稍后手动排查)。" >&2
else
    echo "[entrypoint] PHORGE_START_PHD=$PHORGE_START_PHD,跳过 phd 启动。"
fi

使用 su -s /bin/sh,可以避免为了一个降权动作再向镜像中引入 gosu。但降权不能孤立完成:进程所需的仓库目录、日志目录和配置目录必须同时允许 www-data 写入,这正是第一个坑要解决的问题。只做其中一半,反而会把“能跑但留下脏文件”变成直接报权限错误。

更彻底的方案,仍然是让 Web 和 phd 使用同一个镜像、不同的 command,分别运行在两个容器中。那样进程生命周期、资源限制和健康状态都会更清楚。

r2 暂时保留单容器,是一个有意识的阶段性取舍:它的目标仍然是让第一次接触 Phorge 的用户执行一次 docker compose up 就能看到页面。先把权限和持久化做对,再拆分进程,迁移路径会更平滑。

五、回收构建依赖,不能只删除 *-dev

编译 PHP 扩展需要安装 libpng-devlibcurl4-openssl-devlibonig-dev 等开发包。扩展编译完成后,头文件和编译辅助文件通常已经没有运行价值,但扩展实际链接的 libpng16-16libcurl4 等共享库必须保留。

如果简单执行下面这样的命令:

apt-mark auto 'lib*-dev'
apt-get autoremove

APT 可能把作为开发包依赖安装的运行时共享库一并清掉。镜像构建阶段不会因此失败,等容器启动、PHP 尝试加载 gdcurlmysqli.so 文件时,才会发现动态库缺失。

r2 采用了 PHP、WordPress 等官方镜像常用的处理方式:先保存原本手工安装的软件包;扩展编译完成后,把软件包标记为自动安装;然后用 ldd 反查扩展真正依赖的共享库,把它们所属的软件包重新标记为手工安装;最后才执行 autoremove

RUN set -eux; \
    savedAptMark="$(apt-mark showmanual)"; \
    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-install -j"$(nproc)" \
        mysqli gd curl mbstring pcntl posix opcache iconv zip; \
    apt-mark auto '.*' > /dev/null; \
    apt-mark manual $savedAptMark > /dev/null; \
    apt-mark manual git mariadb-client procps > /dev/null; \
    ldd "$(php -r 'echo ini_get("extension_dir");')"/*.so \
        | awk '/=>/ { so = $(NF-1); if (index(so, "/usr/local/") == 1) { next }; gsub("^/(usr/)?", "", so); printf "*/%s\n", so }' \
        | sort -u \
        | xargs -r dpkg-query --search \
        | awk -F: '{ print $1 }' \
        | sort -u \
        | xargs -r apt-mark manual > /dev/null; \
    apt-get purge -y --auto-remove \
        -o APT::AutoRemove::RecommendsImportant=false

这里有三个容易漏掉的细节:

  • /usr/local/ 下的库由基础镜像提供,不属于任何 Debian 软件包,需要跳过;
  • gitmariadb-clientprocps 等运行期命令不会被 ldd 扫到,需要显式保留;
  • 安装依赖前必须保存 apt-mark showmanual,否则可能误删基础镜像原本需要的软件包。

r2 仍然保留了 $PHPIZE_DEPS,也就是 gcc、g++、make 等编译工具。这与 PHP 官方基础镜像的行为一致,也保留了进入容器执行 docker-php-ext-installpecl install 的能力,代价是镜像仍然偏大。如果目标变成发布尽量小、不可变的生产镜像,下一步可以使用多阶段构建,把编译工具留在 builder 阶段。

这一轮还顺手补上了 zip 和 APCu,并把上传、内存与 OPcache 配置拆成三个独立的 INI 文件。特别是上传设置只提高 post_max_size,没有盲目同步放大 upload_max_filesize:Phorge 的大文件上传会使用分块机制,两个参数在这里承担的角色并不完全相同。把这些配置拆开,也比在 Dockerfile 中拼接一大段 echo 更容易审查和覆盖。

这类依赖清理不值得从零发明,直接参考 PHP 官方镜像的 Dockerfile,通常比自己组合一串包名可靠。

六、应用探活要带正确的 Host,但探活不等于自愈

r1 只为 MySQL 配了 Healthcheck。数据库健康只能说明启动依赖满足,不能说明 Apache 和 PHP 仍在正常处理请求。

Phorge 自带 /status/ 端点,很适合做轻量的 HTTP 存活检查。最直觉的写法是从容器内部请求本机:

test:
  - CMD
  - php
  - -r
  - "exit(@file_get_contents('http://127.0.0.1/status/') === false ? 1 : 0);"

默认配置下没有问题,但用户一旦把 phabricator.base-uri 改成正式域名,探针可能一直失败。Phorge 会根据 HTTP Host 匹配站点;容器内请求携带的 Host: 127.0.0.1 与配置域名不同,无法匹配目标站点。

r2 从 PHORGE_BASE_URI 解析域名,再显式发送 Host 头:

healthcheck:
  test:
    - CMD
    - php
    - -r
    - >-
      exit(@file_get_contents("http://127.0.0.1/status/", false,
      stream_context_create(array("http" => array("timeout" => 4,
      "header" => "Host: ".(parse_url(getenv("PHORGE_BASE_URI")
      ?: "http://127.0.0.1/", PHP_URL_HOST) ?: "127.0.0.1")))))
      === false ? 1 : 0);      
  interval: 10s
  timeout: 5s
  retries: 5
  start_period: 60s

这里直接使用 PHP 的 HTTP stream,避免额外依赖 curl 命令;代码也刻意不声明 PHP 变量,省去了在 Compose 中把 $ 写成 $$ 的二次转义。默认不设置 ignore_errors 时,PHP stream 遇到 4xx 或 5xx 会返回 false,正好可以让探针失败。

不过,这个检查只能证明“Apache、PHP 和 Phorge 路由能够返回 /status/”,不能证明 Schema 已升级、数据库可写、phd 正常或仓库能够拉取。它是 liveness,不是完整 readiness。

还要注意,Docker Compose 会把连续失败的容器标成 unhealthy,但不会仅仅因为这个状态自动重启容器。Healthcheck 提供的是可观测信号;是否告警、摘流量或重启,需要由外部监控或编排系统决定。

七、改数据库账号时,别忘了旧数据卷不会重新初始化

r2 不再让应用直接使用 MySQL root,而是通过 MYSQL_USERMYSQL_PASSWORD 创建普通账号,再由一次性的 db-init 服务补充对 phabricator_% 数据库的权限。MySQL 的 3306 端口也不再暴露到宿主机。

这对全新部署没有问题,却在复用 r1 数据卷时暴露出一个迁移断点。

MySQL 官方镜像中的 MYSQL_USER 等初始化变量,只在数据目录为空时生效。旧卷已经完成过初始化,修改 .env 并重启并不会凭空创建新用户。于是 db-init 尝试授权时会遇到“账号不存在”或 ERROR 1045,随后以非零状态退出;依赖它成功完成的 Phorge 容器也不会启动。

这不是数据库坏了,而是新账号模型缺少迁移步骤。

保留旧数据时,应该先备份数据库,再用现有 root 凭据进入 MySQL,创建与 .env 一致的应用账号并授权:

CREATE USER IF NOT EXISTS 'phorge'@'%' IDENTIFIED BY '请替换为环境变量中的密码';
GRANT ALL PRIVILEGES ON `phabricator_%`.* TO 'phorge'@'%';
FLUSH PRIVILEGES;

如果只是一次性测试、数据确定可以丢弃,才可以执行 docker compose down -v 后重新初始化。-v 会删除 Compose 管理的命名卷,不能把它写成不带警告的常规升级命令。

顺便提醒一个边界:MySQL 数据库级授权里的 _% 都是通配符,因此 phabricator_% 比“所有以字面量 phabricator_ 开头的库”稍宽。它依然比给应用 root 权限收敛得多,但如果部署环境中还有名称相近的敏感数据库,应该继续验证转义方式或改用显式库名授权。

一个小建议:涉及账号、凭据或卷结构的改造,必须同时设计“旧部署怎么迁移过来”,不能只保证全新 up 成功。

一次误判:别用“Web 服务器常识”替代上游文档

对比两套 Apache 配置时,我还犯了一个很典型的错误。

r1 使用无条件 rewrite,所有请求都交给 index.php

RewriteRule ^(.*)$ /index.php?__path__=$1 [B,L,QSA]

按照一般经验,我们往往会加一句:

RewriteCond %{REQUEST_FILENAME} !-f

因为按照一般的 Web 服务经验,后者似乎更合理:真实存在的静态文件直接由 Apache 返回,没必要绕过 PHP。我一开始也把 r1 的写法当成了 bug。

然后去看了 Phorge 自带的部署文档。上游推荐的恰恰是无条件 rewrite:

RewriteRule ^(.*)$ /index.php?__path__=$1 [B,L,QSA,UnsafeAllow3F]

至少可以确认,无条件 rewrite 是上游明确支持的请求路径;为真实文件增加 !-f 属于额外的性能取舍,不能仅凭“静态资源应该直出”的常识判断它一定更正确。它是否会绕开 Celerity 对资源请求、版本和缓存的处理,还应该结合实际路由代码与响应头继续验证。

r2 最终没有加入 !-f,只补上了两套方案都遗漏的 UnsafeAllow3F。这个 flag 用于允许经过编码的问号继续参与 rewrite,避免特定 URL 在新版 Apache 中直接返回 403。

这里最值得记住的不是某条 RewriteRule,而是排查顺序:容器化一个并不熟悉的传统应用时,先搜索项目自己的部署文档和默认配置,再套用通用经验。五分钟的源码检索,往往比一次“看起来更标准”的重构便宜。

把验收写成可执行的清单

修改完成后,我用下面这组检查逐项验收。它们都直接对应前面的某个问题。

1. 构建并启动

docker compose up -d --build
docker compose ps

MySQL 应该进入 healthydb-init 应该以状态码 0 完成,Phorge 在启动宽限期后也应该进入 healthy

2. 检查 PHP 扩展和 Web 页面

docker compose exec phorge php -m
curl -sL -o /dev/null -w '%{http_code} %{url_effective}\n' \
  http://127.0.0.1:8088/

mysqligdcurlmbstringpcntlposixopcachezipapcu 应能正常加载;首次访问则应该进入账号初始化页面。这一步可以发现构建依赖清理过头,以及 Apache rewrite 或 base-uri 配置错误。

3. 检查卷内目录权限

docker compose exec phorge \
  stat -c '%U:%G %a %n' \
  /var/repo /var/tmp/phd /opt/phorge/phorge/conf/local

属主和属组应该是 www-data:www-data。如果复用了旧卷,这一步也会立刻暴露历史文件的属主问题。

4. 检查守护进程身份

docker compose exec --user www-data phorge \
  /opt/phorge/phorge/bin/phd status

应该能够看到 Repository、Trigger、Fact 和 Taskmaster 等守护进程,而不是权限错误。

5. 检查配置是否真正持久化

docker compose exec phorge \
  sha256sum /opt/phorge/phorge/conf/local/local.json

docker compose down
docker compose up -d

docker compose exec phorge \
  sha256sum /opt/phorge/phorge/conf/local/local.json

两次哈希应该一致。这里故意不用 down -v,因为验收的正是配置卷能否跨容器重建继续存在。

6. 单独演练旧卷升级

全新部署成功之后,再用一份 r1 的数据卷执行升级,确认普通数据库账号已经创建并得到授权。涉及状态的系统,只测 fresh install 远远不够;升级测试和回滚准备应该与首次安装拥有同样的地位。

最后

这一轮改造没有急着把所有进程拆开,而是先把一个“能启动”的镜像补成更像长期可用的容器方案:配置不会被重启覆盖,卷目录有明确属主,守护进程不再使用 root,构建依赖能够安全回收,应用本身也有了可观测的存活状态。

目前仍然有几项债务没有解决:Web 和 phd 还共享一个容器,Healthcheck 还不是完整的 readiness,MySQL 客户端的跳过 TLS 配置也只适合受控的本地网络;邮件发送、Aphlict 实时通知和更严格的凭据管理同样需要继续补齐。

下一篇先把视角移到容器入口:Phorge 接入 Stargate 和 Traefik Forward Auth 后,把问题聚焦可信身份如何沿代理链抵达应用,以及后端怎样避免被绕过。

–EOF