1. 项目概述:这不是“黑客速成班”,而是一份脚本能力进阶者的认知地图
“精通脚本黑客读书笔记(一)”这个标题,乍看容易让人联想到影视剧中敲着键盘、三秒黑掉服务器的炫技场面。但实际翻开任何一本被业内公认扎实的脚本类技术书——比如《Unix/Linux编程实践教程》《Effective Python》《Shell Scripting Recipes》——你会发现,所谓“脚本黑客”,根本不是教你怎么入侵系统,而是指用脚本语言作为思维杠杆,把重复、琐碎、易错的手工操作,压缩成可复现、可验证、可协作的一行命令或一个脚本文件。它解决的是真实世界里每天都在发生的效率塌方:运维要手动查20台服务器的磁盘使用率;数据同事每周花3小时整理Excel里的脏数据;测试同学反复点击UI做回归验证;甚至设计师导出100张图还要调参数、改路径、重命名……这些不是“问题”,是“默认状态”。而脚本能力,就是把这种默认状态打个结、剪一刀、重新系上更结实的绳扣。
我带过不少刚转行的学员,也帮某高校实验室做过自动化工具链重构。最常听到的困惑是:“学了Python/Shell,还是写不出能用的脚本。”原因很实在:没人告诉你,写脚本的第一步不是写代码,而是定义“边界”——这个脚本到底要接管哪一段人工流程?输入从哪儿来?输出到哪儿去?失败时该沉默、报错,还是自动重试?这些决策比语法重要十倍。这篇笔记不讲for循环怎么嵌套,也不列sed的50个正则写法,而是还原一个真实场景:我们如何把一份原始日志文本,从“人眼扫描找异常”的状态,变成“./parse_log.sh error | head -20就能定位根因”的状态。过程中会暴露所有教科书不会写的细节:为什么awk字段分隔符设成[[:space:]]+比空格更稳?为什么脚本开头必须加set -euo pipefail?为什么日志时间戳解析要用date -d而不是字符串截取?这些不是技巧,是踩过坑之后长出来的肌肉记忆。适合两类人:一类是已经会基础语法,但总卡在“写完跑不通/跑通不敢上线”的实战者;另一类是想系统建立脚本工程化思维的初学者——你不需要懂网络协议,但得明白,让机器替你思考的前提,是你先想清楚自己到底在想什么。
2. 核心思路拆解:为什么“读书笔记”比“教程”更有价值?
2.1 从“功能实现”到“意图建模”的思维跃迁
多数脚本教学停留在“功能层”:教你怎么用grep找关键词,用curl发请求,用jq解析JSON。这就像教人开车只讲油门刹车,不讲路况预判和路线规划。而真正高效的脚本开发者,第一步永远是对任务做意图建模。以“分析Nginx访问日志中的高频404错误路径”为例:
- 初级做法:
grep "404" /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -nr | head -10 - 进阶做法:先问三个问题:
- 时效性要求:是查历史全量日志,还是实时监控滚动日志?前者可用
zcat处理压缩归档,后者必须用tail -F配合信号捕获; - 数据可信度:日志中$7字段是否可能为空或含空格?直接
awk '{print $7}'在"GET /api/v1/users?id=123 HTTP/1.1"这种格式下会截断为/api/v1/users?id=123,漏掉参数部分; - 失败容忍度:如果日志路径不存在,脚本是直接退出报错,还是优雅降级为提示“未找到日志文件,请检查配置”?
- 时效性要求:是查历史全量日志,还是实时监控滚动日志?前者可用
这三个问题的答案,直接决定脚本的健壮性。我在某公司做日志分析工具时,就因忽略第二点,在一次大促期间,因某个前端URL含空格(GET /product/detail?name=MacBook%20Pro HTTP/1.1),导致awk按空格切分后取到/product/detail?name=MacBook%20Pro,后续统计完全失真。修复方案不是加更多if判断,而是改用awk -F'"' '{print $2}'按引号分割,精准提取请求行——因为Nginx日志规范明确要求请求行用双引号包裹。你看,问题根源不在语法,而在对日志格式规范的理解深度。
2.2 “读书笔记”本质是知识压缩与经验锚定
为什么强调“读书笔记”而非“教程”?因为经典技术书的价值,不在于提供标准答案,而在于构建认知坐标系。比如《The Linux Programming Interface》里讲fork()系统调用,会同时展开:内核如何复制页表、COW(写时复制)机制如何节省内存、子进程继承哪些文件描述符、vfork()为何被弃用……这些内容单看枯燥,但当你在写一个需要后台守护进程的脚本时,突然意识到“哦,原来nohup就是靠fork()两次+setsid()实现的”,知识点就从纸面跳进了你的肌肉记忆。我的笔记习惯是:每读完一章,强制用三句话总结:
- 这个机制解决了什么现实痛点?(例:
inotifywait解决轮询消耗CPU的问题) - 它的典型误用场景是什么?(例:在
while read line; do ... done < file中,line变量在循环外不可见,因管道创建子shell) - 我最近哪个项目可以用它优化?(例:用
inotifywait -m -e create /tmp/watch | while read path action file; do ... done替代每5秒ls检测)
这种压缩不是摘抄,而是把书本知识钉在自己的项目坐标上。某次给某实验室写数据采集脚本,需要监听USB设备插拔事件。翻书看到udevadm monitor --subsystem-match=usb,立刻联想到笔记里记的“udev规则优先级高于用户态脚本”,于是放弃inotify方案,直接写.rules文件触发脚本——省去进程常驻和权限管理的麻烦。这就是读书笔记的复利:它不教你造轮子,但让你一眼认出哪个轮子该装在哪辆车上。
2.3 工具链选型逻辑:为什么Shell仍是不可替代的“胶水语言”
当前AI编程工具火爆,有人问:“还用学Shell吗?”我的答案很直接:Shell不是编程语言,是操作系统的人机接口协议。你可以用Python写一个完美的日志分析器,但把它集成进CI/CD流水线时,90%的场景仍需一行bash -c 'python3 parse.py --input $ARTIFACT_PATH'来调用。原因有三:
- 启动开销极低:
sh进程启动耗时约0.1ms,Python解释器约10ms,Java虚拟机约100ms。在需要高频调用的场景(如Git钩子、Makefile依赖),毫秒级差异就是体验分水岭; - 环境耦合度最低:Linux发行版默认自带
sh/bash,无需额外安装依赖。而Python脚本若用到pandas,就得打包环境或要求目标机器预装; - 管道哲学天然契合:
command1 | command2 | command3这种数据流模型,比Python里result = func3(func2(func1(data)))更直观体现“数据在组件间流动”的本质。某次优化一个部署脚本,原用Python读取JSON配置、拼接SSH命令、执行远程操作,耗时2.3秒;改用jq -r '.hosts[] | "\(.ip) \(.user)"' config.json | while read ip user; do ssh $user@$ip "systemctl restart app"; done后,降到0.8秒——不是Python慢,是避免了JSON解析、对象序列化、进程间通信的冗余开销。
所以我的工具链原则很朴素:用Shell做“骨架”(流程控制、环境适配、快速原型),用Python/Go做“器官”(复杂算法、网络IO、结构化数据处理)。笔记里专门有一节记录不同场景的选型决策树,比如“当脚本需要处理超过10MB的CSV文件且需多列关联计算时,强制切换至Python pandas;若只是按列筛选并导出,awk -F, '$3 > 100 {print $1,$2}'更稳”。
3. 核心细节解析:从一行命令到生产级脚本的七道关卡
3.1 第一道关:脚本头声明与严格模式(set -euo pipefail)
新手常忽略脚本第一行#!/bin/bash之后该写什么。很多人直接开始写echo "hello",结果在Alpine容器里运行报错——因为Alpine默认用ash而非bash。正确姿势是:
#!/usr/bin/env bash set -euo pipefail#!/usr/bin/env bash:用env查找PATH中的bash,兼容不同系统路径(Ubuntu的/bin/bashvs macOS的/opt/homebrew/bin/bash);set -e:任一命令非零退出即终止脚本,避免cd /tmp && rm -rf *中cd失败后仍执行危险删除;set -u:引用未定义变量时报错,防止cp $SRC_DIR/file.txt $DST_DIR/中$SRC_DIR为空导致cp file.txt $DST_DIR/误删当前目录文件;set -o pipefail:管道中任一命令失败即整体失败,否则cmd1 | cmd2中cmd1失败但cmd2成功,脚本仍返回0。
提示:
set -u有时会误伤,比如检查变量是否存在:if [ -n "$VAR" ]在VAR未定义时会报错。安全写法是if [ -n "${VAR:-}" ],${VAR:-}表示“VAR为空或未定义时取空字符串”。
我在某次部署中吃过亏:脚本里有rsync -avz $SOURCE/ $DEST/,但$SOURCE因环境变量未加载为空,结果rsync -avz / $DEST/把整个根目录同步过去了。加了set -u后,脚本在$SOURCE未定义时立即报错退出,避免灾难。
3.2 第二道关:参数解析的工业级写法(getoptsvsgetopt)
脚本传参不能只靠$1 $2。比如./deploy.sh --env prod --timeout 300 --force,用$1硬匹配既难维护又不支持长选项。getopts是POSIX标准,轻量可靠:
while getopts "e:t:f" opt; do case $opt in e) ENV="$OPTARG" ;; t) TIMEOUT="$OPTARG" ;; f) FORCE=true ;; *) echo "Usage: $0 [-e env] [-t timeout] [-f]"; exit 1 ;; esac done但getopts不支持长选项(--env)和带空格的参数。此时用getopt(注意不是getopts):
TEMP=$(getopt -o 'e:t:f' --long 'env:,timeout:,force' -n "$0" -- "$@") eval set -- "$TEMP" while true; do case "$1" in -e|--env) ENV="$2"; shift 2 ;; -t|--timeout) TIMEOUT="$2"; shift 2 ;; -f|--force) FORCE=true; shift ;; --) shift; break ;; *) echo "Internal error!"; exit 1 ;; esac done关键细节:getopt输出需eval set --才能正确处理空格参数;--是分隔符,之后的参数归入$@供脚本使用。某次写数据库备份脚本,参数含--db-name "my app",不用getopt的话,$2只能取到my,app丢失。
3.3 第三道关:路径处理的防坑指南
脚本中路径错误是最高频Bug。常见陷阱:
cd /path/to/dir && ./script.sh:若/path/to/dir不存在,cd失败但./script.sh仍在当前目录执行;SCRIPT_DIR=$(dirname $0):$0可能是相对路径(如../bin/script.sh),dirname返回../bin,后续cd $SCRIPT_DIR会进入错误目录;pwd获取当前路径,但脚本可能被软链接调用,pwd返回链接所在路径而非真实路径。
工业级写法:
# 获取脚本真实所在目录(兼容软链接) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 检查必要目录是否存在 if [[ ! -d "$SCRIPT_DIR/../config" ]]; then echo "Config directory missing: $SCRIPT_DIR/../config" >&2 exit 1 fiBASH_SOURCE[0]始终指向当前脚本文件路径,cd && pwd确保得到绝对路径。某次在Kubernetes Job中运行脚本,因容器内/bin/sh不支持BASH_SOURCE,改用readlink -f "$0" | xargs dirname(需安装coreutils)。
3.4 第四道关:日志与调试的黄金组合
生产脚本必须有日志,但echo满天飞会污染标准输出。标准做法:
- 标准输出(stdout)留给脚本核心结果(如
find . -name "*.log" | xargs ls -lh的文件列表); - 标准错误(stderr)留给日志和错误信息(
echo "INFO: Starting backup..." >&2); - 调试模式用
set -x(打印执行命令)+set +x(关闭)控制。
进阶技巧:用logger命令写系统日志,或重定向到文件:
LOG_FILE="/var/log/myscript.log" exec 3>&1 4>&2 # 备份stdout/stderr到fd3/fd4 exec 1>"$LOG_FILE" 2>&1 # stdout/stderr重定向到日志 # 脚本主体... echo "Backup completed" >&3 # 仍输出到终端 exec 1>&3 2>&4 3>&- 4>&- # 恢复stdout/stderr这样日志文件记录详细过程,终端只显示关键结果。某次排查定时任务失败,因没开日志,只能靠journalctl -u cron查到“脚本退出码1”,却不知哪行出错。加了日志后,一眼看到cp: cannot create regular file '/backup/data.tar.gz': No space left on device。
3.5 第五道关:临时文件与资源清理的原子性
脚本常需创建临时文件,但忘记清理会导致磁盘占满。错误做法:
TMP_FILE=$(mktemp) # ... do something ... rm $TMP_FILE # 若中间出错,rm不执行!正确做法:用trap注册退出清理函数:
cleanup() { rm -f "$TMP_FILE" "$TMP_DIR" echo "Cleanup done" >&2 } trap cleanup EXIT INT TERM TMP_FILE=$(mktemp) TMP_DIR=$(mktemp -d)trap确保无论脚本正常退出、Ctrl+C中断(INT)或被kill(TERM),都会执行cleanup。更进一步,用mktemp -p /dev/shm将临时文件放在内存盘(/dev/shm),避免I/O瓶颈。某次处理GB级日志,用sort去重,临时文件写到磁盘耗时47秒;改用sort -T /dev/shm后降到8秒——因为内存盘随机读写速度是SSD的10倍以上。
3.6 第六道关:跨平台兼容性的务实妥协
“Write once, run anywhere”在Shell里是毒药。不同系统date命令参数不同:
- Linux:
date -d "2023-01-01" +%s - macOS:
date -j -f "%Y-%m-%d" "2023-01-01" +%s
强行统一写法会增加复杂度。我的策略是:识别平台,分而治之:
if [[ "$(uname)" == "Darwin" ]]; then DATE_CMD="date -j -f '%Y-%m-%d' '%s'" else DATE_CMD="date -d '%s' +%s" fi EPOCH=$(eval "$DATE_CMD" "$INPUT_DATE")或者,用Python做跨平台胶水(牺牲一点启动速度换稳定性):
EPOCH=$(python3 -c "import sys,time; print(int(time.mktime(time.strptime(sys.argv[1], '%Y-%m-%d'))))" "$INPUT_DATE")某次给客户交付脚本,对方用macOS服务器,因date参数不兼容导致定时任务全部失败。后来在脚本开头加check_platform()函数,不兼容时直接echo "Error: This script requires GNU date. Install coreutils via brew." >&2; exit 1。
3.7 第七道关:错误处理的分级响应机制
脚本错误不能只exit 1。要区分:
- 可恢复错误:网络超时,应重试3次;
- 不可恢复错误:配置文件缺失,应报错退出;
- 警告性错误:磁盘空间低于10%,应记录日志但继续执行。
实现分级:
retry_on_failure() { local max_retries=3 local retry_count=0 while [[ $retry_count -lt $max_retries ]]; do if "$@"; then return 0 else ((retry_count++)) echo "WARN: Command failed ($retry_count/$max_retries): $*" >&2 sleep $((2 ** $retry_count)) # 指数退避 fi done echo "ERROR: Command failed after $max_retries retries: $*" >&2 return 1 } # 使用 retry_on_failure curl -s http://api.example.com/health某次调用外部API,因对方服务抖动,脚本连续失败。加了重试后,成功率从82%提升到99.7%。关键是重试间隔用2**n(1s, 2s, 4s),避免雪崩式重试压垮对方。
4. 实操过程:从零构建一个生产级日志分析脚本
4.1 需求定义与边界确认
场景:某公司运维团队需每日早9点分析前一日Nginx访问日志,生成三份报告:
error_report.txt:HTTP状态码4xx/5xx的请求TOP 20路径;slow_report.txt:响应时间>1s的请求TOP 10路径;bot_report.txt:User-Agent含bot、crawler的请求IP TOP 5。
边界确认:
- 输入:
/var/log/nginx/access.log-YYYYMMDD.gz(gzip压缩); - 输出:三个TXT文件,存于
/report/daily/YYYYMMDD/; - 失败处理:若日志不存在,记录警告但不中断;若磁盘空间不足,立即退出并报警;
- 性能要求:单次分析耗时<30秒(日志约500MB)。
4.2 脚本骨架搭建与环境检查
#!/usr/bin/env bash set -euo pipefail # --- 配置区 --- LOG_DIR="/var/log/nginx" REPORT_DIR="/report/daily" DATE=$(date -d 'yesterday' +%Y%m%d) # 昨日日期 LOG_FILE="${LOG_DIR}/access.log-${DATE}.gz" REPORT_SUBDIR="${REPORT_DIR}/${DATE}" TMP_DIR=$(mktemp -d) # --- 环境检查 --- check_disk_space() { local required_mb=1000 local avail_mb=$(df "$REPORT_SUBDIR" | tail -1 | awk '{print int($4/1024)}') if [[ $avail_mb -lt $required_mb ]]; then echo "ERROR: Insufficient disk space. Required: ${required_mb}MB, Available: ${avail_mb}MB" >&2 exit 1 fi } check_log_file() { if [[ ! -f "$LOG_FILE" ]]; then echo "WARN: Log file not found: $LOG_FILE" >&2 return 1 # 不退出,继续生成空报告 fi } mkdir -p "$REPORT_SUBDIR" check_disk_space check_log_file这里体现两个关键设计:check_log_file返回1而非exit,因需求允许“日志缺失时生成空报告”;mkdir -p确保目录存在,避免后续cp失败。
4.3 核心分析逻辑:高效解压与流式处理
关键挑战:500MB压缩日志,解压到磁盘再处理会耗时且占空间。最优解是流式解压+管道处理:
# 解析错误路径TOP20 zcat "$LOG_FILE" 2>/dev/null | \ awk -F'"' ' $3 ~ / (4|5)[0-9][0-9] / { # $3是请求行,匹配4xx/5xx match($3, /GET ([^ ]+) HTTP/); if (RSTART > 0) path = substr($3, RSTART+4, RLENGTH-4); else path = "UNKNOWN"; errors[path]++ } END { for (p in errors) print errors[p], p | "sort -nr | head -20" }' | \ awk '{print $2 "\t" $1}' > "${REPORT_SUBDIR}/error_report.txt" # 解析慢请求TOP10(假设日志第10字段为响应时间) zcat "$LOG_FILE" 2>/dev/null | \ awk '$10 > 1000 {print $7 "\t" $10}' | \ sort -k2,2nr | head -10 > "${REPORT_SUBDIR}/slow_report.txt" # 解析爬虫IPTOP5 zcat "$LOG_FILE" 2>/dev/null | \ awk -F'"' '$6 ~ /(bot|crawler)/i {ips[$1]++} END {for (i in ips) print ips[i], i}' | \ sort -nr | head -5 | awk '{print $2 "\t" $1}' > "${REPORT_SUBDIR}/bot_report.txt"技术要点:
zcat直接解压输出到管道,避免写临时文件;awk -F'"'按引号分割,精准提取请求行($3)和User-Agent($6),规避空格截断;- 正则
/ (4|5)[0-9][0-9] /匹配状态码,注意前后空格确保精确(避免4040被误匹配); 2>/dev/null忽略zcat对不存在文件的报错,与check_log_file的警告逻辑一致。
实测耗时:在8核服务器上,整个流程(含解压)耗时18.3秒,符合<30秒要求。
4.4 健壮性增强:超时控制与资源限制
为防脚本失控,加timeout命令:
# 对耗时操作加120秒超时 timeout 120s zcat "$LOG_FILE" 2>/dev/null | \ awk -F'"' '$3 ~ / (4|5)[0-9][0-9] / { ... }' > "${REPORT_SUBDIR}/error_report.txt" || { echo "ERROR: Error report generation timed out" >&2 exit 1 }同时用ulimit限制内存:
# 限制脚本最大内存为512MB,超限时OOM Killer终止 ulimit -v $((512 * 1024)) # 单位KB某次因awk处理超大日志内存暴涨,ulimit及时阻止了服务器OOM。
4.5 报告封装与交付
最后一步:生成摘要HTML报告,方便邮件发送:
cat > "${REPORT_SUBDIR}/summary.html" <<EOF <html><body> <h2>Daily Report: ${DATE}</h2> <p><a href="error_report.txt">Error Report</a> (Top 20)</p> <p><a href="slow_report.txt">Slow Request Report</a> (Top 10)</p> <p><a href="bot_report.txt">Bot Report</a> (Top 5)</p> <p>Generated at $(date)</p> </body></html> EOF并压缩整个报告目录:
tar -czf "${REPORT_DIR}/${DATE}.tar.gz" -C "$REPORT_DIR" "$DATE"这样运维只需scp一个压缩包即可归档。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令 | 解决方案 |
|---|---|---|---|
| 脚本在crontab中运行失败,手动执行正常 | crontab环境变量缺失(PATH、HOME) | * * * * * env > /tmp/cron_env.txt | 在crontab中显式设置PATH=/usr/local/bin:/usr/bin:/bin,或脚本开头用source /etc/environment |
awk处理大文件时内存溢出 | awk数组存储过多键值对 | ps aux | grep awk查看RSS内存 | 改用sort | uniq -c替代awk计数,或用gawk --profile分析性能瓶颈 |
sed替换中文字符乱码 | 终端编码与文件编码不一致 | file -i filename检查编码 | 用iconv -f GBK -t UTF-8 filename | sed 's/旧/新/g'转换编码 |
ssh在脚本中卡住 | SSH连接等待密码输入 | ssh -o ConnectTimeout=5 user@host "date" | 加-o ConnectTimeout=5 -o BatchMode=yes禁用交互 |
find命令报Argument list too long | 匹配文件过多,超出ARG_MAX限制 | getconf ARG_MAX查看限制 | 用find ... -print0 | xargs -0 cmd替代find ... -exec cmd {} \; |
5.2 我踩过的五个深坑与独家技巧
坑1:for file in *.log在无匹配文件时遍历字面量*.log
现象:脚本在空目录运行,for file in *.log把file设为字符串*.log,后续cp *.log /dest/实际执行cp *.log /dest/,报错。
技巧:启用nullglob选项:shopt -s nullglob,此时*.log无匹配时扩展为空,循环不执行。
坑2:$(command)捕获输出时丢失尾部空行
现象:output=$(printf "a\nb\n\n"); echo "$output" \| wc -l输出2而非3,因命令替换自动trim尾部空行。
技巧:用output=$(printf "a\nb\n\n"; echo x); output=${output%x}; echo "$output",通过追加标记再切除。
坑3:read读取含反斜杠的行被转义
现象:日志中有error: file\name.txt,while read line; do echo "$line"; done输出error: filename.txt(\n被解释为换行)。
技巧:while IFS= read -r line; do ... done,-r禁用反斜杠转义,IFS=保留首尾空格。
坑4:date命令在不同locale下月份缩写不一致
现象:date -d "Jan 01 2023" +%s在英文locale正常,在中文locale报错,因Jan被识别为1月。
技巧:临时设置locale:LC_ALL=C date -d "Jan 01 2023" +%s,Clocale保证英文缩写。
坑5:curl下载大文件时内存爆满
现象:curl -o bigfile.zip http://...下载1GB文件,脚本RSS内存飙升至1.2GB。
技巧:用curl -sL http://... \| cat > bigfile.zip,管道避免curl内部缓冲区膨胀;或加--limit-rate 1M限速。
5.3 调试三板斧:从表象到根因
当脚本行为异常,我固定按此顺序排查:
- 复现最小化:剥离无关代码,用
echo打印关键变量,确认问题是否仍在。例如echo "LOG_FILE=$LOG_FILE",发现$LOG_FILE为空,顺藤摸瓜找到DATE计算错误。 - 隔离执行环境:在干净shell中运行
bash -x ./script.sh,-x开启调试模式,逐行打印执行命令及参数。某次发现$PATH被意外覆盖,which python返回空。 - 检查系统状态:用
strace -e trace=open,read,write ./script.sh 2>&1 \| head -50跟踪系统调用,看脚本到底打开了哪些文件、读取了什么内容。曾用此法发现脚本在/proc/sys/kernel/random/uuid上阻塞,因熵池枯竭。
最后分享一个小技巧:在脚本关键节点加echo "DEBUG: $(date +%H:%M:%S) - Stage X completed" >&2,生成带时间戳的调试日志。当问题发生在凌晨3点,这个时间戳比任何print都珍贵——它告诉你,是定时任务调度问题,还是系统负载高峰导致的超时。
我在实际使用中发现,最浪费时间的从来不是写错一行代码,而是花两小时怀疑代码错了,结果发现是磁盘满了、网络断了、或者同事悄悄改了配置文件。所以现在所有脚本,第一行必是环境自检,最后一行必是清理收尾。脚本不是魔法,它是把人类的确定性,翻译成机器能执行的确定性。而这份确定性,就藏在每一处set -u的报错、每一次trap的清理、每一个-o pipefail的坚持里。