news 2026/9/18 21:50:59

Phorge迁移Docker后必做的七项容器化改造

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Phorge迁移Docker后必做的七项容器化改造

Phorge 从裸机搬进 Docker 之后,我一度以为事情结束了。直到有一天登录后台,页面直接白屏,F12 里静态资源全是 404;紧接着 worker 进程又静默退出,邮件通知一整天没发出去。这些问题的根源其实都指向同一个地方:我把容器化想得太简单了。Phorge 不是那种“nginx + php 塞进一个镜像就能收工”的普通 Web 应用,它身上同时挂着 PHP-FPM、常驻 daemon、Git/SSH 和一堆构建期资源,容器化这个动作,其实覆盖了镜像构建、进程编排、配置注入、数据持久化、可观测性好几个层面。

这篇文章是 Phorge 现代化改造系列的第二篇,重点记录我对容器化做的七项改进。如果你只是想让 Phorge “能跑起来”,网上现成的 docker-compose 一抓一大把;但如果你的目标是让它“长期稳定地跑”,并且每次升级都不慌,那我这些踩过坑之后沉淀下来的细节,应该能帮你省下不少周末。

1. 改造前先摸清 Phorge 到底“吃”什么

大多数 Web 应用容器化很简单:一个 Web 服务进程,连一个数据库,完事。Phorge 不一样,它其实是好几类进程的集合:

  • PHP-FPM:处理网页请求,跑的是webroot/index.php,这是用户直接看到的界面。
  • phd 常驻进程:负责后台任务,包括邮件发送、通知推送、仓库抓取、搜索索引。没有它,Phorge 就像一个人只有大脑没有手脚,功能明显残废。
  • SSH 服务:Phorge 内部通过 SSH 暴露 Git 仓库的读写能力,端口通常独立于 Web。
  • 可选的通知服务:Aphlict 这类 Node.js 进程,用于浏览器端实时提醒,规模小的时候可以先不跑。

之前我图省事,用官方镜像里的 supervisor 把 PHP-FPM、phd、SSH 全部塞进同一个容器。表面上确实能启动,但运行一段时间就会暴露问题:docker logs输出的日志混杂在一起,某个进程崩了之后 supervisor 会自动拉起,可你根本分不清是谁崩的;想单独重启 phd 释放资源,结果把整个容器带着一起重启;docker stop的时候,supervisor 如果没把 SIGTERM 传给子进程,还可能出现残留进程,数据库连接迟迟不释放。

所以这次改造我定了一个原则:镜像只负责把 Phorge 代码和依赖“烤好”,进程边界完全交给容器编排去切分。下面这七个细节,就是沿着这条线展开的。

细节解决的问题改造方向
多阶段构建镜像中残留编译工具和.git目录构建产物与运行环境分离
配置外部化不可变镜像无法调整实例配置用 local.json 作为唯一配置层
进程边界拆分supervisor 不能提供进程级可观测性Web、phd、SSH 独立容器
静态资源预生成Celerity 资源映射随机构建导致白屏构建期生成 map 文件
数据库迁移显式化多副本并发迁移引发冲突migrate 作为独立发布步骤
数据卷分类数据混在一起难备份难回滚按库、文件、仓库三类隔离卷
日志与自愈进程崩了没人知道stdout 化日志 + 容器级自愈

2. 七个细节逐一落地

2.1 多阶段构建:镜像里不该出现编译器和源码历史

先说最外面一层,镜像本身。Phorge 运行期依赖其实不算多:PHP 扩展、git、mercurial、subversion、unzip。但构建期为了装 PHP 扩展,会用docker-php-ext-install,这个命令需要 gcc、make、autoconf 这些编译工具;如果再加上 composer 安装依赖,又会引入一堆构建期文件。

