把 CodeIgniter 4 应用从 IIS 迁移到 OpenLiteSpeed

· 3 min read · engineering codeigniterphpopenspeedwaycyberpaneliismigrationlitespeedlinux

我维护着一个数字命理报告生成器:一个 CodeIgniter 4 应用,接收出生日期和姓名,跑一遍命理引擎,渲染出 19 页 A4 报告。它在 Windows Server + IIS 上跑了很多年。2026 年 9 月,我把它迁到 CyberPanel 上的 OpenLiteSpeed,跑在 Linux 上,换了个域名。

迁移本身没什么好说的——拷文件、把 docroot 指向 public/、装依赖。真正值得写下来的是:新主机上第一个未认证请求,就在两条路由上直接致命崩溃——而这两条路由在 IIS 上”一直好好的”。

两个 bug 都是真的。两个从一开始就存在于 IIS 上。两个都看不见,因为 IIS 在出问题的路由前面恰好挡了一层 authentik SSO。

为什么这件事重要

如果你的自托管应用前面挂了一层认证网关,那这层网关做的事不止是保护数据——它同时在帮你藏 bug。任何”只对未认证用户出错”的东西永远不会以未认证身份运行,所以永远不会报错。而它会在最糟的时刻暴露:你迁移的时候、你拆掉网关的时候、你把路由暴露到公网的时候。

我这个应用,就”健康”了整整几年。我把新域名指过去的那一刻,两条路由返 HTTP 500。代码一行没改。唯一变的是网关没了。

两个修复加起来大概二十分钟。找到它们花了两个小时——因为真正的错误信息被 CodeIgniter 的 CLI 错误渲染器挡在了后面。

环境

这是一个相当普通的 CI4 项目,但有一个不太常见的架构细节:报告引擎不是库,而是同一台主机上的 HTTP 端点。控制器拼一个请求,curl 回自己域名的 /api/single,引擎返回算好的 JSON 数值,控制器再把它渲染成可打印的报告。

正是这个”自调用”设计,让 IIS 上必须有 CONST_IIS_INTERNAL_BASE。这点我后面会回来说——它才是真正有意思的部分。

迁移前迁移后
操作系统Windows ServerUbuntu (CyberPanel)
Web 服务器IISOpenLiteSpeed 1.9.0
PHP8.4 (Windows 版)8.4.25 (lsphp84)
DocrootC:\inetpub\calc.hoelee.com/home/<域名>/public_html/public
认证网关authentik SSO 挡 /lifecode、/api/*无

第一步:docroot 指向 public/,不是项目根目录

CI4 是两段式目录结构。app/、vendor/、writable/ 和 .env 都在项目根目录,只有 public/ 本该对 Web 可见。

CyberPanel 建的站点根目录叫 public_html。我的第一反应是把整个项目解压进 public_html,docroot 不动。

那样会把 app/、vendor/、.env 文件和 writable/ 的 session 数据全部暴露在 HTTP 之下。 这个项目的 .env 里有一个 webhook 凭证。任何人猜到 /../.env——或者干脆直接请求 /.env,因为这文件就躺在被服务的目录下面——就拿到了。

正确做法是解压进 public_html,然后把 vhost 的 docroot 往下一层,改成 public_html/public:

# 解压成 app/、vendor/、.env 位于 public_html 之下
cd /home/<域名>/public_html
tar -xzf /tmp/deploy.tar.gz

# docroot 必须是 public_html/public,不是 public_html
sudo sed -i 's#/home/<域名>/public_html\$#/home/<域名>/public_html/public#' \
  /usr/local/lsws/conf/vhosts/<域名>/vhost.conf

sudo /usr/local/lsws/bin/lshttpd -t
sudo systemctl restart lsws

这是整个迁移里最重要的一步,也是大多数教程跳过的一步。其他什么都不做,这一步也得做。

第二步:IIS 藏起来的两个致命错误

致命错误一 —— env() 调用得太早

第一个 500 完全没有响应体。这不太寻常——CI4 正常会渲染点东西出来。一个空白的 500 通常意味着 PHP 在框架的错误处理器装上之前就死了。

CI4 的启动流程会在 Boot::bootWeb() 里很早加载 app/Config/Constants.php(通过 Boot::loadConstants())。而这件事发生在 Common.php 助手文件加载之前——env() 就定义在那个文件里。

所以这一行:

// app/Config/Constants.php —— 错误写法
define('CONST_fullBase', env('hoelee.fullBase'));

会失败于:

Fatal error: Uncaught Error: Call to undefined function env()
in app/Config/Constants.php:96

规则是绝对的:Constants.php 里只能放普通常量。 不能有 env()、不能有 getenv()、不能有配置助手。如果你真需要依赖环境的值,就定义到启动流程更靠后的位置——或者把这个常量降级成 fallback,真正的值后面再读。

我做的正是后者。CONST_fullBase 变回普通字符串,消费它的助手函数改为先查框架自己的 app.baseURL(那个才是 .env 驱动的):

// app/Helpers/hoelee_helper.php
function getFullBase(bool $selfCall = false): string
{
    // .env 驱动的 baseURL 优先;CONST_fullBase 只是 fallback
    if ($selfCall && defined('CONST_IIS_INTERNAL_BASE') && CONST_IIS_INTERNAL_BASE) {
        return rtrim(CONST_IIS_INTERNAL_BASE, '/');
    }
    $appBase = config('App')->baseURL;
    if ($appBase) return rtrim($appBase, '/');
    if (defined('CONST_fullBase') && CONST_fullBase) return rtrim(CONST_fullBase, '/');
    return '';
}

这个是我自己搞出来的。 同一周做凭证清理重构时引入的——我把一个硬编码 URL 挪进 .env,然后在 Constants.php 里用 env() 读它。在我机器上是通的,因为本地 .env 走的是另一条路径被读到。干净启动下它从来没通过。我是在部署测试后一小时内抓到的;如果只是代码审查,很可能就这么放过去了。

致命错误二 —— CI4 控制器里的 parent::__construct()

第二个失败在 /lifecode。这个是应用里原本就存在的 bug,不是我引入的。

绕过 CLI 渲染器之后,真正的报错是:

Error: Cannot call constructor

CI4 的基础类 CodeIgniter\Controller 根本没有构造函数。它实现的是 initController(),由框架把 request、response、logger 三个对象传进去。标准写法是:

// CI4 的正确写法
public function initController(
    RequestInterface $request,
    ResponseInterface $response,
    LoggerInterface $logger
) {
    parent::initController($request, $response, $logger);
    // 你的初始化代码
}

但 Lifecode.php 和 ApiEn.php 用的是传统构造函数,还在调 parent::__construct():

// 错误 —— CodeIgniter\Controller 没有 __construct()
public function __construct()
{
    parent::__construct();
    // ...
}

对一个没有定义 __construct() 的父类调 parent::__construct(),在 PHP 8 里是致命错误。把两个控制器改成 initController() 就修好了——同时要为新签名补上三个 interface 的 use 语句。

我没有盲信报错信息,而是先确认父类确实没有构造函数:

grep -n 'function __construct\|function initController' \
  vendor/codeigniter4/framework/system/Controller.php

只出来 initController()。这一步值得做——“Cannot call constructor” 在父类有构造函数但内部报错时也会出现,重写签名之前你得先搞清楚自己是哪种情况。

为什么这两个 bug 在 IIS 上都不出现

这是让我改变部署观念的部分。

在 IIS 主机上,/lifecode 和 /api/* 挡在 authentik SSO 后面。对这两条路由的未认证请求返回 HTTP 302 跳转到 auth.hoelee.com——压根不会碰到控制器。那个坏掉的构造函数从来没被执行过。这条路由大概从写出来那天就是坏的,而它从来没被要求真正处理过一次请求。

我直接对比了两台主机来确认:

curl -sI https://calc.hoelee.com/lifecode | head -1
# HTTP/2 302   <- authentik 跳转;控制器根本没运行

curl -sI http://<新主机>/lifecode | head -1
# HTTP/1.1 500  <- 没有网关;控制器运行并致命崩溃

那个 302 就是这个 bug 活下来的原因。应用看起来健康,只是因为它不健康的部分根本触不到。

教训说白了就是:一条路由前面挂了认证网关,就意味着这条路由自己的代码没有任何有效的测试覆盖。 如果你要迁移或者拆网关,请专门留出时间,用未认证身份把每一条曾被网关挡着的路由都打一遍,再宣布迁移完成。小应用上这只是十行 curl 循环。在我这里,它本来几秒钟就能找出这两个 bug,而不是两个小时:

for p in / /read/single /read/partner /lifecode /api/date /api/single; do
  printf '%-16s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://<主机>$p)"
done

第三步:那个不再是 loopback 的自调用

现在是这个架构里真正有意思的后果。

报告引擎是靠控制器 curl 自己的主机来调用的。在 IIS 上,CONST_IIS_INTERNAL_BASE 把这个调用指向 http://localhost:7296——loopback 接口,请求不出机器,也不碰 DNS 和 TLS。

在 LiteSpeed 上这个常量被故意不定义,于是 getFullBase() 落到 app.baseURL。这意味着每一次生成报告,现在都是一个真实的对外 HTTPS 请求,打到应用自己的公网 URL,再从 Cloudflare 绕回来。

那问题就来了:这到底能不能跑?

答案是能,但值得专门测一次,因为它的失败模式很容易误导人。这是确切的测法——在服务器上跑,不是在你的笔记本上:

curl -s -o /dev/null -w 'HTTPS self-call: %{http_code}\n' \
  -X POST 'https://<域名>/api/single' -d 'nameCn=test&dob=1990-01-01'

两个要注意的地方:

这里藏着一个真实的取舍,我还没完全解决:走公网 URL 的自调用意味着生成报告依赖 Cloudflare 在线、每次报告多一次 TLS 握手、并且在整个过程中占用两个 PHP worker。在 LiteSpeed 上 worker 池不大的情况下,并发报告一多就可能死锁——每个请求都在等另一个请求,而后者拿不到空闲 worker。以目前的流量没问题。但在规模上去之前,这个自调用应该换成绕过 CDN 的内部路径。

第四步:验证真实产出,不是状态码

/ 返 200 只证明落地页能渲染。它不证明报告引擎能用,而引擎才是整个产品。所以最后一步是端到端生成一份真报告:

curl -s -X POST 'https://<域名>/read/single' \
  -d 'nameCn=test&dob=1990-01-01&gender=m' \
  -o report.html -w 'HTTP:%{http_code} bytes:%{size_download}\n'

结果:HTTP 200、99,189 字节、19 页 A4。 我又用中文字符输入跑了一遍,把引擎的 UTF-8 路径也走通——HTTP 200、99,205 字节、19 页、姓名渲染正常、输出里零 PHP 警告。

真正有意义的检查,是新旧主机之间页数可比。它证明字体、DPI 和分页逻辑都完整地熬过了这次搬迁。

关于那个 UTF-8 测试的插曲:我第一次跑的时候返回了 500,差点就一头扎进去查 bug。原因是我自己的 shell——中文字符在进 curl 的路上被终端编码搞坏了,应用收到的是非法字节。把同一个请求写成服务器上的脚本再跑,编码在我控制之内,就返回了 200。在你调试编码故障之前,先确认服务器真正收到的字节就是你打算发出去的字节。

如果重来一次,我会怎么改

迁移前就测未认证路由,而不是迁移后。 那两个小时的排查,用一个遍历路由列表的 curl 循环就能完全避免。我现在把”列出所有路由、以未认证身份逐个请求、记录状态码”当成任何迁移的第零步。

不要在 Constants.php 里调 env()。 我在文件里留了注释专门写这件事,因为下一个人——多半是六个月后的我自己——一定会想这么干。

迁移前先查框架版本对应的 project-space 配置。 同一周我把 CI4 从 4.6.3 升到 4.7.4,它在两个升级指南里根本没提的属性上致命崩溃了两次:Config\App::$permittedURIChars(4.7 的 Router 要求)和 Config\Format::$jsonEncodeDepth(JSONFormatter 要求,而第二个正好打断了引擎的 JSON 自调用)。Composer 会更新 vendor/,但永远不会合并 app/Config/*.php,因为那些属于 project-space。我最后往 14 个配置文件里手工合并了新属性。那是另一篇文章,但迁移的教训是同一个形状:框架会告诉你 vendor/ 里改了什么;没有任何东西会告诉你 app/ 里改了什么。

先确认 docroot,再动别的。 如果我解压进 public_html 就收手,应用是能跑的——同时静静地把 .env 通过 HTTP 服务出去。能跑的迁移,不等于安全的迁移。

结果

一个晚上。六条路由全部验证 200,中英文输入的报告产出在字节层面可比,零 PHP 警告,另外还从代码库里清掉了两个长期潜伏的 bug——而这两个 bug 一直被 IIS 的认证网关遮着。

应用现在跑在 OpenLiteSpeed 上,docroot 正确分离,主机可自动化管理,凭证放在 .env 而不是写死在常量里。


想让你的应用也做这套迁移? 我可以在 IIS、Apache、nginx 和 LiteSpeed 之间迁移 PHP 应用——包括那些麻烦的部分:自调用架构、藏 bug 的 SSO 网关,以及会碰到 project-space 配置的框架升级。

WhatsApp +60 12-797 2969 · [email protected] · hoelee.com

Lee Teong Hoe

Full-stack developer & DevOps engineer. I build web apps, self-host infrastructure, and automate things — this blog is my living portfolio.