1Panel 部署 ThinkPHP8 踩坑实录

文章来源声明: 原文作者:刘胡子大叔; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689440529826562048; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

从面板失真到 ldd 定位、lock 分段的完整排查链,适合用容器化 PHP 部署 ThinkPHP 的运维与后端开发者收藏,重建后重放脚本是可落地的经验。

1Panel 的 PHP 扩展面板上那一排绿勾,不能信。

我把一台全新云服务器交给自己从零重建,OpenResty、MySQL、PHP 全走 1Panel 的容器化环境。装完那天我打开 PHP 扩展配置页,该勾的都勾上,保存、重启,界面干干净净,一个红点都没有。然后我进容器敲了一条命令,回报给我四行 Unable to load dynamic library。

这不是 1Panel 的 bug,也不是我勾错了。它是容器化 PHP 环境的一个结构性事实:面板告诉你一切正常,只有命令行会告诉你真相。

先把环境交代清楚。脱离环境谈踩坑等于耍流氓,同一个坑换别的镜像、别的 PHP 版本未必成立。我用的是一台 4 核 8G、200G 系统盘的云服务器,1Panel v2。Web 服务器是容器里的 OpenResty,镜像 1panel/openresty:1.31.1.1-2-4-noble。数据库 MySQL 8.4.11 容器,PHP 8.4.25 容器,后者基础镜像 Debian 13(trixie)。应用是 ThinkPHP 8.0,app/ 目录下 104 个类,依赖由 Composer 管,composer.lock 已锁版本。

调用链是一条直线:OpenResty 打到 127.0.0.1:9000,进 PHP-FPM 容器,再连 MySQL 容器。三个组件各在独立容器里,靠 1Panel 创建的同名 Docker 网络互通。

为了让命令能直接复制,我先在终端里定义几个变量,后续命令全部复用:

<span># 换成你在 1Panel 里给 PHP 运行环境起的名字(= 容器名)</span>
<span>export</span> PHP_CTN=<span>"php84"</span>
<span># 换成你在 1Panel 里给 MySQL 起的名字</span>
<span>export</span> MYSQL_CTN=<span>"mysql84"</span>

<span># 确认两个容器都在跑</span>
docker ps --format <span>"table {{.Names}}\t{{.Image}}\t{{.Status}}"</span> | grep -E <span>"<span>$PHP_CTN</span>|<span>$MYSQL_CTN</span>"</span>

下文所有回显都是我在自己服务器上跑出来的真实输出,实测于 2026-09-16 和 09-17。为了让命令通用,我把回显里的容器名统一替换成了上面的变量名,除此之外一字未改。

1Panel 的 PHP 扩展页勾选后,最终会落到容器的 php -m 上。所以验证动作只有一条,别信面板,去问容器:

<span># 一次筛出本项目关心的扩展 + opcache/zip,看有没有加载告警</span>
docker <span>exec</span> -i <span>"<span>$PHP_CTN</span>"</span> php -m | grep -Ei <span>'pdo_mysql|mbstring|openssl|curl|fileinfo|bcmath|opcache|gd|zip'</span>

回显是这样的(实测 2026-09-16):

PHP Warning:  PHP Startup: Unable to load <span>dynamic</span> library <span>'gd'</span> (tried: .../gd.so (libpng16.so<span>.16</span>: cannot open shared <span>object</span> file: No such file <span>or</span> directory)) <span>in</span> Unknown <span>on</span> line <span>0</span>
PHP Warning:  PHP Startup: Unable to load <span>dynamic</span> library <span>'intl'</span> (tried: .../intl.so (libicuio.so<span>.76</span>: cannot open shared <span>object</span> file: No such file <span>or</span> directory)) <span>in</span> Unknown <span>on</span> line <span>0</span>
PHP Warning:  PHP Startup: Unable to load <span>dynamic</span> library <span>'zip'</span> (tried: .../zip.so (libzip.so<span>.5</span>: cannot open shared <span>object</span> file: No such file <span>or</span> directory)) <span>in</span> Unknown <span>on</span> line <span>0</span>
PHP Warning:  PHP Startup: Unable to load <span>dynamic</span> library <span>'memcached.so'</span> (tried: .../memcached.so (libmemcached.so<span>.11</span>: cannot open shared <span>object</span> file: No such file <span>or</span> directory)) <span>in</span> Unknown <span>on</span> line <span>0</span>
bcmath
curl
fileinfo
mbstring
openssl
pdo_mysql

判读很直接。下面那 6 行是 stdout,真加载成功了;上面 4 行 Warning 是 stderr,加载失败。面板上这 4 个扩展统统显示「已启用」,实际一个都没生效——gd、intl、zip、memcached。

再看 Warning 里括号的内容:

<span>'zip'</span> (tried: .../<span>zip</span>.so (libzip.so<span>.5</span>: cannot <span>open</span> shared <span>object</span> file: No such file <span>or</span> directory))

翻译过来是三层:zip.so 是存在的(否则不会提示 tried),但 zip.so 自己要用的 libzip.so.5 不存在。

这就是 1Panel 扩展面板失真的原因:镜像里把扩展的 .so 编好了,却漏装了这些 .so 所依赖的运行库。面板只检查扩展列表里有没有勾这一项,管不到勾了之后 dlopen 能不能成功。面板状态和实际加载结果,是完全脱钩的两件事。

整条加载过程长这样:

flowchart TD
    A[&#34;1Panel 面板勾选扩展<br/>界面显示:已启用&#34;] --> B[&#34;生成 conf.d/docker-php-ext-xxx.ini&#34;]
    B --> C[&#34;容器启动,php-fpm 读取 ini&#34;]
    C --> D[&#34;dlopen 加载 xxx.so&#34;]
    D --> E{&#34;该 .so 依赖的共享库<br/>在基础镜像里存在吗?&#34;}
    E -->|存在| F[&#34;扩展加载成功<br/>php -m 中可见该模块&#34;]
    E -->|缺失| G[&#34;扩展加载失败<br/>Unable to load dynamic library&#34;]

    style A fill:#eaf2fd,stroke:#4a7fd4,color:#1f2328
    style E fill:#fff8e1,stroke:#d4a72c,color:#1f2328
    style F fill:#eaf7ee,stroke:#2da44e,color:#1a7f37
    style G fill:#fdecec,stroke:#d1242f,color:#d1242f

面板只校验第 1 步的勾选状态,第 4 步的 dlopen 结果它根本管不到。

不要猜缺哪个库。ldd 会把一个二进制依赖的动态库逐个列出来,缺的标 not found:

<span># 逐个扩展跑 ldd,只挑出「找不到」的库</span>
docker <span>exec</span> -i <span>"<span>$PHP_CTN</span>"</span> bash -c <span>'
  for e in gd intl zip memcached; do
    echo "--- $e ---"
    ldd /usr/local/lib/php/extensions/no-debug-non-zts-*/$e.so 2>/dev/null | grep "not found"
  done'</span>

--- gd ---
<span>libpng16.so.16</span> => not found
<span>libavif.so.16</span> => not found
<span>libwebp.so.7</span> => not found
<span>libjpeg.so.62</span> => not found
<span>libXpm.so.4</span> => not found
<span>libfreetype.so.6</span> => not found
--- intl ---
<span>libicuio.so.76</span> => not found
<span>libicui18n.so.76</span> => not found
<span>libicuuc.so.76</span> => not found
--- zip ---
<span>libzip.so.5</span> => not found
--- memcached ---
<span>libmemcached.so.11</span> => not found

四个扩展一共缺 11 个共享库。gd 缺 libpng16、libjpeg、libwebp、libfreetype、libXpm、libavif;intl 缺 libicuuc、libicui18n、libicuio;zip 缺 libzip.so.5;memcached 缺 libmemcached.so.11。

顺带一个副产品:libicuuc.so.76 的版本号把基础镜像暴露了,ICU 76 对应 Debian 13(trixie)。对新手来说这是个实用技巧,.so 的版本后缀能反推系统版本,比去翻镜像文档快。

另外路径里的 no-debug-non-zts-20240924 是 PHP 的 API 版本号,不同 PHP 版本不一样。所以我上面用了通配符 no-debug-non-zts-*。写死在脚本里,换版本就失效。

知道缺什么之后,最容易犯的错是立刻去补库。别急,先分清哪些扩展真的需要,否则你会为了消掉告警,给一台生产服务器塞进一堆用不到的运行库。

判断依据有两个,都不靠经验。

第一个是 composer.lock 的 require 段。Composer 安装时会校验平台依赖(ext-*),但它只认 require 段,不看你的代码实际调没调。所以 composer.lock 是一份比「我印象里需要什么」可靠得多的扩展清单。

<span># 在项目根目录,列出 lock 里所有 ext-* 平台依赖</span>
grep -n <span>'"ext-'</span> composer.lock

但这里有个很多人会漏的分辨点:composer.lock 里有两个段,packages(生产)和 packages-dev(开发)。生产部署走的是 composer install --no-dev,packages-dev 整个不会被装,也就不会被校验。

我写了个小脚本把两个段拆开看(已实跑):

<span><?php</span>
<span>// 解析 composer.lock,把 ext-* 平台依赖按「生产/开发」两个段拆开</span>
<span>$lock</span> = <span>json_decode</span>(<span>file_get_contents</span>(<span>'composer.lock'</span>), <span>true</span>);

<span>foreach</span> ([<span>'packages'</span> => <span>'生产段'</span>, <span>'packages-dev'</span> => <span>'开发段'</span>] <span>as</span> <span>$sec</span> => <span>$label</span>) {
    <span>foreach</span> (<span>$lock</span>[<span>$sec</span>] ?? [] <span>as</span> <span>$pkg</span>) {
        <span>foreach</span> ([<span>'require'</span>, <span>'require-dev'</span>] <span>as</span> <span>$kind</span>) {
            <span>foreach</span> (<span>$pkg</span>[<span>$kind</span>] ?? [] <span>as</span> <span>$dep</span> => <span>$ver</span>) {
                <span>// 只关心平台依赖(ext-*),PHP 扩展都在这里</span>
                <span>if</span> (<span>str_starts_with</span>(<span>$dep</span>, <span>'ext-'</span>)) {
                    <span>printf</span>(<span>"[%s] %-28s %-11s -> %s\n"</span>, <span>$label</span>, <span>$pkg</span>[<span>'name'</span>], <span>$kind</span>, <span>$dep</span>);
                }
            }
        }
    }
}

在我本机 PHP 8.4.25 CLI 上,用真实 composer.lock 跑出来:

[生产段] league/flysystem-local       require     <span>-></span> ext-fileinfo
[生产段] league/mime-<span>type</span>-detection   require     <span>-></span> ext-fileinfo
[生产段] topthink/framework           require     <span>-></span> ext-ctype
[生产段] topthink/framework           require     <span>-></span> ext-json
[生产段] topthink/framework           require     <span>-></span> ext-mbstring
[生产段] topthink/think-orm           require     <span>-></span> ext-json
[生产段] topthink/think-orm           require     <span>-></span> ext-pdo
[开发段] symfony/polyfill-mbstring    require     <span>-></span> ext-iconv
[开发段] league/flysystem             require-dev <span>-></span> ext-zip

结果很干净,两条结论。生产环境真正被校验的只有 5 条:fileinfo、ctype、json、mbstring、pdo。ext-iconv 来自 symfony/polyfill-mbstring,而它在 packages-dev 段,--no-dev 时根本不会被校验;ext-zip 更彻底,它在 league/flysystem 的 require-dev 里,对下游使用者无效。

这里也顺手纠正一个常见说法:ext-zip 不是项目依赖,但它确实是 Composer 自己的工具层依赖,解压 dist 包要用。所以它该不该补,跟业务代码用不用 zip 是两件事。

第二个依据是代码级实证。lock 只说包要求什么,还得看我的代码用什么。直接搜函数名:

<span># 高精度计算(bcmath 提供的是 bcadd/bcsub/bcmul/bcdiv/bccomp 等函数)</span>
grep -rn <span>"bcsub\|bcadd\|bcmul\|bcdiv\|bccomp"</span> app

<span># 图像处理(gd)与国际化(intl)的典型调用</span>
grep -rn <span>"imagecreatefrom\|getimagesize"</span> app
grep -rn <span>"Collator\|NumberFormatter"</span> app

实测下来,bcmath 在 app/v1/logic/index/GetTang.php:28 的 bcsub() 里被引用了 1 处,必留,砍了接口直接 500。gd、intl、memcached 都是 0 处引用,可不装——缓存与 session 都走文件驱动。

最终清单是 5 条 lock 生产硬依赖,加 bcmath 的代码实证,加运行时必需的 curl 和 openssl:

bcmath  c<span>type</span>  <span>curl</span>  fileinfo  json  mbstring  openssl  pdo_mysql

iconv 属于开发段要求、且是 PHP 默认编译项(镜像自带),核验一下不吃亏,但不必为它专门做什么。

清单定完,动作就剩下取舍。我这次的路线是全救,11 个库全补、4 个扩展全留,理由是第六节那个坑:动扩展勾选可能触发容器重建。

补库命令用的是自适配写法,Debian 13 上有几个包名处于过渡期,所以对同义词做逐个尝试:

docker <span>exec</span> -i <span>"<span>$PHP_CTN</span>"</span> bash -lc <span>'
  set -e
  apt-get update -qq

  # ① unzip 命令:包名确定、无依赖链
  apt-get install -y --no-install-recommends unzip

  # ② libzip(给 zip 扩展补依赖)——Debian 13 包名二选一
  for p in libzip5 libzip4; do
    apt-get install -y --no-install-recommends "$p" 2>/dev/null && { echo "libzip via $p OK"; break; }
  done

  # ③ gd 的图像库
  apt-get install -y --no-install-recommends libjpeg62-turbo libwebp7 libfreetype6 libxpm4 libavif16
  # libpng 有 t64 过渡改名(libpng16-16 -> libpng16-16t64),同样逐个试
  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

  # ④ intl 的 ICU 库
  apt-get install -y --no-install-recommends libicu76
'</span>
docker restart <span>"<span>$PHP_CTN</span>"</span>

顺序很重要。如果你打算走「只留需要的扩展」这条路,先在 1Panel 改扩展列表,之后再到容器里 apt-get。反过来做,改扩展时一旦触发容器重建,你刚补的库会被一起冲掉,白干一次。

复验只有一条命令:

<span># 有告警就会有输出;空输出 = 全部加载成功</span>
docker <span>exec</span> -i <span>"<span>$PHP_CTN</span>"</span> php -m 2>&1 | grep -i <span>'Unable to load'</span>

回显(实测 2026-09-17):

(无输出)

Day 1 这里躺着 4 条 Warning,现在空了。再打一次全量确认:

docker <span>exec</span> -i <span>"<span>$PHP_CTN</span>"</span> php -m

<span>[PHP Modules]</span>
bcmath      Core        ctype       curl        date        dom
fileinfo    <span>filter</span>      ftp         gd          gettext     hash
iconv       intl        json        libxml      mbstring    memcached
mysqli      mysqlnd     openssl     pcntl       pcre        PDO
pdo_mysql   pdo_sqlite  Phar        posix       random      readline
redis       Reflection  session     shmop       SimpleXML   soap
sockets     SPL         sqlite3     standard    sysvsem     tokenizer
xml         xmlreader   xmlrpc      xmlwriter   Zend OPcache zip
zlib

<span>[Zend Modules]</span>
Zend OPcache

gd、intl、memcached、zip 全部出现在列表里,48 个 PHP 模块加 1 个 Zend 模块,零告警。

顺带把解包能力补成双通道。原因在第 2 节那个 Warning 里:zip 扩展依赖 6 个容器内 apt-get 装的库,链条长、易断;而 unzip 是独立二进制,装上就基本不会挂。两条路互为备份,Composer 解压 dist 包时哪条通走哪条:

<span># 补装 + 复验</span>
docker <span>exec</span> -i <span>"<span>$PHP_CTN</span>"</span> bash -lc <span>'apt-get update -qq && apt-get install -y --no-install-recommends unzip'</span>
docker <span>exec</span> -i <span>"<span>$PHP_CTN</span>"</span> bash -c <span>'command -v unzip && unzip -v | head -1'</span>

/usr/bin/unzip
UnZip <span>6.00</span> of <span>20</span> April <span>2009</span>, <span>by</span> Debian. Original <span>by</span> Info-ZIP.

安装过程里会刷出一堆 debconf: unable to initialize frontend ... falling back to Noninteractive。这是无害提示——docker exec 没有 TTY,debconf 依次尝试 Dialog、Readline、Teletype 前端都失败,最终降级到 Noninteractive,包正常装完。另外 26 not upgraded 也是常规提示,容器内不要顺手做全量 apt-get upgrade,那会破坏镜像一致性。

后面这条最该记住。

我把 11 个库和 unzip 都装进了容器,它们全部活在容器的可写层里,不在镜像里。docker restart 时都还在,不用处理;但容器重建(改运行环境、compose up、升级镜像)会重置文件系统,补的东西全丢,必须重放。

我一开始判断错了,以为 unzip 不依赖任何库、重建后仍在。错的。unzip 同样是 apt-get 装的,容器重建时文件系统重置,补的库和 unzip 一起消失。真正要区分的是操作类型,不是这个包有没有依赖。

所以「装双通道」的真实价值是互为备份、以及 restart 场景免维护,不是抗重建。抗重建的唯一正解是把补库命令存档,重建后重放一次。建议你现在就在仓库里放一个 scripts/php-ext-replay.sh,把上面那段补库命令原封不动存进去,下次容器一重建,一行命令恢复。

四个坑排一下。面板勾了不等于扩展生效,一眼判据是 php -m 里有 Unable to load dynamic library。.so 在、共享库不在,判据是 ldd xxx.so | grep "not found" 有输出。扩展清单别凭经验列,去查 composer.lock,而且要分清 packages 与 packages-dev。容器内补装不抗重建,重建后重放脚本即可复原。

第 3 条我想再说一次:ext-iconv 是本次最容易误判的一个,它出现在 lock 里、看着像硬依赖,实际在 packages-dev 段,composer install --no-dev 时压根不校验。不看段号就下结论,是这类排查里最典型的翻车点。

下一个坑埋在同一个 PHP 运行环境里——看起来像故障、其实完全正常的回显。get_loaded_extensions(true) 只返回一个 Zend OPcache,是不是扩展都没了?php -i | grep 'Opcode Caching' 显示 Disabled,opcache 到底开没开?docker exec 里 cat /opt/1panel/.../php.ini 报 No such file,文件丢了?三个答案都是没坏,下一篇逐个拆。