如何将香港服务器上的遗留应用从 Windows Server 2008 迁移到 Ubuntu 22.04,避免依赖性冲突?

昨天夜里,值班工程师把我领到 12 楼冷得发抖的机房,指着一台贴着“2008 产线”的 2U 旧机器:“这台必须今晚退役,业务不能停。”
我摸了摸前面板上已经被擦得发白的服务标签,心里暗骂早该动刀——遗留应用还跑在 Windows Server 2008 + IIS 7.0 上,.NET Framework 3.5,连补丁都停在多年前。目标是把它搬到隔壁机柜里新装好的 Ubuntu 22.04 LTS 上,第二天早上 7 点前切流。避坑和“依赖冲突”就是今晚的主旋律。
场景与目标
现状(源端)
机房:葵涌 iDC,双路市电 + UPS,A/B 路供电
旧服务器:Dell R710(2U),2× Xeon X5670,96GB RAM,RAID10(SAS 10K × 8),双口 1GbE(接 ToR:Arista 7050)
系统:Windows Server 2008 Standard,IIS 7.0
应用构成:
LegacyWeb: ASP.NET WebForms(.NET 3.5)
FileDropSvc: Windows Service,定时把 CSV 转 Excel 并上传到共享
NightlyJob: Windows 计划任务,跑一段 ETL(ODBC 连 MS SQL 2008 R2,DB 在异地)
依赖:COM 组件(Excel Interop)、ODBC 驱动、IIS URL Rewrite、旧版中文字体(宋体/仿宋)
目标(目的端)
服务器:Supermicro 1U,2× Xeon Silver 4310,256GB RAM,系统盘 NVMe(RAID1),数据盘 U.2(RAID10),双口 10GbE(Bonding LACP)
系统:Ubuntu 22.04.4 LTS(GA 内核)
运行时:.NET 6(Web)、Python 3.10(ETL/报表)、Nginx(反代)、Docker(隔离/避免冲突)
约束:不得中断业务、快速回滚、可平滑演进(允许部分功能先以兼容形态运行)
1. 迁移策略(避免依赖冲突的总思路)
- 分层隔离:把“运行时”和“系统”解耦,用容器/虚拟环境把各自的依赖钉死。
- 最小可用:不在一夜之间“重写”功能;能替代就替代(Mono/.NET 兼容、Python 替代 COM)。
- 蓝绿切换:在同一 L7 入口(HAProxy/Cloudflare/Nginx)上灰度路由,保持回滚路径。
- 可观测:上线前就埋好 metrics/log/trace,遇到冲突/崩溃迅速定位。
- 数据侧不动:数据库仍指向原 SQL Server(先通后优)。
2. 盘点与 SBOM(源端清单)
在旧机上生成可迁移清单(SBOM),别把“隐性依赖”带雷过来:
:: IIS 站点与应用池
%windir%\system32\inetsrv\appcmd list apppool /text:*
%windir%\system32\inetsrv\appcmd list site /text:*
:: 已安装组件
wmic product get name,version > C:\sbom\installed_programs.txt
:: .NET/全局程序集缓存
dir C:\Windows\assembly /s > C:\sbom\gac_list.txt
:: 计划任务
schtasks /query /v /fo LIST > C:\sbom\tasks.txt
:: ODBC DSN
reg export "HKLM\SOFTWARE\ODBC" C:\sbom\odbc.reg /y
:: COM 注册
reg query "HKLM\SOFTWARE\Classes" /s /f "Excel.Application" > C:\sbom\com_excel.txt
结果要点(真实案例归纳):
| 模块 | 依赖 | 风险点 | Linux 替代 |
|---|---|---|---|
| WebForms .NET 3.5 | GDI+、System.Web、URL Rewrite | 与 .NET 6 API 差异大 | 升级到 ASP.NET Core(Upgrade Assistant)或临时跑 Mono |
| FileDropSvc | Excel Interop(COM) | Linux 无法使用 Office COM | Python openpyxl/pandas 生成 XLSX |
| NightlyJob | ODBC Driver 11 + SQL 2008 | 字符集/时区/加密 | msodbcsql18 + FreeTDS/ODBC,校准 tds version |
| 字体 | 宋体/仿宋 | PDF/图片排版差异 | 安装 fonts-noto-cjk + 拷贝企业字体 |
| URL 重写 | IIS Rewrite | 语法差异 | 转 Nginx rewrite/map |
3. 目的端硬件与网络落盘(Ubuntu 22.04)
3.1 RAID/分区/挂载
# 以 mdadm 举例(供应商 RAID 卡可跳过)
apt-get update && apt-get install -y mdadm lvm2
# 数据盘 /dev/nvme1n1...nvme4n1 组 RAID10
mdadm --create /dev/md0 --level=10 --raid-devices=4 /dev/nvme[1-4]n1
pvcreate /dev/md0 && vgcreate vgdata /dev/md0
lvcreate -L 2T -n appdata vgdata
mkfs.xfs /dev/vgdata/appdata
mkdir -p /data && echo "/dev/vgdata/appdata /data xfs defaults,noatime 0 0" >> /etc/fstab
mount -a
3.2 网络(Bond + VLAN)
# /etc/netplan/01-lacp.yaml
network:
version: 2
ethernets:
eno1: {}
eno2: {}
bonds:
bond0:
interfaces: [eno1, eno2]
parameters:
mode: 802.3ad
mii-monitor-interval: 100
mtu: 9000
vlans:
bond0.120:
id: 120
link: bond0
addresses: [10.20.120.23/24]
gateway4: 10.20.120.1
nameservers:
addresses: [8.8.8.8, 1.1.1.1]
3.3 基础安全与内核/FD 限制
timedatectl set-timezone Asia/Hong_Kong
ufw allow 22,80,443/tcp
cat >/etc/security/limits.d/nofile.conf <<'EOF'
* soft nofile 1048576
* hard nofile 1048576
EOF
cat >/etc/sysctl.d/99-tune.conf <<'EOF'
net.core.somaxconn=1024
fs.inotify.max_user_watches=1048576
vm.swappiness=10
EOF
sysctl --system
4. “避免依赖冲突”的落地:分容器钉版本
核心做法:所有运行时进容器,把版本钉在 Dockerfile,宿主机只维持 Docker + Nginx + 监控代理。
4.1 Nginx 反向代理(宿主机)
apt-get install -y nginx
cat >/etc/nginx/sites-available/prod.conf <<'EOF'
upstream app_web { server 127.0.0.1:5000; }
server {
listen 80;
server_name legacy.example.com;
client_max_body_size 20m;
location / {
proxy_pass http://app_web;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /healthz { return 200 "ok\n"; }
}
EOF
ln -s /etc/nginx/sites-available/prod.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
4.2 Web:.NET 迁移两段式
A 档(理想):用官方 upgrade-assistant 升级到 ASP.NET Core 6(最小改动保活)。
dotnet tool install -g upgrade-assistant
upgrade-assistant analyze ./LegacyWeb.sln
# 根据报告逐项替换,先从包升级、移除 System.Web 绑定开始
B 档(兜底):短期用 Mono 跑 .NET Framework 4.x(只建议做过渡)。
# Dockerfile.web (A 档)
FROM mcr.microsoft.com/dotnet/sdk:6.0-jammy AS build
WORKDIR /src
COPY . .
RUN dotnet restore && dotnet publish -c Release -o /out
FROM mcr.microsoft.com/dotnet/aspnet:6.0-jammy
WORKDIR /app
COPY --from=build /out .
ENV ASPNETCORE_URLS=http://0.0.0.0:5000
EXPOSE 5000
ENTRYPOINT ["dotnet","LegacyWeb.dll"]
这样做的意义:把 .NET 版本钉死在镜像标签上(6.0-jammy),避免系统升级带来的 ABI 冲突或包回退困难。
4.3 ETL/报表:用 Python 替代 COM + ODBC
弃用 Excel Interop(COM):Linux 上没有 Office COM,把 CSV→XLSX 用 pandas/openpyxl。
连接 MS SQL:msodbcsql18 + pyodbc(或 pymssql/FreeTDS)。
# Dockerfile.job
FROM ubuntu:22.04
# 钉住 OS 层依赖版本,避免后续 apt 自动升级导致 ABI 变更
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
curl apt-transport-https gnupg ca-certificates locales && \
locale-gen zh_CN.UTF-8
# SQL Server ODBC 驱动
RUN curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - && \
echo "deb [arch=amd64] https://packages.microsoft.com/ubuntu/22.04/prod jammy main" > /etc/apt/sources.list.d/msprod.list && \
apt-get update && apt-get install -y msodbcsql18 unixodbc-dev python3.10 python3-pip
# Python 依赖(全部钉版本)
COPY requirements.txt /tmp/requirements.txt
# 示例 requirements.txt:
# pandas==2.2.2
# openpyxl==3.1.3
# pyodbc==4.0.39
# SQLAlchemy==2.0.32
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
WORKDIR /app
COPY jobs/ /app
ENV LANG=zh_CN.UTF-8 TZ=Asia/Hong_Kong
ENTRYPOINT ["python3","run_jobs.py"]
示例:CSV → XLSX 并上传(SFTP/SMB 皆可,这里以 SFTP 为例)
# jobs/export_report.py
import pandas as pd
import sqlalchemy as sa
engine = sa.create_engine(
"mssql+pyodbc://username:password@10.1.2.3/LegacyDB"
"?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no"
)
df = pd.read_sql("EXEC dbo.usp_daily_report @date = CONVERT(date, GETDATE())", engine)
df.to_excel("/tmp/daily.xlsx", index=False)
# 上传
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect("10.1.9.9", username="uploader", password="***")
sftp = client.open_sftp()
sftp.put("/tmp/daily.xlsx", "/upload/daily.xlsx")
sftp.close(); client.close()
4.4 docker-compose 统一编排
# docker-compose.yml
version: "3.9"
services:
web:
build:
context: ./web
dockerfile: Dockerfile.web
image: registry.local/legacyweb:6.0.1
restart: always
ports: ["127.0.0.1:5000:5000"]
environment:
- ASPNETCORE_ENVIRONMENT=Production
depends_on: [job]
job:
build:
context: .
dockerfile: Dockerfile.job
image: registry.local/legacyjob:1.4.0
restart: always
volumes:
- /data/reports:/reports
environment:
- SQLSERVER=10.1.2.3
- TZ=Asia/Hong_Kong
关键点:镜像名带版本号,组合 image:tag 成为可回退单位;容器之间不直接共享系统库,冲突在容器边界被“截断”。
5. 系统服务化与启动顺序(systemd)
# /etc/systemd/system/stack.service
[Unit]
Description=Legacy Stack via Docker Compose
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/legacy
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now stack.service
6. 与 SQL Server 的“坑位”与规避
驱动/协议:SQL 2008/2012 常需要 tds version = 7.4 或 7.3;ODBC18 默认 Encrypt=yes,老库没 TLS 时需设置 Encrypt=no 或启用 TrustServerCertificate=yes(与安全策略评估后决定)。
字符集/排序规则:确认 nvarchar/varchar 映射;日志中看到 “编码错误” 时,优先检查 LANG/TZ 与连接字符串。
时区:生产库多用 UTC,报表用 HKT,一律以 ISO 8601 存储、前端展示换时区。
连接池:Python SQLAlchemy 配 pool_pre_ping=True,避免夜间短断导致“连接已关闭”。
7. URL 重写规则迁移(IIS → Nginx)
IIS 示例:
<rule name="ForceWWW">
<match url="(.*)" />
<conditions>
<add input="{HTTP_HOST}" pattern="^example\.com$" />
</conditions>
<action type="Redirect" url="https://www.example.com/{R:1}" redirectType="Permanent" />
</rule>
Nginx 等价:
server {
listen 80;
server_name example.com;
return 301 https://www.example.com$request_uri;
}
复杂的 rewrite 建议用 map 拆分,并写用例回归(见第 10 节)。
8. 字体/报表与中文排版
apt-get install -y fonts-noto-cjk fonts-arphic-ukai fonts-arphic-uming
# 如需企业授权字体(SimSun/SimHei),放到 /usr/local/share/fonts/chs/ 并:
fc-cache -fv
常见现象:换行/字重不一致导致导出的 PDF 偏移;用 wkhtmltopdf/weasyprint 渲染前先在容器内装同款字体并固定 CSS 行高。
9. 观测与日志
- Nginx:access/error 合理分卷到 /var/log/nginx/*.log
- 应用:.NET Serilog 输出 JSON 到 STDOUT,再由宿主 journald 收集
- Metrics:dotnet-counters/prometheus-net 暴露 /metrics,Nginx 透传
- 健康检查:/healthz 返回 OK,同时检查 SQL 连通
10. 验收脚本与回归用例
接口冒烟(基础可用性测试):执行最小化脚本,验证服务能启动、接口能响应。例如:
set -e
host=https://legacy.example.com
curl -fS $host/healthz
curl -fS $host/api/v1/ping | grep pong
URL 重写回归(节选):
| 输入 | 期望输出 |
|---|---|
http://example.com/a |
https://www.example.com/a |
/legacy.aspx?id=1 |
/legacy/1 |
/download?file=报告.csv |
200 + 正确 Content-Type |
数据一致性:
- 对比新旧报表行数、汇总金额(±0 容忍)
- 连续三日凌晨 2 点~3 点 ETL 成功率 100%
11. 蓝绿与切换
HAProxy(示例):按 Header 灰度 10%
frontend fe_http
bind *:80
acl canary hdr(X-Canary) -i 1
use_backend be_new if canary
default_backend be_old
backend be_old
server s1 10.20.120.10:80 check
backend be_new
server s2 10.20.120.23:80 check
回滚:移除 be_new 绑定或把 DNS CNAME 指回旧 VIP(保持旧机一周只读观察)。
12. 我遇到的几个典型坑 & 现场解法
- ODBC 崩溃/乱码:msodbcsql18 + 老库 TLS,连不上。解决:Encrypt=no;TrustServerCertificate=yes(临时),随后给 SQL 加证书并启用 TLS1.2。
- 报表中文变方块:容器没字库,装 fonts-noto-cjk,并在 CSS 指定 font-family。
- 上传 413:Nginx 默认 1MB,设置 client_max_body_size 20m。
- inotify 溢出:文件监控多,fs.inotify.max_user_watches=1048576。
- CRLF 引发脚本异常:从 Win 拷贝的脚本,dos2unix 一遍好。
- 文件名大小写:Linux 区分大小写,旧代码硬编码了 Report.xls/report.xls 混用,统一改名 + 单测兜底。
- 时区偏 8 小时:容器没设置 TZ,Excel 日期错位;镜像里添加 ENV TZ=Asia/Hong_Kong 并装 tzdata。
- Mono 暂用方案的边界:遇到 GDI+ 兼容差异导致图表异常,最终还是把该模块用 ASP.NET Core 重写,Mono 仅作为过桥方案,不留恋。
13. 最终交付与参数表(摘录)
| 项 | 值 |
|---|---|
| 宿主 OS | Ubuntu 22.04.4 LTS(GA 内核) |
| 反代 | Nginx 1.18(APT) |
| Web 运行时 | .NET 6.0.XX(容器镜像 mcr…aspnet:6.0-jammy) |
| 作业框架 | Python 3.10 + pandas 2.2.2 + pyodbc 4.0.39 |
| SQL 驱动 | msodbcsql18 + unixODBC 2.3.9 |
| 资源限制 | nofile=1,048,576,Bond LACP,MTU 9000 |
| 部署 | docker-compose v2,image:tag 固定 |
| 监控 | /healthz + 应用指标 + Nginx 日志 |
14. 清单:一键检查(可放在 checklist.md)
- /healthz 正常,SQL 可连
- URL 重写全部过用例
- 报表中文&数字格式正确
- Nginx/应用日志滚动策略就绪
- 容器镜像 tag 落库记录(可回滚)
- 回滚预案验证(把流量切回旧机 1 分钟)
- 运维手册与故障指引更新
15. 尾声
早上 6:40,我站在机房走道尽头,看着新服务器前面板的蓝灯有节奏地闪。风声还在,但心里安定多了:入口切到了新集群,报表也按时生成,工单系统里“遗留应用迁移完成”的状态被点成了绿色。
走出机房时,保安大叔端着热水给我比了个大拇指。我知道这事儿还没完——接下来我们会把 Mono 过渡模块彻底下线,把数据侧做成只读缓存;但至少此刻,我们把 2008 的锚拔掉了,也把“依赖冲突”的炸弹拆在了容器边界。
这不是一篇完美的迁移剧本,却是一次真实的夜战:能跑、好管、可回滚。剩下的,等下一个无风的夜里,再慢慢打磨。
附:可复用的“避冲突”实践要点(摘取成卡片)
- 容器化一切运行时:在 Dockerfile 中钉死基础镜像与库版本。
- 替代 Office COM:用 pandas/openpyxl,彻底绕开 Windows-only 依赖。
- IIS → Nginx:用回归用例驱动迁移,复杂规则拆 map。
- ODBC 老库兼容:明确 Encrypt/TrustServerCertificate/tds version。
- 字体与地区:安装 CJK 字体、设 LANG/TZ,保证 PDF/Excel 一致。
- 蓝绿/回滚:DNS/HAProxy 双通道,镜像 tag 记录在案。
- 观测优先:健康检查、结构化日志、基础指标先行。
如果你也正准备把 2008 上的历史债务搬到 22.04,记得先把“依赖冲突”挡在容器门外,再谈优雅重写。祝你夜战顺利。