一个运行了 15-y 年的 Linux(https://ostechnix.com/tag/linux/)应用,如果仍在支撑核心业务逻辑,那它不是一个需要解决的问题,而是一项没人愿意触碰的资产,因为没人完全理解它现在的一切行为。
应用本身很少是风险所在;风险在它周围的环境。老化的操作系统、不受支持的软件包、手动配置步骤和未记录的依赖,都可能让每次部署变成一场小赌博。
有些组织会引入软件合作伙伴来现代化遗留系统而不替换它来直接管理这种风险。对于希望内部做容器(https://ostechnix.com/tag/container/)化的团队,容器化可以把应用及其运行依赖打包进隔离、可复现的环境,提供一个受控的起点。
这里以 Docker(https://ostechnix.com/tag/docker/)为例,你也可以使用 Podman(https://ostechnix.com/tag/podman/)。这些都是开源(https://ostechnix.com/tag/open-source/)生态中的常见工具。以下步骤按顺序走:评估应用是否适合容器化,映射真实依赖,构建并测试容器,然后逐步投入生产。
开始之前
以下步骤适用于仍在 Linux 服务器上运行的遗留应用。执行前,需要一个能读取目标进程的控制台(通常需要 root 或 sudo),以及 Docker 或 Podman 这类容器运行时。strace(strace)在附加到进程时会增加明显开销,因此只应在短时间窗口内对生产进程使用;若需要长时间观察,改用 eBPF 工具(下文会提到)。
确认应用是否适合容器化
在编写任何内容之前,先对照两个清单(https://ostechnix.com/containerize-legacy-linux-application/)检查应用。
通常适合的候选者具有:
- 清晰的应用边界
- 可复现的依赖(即使列表很长,也能列出来)
- 标准网络接口(TCP、HTTP 或类似协议)
- 可管理的存储需求
- 没有特殊硬件依赖
- 运行时不需要特殊主机权限
难以容器化的候选者通常涉及:内核特定行为、专用硬件驱动、极端的延迟敏感性、绑定物理机器的许可证,或与主机操作系统紧密耦合的逻辑。
如果应用大多属于第二类,容器化花费的精力会大于回报。在继续前先确认你的系统属于哪一类。
盘点所有依赖
从清单开始:操作系统版本、运行时、共享库、已安装软件包、环境变量、配置文件、文件系统路径、数据库、外部 API、网络端口、证书、密钥、cron 作业、后台进程和存储位置。
然后用观察实际行为的工具确认清单,而不是依赖记忆或旧文档。
找出进程运行时接触的每个文件:
strace -f -e trace=open,openat -p <PID> 2>&1 | grep -v ENOENT
这会打印应用打开的每个文件,包括没人记录过的。在正常运行期间运行,而不是只在启动时运行,因为 cron 作业和后台任务通常接触与主进程不同的路径。
找出每个开放的网络连接和文件句柄:
lsof -p <PID>
这会一次显示打开的套接字、打开的文件及其路径。与清单交叉对照;列表上任何未记录的内容都是隐藏依赖。
找出二进制文件链接了哪些库:
ldd /path/to/binary
这会列出应用运行时需要的共享库。标记为 “not found” 的库,需要在容器镜像里安装。
strace 会带来可见开销,并可能在附加时减慢进程,所以只适合短时间窗口。如果要更长时间观察生产系统,基于 eBPF 的工具 bpftrace(bpftrace)能捕获同样的系统调用和文件访问数据,开销低得多,因为它在内核中运行,而不是逐个拦截调用。
这一步暴露的意外大多属于四类:
- 文件系统假设。 对
/etc/、/opt/、/var/lib/、/tmp/的读写,假设这些路径始终存在。 - 权限假设。 以 root 运行,或期望特定 UID/GID,而容器的默认用户没有。
- 网络假设。 调用
localhost访问曾经在同一台物理机上运行的服务,或引用固定主机名/静态 IP。 - 操作系统假设。 依赖特定的内核行为、时区、locale,或默认最小镜像中没有安装的系统工具。
一旦 strace、lsof(lsof)和 ldd(ldd)的输出不再出现新内容,清单就足够完整,可以开始构建。
选择基础镜像
FROM ubuntu:latest 并不自动是正确的起点。基础镜像是容器构建时的起始文件系统和工具集。
把基础镜像与步骤 2 的发现匹配,而不是选择最新的:
# Check the glibc version the old server uses
ldd --version
# Check the OpenSSL version
openssl version
# Compare against what's available in a candidate base image
docker run --rm ubuntu:24.04 bash -c "ldd --version && openssl version"
如果版本接近,该基础镜像是合理起点;如果相差几个大版本,预期 ldd 发现的库行为会不同,并计划在容器运行后专门测试。
Ubuntu 26.04 LTS 是当前版本,但它不自动是遗留迁移的正确选择。对于 15-y 年历史的应用,ubuntu:24.04(Ubuntu 24.04 LTS)通常是更安全的起点;它仍是受支持的 LTS 版本。容器稳定后,如果确实需要更新的工具链,再规划迁移到 26.04 LTS。
编写第一个 Dockerfile,并预期它会失败
从运行应用所需的最小内容开始,直接基于步骤 2 的清单:
FROM ubuntu:24.04
# Install the runtime and OS packages identified during dependency analysis
RUN apt-get update && apt-get install -y \
libssl3 \
libpq5 \
<other-packages-from-your-inventory> \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY ./legacy-app /app/
EXPOSE 8080
CMD ["/app/legacy-app"]
构建并运行:
docker build -t legacy-app:test .
docker run --rm -p 8080:8080 legacy-app:test
此时预期出现具体、可诊断的失败,而不是干净启动。容器状态通过并不代表应用正常工作,因为健康检查只确认进程已启动。
常见的首次运行(first-rrun)失败,通常都能从错误消息直接定位到上文列出的四类假设之一。
一次修复一个失败,重建并重跑。这个循环,而不是最初的 Dockerfile,才是大部分实际工作发生的地方。
将状态移出容器
容器的文件系统默认是临时的;容器被移除时,其中写入的任何内容都会消失。确定应用实际需要持久化的内容(上传文件、生成报告、日志、数据库文件,如果它没用外部数据库),然后只挂载这些路径:
docker run -d \
-v /host/data/uploads:/app/uploads \
-v /host/data/reports:/app/reports \
-p 8080:8080 \
legacy-app:test
不要把整个旧服务器文件系统挂进来当捷径。只挂载步骤 2 中确定为需要持久化的路径。每个额外挂载都是容器对主机路径的新依赖,每一个都应是刻意选择。
分离后台进程
一个 15-y 年历史的系统,往往实际上是一台服务器同时跑应用、worker、cron 作业、清理脚本和日志轮转,因为没人曾把它们分开。
列出步骤 2 发现的每个后台进程,并把它归入以下职责之一:
# Example docker-compose.yml separating responsibilities
services:
app:
image: legacy-app:latest
ports:
- "8080:8080"
worker:
image: legacy-app:latest
command: ["/app/run-worker.sh"]
cron:
image: legacy-app:latest
command: ["/app/run-cron.sh"]
常见容器模式是给每个独立管理的职责一个自己的容器。这让生命周期、扩展、故障和监控更可控。把独立管理的职责捆进一个容器,可能会重新引入你本想消除的操作耦合。
在生产前加固容器
容器不会默认修复安全问题;它隔离进程,但进程仍然带着原有风险。
以非 root(non-root)用户运行:
RUN groupadd -r appuser && useradd -r -g appuser appuser
USER appuser
如果使用 Podman,优先选择 rootless 模式;这样构建和运行容器都不需要主机上的 root 特权。
设置明确的资源限制:
docker run -d \
--memory="512m" \
--cpus="1.0" \
legacy-app:production
没有资源限制时,容器可能消耗超出预期的主机 CPU 或内存,影响其他工作负载。
部署前扫描镜像,查找已知漏洞:
docker scout cves legacy-app:production
这需要 Docker 账户;如果尚未认证 CLI,先运行 docker login。也可以使用你偏好的镜像扫描工具。
把密钥从镜像和环境变量移出,放入适合平台的密钥管理器。只发布应用实际需要的端口,并通过容器运行时、反向代理、防火墙或编排平台限制网络访问。在启用 SELinux 或 AppArmor 的主机上,为容器进程配置正确的标签或配置文件,不要依靠关闭强制访问控制来绕过问题。
对照遗留系统验证
遗留应用是正确行为的基线;容器必须匹配它,而不只是成功启动。对两个系统跑相同工作流并比较。
运行基本负载比较,尽早发现性能回归:
# Example using Apache Bench against both systems
ab -n 1000 -c 10 http://legacy-server:8080/
ab -n 1000 -c 10 http://localhost:8080/
在所有适用工作流都验证通过、且与遗留基线的任何差异都被理解之前,不要进入发布。
也可以使用适合应用真实工作负载的负载测试工具(load-t testing),比较遗留版和容器版的响应时间、吞吐量、CPU、内存和错误率。
接入 CI/CD
容器化之后,把构建和测试接入 CI/CD:每次变更都构建镜像,运行镜像扫描 docker scout cves legacy-app:production,在测试环境启动容器并跑冒烟测试。镜像一旦发布,生产环境就从镜像仓库取用同一个工件,而不是每次手工配置环境。使用 rootless Podman 的 CI 环境,可以在不给构建节点 root 权限的情况下完成这些步骤。
逐步发布
在同一时刻把所有生产流量从旧系统切到新系统,是风险最高的部署方式。在反向代理后并行运行两个系统,逐步转移流量:
upstream backend {
server legacy-server:8080 weight=9;
server container-app:8080 weight=1;
}
随着信心增强,提高容器权重,并在每一步监控错误率和响应时间。
如果应用会随时间拆成更小的部分,而不是作为一个整体搬进容器,则适用绞杀者图模式:代理把每个请求路由到遗留应用,或已替换该功能的新组件。这让你一次迁移一个能力;它适合逐步分解功能的场景,不适合把完整应用一步移入容器。
任何切换之前,先写清楚成功和失败分别是什么、要监控什么、需要时如何把流量切回,以及谁有权做出回退决定。安全迁移不是失败不可能发生,而是失败有一条受控的、预先定义(pre-defined)的路径回到工作状态。
容器化能修复什么,不能修复什么
容器化通常改善依赖隔离、环境可复现性、部署一致性、可移植性、回滚速度和 CI/CD 集成。
它不能修复糟糕的架构、既有技术债、过时的业务逻辑、测试覆盖不足、单体设计、底层数据库问题,或从未记录的业务规则。
明白这个区别,可以避免一个常见错误:把容器化当成它本就不打算承担的架构改造工作。
何时先容器化,何时不
当应用正常工作而环境是实际问题时,当依赖可以可靠复现时,当工作负载不需要特殊主机访问时,当完全重写会引入比移除更多风险时,先容器化。
当架构本身是瓶颈时,当应用依赖已不受支持的操作系统行为时,当硬件访问对功能至关重要时,当直接替换更便宜时,或者当它当初要满足的业务需求已根本改变时,考虑其他路径。
没有普遍正确的答案;正确选择取决于你的系统实际符合哪些条件。
容器化是步骤,不是终点
一个可用的容器是基础,不是终点。它通常成为 CI/CD 自动化、更好测试、改进可观测性和进一步现代化工作的基础,如果且当这些工作变得必要。
遗留 Linux 应用不必先重写再现代化。容器化可以是一个受控的第一步,走向更可复现、更可移植、更易维护的环境,而不触碰生产已经依赖的业务逻辑。