一个不做多阶段构建的镜像,体积轻轻松松超过 1GB,而且里面还可能带着 Phorge 源码仓库的.git目录。如果你拿官方仓库直接docker build.git目录会被原样复制进去。虽然 Phorge 本身需要 git 信息来显示版本号,但把整个.git带进生产镜像并不明智,一来体积大,二来如果源码目录里意外混入什么敏感文件,等于一起进了镜像。

我的 Dockerfile 分成了三段:

# 基础环境:PHP-FPM + 运行期系统包 FROM php:8.2-fpm-bookworm AS base RUN apt-get update && apt-get install -y --no-install-recommends \ git mercurial subversion unzip \ && docker-php-ext-install mysqli mbstring exif zip \ && rm -rf /var/lib/apt/lists/* COPY --from=composer:2 /usr/bin/composer /usr/bin/composer # 构建阶段:拷源码、生成资源映射、处理依赖 FROM base AS build COPY phorge-src/ /opt/phorge/ RUN cd /opt/phorge \ && ./bin/celerity map \ && composer install --no-dev --optimize-autoloader # 运行阶段:只保留运行期需要的文件 FROM base COPY --from=build /opt/phorge/ /opt/phorge/ RUN printf '%s\n' "$COMMIT_SHA" > /opt/phorge/VERSION

COMMIT_SHA在构建时通过--build-arg传入,这样即使不依赖.git目录,也能知道当前跑的是哪个提交。.dockerignore里我专门排除了.gitstoragetmp这些目录。

这里有一个很多人忽略的点:composer install应该在构建阶段做,而不是在容器启动时做。Phorge 的 PHP 依赖虽然不多,但每次启动都去解析依赖,既慢又不确定。

2.2 配置外部化:local.json 是唯一需要关心的配置层

Phorge 的配置体系分好几层,默认配置在conf/config.php,实例配置在conf/local/local.json。local.json 的优先级最高,里面覆盖的任何键都会覆盖默认值。这意味着,你想针对某个部署实例调整配置,根本不需要重建镜像,只需要改 local.json。

官方文档喜欢用./bin/config set key value来写配置,这个命令最终也是把内容写进conf/local/local.json。容器化之后,我建议让入口脚本基于环境变量生成这个文件,而不是在 Dockerfile 里写死配置:

#!/bin/bash set -euo pipefail CONF_DIR="/opt/phorge/conf/local" mkdir -p "$CONF_DIR" # 首次启动才生成 local.json,避免覆盖已有配置 if [[ ! -f "$CONF_DIR/local.json" ]]; then /opt/phorge/bin/config set phabricator.base-uri "$PHORGE_BASE_URI" --quiet /opt/phorge/bin/config set mysql.host "$PHORGE_MYSQL_HOST" --quiet /opt/phorge/bin/config set mysql.user "$PHORGE_MYSQL_USER" --quiet /opt/phorge/bin/config set mysql.pass "$PHORGE_MYSQL_PASS" --quiet /opt/phorge/bin/config set mysql.db "$PHORGE_MYSQL_DB" --quiet /opt/phorge/bin/config set repository.default-local-path "$PHORGE_REPOS_PATH" --quiet fi exec "$@"

这样 web 容器和 phd 容器首次启动时,如果挂载的配置卷是空的,就会自动生成一份配置。之后你再改配置,直接修改配置卷里的 local.json,或者把环境变量改掉然后删除 local.json 重启容器,它又会重新生成。

不过要提醒一个坑:bin/config set每次写入都会读取并重写整个 local.json,如果 web 和 phd 两个容器同时首次启动,都去写同一个文件,可能发生写冲突。所以我建议首次初始化时,手动用一条命令生成配置,之后就把整个conf/local目录以只读方式挂载进去。官方docker run --rm phorge ./bin/config set ...这种方式初始化一次就够了。

2.3 进程边界:php-fpm、phd、SSH 别再挤一个容器

