apt-get 补的库,一次重建全没了

我把一台 PHP 容器的 php -m 调到零告警,四个扩展全都能加载。后来在面板里改了一次环境设置,四个扩展全挂回去了。

补的东西没进镜像。它一直在容器的可写层里。

这台是 1Panel v2 起的环境,PHP 8.4.25,镜像 1panel-php-fpm:8.4.25,基础镜像 Debian 13(trixie),服务器 4 核 8G。事情出在 9 月 16 号和 17 号这两天,下面的回显数值都原样保留。

容器名我在下面统一用变量,你在 1Panel 里起的名字是什么就填什么。

bash 复制代码
# 换成你在 1Panel 里给 PHP 运行环境起的名字(= 容器名)
export PHP_CTN="php84"

同样是往容器里动手,结果完全不一样。

docker restart "$PHP_CTN" 只重启进程,容器文件系统原封不动,你 apt-get 补的库和命令都还在,不用管它。

容器重建就全丢了。docker compose up、在 1Panel 里改环境设置、升级镜像,这三种都算重建。

flowchart TD A[&#34;镜像层<br/>只读,来自 1panel-php-fpm:8.4.25&#34;] --> B[&#34;容器可写层<br/>你 apt-get 补的库都写在这里&#34;] B --> C{&#34;执行了什么操作?&#34;} C -->|docker restart| D[&#34;容器文件系统保留<br/>补装项都还在&#34;] C -->|容器重建| E[&#34;可写层被丢弃<br/>补装项全部消失&#34;] style A fill:#eaf2fd,stroke:#4a7fd4,color:#1f2328 style B fill:#f6f8fa,stroke:#d0d7de,color:#1f2328 style C fill:#fff8e1,stroke:#d4a72c,color:#1f2328 style D fill:#eaf7ee,stroke:#2da44e,color:#1a7f37 style E fill:#fdecec,stroke:#d1242f,color:#d1242f

这条我当时判断错过一次。我以为 unzip 不依赖任何库,重建之后应该还在。错了。unzip 同样是 apt-get 装的,跟那些库一样活在容器的可写层里。决定去留的是你执行了哪种操作,跟这个包有没有依赖链没关系。

两层文件系统

Docker 容器 = 镜像层(只读)+ 容器可写层。

镜像层的内容来自 docker pull 拉下来的镜像,所有容器共享,容器内的任何操作都改不动它。

你在容器里做的写操作,apt-get install、改文件、建文件,全都落在可写层。这一层是这个容器实例私有的。

docker restart 只是把容器进程重启一遍,可写层不动,补装项就还在。

重建的本质是销毁旧容器实例、用同一个镜像重新建一个新实例。新实例的可写层是全新的空白层,旧的连带里面所有补装内容一起被丢弃。

你补的东西从头到尾就没进过镜像,只在某一代容器的可写层里待着。

第一次补库时,我是照着报错信息一个个试包名的。重放的时候换了个思路:不记当时敲了哪条命令,记怎么重新推出这条命令。

包名会变,后面有真实案例。存命令迟早过期,存方法不会。

第一步是问 PHP 自己扩展目录在哪。硬编码路径是个坑,目录名里带着 PHP 的 API 版本号:

lua 复制代码
/usr/local/lib/php/extensions/no-debug-non-zts-20240924/
                                          ^^^^^^^^^^ 换 PHP 版本就变

换个小版本,20240924 这段就变了。

bash 复制代码
# 本机验证:这条能直接拿到扩展目录
docker exec -i "$PHP_CTN" php -r 'echo ini_get("extension_dir"), "\n";'

然后把遍历扩展目录、逐个 ldd、只留 not found 打包成一条:

bash 复制代码
# 遍历所有扩展 .so,找出依赖缺失的那些
docker exec -i "$PHP_CTN" bash -c '
  EXT_DIR="$(php -r "echo ini_get(\"extension_dir\");")"
  for so in "$EXT_DIR"/*.so; do
    [ -e "$so" ] || continue
    miss=$(ldd "$so" 2>/dev/null | grep "not found")
    if [ -n "$miss" ]; then
      echo "--- $(basename "$so") ---"
      echo "$miss"
    fi
  done'

三个细节,都是踩过才知道的。

[ -e "$so" ] || continue:通配符一个都没匹配到的时候,$so 会是字面量 *.so,这句把它挡掉。

2>/dev/null:有些 .so 不是动态库,ldd 会往 stderr 刷 not a dynamic executable。

ldd 只回答缺不缺,不回答该不该装。缺库的扩展不代表你都需要,补之前先确认哪些扩展是项目真在用的。

这套遍历加过滤的脚本逻辑,我在同类环境上用替身函数做过 dry-run,把 ldd 换成模拟输出,遍历、continue 保护、basename、计数全部符合预期。稿子里标「本机验证」的就是这一类,它验的是脚本逻辑,不是我那台机器上的结果。

补库之前,这条命令当时还是硬编码路径的版本,跑出来的是:

ini 复制代码
--- gd.so ---
libpng16.so.16 => not found
libavif.so.16 => not found
libwebp.so.7 => not found
libjpeg.so.62 => not found
libXpm.so.4 => not found
libfreetype.so.6 => not found
--- intl.so ---
libicuio.so.76 => not found
libicui18n.so.76 => not found
libicuuc.so.76 => not found
--- zip.so ---
libzip.so.5 => not found
--- memcached.so ---
libmemcached.so.11 => not found

四个扩展,一共缺 11 个共享库,9 月 16 号实测。

补完再跑这条复验:

bash 复制代码
# 复验:有告警就有输出,空输出 = 全部加载成功
docker exec -i "$PHP_CTN" php -m 2>&1 | grep -i 'Unable to load'

9 月 17 号实测,空输出,零告警。

重建之后怎么重放

按这个顺序走一遍:

bash 复制代码
# ① 先看丢没丢 ------ 有输出说明扩展又挂了
docker exec -i "$PHP_CTN" php -m 2>&1 | grep -i 'Unable to load'

# ② 反查现在缺哪些库

# ③ 补库
docker exec -i "$PHP_CTN" bash -lc '
  apt-get update -qq
  apt-get install -y --no-install-recommends unzip
  # 包名逐个试:Debian 版本过渡期同名包会有多个候选
  for p in libzip5 libzip4; do
    apt-get install -y --no-install-recommends "$p" 2>/dev/null && { echo "libzip via $p OK"; break; }
  done
  apt-get install -y --no-install-recommends libjpeg62-turbo libwebp7 libfreetype6 libxpm4 libavif16
  for p in libpng16-16t64 libpng16-16; do
    apt-get install -y --no-install-recommends "$p" 2>/dev/null && { echo "libpng via $p OK"; break; }
  done
  apt-get install -y --no-install-recommends libicu76
  # memcached 的库;同样是 t64 过渡改名,包名别按规律硬推
  for p in libmemcached11t64 libmemcached11; do
    apt-get install -y --no-install-recommends "$p" 2>/dev/null && { echo "libmemcached via $p OK"; break; }
  done
'
docker restart "$PHP_CTN"

# ④ 复验
docker exec -i "$PHP_CTN" php -m 2>&1 | grep -i 'Unable to load'

有一件事得说清楚。我在容器里补库的时候没把实际执行的命令存档,事后想重放才发现包名记不全了。上面这段是我事后重建的自适配版本,用 for 循环逐个试候选包名,容忍包名不确定。它不等于当初那条。

更打脸的是,这个自适配版本第一次写出来时,自己就漏了一个扩展:四个扩展里 memcached 那条没写进去,后来拿实际回执逐项核对才发现。

漏一项的后果跟全没装一样,那个扩展照样 Unable to load。只是告警从 4 条变成 1 条,很容易被当成差不多好了。

把它验收干净靠的是遍历所有扩展逐个 ldd,人眼会漏。所以这一课比「记得存档」更值钱:存下来的清单会写错,能自动验收的方法不会。

顺便说一句,unzip 这个包名是确定的,实测装的是 unzip 6.0-29+deb13u1,没有依赖链,是清单里最稳的一条。所以 zip 扩展躺着的时候,unzip 命令往往是你唯一剩下的解包通道。

Debian 正在做 64 位 time_t 过渡(t64),一批包改了名。

libpng16.so.16 这个库,包名从 libpng16-16 变成了 libpng16-16t64。你的补库命令要是写死了旧名,在新系统上就是 E: Unable to locate package。

同一个坑我踩了两次。libmemcached.so.11 对应的包,我按 Debian 的命名规律(lib<name><so 主版本>)推成了 libmemcached11,到 Debian 13 上一看,实际叫 libmemcached11t64,又是 t64 改名。按规律推断包名一样会错。

所以上面那段用了 for p in libpng16-16t64 libpng16-16 逐试的写法。同样的坑还有 ICU 库,libicu76 里的 76 是 ICU 版本号,跟着系统走。

重放清单得有人存、有人找、有人执行。它天然适合放进项目仓库,比如 scripts/php-ext-replay.sh,前提是你真的写了、下次真的想起来。

重建之后有一段空窗期

从重建完成,到你想起去重放,中间这段时间服务是带病运行的。扩展没加载,相关功能直接报错。重建要是发生在你改完别的配置顺手做了个 compose up 的时候,这个窗口可能一直持续到你下次发现问题。

上面三条坑的共同原因只有一个:补装没进镜像。那就让它进镜像,基于 1Panel 的 PHP 镜像构建自己的镜像:

dockerfile 复制代码
# Dockerfile ------ 基于 1Panel 的 PHP 运行环境镜像,把补装固化进镜像层
FROM 1panel-php-fpm:8.4.25

# Debian 13(trixie)源已就绪,一次性补共享库 + unzip
# 注意:libpng / libmemcached 在 t64 过渡期都有两个候选包名,各用一个循环逐个试
#       ------ 两个循环不能合并:合并后前者一命中就 break,后者会被整个漏掉
RUN set -eux; \
    apt-get update -qq; \
    apt-get install -y --no-install-recommends \
        unzip \
        libzip5 \
        libjpeg62-turbo libwebp7 libfreetype6 libxpm4 libavif16 \
        libicu76; \
    for p in libpng16-16t64 libpng16-16; do \
        apt-get install -y --no-install-recommends "$p" && break; \
    done; \
    for p in libmemcached11t64 libmemcached11; do \
        apt-get install -y --no-install-recommends "$p" && break; \
    done; \
    apt-get clean; \
    rm -rf /var/lib/apt/lists/*

写这个 Dockerfile 时踩到一个坑:注释不能写在续行 RUN 的中间。Docker 解析 \ 续行时会把注释行按普通内容并进来,整条指令被从注释处截断,后半段的 for 循环和 apt-get clean 全丢了。所以上面把所有注释都挪到了 RUN 之前。

这类语法错误在构建时不一定报错,只是少装了几个包。建议用 hadolint 之类的工具过一遍。

构建、打标、在 1Panel 里把这个镜像指过去。此后再怎么重建,补装项都随镜像一起回来。

我这次为什么没走这条路?两个现实原因。

一是当时的目标是尽快让环境可用。补库当场就解决了阻塞,自建镜像要额外处理构建、打标、以及 1Panel 的运行环境关联。

二是会脱离 1Panel 的自动管理。PHP 运行环境是面板托管的,换成自建镜像后,面板的升级运行环境这类操作行为就不再可预期,升级路径得你自己维护。

怎么取舍:你只是这次把环境搭起来,重放清单够了;你要长期运维、还会反复重建,构建自定义镜像是明显更划算的一步。

相关推荐
hsfxuebao1 小时前
Harness工程
后端·架构
暗夜行者之光1 小时前
LangGraph 实战:构建“自我修正”的代码生成 Agent
后端
IT枫斗者枫哥1 小时前
AI返回合法JSON,字段就可信吗?给抽取结果补一道业务校验
java·人工智能·后端
IT枫斗者枫哥1 小时前
同一个requestId换了参数,为什么不能直接返回旧结果?
java·后端
溪语流沙1 小时前
Django + Vue电商项目第005讲:后端骨架|Django初始化、配置分层与DRF接入
vue.js·后端·python·django
益达是我啊1 小时前
前端工程师的 Java 后端 30 天快速入门(1)
后端
IT枫斗者枫哥1 小时前
ORDER BY时间还会漏单?用相同时间和插入记录测一次分页
java·后端
IT枫斗者枫哥1 小时前
UPDATE影响0行,接口却返回成功:把版本冲突接回业务
java·后端
一帅2 小时前
Muzzle:给 Java Agent 戴上的"安全口罩"
后端