欢迎访问晨星博客!
容器镜像构建完成、推到集群、Pod 起来之后前几十个请求明显偏慢,接着又因为扩缩容或滚动更新反复出现同样的现象——这类问题在把 PHP 接口服务塞进只读文件系统之后特别常见。共享内存里的 OPcache 每个进程组重启就清空一次,而只读挂载又让 PHP 没法把编译结果写到磁盘上留给下一次启动。OPcache 的文件缓存加上只读模式,正是针对这种"进程频繁启动、文件系统不可写"的部署形态设计的:编译在构建阶段做完,运行阶段只负责读。

OPcache 的常规工作方式是把编译后的字节码放在共享内存里,后续请求跳过编译步骤。文件缓存是另一条路径:把编译产物同时落到磁盘目录,进程重新启动时可以从磁盘恢复,而不必从源码重新编译。它对冷启动时间的价值集中在 PHP 进程频繁启动的场景,例如把 PHP 跑在函数计算类环境里;反过来,如果你的 FPM 容器一跑就是几周、极少重启,收益会被摊薄到几乎看不见。
只读模式(opcache.file_cache_read_only)在此基础上再加一层约束:PHP 不再尝试写入这个目录,只从中读取。这带来两个直接后果——缓存目录可以随镜像一起被打包成不可变内容,同时运行阶段任何缓存内容的缺失都不会被"自动补上",只会静默退回到正常编译流程。
顺带一提,从 PHP 8.5 起 OPcache 已经是 PHP 自身的组成部分,总是随 PHP 安装,但并不意味着一定处于启用状态。上线前请确认 opcache.enable 的实际生效值,别默认它已经开着。
只读文件缓存能不能用,取决于构建产物和运行环境是否足够一致。判断标准可以归结为几条:
opcache.save_comments这几条里最容易翻车的是路径。文件缓存的键与脚本路径相关,opcache.use_cwd 默认值为 1,意味着相对路径的解析方式也会参与进来。如果你的构建阶段在 /build 下编译,运行阶段代码却挂在 /var/www/html,缓存会一条都命中不了,而且不会报错。
思路是给两个阶段各一份 INI 片段。构建阶段允许写入,运行阶段只读。
构建阶段:
opcache.enable=1
opcache.enable_cli=1
opcache.file_cache=/var/cache/opcache
opcache.file_update_protection=0
opcache.validate_timestamps=1
opcache.file_update_protection 的作用是拒绝缓存修改时间过近的文件,避免把写了一半的文件编译进去。构建阶段刚 COPY 进来的源码全都是"刚刚才有的",如果保留默认值,预热脚本大概率什么都缓存不到。官方文档的说明是:当文件更新是原子的时候,把它设为 0 可以让文件立刻被缓存。构建镜像正是这种情况。
预热本身需要显式触发编译,通常是写一个 CLI 脚本遍历需要缓存的目录,对每个 PHP 文件调用 opcache_compile_file(),注意 opcache.enable_cli 必须为 1,否则 CLI 进程里的 OPcache 根本没启用。
运行阶段:
opcache.enable=1
opcache.file_cache=/var/cache/opcache
opcache.file_cache_read_only=1
opcache.validate_timestamps=0
镜像里的代码在运行期不会变化,因此关闭时间戳校验既省掉每次请求的 stat 开销,也与只读缓存的语义一致(默认值是 opcache.validate_timestamps=1、opcache.revalidate_freq=2,适合会改文件的传统部署,不适合不可变镜像)。共享内存相关的 opcache.memory_consumption 默认 128MB,多数项目够用,代码量或扩展多的项目再往上调;opcache.max_accelerated_files 默认 10000,文件数超过这个量级时需要调整,否则超出部分不会进缓存。这些具体数值都建议对照 PHP 官方的 OPcache 配置文档 逐项确认默认值和可修改级别,因为部分指令属于 INI_SYSTEM,运行期改不了。
只读缓存的失败方式是安静的,所以验证步骤不能省。按下面的顺序查,能把大部分问题定位在上线前:
opcache_get_status() 与 phpinfo 的 OPcache 段,确认文件缓存路径与只读标志是与预期一致的生效值,而不是你以为写进去的那份 INIopcache_get_status() 里的命中与缓存脚本数量,在容器刚启动、只处理过少量请求时采样一次第二步和第五步是关键。生效值和配置文件不一致,通常是 INI 片段没被加载、被后续目录里的文件覆盖,或者容器编排层注入了额外的环境变量配置。
命中失败几乎都能归到"构建阶段与运行阶段存在差异"这一类。按代价从低到高排查:
先比对路径。把运行容器里的实际脚本绝对路径和构建阶段的路径打出来对照,注意发布目录带版本号、通过软链接指向当前版本的做法会让路径每次发布都变。再比对 PHP 环境,版本号、扩展列表、编译参数不同都会让缓存文件不被接受。然后回到构建阶段确认预热脚本的覆盖范围,只预热了入口文件而没有遍历依赖目录,是很常见的疏漏。最后确认目录本身在最终镜像层里存在,多阶段构建漏掉一条 COPY 就会得到一个空目录,而只读模式下 PHP 不会抱怨,只会一直重新编译。
如果所有条件都对上但收益依然感知不到,回头审视场景本身:你的容器重启频率是否真的高到值得引入这套构建复杂度。不要用未经压测的数字说服自己或团队,是否有效应该以你自己环境下的冷启动观测为准。
| 维度 | 可写文件缓存 | 只读文件缓存 |
|---|---|---|
| 文件系统要求 | 需要可写目录或卷 | 兼容只读根文件系统 |
| 缓存产生时机 | 运行期按需生成 | 构建期一次性生成 |
| 配置错误的表现 | 多数能自行恢复 | 静默退回编译,不报错 |
| 内容一致性 | 取决于卷的生命周期与共享方式 | 与镜像一同固化,实例间完全一致 |
| 更新方式 | 运行期可被覆盖 | 必须重新构建镜像 |
| 排查难度 | 可在运行环境中试错 | 需要回到构建流程复现 |
可写模式的优势是宽容:配错了、缓存缺了,运行期自己会补。代价是引入了一个有状态目录,多个实例共享同一个卷时还要考虑并发写入与残留内容。只读模式把不确定性全部前移到构建阶段,运行阶段的行为完全可预测,但也意味着调试窗口从"线上调参"变成了"改流水线重新出镜像"。
还有一个容易被忽略的区别:只读模式要求你的发布流程本身是可靠的。缓存内容与镜像绑定之后,任何"热修一个文件"的操作都会让那个文件脱离缓存覆盖范围,而你从监控上看不出来。
在动手改 Dockerfile 之前,先回答几个问题:容器的启动频率是否高到冷启动成为真实痛点;根文件系统是否已经是只读,或者计划改成只读;构建镜像和运行镜像是否共用同一个 PHP 基础层;代码路径在两个阶段是否逐字符相同;发布流程里是否已经没有运行期改文件的操作。
五个问题里有明确否定项的,先去解决那一项,而不是先启用只读缓存——它不会替你解决部署一致性问题,只会把不一致的后果变成一条查不到的慢请求。
声明:原创文章请勿转载,如需转载请注明出处!