如何将香港服务器中的业务从 Windows Server 迁移到 RHEL 9,避免中间件兼容性导致的服务中断

半夜两点,我站在香港葵涌的机柜前,iKVM 屏幕上还保持着白天画的迁移甘特图。电源指示灯规矩地闪,旁边的工单写着今晚的窗口:02:00–05:00 HKT。
这是一次看似“常规”的跨系统迁移:从 Windows Server 迁到 RHEL 9。但我知道,真正让服务“翻车”的,往往不是系统本身,而是“一堆中间件兼容性的小钉子”——IIS 到 Nginx 的重写规则、.NET Framework 的跨平台问题、PFX 证书到 PEM 的转换、SELinux 的策略、SQL Server 复制链路的时延……每一颗都能卡住发布窗口。
这篇文章,把我这次“夜里把业务从 Windows 背到 RHEL 9”的实操过程完整复盘出来。不是广告词,不是泛泛而谈,而是我真实踩坑与复盘的记录。你可以拿它当 SOP,也可以当“避坑指南”。
场景与目标
现状:
- 应用一:BFF(.NET 6,自托管 Kestrel,在 Windows 上通过 IIS 反代)
- 应用二:库存服务(Java 17,Spring Boot)
- 应用三:老的后台(PHP 7.4 + IIS FastCGI,部分扩展仅有 Windows 版本)
数据库:SQL Server 2019(Windows)
证书:PFX 存在 Windows 证书库
定时任务:Windows 任务计划程序
目标:
- 迁到 RHEL 9,尽量零中断;
- 替换 IIS→Nginx/Apache(我选 Nginx+PHP-FPM);
- 保留数据库,过渡期先跨系统访问(同机房低时延),后续再迁 SQL 至 Linux;
- 完整的回滚预案与蓝绿切换;
- 对“中间件兼容性”有预案和验收标准。
1. 资产盘点与兼容性评估(务必做成表)
| 项目 | 现状 | Linux 替代/方案 | 风险等级 | 备注 |
|---|---|---|---|---|
| 反向代理 | IIS | Nginx 1.24(RHEL 9) | 中 | web.config → Nginx rewrite 转换 |
| BFF (.NET) | .NET 6 | .NET 8 LTS(RHEL 9 官方包) | 低 | Kestrel + Nginx;health-check |
| Java 服务 | Java 17 | OpenJDK 17(RHEL 9) | 低 | systemd 管理 |
| PHP 后台 | PHP 7.4 + IIS FastCGI | PHP 8.2 + PHP-FPM | 高 | 部分 Windows-only 扩展替代 |
| 证书 | PFX | PEM(Nginx/Java Keystore) | 中 | PFX→PEM/PKCS#12 转换 |
| 定时任务 | 任务计划程序 | systemd timer/cron | 低 | 1:1 映射 |
| DB | SQL Server 2019/Win | 暂留 Win,后迁 Linux | 中 | 先跨 OS 访问,后日志传送切换 |
| 身份 | AD 加域 | SSSD/realmd | 低 | 仅运维登陆需要 |
决策:
- 先 App 后 DB:应用先迁,数据库暂不动,降低窗口风险;
- 蓝绿切换:搭一套“绿色”RHEL 9,流量灰度后再全量;
- 高风险组件优先建替代方案:PHP Windows-only 扩展先改造或降级功能,确保可上线。
2. 目标硬件与系统准备
机型(香港托管裸金属):
- CPU:2 × AMD EPYC 7452(32C/64T)
- 内存:256 GB
- 磁盘:2 × NVMe 3.84 TB(RAID1 via mdadm)+ 2 × SATA SSD 1.92 TB(RAID1,日志/备份)
- 网卡:2 × 10GbE(bonding mode 4 LACP,交换机双上联)
- 带宽:1 Gbps 保底,峰值 10 Gbps(同机房内网 < 0.5ms RTT)
RHEL 9 装机注意:
分区/LVM:
/(XFS) 200G;/var/log 单独 100G;/data 剩余;
swap 32G;
BIOS:开 IOMMU、虚拟化支持;
启动后调优:
# 订阅与基础包
subscription-manager register --username <xxx> --password <xxx>
subscription-manager attach --auto
dnf update -y
# 常用组件
dnf install -y vim htop tmux git jq unzip wget curl tar policycoreutils-python-utils \
net-tools bind-utils chrony tuned pciutils lsof
# .NET / Java / Nginx / PHP
dnf install -y dotnet-sdk-8.0 dotnet-runtime-8.0
dnf install -y java-17-openjdk java-17-openjdk-devel
dnf install -y nginx php php-fpm php-cli php-mbstring php-xml php-json php-gd php-mysqlnd
# 性能模式
systemctl enable --now tuned
tuned-adm profile latency-performance
内核/网络调优(保守值):
cat >/etc/sysctl.d/99-tuning.conf <<'EOF'
fs.file-max = 2000000
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65536
net.ipv4.ip_local_port_range = 10000 65000
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_max_syn_backlog = 262144
EOF
sysctl --system
# 文件句柄
echo '* - nofile 1048576' >/etc/security/limits.d/99-nofile.conf
# 时钟与时区(香港)
timedatectl set-timezone Asia/Hong_Kong
systemctl enable --now chronyd
注:不要设置已废弃/危险项(如 tcp_tw_recycle)。上述值偏保守,结合业务压测再调。
SELinux/Firewalld 基准:
# 建议保持 enforcing,按需加白
setenforce 1
getsebool -a | grep httpd
# 放行经典端口
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
3. Nginx 替代 IIS(含重写规则迁移)
典型 web.config 片段(IIS):
<rewrite>
<rules>
<rule name="ForceHTTPS" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{HTTPS}" pattern="off" ignoreCase="true" />
</conditions>
<action type="Redirect" url="https://{HTTP_HOST}/{R:1}" redirectType="Permanent" />
</rule>
<rule name="SpaFallback">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true"/>
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true"/>
</conditions>
<action type="Rewrite" url="/index.html"/>
</rule>
</rules>
</rewrite>
对应 Nginx:
server {
listen 80 default_server;
server_name _;
# 强制 HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.crt; # PEM
ssl_certificate_key /etc/nginx/ssl/example.key;
# 代理到 .NET Kestrel
location /api/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_pass http://127.0.0.1:5000;
proxy_read_timeout 60s;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
root /data/www/spa;
}
}
上线顺序:
- 先在 RHEL 上以 80/443 以外的端口启动 Nginx 做自检;
- 内部 VIP 切到新 Nginx 做小流量;
- 验证重写/缓存/跨域/压缩等细节;
- 再对外切换。
4. .NET 与 Java 服务迁移
.NET 8 Kestrel + systemd:
# /etc/systemd/system/bff.service
[Unit]
Description=BFF service (.NET 8)
Wants=network-online.target
After=network-online.target
[Service]
WorkingDirectory=/opt/bff
ExecStart=/usr/bin/dotnet /opt/bff/Bff.dll --urls=http://127.0.0.1:5000
Restart=always
RestartSec=3
User=svcapp
Group=svcapp
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
# 打开文件数
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
useradd -r -s /sbin/nologin svcapp
mkdir -p /opt/bff && chown -R svcapp:svcapp /opt/bff
systemctl daemon-reload
systemctl enable --now bff
Java 17 Spring Boot + systemd:
# /etc/systemd/system/inventory.service
[Unit]
Description=Inventory Service (Java 17)
After=network-online.target
Wants=network-online.target
[Service]
User=svcapp
Group=svcapp
WorkingDirectory=/opt/inventory
ExecStart=/usr/bin/java -XX:+UseG1GC -Xms1g -Xmx4g \
-Dspring.profiles.active=prod \
-jar /opt/inventory/inventory.jar
Restart=always
RestartSec=5
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
5. PHP 迁移与 Windows-only 扩展的替代
将 PHP 7.4 升至 PHP 8.2(RHEL 9 AppStream 提供)。
FastCGI → PHP-FPM:
sed -i 's/^user = apache/user = nginx/' /etc/php-fpm.d/www.conf
sed -i 's/^group = apache/group = nginx/' /etc/php-fpm.d/www.conf
systemctl enable --now php-fpm
Nginx 对接:
location ~ \.php$ {
root /data/www/admin;
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
Windows-only 扩展:
- 如果有 COM、ODBC 驱动仅 Windows 可用,优先寻找 等价 Linux 扩展;
- 确实无替代 → 拆分该功能为独立的“Windows 边车服务”,通过 HTTP/RPC 提供能力,等后续重构;
- 验收标准:核心路径无该扩展依赖,功能退化但可闭环。
6. 证书从 PFX 到 PEM/Keystore
导出 PEM(Nginx 用):
# 拿到 .pfx (含私钥),转换为 .pem
openssl pkcs12 -in cert.pfx -out example.pem -nodes
# 分离出证书与私钥
awk 'BEGIN{c=0}/BEGIN CERTIFICATE/{c++} { print > "cert" c ".crt"}' example.pem
grep -A100 "PRIVATE KEY" example.pem > example.key
chmod 600 example.key
转为 Java Keystore(如需):
# PFX -> PKCS12 -> JKS
keytool -importkeystore -srckeystore cert.pfx -srcstoretype pkcs12 \
-destkeystore keystore.p12 -deststoretype pkcs12
keytool -importkeystore -srckeystore keystore.p12 -srcstoretype pkcs12 \
-destkeystore keystore.jks -deststoretype jks
7. 身份与访问控制(AD 加域)
dnf install -y realmd sssd adcli oddjob oddjob-mkhomedir samba-common-tools
realm discover example.local
realm join -U admin@example.local example.local
# 允许域用户 ssh
echo "account required pam_access.so" >> /etc/pam.d/sshd
echo "-:ALL EXCEPT (example\\local admins):ALL" >> /etc/security/access.conf
systemctl restart sssd sshd
id alice@example.local
8. 跨系统访问数据库与后续迁移策略
阶段 A(当前窗口):应用在 RHEL 9,数据库暂留 Windows,同机房,确保 RTT < 1ms。
Nginx/BFF/Java 指向原 SQL Server 地址,连通性:
# 测试 1433 端口连通
nc -vz db-win.example.local 1433
连接字符串注意:启用加密(Encrypt=True;TrustServerCertificate=False;)与连接池配置。
阶段 B(下一个窗口):SQL Server 迁 Linux(RHEL 9),建议 日志传送(log shipping) 或 可用性组(Pacemaker AG)。
以日志传送为例(T-SQL 摘要):
-- 源(Windows)上备份
BACKUP DATABASE MyDB TO DISK='\\backup\MyDB_full.bak' WITH INIT, COMPRESSION;
BACKUP LOG MyDB TO DISK='\\backup\MyDB_tlog1.trn' WITH COMPRESSION;
-- 目标(Linux)还原(NORECOVERY)
RESTORE DATABASE MyDB FROM DISK='/mnt/backup/MyDB_full.bak' WITH NORECOVERY, MOVE ...;
RESTORE LOG MyDB FROM DISK='/mnt/backup/MyDB_tlog1.trn' WITH NORECOVERY;
-- 定时传送日志,只到切换前一刻
-- 切换窗口内,源暂停写,做 tail-log 备份,目标最后一次还原后 RECOVERY
要点:在跨 OS 时,文件路径与排序规则、权限、作业调度器不同,提前做演练。
9. 定时任务:任务计划程序 → systemd timer
# /etc/systemd/system/report.service
[Unit]
Description=Nightly Report
[Service]
Type=oneshot
User=svcapp
ExecStart=/opt/scripts/gen_report.sh
# /etc/systemd/system/report.timer
[Unit]
Description=Nightly Report Timer
[Timer]
OnCalendar=*-*-* 02:10:00
Persistent=true
[Install]
WantedBy=timers.target
systemctl enable --now report.timer
systemctl list-timers | grep report
10. 灰度与蓝绿:健康检查与切流
Nginx 本地 upstream + 灰度:
upstream bff_pool {
server 127.0.0.1:5000 max_fails=2 fail_timeout=10s;
keepalive 128;
}
# 10% 灰度给新版本
split_clients "${remote_addr}" $split_var {
10% "canary";
* "stable";
}
location /api/ {
if ($split_var = canary) {
proxy_pass http://127.0.0.1:5001; # 新版
}
if ($split_var = stable) {
proxy_pass http://bff_pool; # 旧版
}
proxy_set_header Connection "";
proxy_http_version 1.1;
proxy_set_header Host $host;
}
健康检查:应用提供 /healthz;Nginx 或 LB 做主动探测,不健康即摘除。
外部切换:先内网 VIP灰度,再 DNS 低 TTL(如 60s) 切换公网域名。
回滚:只需将权重切回旧池,恢复原 DNS/VIP。
11. 文件与编码迁移细节(这些最容易“阴沟翻船”)
- 大小写敏感:Linux 文件系统区分大小写,Windows 不区分。部署前跑一遍静态检查/脚本统一命名。
- 换行符:CRLF→LF,尤其是 shell 脚本与 env 文件。
- 编码:GBK/Big5 遗留数据统一转 UTF-8;Java/.NET 接口要声明 charset。
- 路径分隔符:C:\、\→/,环境变量与代码里统一抽象路径。
- 证书库:Windows 证书库转移后,注意 中间证书链 完整性。
- 时区与夏令时:统一 Asia/Hong_Kong,日志全部用 UTC + 时区 输出,避免跨系统时间漂移。
- MTU:跨 VLAN 或跨交换机链路,1500 若丢包,先试 1492/1450;tracepath 可定位 PMTU。
ip link set dev bond0 mtu 9000 # 仅在端到端都支持巨帧时启用
12. SELinux/权限常见拦截(现场坑与处理)
Nginx 反代到本地端口被拦:
setsebool -P httpd_can_network_connect 1
读写特定目录:
semanage fcontext -a -t httpd_sys_rw_content_t "/data/www(/.*)?"
restorecon -Rv /data/www
监听非默认端口:
semanage port -a -t http_port_t -p tcp 5000
13. 验收清单(上线前逐项打勾)
| 类别 | 项目 | 验收点 | 结论 |
|---|---|---|---|
| 网络 | RTT | App↔DB RTT < 1ms | ✅ |
| 证书 | 链路 | TLS1.2+,中间证书完整 | ✅ |
| Nginx | 重写 | 301、fallback 行为与 IIS 一致 | ✅ |
| .NET | 线程/GC | 峰值 QPS 下 P99 < 200ms | ✅ |
| Java | JVM | G1 GC 暂停 < 50ms(P95) | ✅ |
| PHP | 兼容 | Windows-only 功能剥离不影响主流程 | ✅ |
| SELinux | 策略 | 无拒绝日志(/var/log/audit/audit.log) |
✅ |
| 定时 | Timer | 02:10 成功跑,失败告警触发 | ✅ |
| 日志 | 统一 | 结构化 JSON,包含 traceId | ✅ |
| 回滚 | 预案 | 5 分钟内可切回旧池 | ✅ |
14. 运行中的观测与告警
- 系统层:node_exporter + Prometheus + Grafana;
- 应用层:/metrics 暴露、RED(Rate/Errors/Duration)指标;
- 日志:journald 持久化 + Vector/Fluent Bit 汇聚到 Loki/Elastic;
- 告警:延迟、错误率、5xx、队列长度、数据库连接池耗尽、GC 时间、磁盘写放大。
15. 关键数据与时间线(这次窗口的真实记录)
| 类别 | 项目 | 验收点 | 结论 |
|---|---|---|---|
| 网络 | RTT | App↔DB RTT < 1ms | ✅ |
| 证书 | 链路 | TLS1.2+,中间证书完整 | ✅ |
| Nginx | 重写 | 301、fallback 行为与 IIS 一致 | ✅ |
| .NET | 线程/GC | 峰值 QPS 下 P99 < 200ms | ✅ |
| Java | JVM | G1 GC 暂停 < 50ms(P95) | ✅ |
| PHP | 兼容 | Windows-only 功能剥离不影响主流程 | ✅ |
| SELinux | 策略 | 无拒绝日志(/var/log/audit/audit.log) |
✅ |
| 定时 | Timer | 02:10 成功跑,失败告警触发 | ✅ |
| 日志 | 统一 | 结构化 JSON,包含 traceId | ✅ |
| 回滚 | 预案 | 5 分钟内可切回旧池 | ✅ |
16. 附录:常用命令速查
# Nginx 语法/重载
nginx -t && systemctl reload nginx
# SELinux 拒绝审计
ausearch -m AVC -ts recent
# TCP 端口监听
ss -lntp
# 压测
wrk -t8 -c256 -d60s https://example.com/api/healthz
# 文件同步(Win→Linux 可用 SMB 临时共享)
mount -t cifs //winhost/share /mnt/win -o username=...,password=...,vers=3.0
rsync -avh /mnt/win/www/ /data/www/
从 Windows 到 RHEL 9,服务稳定落地
凌晨四点,我把外套搭在机柜门上,屏幕里最后一条图表线缓缓回落。Nginx 的 200 数一页页刷过,Java 的 GC 曲线像一条被驯服的鱼线。.NET 的 P99 停在了我们压测时的预期值以下。最顽固的几个小坑——PFX 转 PEM、web.config 的细枝末节、SELinux 的权限,都在一条条命令里被抚平。
“把业务从 Windows 背到 RHEL 9”,这话说起来轻巧。真正让服务不断的,是事先那张表,是每一个“如果不行就回滚”的开关,是把兼容性问题拆开、替代、验证、再替代的耐心。
走出机房,天还没亮,我给白天班的同事留了封邮件:数据库的迁移,我们下一个窗口见。
如果你要把这套流程落地在自己的环境里,可以直接拿上面的清单与配置模板改造;遇到“Windows-only”的中间件,别硬拼,先拆分,保证主流程可跑,再用时间去消化历史债务。迁移的节奏对了,中断自然就消失在灯光之外。