1. 引子:更新之后系统还能继续运行,到底哪里不对?
最近排查线上环境时遇到一个很有意思的问题:运维同事在执行系统更新后,业务并没有中断,服务看起来一切正常,但几小时后开始陆续出现诡异故障。
- 某个 Python 服务调用加密库报出符号找不到;
- Nginx 转发请求偶尔出现 502;
- 用
ldd查看某个二进制,显示的依赖库和磁盘上的实际文件对不上; - 更奇怪的是,明明
apt log里显示某个软件包已经升级成功,但systemctl status里的进程启动时间还是几天前。
仔细看下来,问题其实可以浓缩为一句话:Linux 系统在执行更新时,系统仍然处于可操作状态,更新后的文件没有真正被运行中的进程加载,新旧版本混在一起跑,最终导致状态不一致。
这不是某个发行版的特定问题,而是 Linux 下“文件替换”与“进程内存映射”之间的经典矛盾。本文打算从一次真实的问题排查出发,拆解这个现象背后的底层原理,再给出完整的复现、排查和修复方案。不管你是运维、后端开发,还是刚接触 Linux 的初学者,这篇文章都能帮你少踩不少坑。
2. Linux 更新期间进程持续运行的底层原理
2.1 文件被替换后,进程为什么还能继续运行
要理解这个问题,先要弄清楚一个关键事实:Linux 中,进程一旦启动,就不再依赖磁盘上的可执行文件来运行了。
当我们在 shell 里执行一个二进制程序时,内核会把这个 ELF 文件的代码段、数据段等内容加载到内存中,进程实际执行的是内存中的映射副本。即使磁盘上的文件被删除、覆盖或者改名,正在运行的进程也不会立刻崩溃,它持有的文件描述符和内存映射仍然指向旧的 inode。
这个行为可以通过一个简单的实验来观察。
假设目录/tmp/bug-demo下有一个 C 程序:
// 文件路径:/tmp/bug-demo/old-version.c #include <stdio.h> #include <unistd.h> int main() { printf("old version running, pid=%d\n", getpid()); sleep(30); printf("old version exit\n"); return 0; }先编译并启动:
cd /tmp/bug-demo gcc -o app old-version.c ./app &然后用新版源码重新编译,并直接覆盖原文件:
gcc -o app new-version.c此时app进程并不会退出,也不会开始执行新版本代码。它会继续运行内存中的旧代码,直到 30 秒睡眠结束。
在 Linux 下,这种“已经被删除但还被进程打开”的文件,会出现在/proc/<pid>/fd/中,并且ls -l /proc/<pid>/exe通常会显示(deleted)标记。
2.2 动态链接库不是热替换
更隐蔽的是动态链接库的情况。程序启动时,动态链接器会根据DT_NEEDED加载.so文件。一旦动态库被映射到进程地址空间,修改磁盘上的库文件不会影响已经在运行的进程。
这就是很多“更新后服务还是旧版本”事故的根源。
比如线上运行着一个 Java 服务,底层调用了libcrypto.so。运维执行apt upgrade升级了 OpenSSL 相关的库文件,磁盘上的动态库已经是新版本,但 Java 进程仍然持有旧版本库的内存映射。如果新旧版本之间 ABI 不兼容,或者某些符号被移除,进程后续调用这些函数时就会出现各种古怪错误,包括:
symbol lookup error: undefined symbol;- 段错误;
- 随机崩溃;
- 加密证书校验失败。
这里需要特别说明的是,动态库不是“热替换”的设计,更新库文件之后必须重启依赖它的进程,才能让新版本真正生效。
2.3 dpkg / rpm 的替换策略带来状态不一致
大多数 Linux 包管理器在升级文件时,会先写一个新的临时文件,然后通过 rename 替换旧文件。这个操作的原子性本身没有太大问题,但它只保证了“磁盘上的文件状态”是完整的,并没有处理“正在使用这些文件的进程”。
以 Debian/Ubuntu 的 dpkg 为例,升级一个软件包时会执行:
- 解包新的
.deb文件; - 执行维护脚本(
preinst、postinst等); - 覆盖旧文件;
- 更新包数据库。
但如果某个服务正在运行,dpkg 并不会自动帮我们重启它。很多软件包确实会通过postinst脚本尝试重启服务,但业务自己部署的进程、第三方二进制、手工编译的程序往往不在这个范围里。
这就会导致一种状态:包数据库显示已经升级,磁盘文件是新版本,但进程仍然是旧的。
我把这个现象称为“半更新状态”。这也是标题里“更新的时候还能继续使用”这个 bug 的本质。
2.4 另一个隐藏陷阱:更新过程中手动操作
还有一种情况在运维现场非常常见:执行apt upgrade或者yum update的过程中,某些步骤比较慢,另一些同事直接在另一个终端继续做操作。
这就可能引发更多问题:
- 包管理器锁冲突(
Could not get lock /var/lib/dpkg/lock); - 更新进程中文件被外部进程写入;
- systemd 的服务状态与包管理器预期不一致;
- 更新日志被中断,系统处于“未配置完成”的状态。
虽然这通常被归类为操作问题,但它也符合“更新期间系统还能继续使用”这个 bug 的描述。后面我会在排错流程中一起给出处理方法。
3. 复现“更新后进程仍运行旧版本”的现场
3.1 准备一个可控的测试环境
为了把概念讲清楚,我们先在本地完整复现一遍。整个实验可以在任意主流 Linux 发行版上进行,建议在虚拟机或者容器里操作,避免影响宿主机。
先创建测试目录和两个版本的 C 程序。
旧版本:
// 文件路径:/tmp/bug-demo/old-version.c #include <stdio.h> #include <unistd.h> int main() { printf("old version running, pid=%d\n", getpid()); sleep(60); printf("old version exit\n"); return 0; }新版本:
// 文件路径:/tmp/bug-demo/new-version.c #include <stdio.h> #include <unistd.h> int main() { printf("new version running, pid=%d\n", getpid()); sleep(60); printf("new version exit\n"); return 0; }编译两个版本:
cd /tmp/bug-demo gcc -o app-old old-version.c gcc -o app-new new-version.c先启动“旧版本”进程:
./app-old &此时进程会输出类似下面的信息:
old version running, pid=123453.2 用新版本替换目标文件
现在模拟一次“软件包升级”文件替换:
cp app-new app这里我把新二进制复制为app。正常情况下升级脚本可能还会做 rename 操作,但效果类似,磁盘上的app已经变成新版本。
此时再查看正在运行的进程:
ps -ef | grep app你会看到原来的进程仍然存在,COMMAND列显示的还是./app-old。因为程序已经被加载到内存,它不会因为磁盘文件变化而自动切换。
更直观的方式是通过/proc:
ls -l /proc/12345/exe输出中通常会出现:
/proc/12345/exe -> /tmp/bug-demo/app (deleted)这里(deleted)表示该可执行文件已经被删除或替换,但进程仍持有旧 inode 的引用。
3.3 用 lsof 找到正在引用旧文件的进程
lsof是排查这个问题的利器。先安装它:
# Debian / Ubuntu apt install -y lsof # RHEL / CentOS yum install -y lsof然后执行:
lsof +L1+L1的作用是列出 link count 小于 1 的文件,也就是已经被删除但仍有进程打开的文件。
输出中会看到类似这样的一行:
app-old 12345 root mem REG 8,2 16160 1048578 /tmp/bug-demo/app (deleted)这说明进程app-old仍然持有已经删除的/tmp/bug-demo/app。
如果是在真实服务器上,看到大量(deleted)文件和业务进程关联在一起,基本可以断定:系统处于“磁盘文件已经更新,但服务进程还是旧版本”的状态。
4. 排查更新后系统状态异常的完整流程
下面这套流程是我在真实故障中沉淀下来的,适用于 Debian/Ubuntu 系,也提供了 RHEL/CentOS 系的对应命令。遇到更新后服务表现异常,建议按这个顺序排查。
4.1 确认系统包管理器的状态
首先确认更新过程本身是否完成,排除“更新到一半被中断”的可能。
Debian/Ubuntu 系:
sudo dpkg --audit sudo apt --fix-broken install如果没有输出,说明包数据库没有明显的问题。
RHEL/CentOS 系:
sudo dnf check sudo dnf history listdnf history可以查看最近的事务历史,确认最后一次更新是否成功。
4.2 检查是否还有残留的旧文件引用
这一步是整个排查的核心。
sudo lsof +L1 | grep -v '^COMMAND'如果输出结果为空,说明没有进程持有已删除的文件。如果非空,就要根据进程名判断是否需要处理。
比如你看到:
nginx 54321 root mem REG 8,2 34567 1048577 /usr/lib/x86_64-linux-gnu/libssl.so.3 (deleted)说明 Nginx 进程使用的 libssl 已经被替换,但进程还持有旧库。此时需要重启 Nginx,或者安排维护窗口重启相关服务。
4.3 检查进程实际使用的二进制文件
/proc文件系统提供了非常直接的信息。
查看某个进程的可执行文件:
sudo ls -l /proc/54321/exe如果输出带有(deleted),说明二进制已经被替换。
查看进程打开的映射文件:
sudo cat /proc/54321/maps | grep '\.so' | grep deleted这会列出该进程地址空间中已经被删除的动态库。一旦看到这些信息,说明进程与磁盘状态已经不一致。
4.4 判断哪些服务需要重启
对于 Debian/Ubuntu 系,needrestart是一个非常方便的工具,它会扫描当前运行的进程,判断哪些进程使用了已更新的文件,并提示是否需要重启。
安装:
sudo apt install -y needrestart执行:
sudo needrestart -l输出会列出当前可以重启的服务或者进程。如果需要自动重启服务,可以执行:
sudo needrestart -r a不过在生产环境里,我不建议直接使用-r a自动重启,还是应该人工确认重启范围,避免把不能中断的服务一起重启了。
RHEL/CentOS 系可以使用:
sudo dnf needs-restarting如果输出为空,说明没有检测到需要重启的进程;如果有输出,会列出进程名和 PID。
4.5 检查服务状态与实际文件状态
有时候包管理器已经记录了服务需要重启,只是没有执行。
Debian/Ubuntu 系下,很多软件包在升级后会通过invoke-rc.d或 systemd 的机制尝试重启服务。如果失败了,系统会在/var/run/reboot-required里留下标记。
cat /var/run/reboot-required如果文件存在,说明某些文件被更新后需要重启机器才能完全生效,常见于内核、glibc、libssl 等底层组件。
也可以手动检查服务状态:
systemctl status nginx对比状态里的进程启动时间和软件包安装时间,如果服务启动时间早于软件包升级时间,那么服务大概率还在旧版本环境里运行。
4.6 处理“更新后服务假死”问题
标题里提到“更新的时候还能继续使用”,有时候并不是服务还活着,而是服务处于一种假死状态:CPU 占用率正常,网络端口还在监听,但业务请求迟迟不返回。
这种情况常见于:
- 进程使用的动态库版本和磁盘上的新库不兼容;
- 进程内部线程池在加载新库时阻塞;
- 新旧模块之间依赖的全局符号冲突。
如果遇到假死,不要直接kill -9,先采集现场信息:
# 查看线程状态 top -H -p <pid> # 导出进程堆栈 gstack <pid> # 或使用 jstack(Java 进程) jstack <pid>确认问题与库文件变更相关后,再重启服务。
5. 修复策略:把“更新期间还能继续使用”变成可控行为
5.1 明确更新策略:在线更新还是维护窗口
很多人觉得 Linux 更新只是跑一条命令的事情,其实最核心的问题不是命令本身,而是更新策略。
在业务要求“不能停”的场景下,应该明确:
- 哪些服务可以平滑重启?
- 哪些服务必须通过负载均衡摘除后重启?
- 哪些底层库(比如 glibc、OpenSSL)更新后必须重启所有依赖进程?
- 哪些更新需要重启机器才能彻底生效?
把这些内容写进更新预案,而不是等出问题后再排查。
对于不能中断的服务,建议在更新前先做好以下操作:
# 从负载均衡摘除节点 sudo ip addr del <ip>/24 dev eth0 # 或停止业务进程 sudo systemctl stop my-service # 执行更新 sudo apt upgrade -y # 启动服务 sudo systemctl start my-service # 重新挂载到负载均衡 sudo ip addr add <ip>/24 dev eth0这里强调一下:如果更新涉及底层动态库,停止服务是必要步骤,否则更新完成后还是要重启进程,等于白等。
5.2 使用 systemd 维护模式
如果你的系统有维护窗口,可以先用 systemd 进入维护状态,避免更新过程中有其他操作干扰。
sudo systemctl isolate multi-user.target这个操作会把图形界面或非必要服务停止,留下一个最小化的系统环境。更新完成后再切回原来的 target:
sudo systemctl isolate graphical.target注意:这个操作会停止当前环境中的服务,执行前一定要确认不会影响正在运行的远程会话。
更保守的做法是只停止需要更新的服务:
sudo systemctl stop nginx sudo systemctl stop my-app sudo apt upgrade -y sudo systemctl start my-app sudo systemctl start nginx5.3 使用 needrestart 自动判断重启需求
在 Debian/Ubuntu 系中,可以通过配置needrestart让系统在更新后自动重启服务,减少人工判断。
编辑/etc/needrestart/needrestart.conf:
$nrconf{restart_mode} = 'a'; $nrconf{restart_services} = [ 'nginx', 'my-app', ];设置restart_mode为a表示自动重启所有需要重启的服务,list表示只列出不操作。生产环境建议先设置为l(list)或i(interactive),观察一段时间后再决定是否全面自动化。
5.4 Debian/Ubuntu 与 RHEL/CentOS 的处理差异
两个包管理系统在“更新后是否需要重启服务”这个问题上,策略基本一致,但提供的工具不同:
| 功能 | Debian/Ubuntu | RHEL/CentOS |
|---|---|---|
| 检查未安装完成的包 | dpkg --audit | dnf check |
| 检测需要重启的进程 | needrestart | dnf needs-restarting |
| 检测系统级重启需求 | /var/run/reboot-required | /var/run/reboot-required |
| 强制修复包数据库 | apt --fix-broken install | dnf distro-sync |
建议在脚本里加入更新后自动检测:
#!/bin/bash # Debian/Ubuntu 示例 apt update && apt upgrade -y needrestart -l if [ -f /var/run/reboot-required ]; then echo "reboot required" fiRHEL/CentOS 对应版本:
#!/bin/bash dnf update -y dnf needs-restarting if [ -f /var/run/reboot-required ]; then echo "reboot required" fi6. 常见问题与排查清单
下面整理了一份比较常见的问题表,基本覆盖“Linux 更新后还能继续使用”相关的各种异常现象。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 更新后服务进程还在跑,但功能异常 | 动态库文件已更新,进程仍持有旧库内存映射 | 重启对应服务 |
ldd看到的动态库与磁盘文件不一致 | 进程启动前依赖库路径被替换 | 确认 LD_LIBRARY_PATH 和 rpath |
lsof +L1出现大量(deleted)文件 | 二进制或动态库被覆盖但进程未重启 | 根据进程列表重启服务 |
/proc/<pid>/exe指向的文件显示(deleted) | 可执行文件被替换 | 重启该进程 |
apt --fix-broken install报错 | 上次更新被中断 | 先检查/var/lib/dpkg/lock是否被占用 |
Could not get lock /var/lib/dpkg/lock | 另一个 apt 进程还在运行 | 用ps aux | grep apt找到并等待或处理 |
| 更新后服务假死 | 新旧库符号冲突 | 使用 gstack/jstack 采集堆栈并重启 |
| 内核更新后未重启 | 旧内核还在运行 | 检查uname -r与新内核包版本 |
needrestart提示大量进程需要重启 | 底层库如 libc/OpenSSL 更新 | 逐个重启服务或安排重启机器 |
如果你遇到的现象不在表里,可以按下面的清单排查:
- 先确认更新事务是否成功完成;
- 使用
lsof +L1找出所有持有旧文件引用的进程; - 对比服务启动时间和软件包更新时间;
- 检查
/var/run/reboot-required; - 用
needrestart或dnf needs-restarting自动检测; - 必要时重启服务,而不是直接忽略现象。
7. 最佳实践:如何避免更新期间产生奇怪 bug
7.1 运维侧:把更新当作一次变更管理
不要把更新当成一条裸命令执行。建议做到:
- 提前备份配置和数据库;
- 更新前记录当前内核版本和软件包版本;
- 更新后立即检查服务健康状态;
- 使用脚本统一处理“停止服务 -> 更新 -> 重启服务 -> 健康检查”流程;
- 对于数据库、消息队列等有状态服务,务必走维护窗口。
更新后可以快速检查关键服务是否仍然持有旧文件:
sudo lsof +L1 | grep -E 'nginx|mysql|java|python|node'如果有输出,马上安排重启。
7.2 程序侧:编写 Linux 程序时的注意事项
作为开发者,也需要理解 Linux 的进程模型,不要把“磁盘文件更新”等同于“进程行为更新”。
- 程序运行期间如果对外部配置文件做了修改,进程不会自动感知,除非实现了文件监听;
- 如果依赖动态库,不要假设
apt upgrade后进程会自动切换到新库; - 对于守护进程,升级时建议提供优雅退出和重新加载机制,支持更新后平滑重启;
- 在程序启动日志中记录二进制版本、依赖库版本和启动时间,方便后续排查。
7.3 架构侧:考虑不可变部署
如果你经常被“更新期间新旧版本混跑”的问题困扰,更彻底的方案是拥抱不可变部署:
- 使用容器镜像,更新时通过重建镜像替换整个运行环境;
- 使用金丝雀发布,先更新部分节点验证后再全量更新;
- 尽量减少手工登录服务器改文件的运维方式;
- 对需要更新内核和底层驱动的系统,仍然保留必要的维护窗口。
容器化并不意味着“不会出现更新后继续使用旧版本”的问题,但至少镜像的版本是明确的,回滚也更加容易。
7.4 自动化建议
在定期更新的自动化脚本中,可以加入以下步骤:
# 自动更新前通知 echo "$(date) 开始系统更新" >> /var/log/update-maintenance.log # 更新 apt update apt upgrade -y # 标记需要重启的服务 needrestart -l | tee -a /var/log/update-maintenance.log # 自动重启白名单内的服务 for svc in nginx my-app; do systemctl restart $svc done # 检查关键业务端口 sleep 5 curl -fsS http://127.0.0.1:8080/health || echo "health check failed"这个脚本本质上把“更新期间是否还能继续使用”变成了“更新过程中我清楚哪些服务会短暂中断,哪些服务会在更新后重启”,状态是可控的。
8. 总结与实用建议
回到标题里的问题:“Linux 更新的时候还能继续使用”到底是不是 bug?
从原理上看,它更像是 Linux 进程模型与软件包管理机制之间的一种默认行为。文件可以热替换,但进程的运行态不会自动跟随变更。理解这一点后,这个“bug”就不再神秘,而是可以被设计和管理规避的运维风险。
关键点可以回顾一下:
- 进程一旦启动,可执行文件不会因为磁盘上的替换而自动更新;
- 动态库更新后,必须重启依赖它的进程才能生效;
lsof +L1和/proc/<pid>/exe是排查旧文件引用的核心工具;- 更新前要明确服务重启策略,更新后要验证进程状态;
- 架构上优先考虑不可变部署,减少手工变更带来的不确定性。
如果你最近也遇到过“明明更新了,进程还在跑旧代码”的怪象,不妨先用lsof +L1看一下系统里有多少(deleted)文件。这个方法能帮你节省大量排查时间。