FEATURED · 精选文章

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

发布时间 / 2026/9/18 21:51:10
来源 / 创域科博编辑部
栏目 / 资讯中心
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 --fromcomposer: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 --frombuild /opt/phorge/ /opt/phorge/ RUN printf %s\n $COMMIT_SHA /opt/phorge/VERSIONCOMMIT_SHA在构建时通过--build-arg传入这样即使不依赖.git目录也能知道当前跑的是哪个提交。.dockerignore里我专门排除了.git、storage、tmp这些目录。这里有一个很多人忽略的点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 mapRUN 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 yes和log_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-serverutf8mb4 --collation-serverutf8mb4_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_size和X-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 的user和group改成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”这么简单它真正改变的是你对部署、升级、回滚和观测这些环节的掌控力。希望这七个细节能帮你少走一点弯路剩下的等你把生产环境跑起来之后慢慢就会体会到这套思路的底气在哪里。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