本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月06日 统计字数: 10903字 阅读时间: 22分钟阅读 本文链接: https://soulteary.com/2026/09/06/phorge-modernization-part-2-seven-containerization-details.html ----- # Phorge 现代化改造实战(二):改进容器化的七个细节 本文是“Phorge 现代化改造实战”系列第二篇。在第一篇建立可运行基线之后,这一篇先补齐容器内部的工程细节,为后续处理入口认证和外部服务拆分准备一个可信的运行底座。 ## 系列导航 1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线; 2. **改进容器化的七个细节**:补齐权限、持久化、依赖和探活; 3. 接入 Stargate:把 Forward Auth 的信任边界做完整; 4. 拆分 Gorge:无侵入不等于不碰文件。 ## 写在前面 上一篇文章里,我们从一份刚 fork 下来的 Phorge 源码开始,做出了一个能够运行的最小容器:Apache 提供 Web 服务,`phd` 在后台处理任务,MySQL 保存数据,执行一次 `docker compose up` 就能进入初始化页面。 那一版解决的核心是“能不能跑”的问题。这一版的主要工作聚焦在把容器方案从“能运行”改进到“可配置、可持久化、可观测”的程度。 相比较上一个版本,[`2026.09.06-r2`](https://github.com/soulteary/phorge/releases/tag/2026.09.06-r2) 这个版本只多了一个提交,改动了 12 个文件。完整改动差异可以在 [r1...r2](https://github.com/soulteary/phorge/compare/2026.09.06-r1...2026.09.06-r2) 中查看。 这次改动主要补上了这些东西: | 方面 | r1 | r2 | | ---- | --------------- | ------------------------------- | | 配置 | 每次启动逐项覆盖 | 首次原子生成,后续保留 | | 数据库 | 应用直接使用 root | 独立普通账号,一次性授权任务 | | 运行权限 | `phd` 继承 root | `phd` 以 `www-data` 运行 | | 持久化 | 只挂仓库目录 | 配置和仓库分别持久化 | | 镜像 | 构建依赖全部保留 | 按动态链接关系回收无用依赖 | | 可观测性 | 只有数据库探活 | 应用增加 HTTP Healthcheck | | 配置入口 | 参数散落在 Compose 中 | 增加 `.env.example` 和 `DOCKER.md` | 本文记录的七个细节,并不只属于 Phorge。任何需要把传统应用装进容器的项目,大概率都会遇到其中几个。 本文中引用的实现分别位于 r2 的 [`Dockerfile`](https://github.com/soulteary/phorge/blob/2026.09.06-r2/Dockerfile)、[`docker/entrypoint.sh`](https://github.com/soulteary/phorge/blob/2026.09.06-r2/docker/entrypoint.sh) 和 [`docker-compose.yml`](https://github.com/soulteary/phorge/blob/2026.09.06-r2/docker-compose.yml)。 ## 一、命名卷会放大目录属主问题 这是最普适、也最容易在“本机正常,换台机器就坏”时浪费时间的坑。 Phorge 默认把仓库工作副本放在 `/var/repo/`,把守护进程日志放在 `/var/tmp/phd/log/`。 最小方案自然会把命名卷挂到这些路径上: ```yaml 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 在构建镜像时预先创建运行目录并设置属主: ```dockerfile 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.php`,`phd.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 进程写入的路径,都应该在镜像里提前 `mkdir` 和 `chown`;同时把旧卷迁移列入升级步骤,而不是只验证全新安装。 ## 二、Compose 里的 `$$`,放进 Dockerfile 会变成进程号 第二个坑来自过去改动过的项目,而且已经静默存在了很久。参考改动的 Dockerfile 里有一段“替换 Debian 软件源”的代码: ```dockerfile 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 变量即可: ```dockerfile if [ -f "$f" ]; then ``` 这段坏代码之所以长期没被发现,是因为它不会让构建失败。镜像照样能够构建,只是软件源一直是 `deb.debian.org`。 这里的小经验是:一是 Compose、Dockerfile 和 shell 分别有自己的变量展开阶段,跨文件复制脚本时必须重新检查 `$`;二是所有“加速”“缓存”“优化”类代码都需要可观测的验证,例如从构建日志确认真正访问了哪个域名。否则很难区分“已经生效但收益不明显”和“根本没生效”。 ## 三、入口脚本不应该在每次启动时覆盖配置 r1 的入口脚本会在每次启动时依次执行: ```sh "$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 改为只在文件不存在或为空时生成: ```sh 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` 启动守护进程: ```sh 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-dev`、`libcurl4-openssl-dev`、`libonig-dev` 等开发包。扩展编译完成后,头文件和编译辅助文件通常已经没有运行价值,但扩展实际链接的 `libpng16-16`、`libcurl4` 等共享库必须保留。 如果简单执行下面这样的命令: ```sh apt-mark auto 'lib*-dev' apt-get autoremove ``` APT 可能把作为开发包依赖安装的运行时共享库一并清掉。镜像构建阶段不会因此失败,等容器启动、PHP 尝试加载 `gd`、`curl` 或 `mysqli` 的 `.so` 文件时,才会发现动态库缺失。 r2 采用了 PHP、WordPress 等官方镜像常用的处理方式:先保存原本手工安装的软件包;扩展编译完成后,把软件包标记为自动安装;然后用 `ldd` 反查扩展真正依赖的共享库,把它们所属的软件包重新标记为手工安装;最后才执行 `autoremove`。 ```dockerfile 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 软件包,需要跳过; - `git`、`mariadb-client`、`procps` 等运行期命令不会被 `ldd` 扫到,需要显式保留; - 安装依赖前必须保存 `apt-mark showmanual`,否则可能误删基础镜像原本需要的软件包。 r2 仍然保留了 `$PHPIZE_DEPS`,也就是 gcc、g++、make 等编译工具。这与 PHP 官方基础镜像的行为一致,也保留了进入容器执行 `docker-php-ext-install` 或 `pecl install` 的能力,代价是镜像仍然偏大。如果目标变成发布尽量小、不可变的生产镜像,下一步可以使用多阶段构建,把编译工具留在 builder 阶段。 这一轮还顺手补上了 `zip` 和 APCu,并把上传、内存与 OPcache 配置拆成三个独立的 INI 文件。特别是上传设置只提高 `post_max_size`,没有盲目同步放大 `upload_max_filesize`:Phorge 的大文件上传会使用分块机制,两个参数在这里承担的角色并不完全相同。把这些配置拆开,也比在 Dockerfile 中拼接一大段 `echo` 更容易审查和覆盖。 这类依赖清理不值得从零发明,直接参考 [PHP 官方镜像的 Dockerfile](https://github.com/docker-library/php/blob/master/8.3/bookworm/apache/Dockerfile),通常比自己组合一串包名可靠。 ## 六、应用探活要带正确的 Host,但探活不等于自愈 r1 只为 MySQL 配了 Healthcheck。数据库健康只能说明启动依赖满足,不能说明 Apache 和 PHP 仍在正常处理请求。 Phorge 自带 `/status/` 端点,很适合做轻量的 HTTP 存活检查。最直觉的写法是从容器内部请求本机: ```yaml 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 头: ```yaml 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_USER`、`MYSQL_PASSWORD` 创建普通账号,再由一次性的 `db-init` 服务补充对 `phabricator_%` 数据库的权限。MySQL 的 3306 端口也不再暴露到宿主机。 这对全新部署没有问题,却在复用 r1 数据卷时暴露出一个迁移断点。 MySQL 官方镜像中的 `MYSQL_USER` 等初始化变量,只在数据目录为空时生效。旧卷已经完成过初始化,修改 `.env` 并重启并不会凭空创建新用户。于是 `db-init` 尝试授权时会遇到“账号不存在”或 `ERROR 1045`,随后以非零状态退出;依赖它成功完成的 Phorge 容器也不会启动。 这不是数据库坏了,而是新账号模型缺少迁移步骤。 保留旧数据时,应该先备份数据库,再用现有 root 凭据进入 MySQL,创建与 `.env` 一致的应用账号并授权: ```sql 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`: ```apache RewriteRule ^(.*)$ /index.php?__path__=$1 [B,L,QSA] ``` 按照一般经验,我们往往会加一句: ```apache RewriteCond %{REQUEST_FILENAME} !-f ``` 因为按照一般的 Web 服务经验,后者似乎更合理:真实存在的静态文件直接由 Apache 返回,没必要绕过 PHP。我一开始也把 r1 的写法当成了 bug。 然后去看了 Phorge 自带的部署文档。上游推荐的恰恰是无条件 rewrite: ```apache RewriteRule ^(.*)$ /index.php?__path__=$1 [B,L,QSA,UnsafeAllow3F] ``` 至少可以确认,无条件 rewrite 是上游明确支持的请求路径;为真实文件增加 `!-f` 属于额外的性能取舍,不能仅凭“静态资源应该直出”的常识判断它一定更正确。它是否会绕开 Celerity 对资源请求、版本和缓存的处理,还应该结合实际路由代码与响应头继续验证。 r2 最终没有加入 `!-f`,只补上了两套方案都遗漏的 `UnsafeAllow3F`。这个 flag 用于允许经过编码的问号继续参与 rewrite,避免特定 URL 在新版 Apache 中直接返回 403。 这里最值得记住的不是某条 RewriteRule,而是排查顺序:容器化一个并不熟悉的传统应用时,先搜索项目自己的部署文档和默认配置,再套用通用经验。五分钟的源码检索,往往比一次“看起来更标准”的重构便宜。 ## 把验收写成可执行的清单 修改完成后,我用下面这组检查逐项验收。它们都直接对应前面的某个问题。 ### 1. 构建并启动 ```bash docker compose up -d --build docker compose ps ``` MySQL 应该进入 `healthy`,`db-init` 应该以状态码 0 完成,Phorge 在启动宽限期后也应该进入 `healthy`。 ### 2. 检查 PHP 扩展和 Web 页面 ```bash docker compose exec phorge php -m curl -sL -o /dev/null -w '%{http_code} %{url_effective}\n' \ http://127.0.0.1:8088/ ``` `mysqli`、`gd`、`curl`、`mbstring`、`pcntl`、`posix`、`opcache`、`zip` 和 `apcu` 应能正常加载;首次访问则应该进入账号初始化页面。这一步可以发现构建依赖清理过头,以及 Apache rewrite 或 base-uri 配置错误。 ### 3. 检查卷内目录权限 ```bash 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. 检查守护进程身份 ```bash docker compose exec --user www-data phorge \ /opt/phorge/phorge/bin/phd status ``` 应该能够看到 Repository、Trigger、Fact 和 Taskmaster 等守护进程,而不是权限错误。 ### 5. 检查配置是否真正持久化 ```bash 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