Bash 脚本内存泄漏与资源管理故障排查

一、概述

Bash 脚本在运维自动化中广泛应用,但不当的资源管理会导致内存泄漏、文件描述符耗尽、进程僵尸等问题。本文详细介绍 Bash 脚本中常见的资源管理问题及其排查方法。

1.1 常见问题类型

问题类型 症状 严重程度
文件描述符泄漏 too many open files 错误
后台进程失控 系统负载飙升,进程数爆炸
变量内存累积 脚本内存占用持续增长
临时文件堆积 磁盘空间快速消耗
僵尸进程 defunct 进程累积
管道阻塞 脚本 hangs 不退出

1.2 排查工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 进程监控
ps aux | grep script_name
top -p $(pgrep -f script_name)

# 文件描述符检查
lsof -p <pid>
ls -la /proc/<pid>/fd/

# 内存监控
cat /proc/<pid>/status | grep -i vm
pmap -x <pid>

# 系统资源
ulimit -a # 查看资源限制

二、文件描述符泄漏

2.1 问题场景

症状:脚本运行一段时间后报错 bash: cannot create temp file for here-document: No space left on devicetoo many open files

根本原因:打开的文件、管道、socket 未正确关闭

2.2 典型案例

1
2
3
4
5
6
7
8
9
#!/bin/bash
# 错误示例:文件描述符泄漏

while true; do
# 每次循环都打开新文件但未关闭
exec 3<> /tmp/log_$$.txt
echo "log entry" >&3
# 缺少 exec 3>&- 关闭文件描述符
done

2.3 正确做法

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash
# 正确示例:及时关闭文件描述符

LOG_FILE="/tmp/script.log"
exec 3>> "$LOG_FILE" # 打开一次

while true; do
echo "log entry $(date)" >&3
sleep 1
done

# 脚本退出前关闭(或使用 trap)
exec 3>&-

2.4 使用 trap 自动清理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash

# 打开的文件描述符
exec 3>> /tmp/script.log
exec 4<> /tmp/script.lock

# 注册清理函数
cleanup() {
echo "Cleaning up..." >&2
exec 3>&- # 关闭文件描述符 3
exec 4>&- # 关闭文件描述符 4
rm -f /tmp/script.lock
exit $1
}

trap 'cleanup $?' EXIT INT TERM

# 主逻辑
for i in {1..100}; do
echo "Processing $i" >&3
sleep 0.1
done

2.5 排查步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
# 诊断脚本:检查文件描述符泄漏

PID=$1
if [ -z "$PID" ]; then
echo "Usage: $0 <pid>"
exit 1
fi

echo "=== 进程 $PID 文件描述符分析 ==="

# 统计 FD 数量
FD_COUNT=$(ls /proc/$PID/fd 2>/dev/null | wc -l)
echo "打开的文件描述符数量:$FD_COUNT"

# 查看系统限制
MAX_FD=$(cat /proc/$PID/limits 2>/dev/null | grep "open files" | awk '{print $4}')
echo "系统限制:$MAX_FD"

# 列出所有 FD
echo ""
echo "=== 文件描述符详情 ==="
ls -la /proc/$PID/fd/

# 分类统计
echo ""
echo "=== FD 类型统计 ==="
ls -la /proc/$PID/fd/ | awk '{print $1}' | sort | uniq -c

三、后台进程失控

3.1 问题场景

症状:系统进程数激增,ps aux | wc -l 显示异常高的进程数

根本原因:后台进程未正确等待或清理

3.2 典型案例

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
# 错误示例:后台进程失控

for i in {1..1000}; do
# 启动后台进程但从未等待
sleep 10 &
# 缺少 wait 命令
done

# 脚本继续执行,但 1000 个后台进程仍在运行
echo "Done" # 这里会立即执行

3.3 正确做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/bash
# 正确示例:控制并发进程数

MAX_JOBS=10
JOB_COUNT=0

