Linux更新后服务为何不中断?如何修复残留旧进程
在实际 Linux 服务器运维中有一种现象经常让新手困惑执行apt upgrade或dnf upgrade更新系统时正在运行的 Web 服务、数据库进程并没有因为文件被替换而立即停止业务请求还能继续走通。于是有人开玩笑说“我竟然修复了 Linux 更新的时候还能继续使用的 bug”。这个说法既对也不对Linux 更新期间系统继续运行并不是 bug而是文件系统与进程模型共同作用的结果真正需要修复的是更新后“磁盘文件是新的内存运行环境却是旧的”这种不一致问题。如果不处理这种不一致过段时间就会出现服务崩溃、动态库版本冲突、内核模块加载失败等各种奇怪故障。这篇文章会用一个可复现的实验说明 Linux 更新期间进程为什么还能继续运行并给出升级完以后如何检查残留旧文件、如何找到需要重启的服务、如何设计一套低风险在线更新流程。文章中的命令基于 Debian/Ubuntu 系列的 apt 和 systemd但排查思路同样适用于 CentOS/RHEL 的 dnf/yum。1. 先搞清楚Linux 更新期间为什么“还能继续使用”1.1 文件可以被替换但旧进程不会感知Linux 中一个文件名只是目录项真正保存数据的是 inode。包管理器在替换文件时通常不是直接修改原文件内容而是把新文件写入一个新的临时文件然后通过rename将新文件移动到目标路径。rename是一个原子操作目录项从旧 inode 指向新 inode。对于已经在运行中的进程来说它打开的文件描述符、映射的内存页都一直指向旧 inode。旧 inode 不会被立即回收因为进程还持有引用。所以当apt-get upgrade提示“已升级 nginx”时磁盘上的/usr/sbin/nginx已经是新文件但如果你在升级之前就已经启动了 nginx这个进程的代码段还映射在旧 inode 上。只要你不重启它它就会一直使用旧版本的二进制。这个机制造成的结果是升级完成后新启动的命令会使用新文件旧进程仍然使用旧文件。文件系统看起来版本统一但实际运行环境是混合的。Linux 默认允许这种“新老共存”因为它优先保证进程不中断而不是强制重启。1.2 动态链接库升级后运行中的进程映射的仍是旧库比替换二进制文件更隐蔽的是动态链接库的升级。比如系统从libc6 2.31升级到2.35正在运行的 Python、nginx、sshd 等进程在启动时已经通过动态链接器加载了旧 libc 并映射到进程地址空间。升级包管理器只是替换了磁盘上的/usr/lib/x86_64-linux-gnu/libc.so.6旧的内存映射并不会被撤销。动态库的新版本只会被之后启动的进程使用。这带来一个非常大的隐患同一个系统里一部分进程使用旧 glibc一部分进程使用新 glibc。如果这些进程之间通过共享内存、Unix Socket、数据库协议或二进制格式传递数据而双方对新旧版本行为存在差异就可能在没有任何代码变更的情况下出现偶发故障。查看一个进程正在使用的动态库最直接的方式是读/proc/pid/mapscat /proc/$(pidof nginx | awk {print $1})/maps | grep libc如果升级后进程仍在运行输出中可能带有(deleted)标记表示该文件路径已被替换但旧 inode 仍被映射。1.3 “还能继续使用”是特性但会产生三类不一致可以把“更新时继续使用”的优点和问题分开看。优点是服务不中断用户请求不受影响。缺点是会产生三类典型不一致二进制版本不一致主程序文件已更新但运行中的进程仍执行旧代码。依赖版本不一致共享库已更新但旧进程仍加载旧版本新进程加载新版本。内核与模块不一致内核文件已更新到新版本但当前运行内核仍是旧版本模块目录也是旧版本。这三类不一致都可能表现为“看起来更新成功了但某些功能没有变化甚至以后莫名其妙崩溃”。所以后面所有修复手段本质上都是解决“不一致”问题而不是禁止 Linux 更新时继续运行。2. 复现一次“更新后继续使用”的典型问题2.1 准备实验环境为了看清整个现象建议准备一台干净的 Ubuntu 20.04 或 22.04 虚拟机内存和磁盘不需要太大2 核 4G 即可。实验环境使用测试机不要直接在生产服务器上操作。安装 nginx 作为实验服务sudo apt-get update sudo apt-get install -y nginx sudo systemctl enable --now nginx curl -I http://127.0.0.1/正常输出HTTP/1.1 200 OK后记录当前 nginx 版本和主进程 PIDnginx -v cat /run/nginx.pid假设输出版本是nginx/1.18.0PID 是 12345。这一步是更新前的基线。2.2 启动一个长期运行的服务为了让实验更接近生产场景可以让一个客户端持续请求 nginx然后在一个终端里发起升级。如果没有压测工具直接用curl循环即可while true; do curl -s -o /dev/null -w %{http_code}\n http://127.0.0.1/ sleep 1 done在另一个终端更新 nginx 包sudo apt-get update sudo apt-get install --only-upgrade nginx注意nginx 的包维护脚本可能已经帮你重启了服务。如果服务被重启那么“旧进程继续运行”的现象就不会出现。为了观察原始现象这里可以换成升级一个不会被维护脚本重启的普通库或者先手动停止 nginx 再启动然后再升级。更稳定的做法是运行一个自己写的进程。比如使用 Python 打开一个配置文件并持续持有文件描述符然后升级该配置所属的包。不过这需要构造包比较麻烦。这里仍以 nginx 为例但强调排查时要结合包维护脚本行为。2.3 升级涉及运行库的软件包如果想复现“动态库被替换但服务未重启”可以升级一个库文件。以升级libc6风险较大建议使用zlib1g或者openssl这类依赖广泛的库sudo apt-get install --only-upgrade zlib1g openssl升级完成后使用lsof检查已经打开但已被删除的文件sudo lsof L1输出中会看到一条类似nginx 12345 www-data txt REG 8,1 0 456789 /usr/sbin/nginx (deleted)L1表示列出 link count 小于 1 的文件也就是文件已经被删除但仍有进程持有。这就是“更新后还在使用旧文件”的直接证据。2.4 观察旧文件被进程持续持有除了lsof还可以直接查看进程的映射和可执行文件ls -l /proc/12345/exe cat /proc/12345/maps | grep deleted如果升级替换了 nginx 二进制/proc/12345/exe会显示/usr/sbin/nginx (deleted)如果升级替换了动态库maps输出中会出现带有(deleted)的库路径。这个现象并不是故障而是说明进程仍运行在旧文件上。只要进程不退出旧 inode 就不会释放磁盘空间也可能因此没有立刻减少。复现到这里基本可以回答“为什么更新时还能继续使用”因为旧进程持有旧 inode。接下来更重要的是如何修复已经产生的残留。3. 修复“更新时继续使用”残留的常见方案3.1 用 needrestart 找出应该重启的服务Debian/Ubuntu 系列默认可能安装了needrestart它的作用就是扫描系统中的进程判断它们是否还在使用已被替换的文件并给出建议。安装并查看需要重启的服务sudo apt-get install -y needrestart sudo needrestart -l输出会列出当前需要重启的服务。也可以使用交互模式sudo needrestart它会提示类似 “Found processes using old versions of upgraded files” 的列表并询问是否重启。生产环境不建议直接在整个系统范围自动重启所有服务而是先通过列表确认影响面再安排维护窗口逐个重启。Debian-goodies 里的checkrestart也可以做类似检查sudo apt-get install -y debian-goodies sudo checkrestartcheckrestart更偏传统输出格式和needrestart略有不同可以同时安装用来交叉验证。3.2 手动重启受影响的 systemd 服务对于 nginx、sshd、docker、mysql 这类有独立服务单元的程序最直接的修复方式是重启服务sudo systemctl restart nginx sudo systemctl restart ssh sudo systemctl restart docker重启后确认进程 PID 是否变化systemctl show -p MainPID --value nginx cat /run/nginx.pid如果 PID 已变化说明进程已经重新从磁盘加载了新文件。此时再运行lsof L1旧的 deleted 文件记录应该消失。需要注意一个细节systemctl reload nginx和systemctl restart nginx并不等价。reload只让 nginx 重新读取配置主进程仍然运行worker 进程可能被重新创建但主程序二进制和已加载的共享库未必会刷新。如果目的是让新版本二进制生效应该使用restart而不是仅仅reload。3.3 处理“被删除但仍被打开”的文件有些进程没有 systemd 服务单元例如用户手动启动的 Python 脚本、后台运行的分析任务。它们持有的旧文件无法通过systemctl restart重启需要单独处理。先用lsof L1获取完整列表sudo lsof L1 | grep deleted输出中每一行包含进程名、PID、用户、文件描述符、文件类型和路径。针对这些 PID先确认进程是否还有存在价值ps -fp 12345,12346如果进程是业务进程尽量通过应用自己的优雅退出机制处理。例如 Python 服务有SIGTERM处理先发信号kill -TERM 12345如果进程是一个常驻会话需要重新登录或重启终端。如果进程是已退化的临时进程确认可以结束后再结束它。还有一种常见场景是日志文件被 logrotate 轮转后进程仍持有旧日志文件的文件描述符导致磁盘空间不释放。这种“deleted 文件”不是包升级造成但排查方法完全一致定位 PID重启日志服务或者让进程重新打开文件。3.4 内核更新后决定要不要重启内核更新与普通软件更新不同。升级linux-image包后磁盘上安装了新内核和新的/lib/modules/$(uname -r)目录但当前运行的内核还是旧版本。此时会出现两个版本并存uname -r输出的是当前运行内核版本。查看磁盘上已安装的内核包dpkg -l | grep linux-image如果磁盘上新内核版本与uname -r不一致就说明需要重启才能加载新内核。Ubuntu 会在内核升级后创建/var/run/reboot-required标记cat /var/run/reboot-required如果文件存在通常内容是*** System restart required ***内核版本不统一带来的问题包括新安装的内核模块无法加载到旧内核中、某些硬件驱动不生效、容器运行时依赖的新内核特性缺失。生产环境如果升级了内核需要计划重启窗口。无法立即重启时要确保操作系统的运行级别正常并将重启窗口明确记录下来避免长期带着旧内核运行。3.5 针对容器应用的滚动更新如果业务已经容器化在线更新的方式会更安全。不要在正在运行的容器里直接替换二进制或动态库而是构建新的容器镜像然后通过编排系统滚动更新。以 Docker Compose 为例先构建新镜像并通过新 tag 引用FROM nginx:1.25 COPY index.html /usr/share/nginx/html/然后更新 compose 文件中的镜像 tag执行docker compose up -d --no-deps web或者使用 Kubernetes 的滚动更新策略先起新副本等健康检查通过后再摘掉旧副本deployment: strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1容器滚动更新的优势是“旧进程”和“新进程”被隔离在不同容器中不会出现同一台物理机里新旧动态库混杂的问题。更新前可以先在测试环境验证更新过程中可以随时回滚。4. 排错出现崩溃、版本错乱时怎么查4.1 从进程信息确认是否仍在运行旧代码现场出现崩溃或行为不一致时不要急着重启服务先采集证据。常用命令按顺序执行lsof L1 | grep deleted cat /proc/pid/maps | grep deleted ls -l /proc/pid/exe systemctl show -p MainPID --value service cat /var/run/reboot-required采集到的信息可以帮助判断如果进程的maps中有多个动态库带(deleted)说明它仍在使用升级前的库。如果/proc/pid/exe指向(deleted)说明二进制本身已被替换。如果该进程有 systemd 服务但 MainPID 和当前系统 PID 对不上说明可能不是由 systemd 启动或服务已经异常。4.2 根据错误特征定位更新残留不同语言和运行时因为“更新后继续使用”而出现的问题往往有不同特征。如果 Python 脚本报ImportError: version GLIBC_2.34 not found看起来是 glibc 版本缺失但实际可能是脚本由旧 Python 解释器启动而解释器尝试加载了新的扩展模块。检查 Python 路径和解释器的动态库映射which python3 ldd /usr/bin/python3 python3 -c import sys; print(sys.version)如果脚本是在更新前启动的常驻进程直接重启这个脚本进程通常可以解决问题。如果 Java 服务报UnsatisfiedLinkError或 JVM 直接退出且代码没有改动要检查 Java 进程是否还在使用被替换的本地库。重启 JVM 前先保留 hs_err 文件和/proc/pid/maps输出。如果程序在执行时提示Text file busy这通常不是包管理器升级导致而是有人直接使用cp覆盖了一个正在运行的二进制文件。Linux 禁止直接写入正在执行的文件。正确的做法是使用两次mv或者通过包管理器完成更新。这个错误在更新脚本中很常见排查时优先看是不是自定义部署脚本覆盖了运行文件。4.3 常见故障现象、原因与处理表下面整理了更新后较常遇到的几类问题供现场排查时快速对照。问题现象常见原因检查方式处理建议服务更新后行为没有变化包已替换但服务进程未重启lsof L1systemctl show -p MainPID执行systemctl restart service进程启动后报动态库版本缺失新进程依赖新库旧库文件名仍残留或路径不对ldd binary检查/etc/ld.so.conf执行ldconfig确认库路径必要时重启进程旧日志文件不释放磁盘空间进程仍持有被 logrotate 删除的文件lsof L1查看 deleted 文件重启日志服务或让进程重新打开文件内核模块加载失败新模块与当前运行内核不匹配uname -r与/lib/modules对比重启到新内核CPU 占满且调用栈指向旧库旧进程使用的库与磁盘新库不一致pstack或perf查看调用栈重启对应进程业务偶发协议解析错误新旧进程使用不同版本库行为有差异检查进程启动时间和 maps统一重启相关服务注意ldconfig只更新动态链接器的缓存不会让已经运行的进程重新加载新库。所以不要以为执行ldconfig后旧进程就“修复”了。4.4 更新日志和痕迹核查包管理器会留下更新记录排查时可以按时间线对齐问题。/var/log/apt/history.log记录 apt 安装、升级、删除操作。/var/log/dpkg.log记录 dpkg 层面安装的具体文件。journalctl --since 2 hours ago | grep -i error查看系统日志中的错误。journalctl -u service --since 2 hours ago查看具体服务日志。先用日志确认“问题发生前是否做过更新”。如果确认做过更新再结合 4.1 的命令检查进程是否还在使用旧文件。大多数更新后残留问题最终都指向“进程没有重启”或“没有重启到新内核”。5. 设计一套低风险的在线更新流程5.1 更新前确认可能受影响的服务生产环境执行更新前先获取升级包列表apt list --upgradable重点判断两类包基础运行库libc6、openssl、zlib1g、libssl等。这类包会影响几乎所有动态链接的服务。正在运行的业务服务包nginx、mysql、php-fpm、docker 等。如果更新包列表为空先不要盲目升级。确认系统当前服务和依赖关系systemctl list-units --typeservice --staterunning lsof L1 2/dev/null | grep deleted cat /var/run/reboot-required 2/dev/null升级前为关键服务做快照或至少在配置层面备份。云主机可以创建磁盘快照物理机可以备份/etc和数据库数据目录。更新和备份是两个动作不要在没有任何回退手段的情况下直接升级。5.2 更新中让包管理器和服务协调在追求最小停机的场景中更新过程并不是“一直点 yes”就够。推荐使用非交互模式并记录输出sudo DEBIAN_FRONTENDnoninteractive apt-get -y upgradeDEBIAN_FRONTENDnoninteractive可以避免debconf弹出交互式配置界面。对于无人值守场景还可以配合sudo apt-get -y --allow-downgrades upgrade但--allow-downgrades要谨慎使用它允许回退到低版本包通常只在紧急回滚时需要。有些包维护脚本会在安装后自动重启对应服务这会导致短暂中断。如果你的业务要求零中断不要依赖这种自动行为应该提前查看包脚本内容或者使用配置管理工具自定义 update 流程。例如在 Ubuntu 上可以通过配置/etc/needrestart/needrestart.conf控制是否自动重启服务$nrconf{restart} l;restart选项控制 needrestart 的自动重启模式常见取值有a自动重启所有需要重启的服务。l只列出需要重启的服务不自动重启。i交互式询问。生产环境建议使用l由运维人员确认后统一处理。5.3 更新后检查重启需求并逐步重启更新完成后依次执行cat /var/run/reboot-required 2/dev/null sudo needrestart -l sudo lsof L1 | grep deleted得到需要重启的服务列表后按依赖顺序处理。例如先重启基础网络服务再重启业务服务。如果多个服务依赖同一个动态库最好在同一个维护窗口内完成否则旧进程和新进程会长期并存。重启方式也有讲究。对于有通信状态的服务优先使用优雅停止sudo systemctl stop service sudo systemctl start service如果服务支持 graceful reload也要确认 reload 是否会重新加载动态库。有的服务 reload 不会重新加载二进制只有 restart 才会。判断依据是reload 是否导致exe和maps刷新。不确认时直接使用 restart。对于数据库这类有数据一致性的服务先执行自身的检查命令然后再重启。MySQL 可以执行FLUSH TABLES WITH READ LOCK但生产环境要评估影响。5.4 生产环境的可选增强手段如果经常需要在线更新可以考虑把“更新”和“重启”做成自动化流程使用unattended-upgrades自动安装安全更新但配置Automatic-Reboot策略保证内核更新后不强制破坏性重启。针对/var/run/reboot-required设置监控告警让内核更新后的重启需求及时暴露。使用 Ansible 或 SaltStack 编写系统升级 playbook步骤包括备份、更新、检查 needrestart、白名单服务重启、报告。对于无状态 Web 服务可以采用滚动优雅重启例如 nginx 配合多 worker或使用负载均衡摘流后逐台更新。这里要强调生产环境没有放之四海而皆准的“一键在线更新”。任何更新都应在测试环境验证过并且计划好回滚路径。不要因为某一次自动更新没出问题就把自动更新和自动重启全量开启。6. 最佳实践清单和扩展方向6.1 在线更新前检查清单提供一个可以钉在运维文档里的清单每次更新前逐项确认。已查看升级包列表确认升级范围。已确认待升级包是否涉及运行中的关键服务。已对配置文件和数据库做备份或确认有快照。已记录当前运行服务的 PID 和版本号便于更新后对比。已准备维护窗口避免在业务高峰期执行。已确认包管理器源可用避免更新中断。已确认磁盘空间充足可以容纳新包和旧 inode 暂存。已明确更新后如需要重启服务由哪个执行人、按什么顺序处理。6.2 应当避免的几种做法不要直接cp覆盖正在运行的二进制文件。会产生Text file busy且不是原子操作。不要在更新后仅执行ldconfig就认为动态库已经生效。旧进程仍然映射旧库。不要对包含内核的系统包升级后长期不重启也不要长期忽略/var/run/reboot-required。不要将需要重启的服务列表直接交给killall或kill -9。先优雅停止再启动。不要在无人值守升级中无脑开启自动重启所有服务。很多服务是有关联的自动重启可能导致连锁中断。不要在没有任何回退手段时同时对一批服务器执行大版本升级。6.3 扩展方向内核 livepatch、容器滚动、不可变系统“修复更新时还能继续使用”的另一种思路是让更新后的系统和运行环境尽量保持一致或者完全抛弃原地更新模型。内核 livepatch 可以在不重启的情况下给内核打补丁。Canonical Livepatch、kpatch 都是这个方向。它的适用范围主要是安全补丁不是所有内核功能变更。容器化可以把更新从“操作系统包级别”提升到“镜像级别”。新版本应用以新容器启动旧容器停止后回收避免旧进程继续持有旧文件。不可变系统的思路是系统根分区只读更新通过整机替换镜像完成。典型的如 Flatcar、Fedora CoreOS 风格。这种方式让“更新后还能继续使用”的场景几乎消失但维护方式变化较大。在应用层尽量让服务支持优雅重启和零停机发布。例如 Web 服务通过负载均衡摘流先停止旧进程再启动新进程再挂回流量。真正需要记住的核心判断只有一条Linux 允许“文件被替换但进程继续运行”的设计是为了服务连续性但这也意味着“升级完成”和“运行环境更新完成”是两件事。排查标题里的那个 bug 时不要试图让更新期间系统停止运行而要让更新完成后所有重要进程都重新加载到与新磁盘文件一致的运行状态。这样“修复”的就不是 Linux 的更新能力而是更新流程里最容易被忽略的“善后”环节。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →