Linux-15 定时任务、日志与包管理
这一篇是运维基建的三件套:定时任务、日志管理、软件包。内容偏「操作」,但每一部分都有几个能让人排查一整天的坑——cron 的环境变量、logrotate 的 copytruncate、动态库版本冲突。
1. cron:三个必知的坑
crontab -e # 编辑当前用户的
crontab -l # 查看
crontab -r # 删除【全部】—— 危险,和 -e 只差一个键
crontab -l -u app # 查看指定用户的
时间格式:
* * * * * command
| | | | |
| | | | +- 星期 (0-7,0 和 7 都是周日)
| | | +--- 月 (1-12)
| | +----- 日 (1-31)
| +------- 小时 (0-23)
+--------- 分钟 (0-59)
*/5 * * * * 每 5 分钟
0 * * * * 每小时整点
0 3 * * * 每天 3:00
0 3 * * 1 每周一 3:00
0 3 1 * * 每月 1 号 3:00
0 9-18 * * 1-5 工作日 9 点到 18 点,每小时
@reboot 开机时执行一次
@daily 等价于 0 0 * * *
1.1 坑一:cron 的环境变量极其贫瘠
这是 cron 问题里占比最高的一类:「脚本手动跑正常,放进 cron 就失败」。
# 你的交互式 shell
echo $PATH
# /usr/local/go/bin:/usr/local/bin:/usr/bin:/bin:/home/lhx/.local/bin
# cron 里的 PATH(验证一下)
* * * * * echo "PATH=$PATH" > /tmp/cron_env.txt
cat /tmp/cron_env.txt
# PATH=/usr/bin:/bin
# ^^^^^^^^^^^^ 只有这两个!
cron 不读 ~/.bashrc、~/.bash_profile、/etc/profile——它只给一个极简环境。所以 go、python3(如果装在 /usr/local/bin)、docker、kubectl 全都找不到。
三种解法:
# ✅ 方案一:在 crontab 顶部显式设置(推荐)
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/bash
MAILTO=ops@example.com
0 3 * * * /opt/scripts/backup.sh
# ✅ 方案二:脚本里用绝对路径
0 3 * * * /usr/local/go/bin/go run /opt/tools/main.go
# ✅ 方案三:脚本自己 source 环境(最可靠,适合复杂依赖)
0 3 * * * /bin/bash -lc '/opt/scripts/backup.sh'
# ^^ -l 表示 login shell,会读 /etc/profile 和 ~/.bash_profile
调试 cron 环境的标准手法:
# 让 cron 任务把完整环境导出来看
* * * * * env > /tmp/cron_env.txt 2>&1
# 然后对比 env 的输出,找出差了什么
1.2 坑二:% 必须转义
% 在 crontab 里是「换行」的意思,不转义会导致命令被截断:
# ❌ 命令在第一个 % 处就被截断了
0 3 * * * /opt/backup.sh > /var/log/backup-$(date +%Y%m%d).log
# ✅ 用 \ 转义每一个 %
0 3 * * * /opt/backup.sh > /var/log/backup-$(date +\%Y\%m\%d).log
# ✅ 更好的做法:把逻辑放进脚本,crontab 里只写一行调用
0 3 * * * /opt/scripts/backup.sh
**规律:crontab 里只写「调用脚本」这一件事,所有逻辑放进脚本。**这样能同时避开 % 转义、引号嵌套、PATH 三个坑。
1.3 坑三:任务重叠执行
**cron 不检查上一次是否还在跑。**如果一个任务每 5 分钟执行、但某次跑了 20 分钟,就会有 4 个实例同时运行——数据库备份场景下这会直接把磁盘和 IO 打满。
# ✅ 用 flock 做互斥(第 5 篇讲过)
*/5 * * * * /usr/bin/flock -n /var/run/sync.lock /opt/scripts/sync.sh
# ^^ -n 非阻塞:拿不到锁就直接退出,不排队
# 如果希望排队等待(有超时)
*/5 * * * * /usr/bin/flock -w 60 /var/run/sync.lock /opt/scripts/sync.sh
flock -n 比「检查 pid 文件」可靠得多:锁依附于文件描述符,进程无论怎么死(包括 kill -9),内核关闭 fd 时锁自动释放,不会留下陈旧的锁导致任务永远起不来。
1.4 日志与输出
cron 默认把任务的 stdout/stderr 通过邮件发给用户。大多数服务器没配 MTA,这些输出就直接丢了——所以「cron 任务失败了但完全没有记录」很常见。
# ✅ 显式重定向到日志文件
0 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# ✅ 或者送给 syslog(会进 journal,可用 journalctl 查)
0 3 * * * /opt/scripts/backup.sh 2>&1 | logger -t backup
# ⚠️ 全部丢弃 —— 只在确认脚本自己有完善日志时才这么做
0 3 * * * /opt/scripts/backup.sh > /dev/null 2>&1
# 查看 cron 本身的执行记录(确认任务有没有被触发)
journalctl -u cron -f # Debian/Ubuntu
journalctl -u crond -f # RHEL 系
grep CRON /var/log/syslog | tail -20
排查顺序:先看 cron 的日志确认「任务是否被触发」,再看任务自己的日志确认「执行是否成功」。这两件事要分开,否则容易把「没触发」误判成「执行失败」。
1.5 其他 cron 位置
/var/spool/cron/crontabs/<user> # crontab -e 实际写入的地方(别手动改)
/etc/crontab # 系统级,【多一个用户名字段】
/etc/cron.d/ # 系统级,按包分文件(推荐给部署脚本用)
/etc/cron.{hourly,daily,weekly,monthly}/ # 放脚本即可,不用写时间
# /etc/cron.d/myapp —— 注意第 6 个字段是【用户名】
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * myapp /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# ^^^^^ 比 crontab 多了这个字段
坑:
/etc/cron.d/下的文件名不能包含点(如myapp.cron),否则 cron 会忽略它——这个规则来自run-parts的行为。用myapp或myapp-backup这样的名字。
2. systemd timer:更好的选择
systemd 的 timer 单元可以完全替代 cron,而且解决了 cron 的几乎所有痛点。
# /etc/systemd/system/backup.service
[Unit]
Description=Database Backup
[Service]
Type=oneshot
User=myapp
# 环境完全可控,不依赖登录 shell
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
EnvironmentFile=-/etc/myapp/env
ExecStart=/opt/scripts/backup.sh
# 资源限制,防止备份任务拖垮机器
Nice=19
IOSchedulingClass=idle
MemoryMax=2G
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily at 3am
[Timer]
OnCalendar=*-*-* 03:00:00
# 错过了就在开机后立即补跑 <- cron 做不到
Persistent=true
# 随机延迟 0~600 秒,避免多台机器同时执行造成尖峰
RandomizedDelaySec=600
AccuracySec=1min
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now backup.timer
# 查看所有 timer 和下次执行时间
systemctl list-timers --all
# NEXT LEFT LAST PASSED UNIT
# Fri 2026-08-07 03:00:00 CST 10h left Thu 2026-08-06 03:00:12 CST 13h ago backup.timer
# 手动触发测试(不用等到时间点)
systemctl start backup.service
# 看执行日志
journalctl -u backup.service --since today
2.1 timer 相比 cron 的优势
| 维度 | cron | systemd timer |
|---|---|---|
| 环境变量 | 极简,需手动设 | unit 里完全可控 |
| 日志 | 靠邮件或手动重定向 | 自动进 journal,可查询 |
| 错过执行(机器关机) | 永久跳过 | Persistent=true 补跑 |
| 防重叠 | 需要 flock |
自动(service 已在运行就不重复启动) |
| 资源限制 | 无 | 完整 cgroup 支持 |
| 随机延迟 | 无 | RandomizedDelaySec |
| 依赖其他服务 | 无 | After=/Requires= |
| 执行状态 | 难查 | systemctl status、退出码明确 |
| 配置复杂度 | 一行 | 两个文件 |
RandomizedDelaySec 值得单独说——100 台机器都配了「每天 3:00 备份」时,cron 会让它们同一秒全部开始,瞬间打满共享存储和网络带宽。timer 的随机延迟能把负载摊平到 10 分钟内。
Persistent=true 也很实用:笔记本或会关机的机器上,cron 的每日任务如果在关机时段就永久跳过了,而 timer 会在开机后补跑。
OnCalendar 的语法:
OnCalendar=*-*-* 03:00:00 # 每天 3:00
OnCalendar=Mon *-*-* 03:00:00 # 每周一 3:00
OnCalendar=*-*-01 03:00:00 # 每月 1 号
OnCalendar=*-*-* *:0/15 # 每 15 分钟
OnCalendar=hourly # 预设值
OnCalendar=daily
OnCalendar=weekly
# 验证语法和下次执行时间 —— 写完必测
systemd-analyze calendar "Mon *-*-* 03:00:00"
# Original form: Mon *-*-* 03:00:00
# Normalized form: Mon *-*-* 03:00:00
# Next elapsed: Mon 2026-08-10 03:00:00 CST
# From now: 3 days left
systemd-analyze calendar 是写 timer 时必用的验证工具——它能立刻告诉你表达式对不对、下次什么时候跑,不用等真实时间。
选型建议:
简单的、一次性的、纯 shell 的任务 -> cron 够用,一行搞定
需要日志、资源限制、防重叠、依赖 -> systemd timer
容器化环境 -> K8s CronJob
3. 日志:journald 与 rsyslog 的分工
现代 Linux 上两套日志系统并存:
应用 / 内核
+--> journald(systemd 自带)
| - 收集所有 systemd 服务的 stdout/stderr
| - 二进制格式,带结构化字段(_PID、_UID、_SYSTEMD_UNIT)
| - 存 /var/log/journal 或 /run/log/journal
| - 用 journalctl 查(第 14 篇讲过)
|
+--> rsyslog(传统)
- 纯文本,写 /var/log/messages、/var/log/syslog
- 支持转发到远程日志服务器(syslog 协议)
- 支持按 facility/priority 灵活分流
两者的分工:journald 负责本机收集和结构化查询,rsyslog 负责转发到远端和写传统文本文件。很多系统上 journald 会把日志转发给 rsyslog,导致同一条日志存两份。
# /etc/systemd/journald.conf
[Journal]
ForwardToSyslog=no # <- 不用 rsyslog 的话关掉,省一半磁盘 IO 和空间
3.1 /var/log 里有什么
ls /var/log/
# syslog / messages 系统总日志(rsyslog 写的)
# auth.log / secure 认证日志:ssh 登录、sudo 使用 <- 安全排查必看
# kern.log 内核日志
# dmesg 开机时的内核环形缓冲快照
# journal/ journald 的二进制日志
# nginx/ mysql/ 各应用自己的日志目录
# 查登录失败记录(排查暴力破解)
grep "Failed password" /var/log/auth.log | tail -20
journalctl _COMM=sshd | grep -i "failed"
# 统计爆破来源 IP
grep "Failed password" /var/log/auth.log \
| grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' \
| sort | uniq -c | sort -rn | head -10
# 8923 45.227.xxx.xxx <- 有人在爆破
# 查 sudo 使用记录(第 3 篇讲的审计)
journalctl _COMM=sudo -n 20
3.2 rsyslog 的 facility 与 priority
facility(来源):auth authpriv cron daemon kern mail syslog user local0-7
priority(级别):emerg alert crit err warning notice info debug
^ 0 ^ 7
# /etc/rsyslog.d/myapp.conf
# 把 local0 的日志单独写一个文件
local0.* /var/log/myapp.log
# 只把 error 及以上转发到远程日志服务器
*.err @@log-server.internal:514
# ^^ @@ = TCP(可靠),@ = UDP(快但可能丢)
# 应用侧用 logger 命令写 syslog
logger -p local0.info -t myapp "任务开始"
logger -p local0.err -t myapp "任务失败: 连接超时"
systemctl restart rsyslog
local0 ~ local7 是留给自定义应用的 facility,用它们可以把应用日志和系统日志分流到不同文件。
4. logrotate:日志轮转
不做日志轮转的后果是确定的:磁盘被撑满,然后所有服务一起挂。
# 主配置
cat /etc/logrotate.conf
# 各应用的配置(推荐放这里)
ls /etc/logrotate.d/
# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily # 每天轮转(可选 hourly/weekly/monthly)
rotate 14 # 保留 14 份
size 100M # 或者超过 100M 就轮转(与 daily 是【或】关系)
missingok # 文件不存在不报错
notifempty # 空文件不轮转
compress # 压缩旧日志
delaycompress # 【延迟一轮】再压缩,见下
dateext # 用日期而不是数字做后缀
dateformat -%Y%m%d
create 0640 myapp myapp # 创建新文件的权限和属主
sharedscripts # 多个文件匹配时,脚本只执行一次
postrotate
# 通知应用重新打开日志文件(第 8 篇讲的关键步骤)
systemctl reload myapp >/dev/null 2>&1 || true
endscript
}
4.1 核心问题:轮转后应用还在写旧文件
这是 logrotate 最重要的一个知识点,它直接关联第 2 篇的 inode 机制和第 8 篇的信号。
logrotate 把 app.log 重命名为 app.log.1
v
应用进程持有的 fd 仍然指向【同一个 inode】
v
应用继续往 app.log.1 里写!新建的 app.log 永远是空的
v
而且旧文件被删除后,磁盘空间也不释放(第 2 篇 §3.1)
两种解法,各有代价:
方案一:postrotate + 通知应用重开文件(推荐)
/var/log/nginx/*.log {
daily
rotate 14
compress
delaycompress
postrotate
# nginx 用 USR1 表示「重新打开日志文件」
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
endscript
}
优点:不丢日志、原子性好。要求:应用必须支持信号触发重开文件。
方案二:copytruncate(应用不支持信号时)
/var/log/myapp/*.log {
daily
rotate 7
compress
copytruncate # 先复制内容,再把原文件【截断为 0】
# inode 不变,所以应用的 fd 仍然有效,继续往同一个文件写
}
copytruncate 的代价必须知道:
① 【会丢日志】:复制和截断之间有时间窗口,这期间写入的内容会丢失
② 需要两倍磁盘空间(复制期间)
③ 大文件时复制很慢,IO 压力大
④ 如果应用用 O_APPEND 以外的方式写,截断后偏移量还在原处
-> 产生巨大的稀疏文件(看起来很大但不占空间)
**规律:能用 postrotate 发信号就别用 copytruncate。**只有在应用完全不支持重开日志(比如某些老程序、或直接重定向 stdout 的容器)时才退而用它。
更好的方案是让应用自己写 stdout,交给 journald 或容器运行时管理——这样完全不需要 logrotate。这也是十二要素应用(12-factor app)的建议。
4.2 delaycompress 为什么需要
不加 delaycompress:
轮转瞬间 app.log -> app.log.1.gz(立即压缩)
但如果应用还在往旧 inode 写(还没重开文件),
这些内容就写进了一个正在被压缩的文件 -> 内容损坏
加 delaycompress:
第一轮:app.log -> app.log.1(不压缩)
第二轮:app.log.1 -> app.log.2.gz(这时才压缩,应用早已重开文件)
配合 postrotate 使用时,delaycompress 是必需的——它给应用留出重新打开文件的时间。
4.3 测试与排查
# 演练:只显示会做什么,不实际执行 <- 改完配置必做
logrotate -d /etc/logrotate.d/myapp
# 强制执行一次(忽略时间条件)
logrotate -f /etc/logrotate.d/myapp
# 查看状态文件(记录了每个文件上次轮转的时间)
cat /var/lib/logrotate/status | grep myapp
# 确认 logrotate 有没有在跑
systemctl list-timers | grep logrotate
# 现代发行版用 systemd timer 而不是 /etc/cron.daily
坑:
logrotate -d(debug 模式)不会更新状态文件,但-f会。测试时用-d,确认无误再用-f。另外如果/var/lib/logrotate/status里记录的时间是「今天已经轮转过」,正常调用就不会再轮转——这是「为什么我的日志没轮转」的一个原因。
5. 包管理
5.1 apt(Debian / Ubuntu)
apt update # 更新软件源索引(不升级软件)
apt upgrade # 升级已安装的包
apt full-upgrade # 允许删除包来完成升级
apt install nginx
apt install nginx=1.24.0-1 # 指定版本
apt remove nginx # 卸载,保留配置文件
apt purge nginx # 卸载并删除配置文件
apt autoremove # 清理不再需要的依赖
apt list --installed | grep nginx
apt list --upgradable
apt show nginx # 包信息
apt-cache policy nginx # 可用版本和来源优先级
# 查文件属于哪个包 / 包含哪些文件
dpkg -S /usr/sbin/nginx
dpkg -L nginx
# 查未完全卸载的包
dpkg -l | grep '^rc'
锁定版本,防止意外升级:
apt-mark hold nginx # 锁定
apt-mark unhold nginx
apt-mark showhold
生产环境的升级纪律:
# ✅ 只升级安全补丁,不动其他
unattended-upgrade --dry-run
# ✅ 升级前看清会动什么
apt upgrade --dry-run | grep -E "^(Inst|Remv)"
# ❌ 生产环境不要盲目 apt upgrade
# 它可能升级内核、glibc、systemd,需要重启且有兼容风险
5.2 dnf / yum(RHEL 系)
dnf check-update
dnf update
dnf install nginx
dnf remove nginx
dnf list installed | grep nginx
dnf info nginx
dnf provides /usr/sbin/nginx # 查文件属于哪个包
# 历史与回滚(dnf 的杀手功能,apt 没有)
dnf history
dnf history info 42
dnf history undo 42 # <- 撤销某次操作
dnf history rollback 40 # 回滚到某个时间点
# 版本锁定
dnf install python3-dnf-plugin-versionlock
dnf versionlock add nginx
dnf versionlock list
dnf history undo 是 apt 没有的能力——升级出问题时可以整体撤销,不需要手动降级每个包。
# rpm 底层操作
rpm -qa | grep nginx # 已安装的包
rpm -ql nginx # 包含的文件
rpm -qf /usr/sbin/nginx # 文件属于哪个包
rpm -qi nginx # 包信息
rpm -V nginx # 校验文件是否被改动过 <- 安全检查有用
rpm -V 能发现被篡改的文件:
rpm -V nginx
# S.5....T. c /etc/nginx/nginx.conf
# ^ ^ ^ ^
# | | | +- 文件路径
# | | +--- c = 配置文件
# | +------------ 5 = MD5 变了(内容被改)
# +-------------- S = 大小变了
配置文件被改是正常的,但如果二进制文件显示被改动,就要警惕入侵。
5.3 源码编译安装
./configure --prefix=/usr/local/myapp # 装到独立目录,便于卸载
make -j$(nproc)
make install
# 更好的做法:用 checkinstall 生成 deb/rpm 包,能被包管理器卸载
checkinstall --pkgname=myapp --pkgversion=1.0
源码安装的问题是「不受包管理器管理」:无法 apt list 看到、无法自动升级、卸载只能手动删文件。所以:
装到 /usr/local/ 下(第 1 篇讲的目录约定)
最好用 checkinstall 打成包
或者干脆用 Docker 避免污染宿主机
6. 动态链接库
「明明装了库还是报找不到」是这一节要解决的问题。
# 看一个程序依赖哪些库
ldd /usr/sbin/nginx
# linux-vdso.so.1 (0x00007ffd8c5f5000)
# libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f2c...)
# libcrypt.so.1 => not found <- 缺这个!
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2c...)
=> not found 就是「程序启动报 error while loading shared libraries」的原因。
6.1 库的搜索顺序
① 编译时写进二进制的 DT_RPATH / DT_RUNPATH
② 环境变量 LD_LIBRARY_PATH
③ /etc/ld.so.cache(由 ldconfig 生成的缓存)
④ /lib、/usr/lib 等默认目录
# 装了库但还是 not found -> 缓存没更新
echo "/usr/local/myapp/lib" > /etc/ld.so.conf.d/myapp.conf
ldconfig # 重建缓存 <- 关键步骤
# 验证
ldconfig -p | grep libmylib
# libmylib.so.1 (libc6,x86-64) => /usr/local/myapp/lib/libmylib.so.1
ldconfig 是最容易漏的一步——把 .so 文件拷到某个目录并不够,必须让 ld.so.cache 知道它。
# 临时指定(调试用,不要写进生产的启动脚本)
LD_LIBRARY_PATH=/opt/mylib ./myapp
# 看程序运行时实际加载了哪些库(比 ldd 准确)
LD_DEBUG=libs ./myapp 2>&1 | head -30
6.2 glibc 版本问题
这是跨发行版部署最常见的报错:
./myapp
# ./myapp: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
# ^^^^^^^^^^ 编译环境的 glibc 比运行环境新
# 查当前系统的 glibc 版本
ldd --version
# ldd (Ubuntu GLIBC 2.35-0ubuntu3) 2.35
# 查二进制需要的最低版本
objdump -T ./myapp | grep GLIBC_ | sed 's/.*GLIBC_\([0-9.]*\).*/\1/' | sort -Vu | tail -1
# 2.34
glibc 向后兼容但不向前兼容:新 glibc 上编译的程序不能在旧 glibc 上运行,反之可以。
解决方案按推荐度排序:
① 在【目标环境相同或更旧】的系统上编译(用 Docker 保证一致)
② Go 项目用 CGO_ENABLED=0 静态编译 —— 完全不依赖 glibc <- 最简单
③ 用 musl 静态链接(Alpine 或 musl-gcc)
④ 打包时带上所需的 .so(麻烦且易出错,不推荐)
# Go 的静态编译,产物可以在任何 Linux 上跑
CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o myapp
# 验证是不是静态的
file myapp
# myapp: ELF 64-bit LSB executable, x86-64, statically linked
# ^^^^^^^^^^^^^^^^ ✅
ldd myapp
# not a dynamic executable ✅ 零依赖
这就是第 1 篇提到的「Alpine + CGO 的坑」的另一面:如果全程 CGO_ENABLED=0,Alpine 和 musl 的问题根本不会出现;一旦开了 CGO(用了 sqlite3、某些图像库、os/user 的部分功能),就必须保证编译环境和运行环境的 libc 一致。
7. 实战:磁盘被日志占满的应急与根治
现象:告警「磁盘使用率 98%」,服务开始报错。
① 快速定位是谁占的
df -h
# /dev/vda1 40G 39G 0.5G 99% /
# 找出最大的目录(第一层)
du -xh --max-depth=1 / 2>/dev/null | sort -rh | head -8
# 32G /var
# 4.2G /usr
# ^^^^ 缩到 /var
# 注意 -x:不跨文件系统,避免统计到其他挂载点
du -xh --max-depth=1 /var/log 2>/dev/null | sort -rh | head -5
# 28G /var/log/myapp
# 2.1G /var/log/journal
ls -lhS /var/log/myapp/ | head -5
# -rw-r----- 1 myapp myapp 26G Aug 6 22:30 app.log <- 单个 26GB
② 检查有没有「已删除但仍占空间」的文件(第 2 篇、第 11 篇讲过)
# 这一步不能跳过 —— 如果之前有人 rm 过日志,空间根本没释放
lsof +L1 2>/dev/null | head -5
# COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
# myapp 1234 myapp 5w REG 253,1 8123456789 0 663214 /var/log/myapp/old.log (deleted)
# ^ NLINK=0
# 找到了:还有 8GB 是被删除但未释放的
③ 应急止血(不重启服务)
# ⚠️ 不要用 rm!会变成上面那种"删了不释放"的情况
# rm /var/log/myapp/app.log ❌
# ✅ 用截断,立即释放空间且 inode 不变,应用的 fd 继续有效
truncate -s 0 /var/log/myapp/app.log
# 或者
: > /var/log/myapp/app.log
# 对于已经"删除但未释放"的,截断它的 fd
: > /proc/1234/fd/5
df -h /
# /dev/vda1 40G 5G 33G 13% / ✅ 空间回来了
truncate -s 0 是应急处理大日志的标准动作——比 rm 安全,因为 inode 不变,应用不需要重启。
④ 找出日志暴增的原因
# 看最近的日志内容,找重复的模式
tail -1000 /var/log/myapp/app.log | \
sed -E 's/[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z?//; s/[0-9a-f]{8,}//g; s/[0-9]+/N/g' | \
sort | uniq -c | sort -rn | head -5
# 892 level=debug msg="cache miss" key=N
# 78 level=info msg="request completed" duration=Nms
# ^^^ debug 日志占了 89%
# 确认日志级别配置
grep -i "log.*level" /etc/myapp/config.yaml
# log_level: debug <- 生产环境误开了 debug
这个 sed 归一化技巧很实用——把时间戳、ID、数字都替换掉,就能统计出「哪类日志最多」,而不是每行都是唯一的。
⑤ 根治:四件事一起做
# ① 改回 info 级别
sed -i 's/log_level: debug/log_level: info/' /etc/myapp/config.yaml
systemctl reload myapp
# ② 配置 logrotate(之前完全没配)
cat > /etc/logrotate.d/myapp <<'EOF'
/var/log/myapp/*.log {
daily
size 200M
rotate 7
missingok
notifempty
compress
delaycompress
dateext
create 0640 myapp myapp
sharedscripts
postrotate
systemctl reload myapp >/dev/null 2>&1 || true
endscript
}
EOF
logrotate -d /etc/logrotate.d/myapp # 演练确认无误
# ③ 限制 journal 大小(第 14 篇提到过)
cat >> /etc/systemd/journald.conf <<'EOF'
SystemMaxUse=1G
MaxRetentionSec=2week
ForwardToSyslog=no
EOF
systemctl restart systemd-journald
journalctl --vacuum-size=1G
# ④ 加磁盘监控告警(治标也要有)
cat > /etc/systemd/system/disk-check.service <<'EOF'
[Unit]
Description=Disk usage check
[Service]
Type=oneshot
ExecStart=/opt/scripts/disk_check.sh
EOF
cat > /etc/systemd/system/disk-check.timer <<'EOF'
[Unit]
Description=Check disk every 10 minutes
[Timer]
OnCalendar=*:0/10
RandomizedDelaySec=60
[Install]
WantedBy=timers.target
EOF
systemctl enable --now disk-check.timer
#!/usr/bin/env bash
# /opt/scripts/disk_check.sh —— 用第 5 篇的脚本规范
set -euo pipefail
THRESHOLD=85
df -x tmpfs -x devtmpfs --output=pcent,target | tail -n +2 | while read -r pct mnt; do
usage=${pct%\%}
usage=${usage// /}
if (( usage >= THRESHOLD )); then
# 顺便查一下有没有"删除未释放"的文件
leaked=$(lsof +L1 2>/dev/null | awk 'NR>1 {s+=$7} END {printf "%.1f", s/1024/1024/1024}')
logger -p local0.err -t disk-check \
"磁盘告警: $mnt 已用 ${usage}%,已删除未释放约 ${leaked}GB"
# 这里接入实际的告警通道(webhook / 短信)
fi
done
结果:磁盘从 99% 降到 13%,日志日增量从 26GB/天降到 400MB/天,并且有了轮转和告警。
规律:磁盘满的应急用 truncate -s 0 而不是 rm;根治要同时做「降日志量 + 配轮转 + 限 journal + 加监控」四件事,只做一件迟早会复发。
另一个相关场景是「
df满了但du加起来对不上」——除了本节的「已删除未释放」,还有「被挂载点遮盖」这种更隐蔽的原因,见 Linux-20 §4。
8. 面试题
Q:为什么脚本手动执行正常,放到 cron 里就失败?
最常见的原因是环境变量。cron 不读 ~/.bashrc、~/.bash_profile 或 /etc/profile,只提供一个极简环境——PATH 通常只有 /usr/bin:/bin。所以装在 /usr/local/bin 或 /usr/local/go/bin 的命令(go、docker、kubectl、自编译的 python)全都找不到。解决:在 crontab 顶部显式写 PATH=...,或脚本里用绝对路径,或用 /bin/bash -lc '脚本'(-l 表示 login shell,会读 profile)。调试手法是让 cron 任务执行 env > /tmp/cron_env.txt,对比缺了什么。其次的原因是 % 未转义(crontab 里 % 表示换行,会截断命令)和相对路径(cron 的工作目录是用户家目录)。
Q:cron 和 systemd timer 怎么选?timer 有什么优势?
简单的、一次性的、纯 shell 任务用 cron(一行就够);需要日志、资源限制、防重叠、依赖管理的用 timer。timer 的优势:① 环境完全可控,在 unit 里声明,不依赖登录 shell;② 日志自动进 journal,可用 journalctl -u xxx 按时间和级别查询,而 cron 默认把输出通过邮件发送(没配 MTA 就直接丢了);③ Persistent=true 能补跑错过的任务(机器关机期间的定时任务,cron 会永久跳过);④ 自动防重叠(service 已在运行就不会重复启动,cron 需要手动加 flock);⑤ RandomizedDelaySec 随机延迟——100 台机器都配「每天 3:00 备份」时 cron 会让它们同一秒开始,瞬间打满共享存储,timer 能把负载摊到 10 分钟内;⑥ 完整的 cgroup 资源限制。代价是要写两个文件。
Q:cron 任务执行时间超过了间隔,会怎样?怎么防止重叠?
cron 不检查上一次是否还在运行,会直接启动新实例。所以每 5 分钟一次的任务如果某次跑了 20 分钟,就会有 4 个实例并发——备份场景下会把磁盘 IO 打满。用 flock 做互斥:*/5 * * * * /usr/bin/flock -n /var/run/task.lock /opt/task.sh,-n 表示拿不到锁就直接退出不排队。flock 比检查 pid 文件可靠得多:锁依附于文件描述符,进程无论怎么死(包括 kill -9),内核关闭 fd 时锁自动释放,不会留下陈旧锁导致任务永远起不来。systemd timer 则自带这个保护。
Q:logrotate 轮转之后,为什么应用还在往旧文件写?
因为 logrotate 默认是重命名文件(app.log → app.log.1),而应用持有的文件描述符指向的是 inode,不是文件名。重命名不改变 inode,所以应用继续往同一个 inode 写——也就是写进了 app.log.1,而新建的 app.log 永远是空的。而且如果旧文件后来被删除,空间也不会释放(第 2 篇讲的机制)。解法有两种:① postrotate 里给应用发信号让它重新 open() 日志文件(nginx 用 SIGUSR1,systemd 服务用 systemctl reload),这是推荐做法,不丢日志;② copytruncate——先复制内容再把原文件截断为 0,inode 不变所以 fd 继续有效,但会丢失复制和截断之间那个时间窗口的日志,还需要两倍磁盘空间。能发信号就别用 copytruncate。
Q:delaycompress 是干什么的?为什么需要它?
它让轮转出来的文件延迟一轮再压缩。不加的话,app.log 刚被重命名为 app.log.1 就立即压缩成 app.log.1.gz——但此时应用可能还没执行完重新打开文件的操作,仍在往那个 inode 写,写进了一个正在被压缩的文件,导致内容损坏或丢失。加了 delaycompress 后,第一轮只重命名不压缩,第二轮才压缩上一轮的文件,这时应用早已重开文件。所以 postrotate + 发信号的方案必须配 delaycompress。
Q:磁盘被日志占满了,怎么应急处理?
不要用 rm——文件被应用持有时,rm 只删目录项,inode 和数据块不释放,空间根本回不来(df 不降、du 又数不到)。正确做法是 truncate -s 0 /var/log/app.log 或 : > /var/log/app.log,截断内容但保留 inode,空间立即释放且应用的 fd 继续有效,不需要重启服务。如果之前已经有人 rm 过,用 lsof +L1 找出 NLINK=0 的文件(已删除但被持有),然后 : > /proc/<pid>/fd/<n> 截断它。根治要做四件事:降日志级别、配 logrotate、限制 journald 大小(SystemMaxUse)、加磁盘监控告警——只做一件迟早复发。
Q:程序报 error while loading shared libraries: libxxx.so.1: cannot open shared object file,怎么排查?
先用 ldd /path/to/binary 看哪个库显示 => not found。然后按库的搜索顺序排查:① 编译时写进二进制的 RPATH/RUNPATH;② LD_LIBRARY_PATH 环境变量;③ /etc/ld.so.cache;④ 默认目录 /lib、/usr/lib。最常见的原因是库文件已经拷到了某个目录但没更新缓存——需要把目录写进 /etc/ld.so.conf.d/xxx.conf 然后执行 ldconfig 重建缓存,用 ldconfig -p | grep libxxx 验证。调试时可以用 LD_DEBUG=libs ./myapp 看运行时实际的库加载过程,比 ldd 准确。
Q:GLIBC_2.34 not found 是什么问题?怎么解决?
编译环境的 glibc 版本比运行环境新。glibc 向后兼容但不向前兼容——新 glibc 上编译的二进制不能在旧 glibc 系统上运行,反之可以。用 ldd --version 看当前系统版本,用 objdump -T ./myapp | grep GLIBC_ 看二进制要求的最低版本。解决方案按推荐度:① 在目标环境相同或更旧的系统上编译(用 Docker 固定编译镜像);② Go 项目用 CGO_ENABLED=0 静态编译,产物完全不依赖 glibc,可以在任何 Linux 上跑(ldd 会显示 not a dynamic executable)——这是最简单的解法,也顺带避开了 Alpine/musl 的所有兼容问题;③ 用 musl 静态链接。注意:一旦项目开了 CGO(sqlite3、某些图像库、部分 os/user 功能),就必须保证编译和运行环境的 libc 一致。
Q:dnf 有什么 apt 没有的能力?
dnf history 系列——完整记录每次安装/升级/卸载操作,并且支持整体撤销和回滚:dnf history 列出所有事务,dnf history info 42 看详情,dnf history undo 42 撤销某次操作,dnf history rollback 40 回滚到某个时间点。这在「升级后服务起不来」时非常有用,不需要手动降级每个包和它的依赖。apt 只能靠 /var/log/apt/history.log 查看记录,回滚要手动指定版本号逐个降级。另外 rpm -V <package> 能校验已安装文件是否被改动过(显示 5 表示 MD5 变了),配置文件被改是正常的,但二进制文件显示被改动就要警惕入侵。
上一篇:Linux-14 启动流程与 systemd | 下一篇:Linux-16 SSH 远程访问原理与实战
xingliuhua