单个容器内跑多个进程,是“能用”和“好用”之间的分水岭。Phorge 的官方镜像默认用 supervisor 同时拉起 nginx、php-fpm、phd 甚至 sshd,但 supervisor 在容器里只是个兜底方案,它不能帮你解决资源隔离、日志分离、重启粒度这些更实际的问题。

我的做法是把同一个镜像跑成三个不同 command 的服务:

phorge-web: image: registry.example.com/phorge:2025.14 command: ["php-fpm"] volumes: - phorge_config:/opt/phorge/conf/local - phorge_files:/opt/phorge/storage/files depends_on: migrate: condition: service_completed_successfully restart: unless-stopped phd: image: registry.example.com/phorge:2025.14 command: ["/opt/phorge/bin/phd", "launch"] volumes: - phorge_config:/opt/phorge/conf/local - phorge_files:/opt/phorge/storage/files - phorge_repos:/var/repos restart: unless-stopped

bin/phd launch作为 phd 容器的前台主进程,这里有个很关键的行为差异:phd start是后台启动,容器主进程会立即退出,Docker 会以为容器结束了;而phd launch是在前台运行,日志直接打到 stdout,非常适合做容器主进程。如果 phd 内部崩溃,进程退出,Docker 根据restart: unless-stopped自动拉起,这样就得到了进程级的自愈能力,不再需要 supervisor 在里面做一层健康检查。

SSH 服务我单独开了一个容器,跑的是同样的镜像,额外映射 2222 端口,挂载公钥目录。说实话,如果你只用 HTTP 方式访问 Git 仓库,SSH 容器可以先不部署;但 Phorge 的 Differential 代码审查体验,结合 SSH clone 才是最顺滑的,所以我还是保留了。

2.4 Celerity 资源预生成:白屏问题的根治

Phorge 的静态资源不是简单丢在/static目录里,它有一套叫 Celerity 的资源管理系统:维护一张resources/celerity/map.php映射表,把逻辑资源名映射成带哈希的物理文件路径。如果你拿到的代码没有生成好这张映射表,浏览器首次访问时才会触发资源生成,这一步在高并发下会互相踩,结果就是某个 CSS 或 JS 文件 404,页面白屏。

这就是为什么我在 Dockerfile 构建阶段就执行了./bin/celerity map

RUN cd /opt/phorge \ && ./bin/celerity map \ && composer install --no-dev --optimize-autoloader

构建期把 map 生成好后,运行期代码树就是只读的,webroot/res/的静态资源也一并被复制进镜像,由 PHP-FPM 或外层 Nginx 直接服务。这样改造之后,我再也没有遇到过资源 404 的白屏问题。

配套还需要在配置里显式打开缓存:

{ "celerity.enable-cache": true, "celerity.resource-hash": true }

如果后续更换了主题或者新增了扩展,记得重新构建镜像,让 map 表跟着代码一起更新。我们还在 CI 里加了一步,构建完镜像先跑一次:

docker run --rm --entrypoint /opt/phorge/bin/celerity phorge:2025.14 map

如果 map 生成失败,CI 直接标红,不给它进入生产的机会。

2.5 数据库迁移变成显式发布步骤:storage upgrade 的坑

Phorge 的数据库 schema 不是应用启动时自动检测的,它由命令行工具bin/storage upgrade管理。这就带来一个容器化特有的麻烦:如果启动脚本里顺手执行 migration,那么当你有多个副本同时启动时,多个迁移进程会同时打数据库,轻则报锁冲突,重则字段重复创建直接失败。

我把迁移从启动流程里拆了出来,变成一个独立的 Compose 服务:

migrate: image: registry.example.com/phorge:2025.14 command: ["/opt/phorge/bin/storage", "upgrade", "--user", "phorge"] volumes: - phorge_config:/opt/phorge/conf/local depends_on: mysql: condition: service_healthy restart: "no"

然后在 web 和 phd 上通过depends_on显式等待迁移完成:

depends_on: migrate: condition: service_completed_successfully

这样部署升级的时候,执行顺序变成:先拉新镜像 → 单独跑一次迁移容器 → 迁移成功退出 → 再滚动拉起 web 和 phd。迁移失败时,新版本代码根本不会上线,不会出现“半初始化”的状态。

升级操作我固定成三条命令,记在运维文档里:

docker compose pull docker compose run --rm --no-deps migrate docker compose up -d

其中docker compose run --rm会以“一次性容器”方式运行迁移服务,跑完自动清理。--no-deps是为了避免它把 web 和 phd 一起带起来。如果你用的还是裸docker run而不是 Compose,思路一模一样:验证新镜像数据迁移通过,再切换整体版本。

2.6 数据卷按“数据库 / 文件 / 仓库”三类拆分,别一锅烩

Phorge 的数据分布在三个地方:数据库里的业务数据、storage/files下的用户上传文件、以及本地仓库缓存目录。这三类数据有完全不同的生命周期和备份方式,混在一个卷里,备份和回滚都很难受。

我的卷定义如下:

volumes: mysql_data: phorge_config: phorge_files: phorge_repos:

对应的挂载点:

  • mysql_data挂到 MySQL 容器的/var/lib/mysql
  • phorge_config挂到 Phorge 的/opt/phorge/conf/local
  • phorge_files挂到/opt/phorge/storage/files
  • phorge_repos挂到/var/repos,对应配置里的repository.default-local-path

分开之后,备份策略就清爽了。数据库用mysqldump做逻辑备份,文件目录用 rsync 做增量同步,仓库目录要在 Phorge 后台关闭“写入模式”之后做快照,否则可能在备份过程中产生半成品仓库。不需要像以前那样,一个超大卷把所有东西打包进去,恢复时只能整卷恢复。

另外,MySQL 镜像的 tag 一定要固定,不要用mysql:latest或者mariadb:latest。Phorge 官方长期用 MariaDB,我固定在mariadb:10.11。如果你之前用的是 MySQL 8.x,升级数据库版本之前一定要先在测试环境跑一遍bin/storage upgrade,很多莫名其妙的乱码和连接问题,都是数据库版本大跳导致的。

2.7 日志从黑洞里捞出来:stdout 化与自愈兜底

容器里最难受的一类问题,是进程还活着但功能已经坏了。Phorge 的 phd 进程尤其如此,它是一组常驻 worker,某个 worker 卡死之后,队列消息堆积,邮件不发,通知不推,但容器看起来一切正常,因为进程还在跑。

要让这类问题可观测,第一步是把日志全部落到 stdout/stderr,让docker logs能直接看到。Nginx 默认写文件,不配置的话docker logs抓不到,我在 Nginx 配置里加了两行:

access_log /dev/stdout; error_log /dev/stderr;

PHP-FPM 的catch_workers_output = yeslog_level = notice也要检查,确保 worker 的报错能进入 stderr。phd 因为用phd launch前台运行,天然会往 stdout 打日志。

第二步是给 phd 加上健康检查。phd 容器内部,我建议用bin/phd status检查 daemon 是否存活:

healthcheck: test: ["CMD", "/opt/phorge/bin/phd", "status"] interval: 60s timeout: 10s retries: 3

如果 status 返回非零,Docker 会标记容器 unhealthy,配合外部监控或者编排系统可以自动干点事。不过说实话,phd status只能检查 daemon 进程在不在,如果 worker 本身陷入死循环,它检测不出来。所以我额外在 phd 容器里跑了一个定时任务,每分钟检查队列积压,如果bin/phd diagnose显示异常就重启容器。

第三步是让 Docker 自己兜底。restart: unless-stopped保证了 phd 进程异常退出后会被重新拉起,这比 supervisor 更简单直接。如果你确实需要把 supervisor 作为 PID 1,那务必确保它能把子进程的信号转发处理好,否则docker stop时容器会卡好久。