for i in {1..100}; do
# 启动后台任务
(
echo "Processing task $i"
sleep 2
) &

JOB_COUNT=$((JOB_COUNT + 1))

# 当达到最大并发数时等待
if [ $JOB_COUNT -ge $MAX_JOBS ]; then
wait -n # 等待任意一个后台作业完成
JOB_COUNT=$((JOB_COUNT - 1))
fi
done

# 等待所有后台作业完成
wait
echo "All tasks completed"

3.4 使用作业控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash

# 启动多个后台作业
for i in {1..5}; do
sleep $((RANDOM % 10)) &
echo "Started job $! (PID)"
done

# 查看所有后台作业
jobs -l

# 等待特定作业
wait %1 # 等待作业号 1

# 或等待所有作业
wait

# 终止后台作业
kill %2 # 终止作业号 2

3.5 排查失控进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/bin/bash
# 诊断脚本:查找失控的后台进程

SCRIPT_NAME="${1:-my_script}"

echo "=== 查找 $SCRIPT_NAME 相关进程 ==="

# 查找主脚本进程
MAIN_PID=$(pgrep -f "$SCRIPT_NAME" | head -1)
if [ -z "$MAIN_PID" ]; then
echo "未找到主进程"
exit 1
fi

echo "主进程 PID: $MAIN_PID"

# 查找子进程
echo ""
echo "=== 子进程树 ==="
pstree -p $MAIN_PID

# 统计进程数量
CHILD_COUNT=$(pgrep -P $MAIN_PID | wc -l)
echo ""
echo "子进程数量:$CHILD_COUNT"

# 检查僵尸进程
ZOMBIE_COUNT=$(ps -o stat= --ppid $MAIN_PID | grep -c Z || echo 0)
echo "僵尸进程数量:$ZOMBIE_COUNT"

# 如果有僵尸进程,列出详情
if [ "$ZOMBIE_COUNT" -gt 0 ]; then
echo ""
echo "=== 僵尸进程详情 ==="
ps -o pid,stat,cmd --ppid $MAIN_PID | grep Z
fi

四、变量内存累积

4.1 问题场景

症状:长时间运行的脚本内存占用持续增长

根本原因:数组或变量不断追加内容但未清理

4.2 典型案例

1
2
3
4
5
6
7
8
9
#!/bin/bash
# 错误示例:内存累积

LOG_BUFFER=""
while true; do
# 不断追加,永不释放
LOG_BUFFER="${LOG_BUFFER}$(date): log entry\n"
sleep 0.1
done

4.3 正确做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
# 正确示例:定期清理变量

LOG_BUFFER=""
MAX_LINES=1000
LINE_COUNT=0

while true; do
LOG_BUFFER="${LOG_BUFFER}$(date): log entry\n"
LINE_COUNT=$((LINE_COUNT + 1))

# 定期清空缓冲
if [ $LINE_COUNT -ge $MAX_LINES ]; then
echo -e "$LOG_BUFFER" >> /tmp/script.log
LOG_BUFFER="" # 释放内存
LINE_COUNT=0
fi

sleep 0.1
done

4.4 数组内存管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
# 错误示例:数组无限增长

declare -a RESULTS
for i in {1..100000}; do
RESULTS+=("result_$i") # 数组持续增长
done

# 正确示例:限制数组大小
declare -a RESULTS
MAX_SIZE=1000

for i in {1..100000}; do
RESULTS+=("result_$i")