3. 改造后的 Compose 全景:可以直接照抄的版本

把上面七点串起来,就是一个相对完整的 docker-compose 文件。我放一个精简但可运行的版本:

version: "3.9" services: mysql: image: mariadb:10.11 command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci environment: MARIADB_DATABASE: phorge MARIADB_USER: phorge MARIADB_PASSWORD: change_me MARIADB_ROOT_PASSWORD: root_change_me volumes: - mysql_data:/var/lib/mysql healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped migrate: image: registry.example.com/phorge:2025.14 command: ["/opt/phorge/bin/storage", "upgrade", "--user", "phorge"] volumes: - phorge_config:/opt/phorge/conf/local depends_on: mysql: condition: service_healthy restart: "no" phorge-web: image: registry.example.com/phorge:2025.14 command: ["php-fpm"] environment: PHORGE_BASE_URI: "https://phorge.example.com" PHORGE_MYSQL_HOST: mysql PHORGE_MYSQL_USER: phorge PHORGE_MYSQL_PASS: change_me PHORGE_MYSQL_DB: phorge PHORGE_REPOS_PATH: /var/repos volumes: - phorge_config:/opt/phorge/conf/local - phorge_files:/opt/phorge/storage/files - phorge_repos:/var/repos depends_on: migrate: condition: service_completed_successfully restart: unless-stopped phd: image: registry.example.com/phorge:2025.14 command: ["/opt/phorge/bin/phd", "launch"] environment: PHORGE_BASE_URI: "https://phorge.example.com" PHORGE_MYSQL_HOST: mysql PHORGE_MYSQL_USER: phorge PHORGE_MYSQL_PASS: change_me PHORGE_MYSQL_DB: phorge PHORGE_REPOS_PATH: /var/repos volumes: - phorge_config:/opt/phorge/conf/local - phorge_files:/opt/phorge/storage/files - phorge_repos:/var/repos depends_on: migrate: condition: service_completed_successfully restart: unless-stopped healthcheck: test: ["CMD", "/opt/phorge/bin/phd", "status"] interval: 60s timeout: 10s retries: 3 volumes: mysql_data: phorge_config: phorge_files: phorge_repos:

注意,这个 Compose 没有暴露 Web 端口,因为我习惯在左边再挂一层 Nginx 反向代理,统一处理 HTTPS、client_max_body_sizeX-Forwarded-Proto。Phorge 对上传文件的大小有两处限制:一层是 Web 服务器(Nginx 里默认只有 1MB),另一层是 Phorge 自身的storage.upload-size-limit。如果你要支持大体积补丁包,两边都要调大,不然用户传大文件时会收到 413 或者被 Phorge 截断。

反向代理层还必须把X-Forwarded-Proto正确传给 Phorge,否则它会把所有请求当成 HTTP,生成的链接全是http://,导致跳转异常或者 API 回调地址错误。

4. 改造后的实测:一些只有线上能教会你的细节

这一套方案跑了一个多月,epoch 里遇到几个有意思的问题,写出来给大家参考。

第一个是配置卷权限。我第一次用 Compose 启动时,phorge_config卷是空的,入口脚本以 root 身份执行了bin/config set,生成的local.json属主是 root。但 PHP-FPM 和 phd 容器内部跑的用户是www-data,读到一半无权限修改,有些配置就是写不进去。后来我在入口脚本里加了chown -R www-data:www-data /opt/phorge/conf/local /opt/phorge/storage/files /var/repos,再把 PHP-FPM 的usergroup改成www-data,问题消失。这里提醒一下,如果反代和 Phorge 容器之间还有一层权限隔离,要注意两个容器用同一个 UID,否则共享卷里会出现“有文件但读不了”的诡异现象。

第二个是phd launch和旧 daemon 打架。Phorge 的 phd 有一套控制机制,新启动的 daemon 会尝试去连接 daemon 控制目录。如果你同时在多个地方用phd start(比如某个容器里手滑配了启动脚本),会出现新 daemon 把旧 daemon 全停了的情况。我后来把所有非phd launch的启动路径全部去掉,只留这一个入口,再也没出现 daemon 无端消失的问题。

第三个是资源更新后浏览器缓存。Celerity map 虽然在构建期重新生成了,但你已经打开过的页面里,旧的带哈希资源路径可能被浏览器缓存住。升版本后如果确实改了前端资源,建议在部署后给静态资源目录加一层短期强缓存,或者告知用户硬刷新一次。这个问题不是容器化造成的,但容器化之后“替换版本”太快,用户更容易怀旧到旧的哈希路径,踩到 404。

第四个是 mysql healthcheck 的假阳性。healthcheck.sh --connect可以检查 MySQL 是否能接受连接,但我早期用的参数少了--innodb_initialized,容器起来后还没完成 InnoDB 初始化就已经通过了健康检查,导致 Phorge 启动迁移时连库超时。加上这个参数之后,健康检查更贴近真实可用状态。

5. 升级节奏和一点点体会

按这套容器化改造做完之后,最近一次 Phorge 小版本升级,我是这么操作的:先备份 MySQL 和仓库卷,再docker compose pull拉新镜像,跑一次迁移容器确认 schema 升级成功,最后docker compose up -d滚动部署。整个流程下来十分钟内结束,期间用户最多看到一次“后端维护中”的短暂提示,没有白屏,没有丢数据,不用像以前那样蹲在服务器前盯日志。

如果你目前也在维护 Phorge 或者类似 Phabricator 系工具,我建议不要一上来就追求全容器化的大而全方案,先把配置和迁移这两个最容易埋雷的点拆出来,再逐步优化镜像和进程边界。容器化改造不是“把一切塞进 Dockerfile”这么简单,它真正改变的是你对部署、升级、回滚和观测这些环节的掌控力。希望这七个细节能帮你少走一点弯路,剩下的,等你把生产环境跑起来之后,慢慢就会体会到这套思路的底气在哪里。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 21:45:54

oh-my-hermes:像管理插件一样玩转 React Native 引擎调优

oh-my-hermes 这个名字,一眼就能看出是照着 oh-my-zsh 那个路子来的。玩过命令行的人都知道,oh-my-zsh 把 zsh 从一把默认配置的“素坯”打磨成了一把趁手的“快刀”。那 oh-my-hermes 想干什么?说白了,就是给移动端开发里那个叫 …

作者头像 李华
网站建设 2026/9/18 21:44:14

VMware虚拟机搭建Ubuntu MPI集群实战指南

简介:本资源是一份面向计算机专业本科生与高性能计算初学者的Ubuntu虚拟机MPI集群搭建实验指南,聚焦并行计算环境配置核心技能,解决在有限硬件条件下开展分布式系统实践的教学与自学难题。文档为单文件Word格式(.docx)…

作者头像 李华
网站建设 2026/9/18 21:43:41

信号与系统工程实践:从LTI到Z域的MATLAB/Python/Simulink验证

简介:本资源是一份面向高校电子、通信、自动化等专业本科生的《信号与系统》课程配套习题集与详解,聚焦夯实基础理论与提升解题能力。内容覆盖信号时域/频域分析、LTI系统特性、傅里叶变换、拉普拉斯变换等核心模块,题型丰富,包含…

作者头像 李华
网站建设 2026/9/18 21:43:03

盒图(N-S图)完全指南:从流程图失控到结构化详细设计

刚接手课程设计那阵子,我用流程图画模块逻辑画得一头乱麻。有一次小组评审,老师指着我图里两条交叉的箭头问“如果这里出现异常,控制流到底走哪条”,我盯着屏幕愣是答不上来。也就是从那天起,我开始认真用盒图&#xf…

作者头像 李华