# 超出限制时移除旧元素
if [ ${#RESULTS[@]} -ge $MAX_SIZE ]; then
# 处理并清空
echo "Processing ${#RESULTS[@]} results..."
unset RESULTS
declare -a RESULTS
fi
done

4.5 内存监控脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/bin/bash
# 监控脚本内存使用

PID=$1
INTERVAL=${2:-5}

if [ -z "$PID" ]; then
echo "Usage: $0 <pid> [interval]"
exit 1
fi

echo "监控进程 $PID 的内存使用 (每${INTERVAL}秒)"
echo "按 Ctrl+C 停止"
echo ""

while true; do
if [ ! -d "/proc/$PID" ]; then
echo "进程 $PID 已退出"
break
fi

# 读取内存信息
VmRSS=$(grep VmRSS /proc/$PID/status 2>/dev/null | awk '{print $2, $3}')
VmSize=$(grep VmSize /proc/$PID/status 2>/dev/null | awk '{print $2, $3}')

TIMESTAMP=$(date '+%H:%M:%S')
echo "[$TIMESTAMP] VmRSS: $VmRSS | VmSize: $VmSize"

sleep $INTERVAL
done

五、临时文件管理

5.1 问题场景

症状/tmp 目录空间快速消耗

根本原因:临时文件创建后未删除

5.2 典型案例

1
2
3
4
5
6
7
8
9
#!/bin/bash
# 错误示例:临时文件泄漏

while true; do
TEMP_FILE="/tmp/data_$$_$RANDOM.tmp"
echo "data" > "$TEMP_FILE"
# 处理文件...
# 缺少 rm "$TEMP_FILE"
done

5.3 正确做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash
# 正确示例:使用 mktemp + trap 清理

# 创建临时文件
TEMP_FILE=$(mktemp /tmp/script.XXXXXX)
TEMP_DIR=$(mktemp -d /tmp/script_dir.XXXXXX)

# 注册清理
cleanup() {
rm -f "$TEMP_FILE"
rm -rf "$TEMP_DIR"
echo "临时文件已清理" >&2
}
trap cleanup EXIT INT TERM

# 使用临时文件
echo "data" > "$TEMP_FILE"
mkdir -p "$TEMP_DIR/output"

# 主逻辑...

5.4 临时文件最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash

# 1. 使用 mktemp 创建唯一文件名
SAFE_TEMP=$(mktemp) # 临时文件
SAFE_DIR=$(mktemp -d) # 临时目录
SAFE_FILE_IN_DIR=$(mktemp "$SAFE_DIR"/file.XXXXXX)

# 2. 设置权限
chmod 600 "$SAFE_TEMP" # 仅所有者可读写

# 3. 使用 trap 确保清理
trap 'rm -f "$SAFE_TEMP"; rm -rf "$SAFE_DIR"' EXIT

# 4. 避免使用固定名称
# 错误:/tmp/my_script.tmp # 可能冲突
# 正确:$(mktemp /tmp/my_script.XXXXXX)

5.5 清理泄漏的临时文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash
# 清理脚本:查找并清理旧临时文件

TEMP_DIR="/tmp"
MAX_AGE_HOURS=24
SCRIPT_PREFIX="my_script"

echo "清理 $TEMP_DIR 中超过 ${MAX_AGE_HOURS} 小时的临时文件..."

# 查找并删除
find "$TEMP_DIR" -name "${SCRIPT_PREFIX}*" -type f -mmin +$((MAX_AGE_HOURS * 60)) -exec rm -v {} \;

# 统计结果
REMAINING=$(find "$TEMP_DIR" -name "${SCRIPT_PREFIX}*" -type f | wc -l)
echo "剩余临时文件:$REMAINING"

六、僵尸进程处理

6.1 问题场景

症状ps aux 显示大量 <defunct> 进程

根本原因:父进程未正确等待子进程退出

6.2 典型案例

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/bash
# 错误示例:产生僵尸进程

for i in {1..100}; do
(
echo "Task $i"
exit 0
) &
# 缺少 wait,子进程退出后成为僵尸
done

# 脚本继续,但僵尸进程累积

6.3 正确做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/bin/bash
# 正确做法:忽略 SIGCHLD 或正确等待

# 方法 1:忽略 SIGCHLD(让内核自动清理)
trap '' SIGCHLD

for i in {1..100}; do
(
echo "Task $i"
) &
done

wait # 仍然需要等待所有作业完成

# 方法 2:使用 wait -n 逐个等待
for i in {1..100}; do
(
echo "Task $i"
) &

# 每启动 10 个就等待一个完成
if [ $((i % 10)) -eq 0 ]; then
wait -n
fi
done

wait # 等待剩余作业

6.4 僵尸进程检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
# 检测僵尸进程

echo "=== 系统僵尸进程统计 ==="

# 统计总数
ZOMBIE_TOTAL=$(ps aux | awk '$8 ~ /Z/ {count++} END {print count+0}')
echo "僵尸进程总数:$ZOMBIE_TOTAL"

# 按父进程分组
echo ""
echo "=== 按父进程分组 ==="
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /Z/ {print $2}' | sort | uniq -c | sort -rn | head -10

# 检查特定脚本的僵尸进程
SCRIPT_NAME="${1:-}"
if [ -n "$SCRIPT_NAME" ]; then
echo ""
echo "=== $SCRIPT_NAME 的僵尸进程 ==="
SCRIPT_PID=$(pgrep -f "$SCRIPT_NAME" | head -1)
if [ -n "$SCRIPT_PID" ]; then
ps -o pid,stat,cmd --ppid $SCRIPT_PID | grep Z
fi
fi

七、管道阻塞问题

7.1 问题场景

症状:脚本 hangs 不退出,即使看起来已完成

根本原因:管道缓冲区满,生产者/消费者阻塞

7.2 典型案例

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
# 错误示例:管道阻塞

# 生产者输出大量数据
cat /var/log/syslog | while read line; do
# 消费者处理慢
sleep 0.1
echo "$line"
done | head -100

# 问题:head -100 退出后,while 循环继续尝试写入已关闭的管道

7.3 正确做法

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
# 正确示例:处理管道信号

cat /var/log/syslog | while read line; do
echo "$line"
done | head -100

# 或者使用进程替换避免子 shell 问题
while read line; do
echo "$line"
done < <(cat /var/log/syslog | head -100)

7.4 使用 timeout 防止无限阻塞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash

# 给命令设置超时
timeout 30s long_running_command

# 带超时的管道操作
{
timeout 60s producer_command
} | {
timeout 60s consumer_command
}

# 检查超时退出码
if ! timeout 30s command; then
echo "命令执行超时" >&2
fi

八、综合排查流程

8.1 快速诊断脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#!/bin/bash
# 综合诊断脚本

SCRIPT_PID="${1:-}"

if [ -z "$SCRIPT_PID" ]; then
echo "用法:$0 <脚本 PID>"
exit 1
fi

echo "============================================"
echo "Bash 脚本资源诊断报告"
echo "进程 PID: $SCRIPT_PID"
echo "时间:$(date)"
echo "============================================"

# 1. 进程基本信息
echo ""
echo "[1] 进程信息"
ps -p $SCRIPT_PID -o pid,ppid,%cpu,%mem,vsz,rss,stat,start,time,cmd

# 2. 文件描述符
echo ""
echo "[2] 文件描述符"
FD_COUNT=$(ls /proc/$SCRIPT_PID/fd 2>/dev/null | wc -l)
MAX_FD=$(cat /proc/$SCRIPT_PID/limits 2>/dev/null | grep "open files" | awk '{print $4}')
echo "已打开:$FD_COUNT / 限制:$MAX_FD"

if [ $FD_COUNT -gt 100 ]; then
echo "⚠️ 警告:文件描述符数量异常"
ls -la /proc/$SCRIPT_PID/fd/ | head -20
fi

# 3. 子进程
echo ""
echo "[3] 子进程"
CHILD_COUNT=$(pgrep -P $SCRIPT_PID 2>/dev/null | wc -l)
echo "子进程数量:$CHILD_COUNT"

if [ $CHILD_COUNT -gt 50 ]; then
echo "⚠️ 警告:子进程数量异常"
pstree -p $SCRIPT_PID | head -30
fi

# 4. 僵尸进程
echo ""
echo "[4] 僵尸进程检查"
ZOMBIES=$(ps -o stat= --ppid $SCRIPT_PID 2>/dev/null | grep -c Z || echo 0)
echo "僵尸进程数:$ZOMBIES"

if [ "$ZOMBIES" -gt 0 ]; then
echo "⚠️ 警告:发现僵尸进程"
ps -o pid,stat,cmd --ppid $SCRIPT_PID | grep Z
fi

# 5. 内存使用
echo ""
echo "[5] 内存使用"
cat /proc/$SCRIPT_PID/status 2>/dev/null | grep -E "^(VmSize|VmRSS|VmPeak):"

# 6. 打开的文件
echo ""
echo "[6] 打开的文件(前 20 个)"
lsof -p $SCRIPT_PID 2>/dev/null | head -20

echo ""
echo "============================================"
echo "诊断完成"

8.2 预防措施清单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
## Bash 脚本资源管理检查清单

### 文件描述符
- [ ] 使用 `exec` 打开的 FD 是否有关闭操作
- [ ] 是否使用 `trap` 注册清理函数
- [ ] 避免在循环中重复打开同一文件

### 后台进程
- [ ] 后台作业是否有 `wait` 对应
- [ ] 是否限制最大并发数
- [ ] 是否处理 SIGCHLD 信号

### 变量管理
- [ ] 长运行脚本是否定期清理大变量
- [ ] 数组是否有大小限制
- [ ] 是否避免不必要的字符串拼接

### 临时文件
- [ ] 是否使用 `mktemp` 创建
- [ ] 是否有 `trap` 清理机制
- [ ] 是否设置合理的文件权限

### 进程管理
- [ ] 是否处理僵尸进程
- [ ] 是否使用 `timeout` 防止阻塞
- [ ] 是否有优雅退出机制

九、实战案例

9.1 案例:日志收集脚本内存泄漏

问题:日志收集脚本运行 24 小时后内存占用从 10MB 增长到 2GB

排查

1
2
3
4
5
# 发现变量不断累积
grep -n "BUFFER\|ARRAY" script.sh

# 发现问题代码
LOG_BUFFER="${LOG_BUFFER}${new_log}" # 从未清空

修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 添加定期刷新机制
FLUSH_INTERVAL=1000
LOG_COUNT=0

add_log() {
LOG_BUFFER="${LOG_BUFFER}$1\n"
LOG_COUNT=$((LOG_COUNT + 1))

if [ $LOG_COUNT -ge $FLUSH_INTERVAL ]; then
echo -e "$LOG_BUFFER" >> /var/log/collected.log
LOG_BUFFER=""
LOG_COUNT=0
fi
}

9.2 案例:监控脚本文件描述符耗尽

问题:监控脚本报错 too many open files

排查

1
2
3
4
5
6
7
# 检查 FD 使用情况
lsof -p $(pgrep -f monitor.sh) | wc -l # 返回 1020+

# 发现问题:每次循环都打开新连接
while true; do
exec 3<>/dev/tcp/host/port # 从未关闭
done

修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 复用连接或及时关闭
exec 3<>/dev/tcp/host/port # 在循环外打开一次

while true; do
echo "ping" >&3
read -t 5 response <&3
done

# 或使用函数封装
check_host() {
exec 3<>/dev/tcp/$1/$2
echo "ping" >&3
read -t 5 response <&3
exec 3>&- # 关闭
}

十、总结

Bash 脚本资源管理的关键要点:

  1. 文件描述符:打开必关闭,使用 trap 确保清理
  2. 后台进程:控制并发,使用 wait 等待完成
  3. 变量内存:长运行脚本定期清理大变量
  4. 临时文件:使用 mktemp,注册清理回调
  5. 僵尸进程:正确处理 SIGCHLD 或使用 wait -n
  6. 管道阻塞:注意消费者提前退出的情况
  7. 超时保护:使用 timeout 防止无限阻塞

通过遵循这些最佳实践,可以编写健壮、资源友好的 Bash 脚本。

参考资源