刚接触Linux那阵子我干过一件特别蠢的事写了个备份脚本丢进定时任务里每天凌晨4点执行。跑了两个礼拜某天想看看日志打开一看文件是空的。更诡异的是脚本里的命令手动执行明明有输出。排查了半天最后发现罪魁祸首就是一行重定向——脚本里用的是单箭头每次执行都会把上一次的日志截断清空。那个文件里只剩最后一次运行的输出而最后一次恰好是空输出。从那次事故之后Linux 输出重定向这六个字在我心里的分量就不一样了。它不只是把输出写到文件里这么简单背后牵扯到文件描述符、标准流的去向、命令执行顺序甚至进程的生命周期。无论是写Shell脚本、维护服务日志还是排查线上问题几乎每天都在跟它打交道。这篇文章想把这几年用重定向攒下的经验一次性讲透既有最基础的、也有21、/dev/null、tee、heredoc、exec这些进阶玩法还有大量实测中踩出来的坑。不管你是刚入门Linux的新手还是已经在写脚本的老手应该都能从中找到点有用的东西。1. 三个标准流的底层逻辑0、1、2到底在指向谁1.1 进程诞生的瞬间终端已经替它开好了三条管道在Linux的世界里“一切皆文件”这句话在输出重定向这件事上体现得格外透彻。当你在终端里敲下一条命令shell会先创建一个子进程这个进程在诞生的一瞬间操作系统就给它准备好了三个默认的文件描述符0号标准输入stdin默认指向键盘1号标准输出stdout默认指向终端屏幕2号标准错误stderr默认也指向终端屏幕。文件描述符是什么你可以把它理解成进程手里拿着的一组“票据”每张票据上写着“我打开的是哪个文件、读写权限如何”。这个文件可以是真正的磁盘文件也可以是一个字符设备甚至可以是另一个进程开出来的管道。这里最关键的一点是stdout和stderr虽然都“默认指向终端”但它们是两个完全独立的句柄。这意味着你可以单独操作其中任何一个只让正常输出进文件、错误输出留在屏幕或者反过来。这正是后面所有重定向操作的基础。先举两个例子感受一下ls /tmp # 正常输出走stdout ls /not_exist # 报错走stderr第二条命令的报错信息在终端上看起来跟普通输出差不多但内核知道它走的是2号通道。这个区分就是重定向能够“分流”的根本原因。1.2 为什么错误信息老是“不翼而飞”2的单独捕获很多人第一次接触输出重定向时都遇到过这种情况执行some_command log.txt跑完打开log.txt里面干干净净但终端上明明刷了一堆红色报错。原因很简单只重定向了1号标准输出2号标准错误仍然直接打在屏幕上。想要把错误也收进文件就要显式操作2号描述符some_command 2 error.log这才是完整的第一步。理解了0、1、2这三条“管道”的独立性后面的21、file、12这些写法就都可以推导出来了。顺带提一个细节file是bash的简写等价于file 21但POSIX sh不支持。如果你的脚本需要用sh script.sh直接跑最好老老实实写file 21别图省事。1.3 输入方向的补充与的日常形态输出重定向经常和输入重定向一起出现。表示把文件内容作为命令的标准输入典型例子wc -l notes.txt别看这个简单它在循环处理和批量场景里很常用。而heredoc则是把一段多行文本整体喂给命令本质也属于输入重定向。这两种方式我会在第5节再深入展开因为heredoc在真实脚本里的可玩性比想象中大得多。2. 单箭头与双箭头的取舍截断与追加背后的事故现场2.1 的截断机制文件在命令执行前就被清空了Shell执行cmd file时并不是先把命令跑完、再把结果写进文件。真正的顺序是shell先以写模式打开目标文件这一步会立刻把文件截断为空然后才启动命令命令的输出再写入这个已经打开的描述符。这个顺序带来两个经典后果第一只要命令被执行文件内容必然被清空哪怕命令随后立刻报错退出、什么都没输出。很多日志丢失事故就是这么来的。我在开头提到的定时备份脚本就是栽在这一条上——单箭头把前一天的所有日志截断了而最后一次执行因为某种原因没有产生输出整个文件变成了0字节。第二如果命令执行失败空文件已经被创建出来了在脚本判断里会形成“文件存在但内容为空”的假象。检查日志时不要只ls -l看文件在不在还要看stat里的大小和修改时间。2.2 追加模式与O_APPEND的并发语义双箭头不会截断而是在文件末尾追加。注意“追加”和“写入到同一个文件”不是一回事。打开文件时带了O_APPEND标志内核会保证每次write都写到当前文件末尾而不是从文件头开始覆盖。这个特性在多个进程同时写同一个日志文件时特别重要。如果都用会有互相截断、覆盖的竞争问题用虽然单次写入的内容可能会在多进程间交错毕竟不是原子的大块写入但至少不会把另一个进程的旧内容清掉。实际部署脚本里常这么写echo backup started at $(date) /var/log/deploy.log目的就是保留完整历史。但也要提醒一句并不适合高并发下的精细日志结构那是专业日志库的活别自己硬写。2.3 umask重定向生成的文件权限是怎么定出来的还有一个容易被忽略的细节用或新建文件时权限不是随便给的而是由0666 ~umask计算得到。默认umask通常是022所以最终权限是0644rw-r--r--。如果服务器上umask设成了077生成的文件就只有600其他用户一律打不开。我曾经排查过一个“脚本运行成功但另一个服务读不到日志”的诡异问题最后发现就是umask导致日志权限只有600而读日志的进程属于另一个用户。如果你的脚本要生成日志给其他账号读最好在脚本开头显式设置umask 022 exec /var/log/app.log 21这样日志权限可控不会因为环境变量不同而翻车。3. 21翻车实录重定向顺序与描述符指向的本质3.1 顺序不同结果天壤之别这是输出重定向里最高频的坑。看这两条命令command file 21 command 21 file第一行的意图是stdout先指向file然后再把stderr指向1号描述符当前指向的位置也就是file最后两个流都进文件。这是正确写法。第二行的顺序则完全不同先把2号指向1号当前指向的位置此刻1号还指向终端再把1号指向file。最终结果stdout进了文件stderr却仍然留在终端。很多初学者以为“反正都写了1和2顺序无所谓”结果错误信息照旧打在屏幕上文件里只有正常输出。定位这类问题记住一个根本原则重定向本质上是在调整文件描述符的“指向”从左到右逐个执行后面的语句引用的是前面语句已经调整过的状态。写法打开顺序最终效果cmd file 211→file2→1当前是filestdout、stderr都进filecmd 21 file2→1当前是终端1→filestdout进filestderr留在终端cmd file同时把1和2指向filestdout、stderr都进file3.2 nohup和定时任务里的顺序陷阱比如nohup command /dev/null 21 这串常用命令为什么必须先 /dev/null再21因为目标是把两个流都丢弃。如果写成nohup command 21 /dev/null stderr会被留在nohup.out里文件越滚越大早晚占满磁盘。定时任务里我个人的习惯是至少留一份真实输出防止排查时两眼一抹黑。比如0 3 * * * /opt/scripts/backup.sh /var/log/backup.log 21正常输出和错误都写在一起时间线也是完整的。要用管道的时候再配合第5节说的pipefail处理退出码。3.3 反向用法12什么时候派得上用场有正写反也有反写。12把标准输出指向标准错误当前指向的位置。这种写法常用在脚本的提示信息上让所有“提示性消息”都走stderr和真正的结果输出分开echo something went wrong 12为什么要这么做因为当脚本输出要作为另一个程序的输入时只有stdout会被读取。如果你把提示信息混进stdout下游解析就会收到脏数据。这是一个非常重要的工程意识人是看stderr的机器是读stdout的。4. 黑洞与分流器/dev/null、tee在日志工程里的正确用法4.1 /dev/null一个永远装不满的字符设备我看过不少人把/dev/null叫做“垃圾桶”其实它更像一个“黑箱”任何写入它的数据会立刻被内核丢弃而且你永远不可能从里面读到东西。它是一个字符设备文件不是普通文件所以不用担心它“写满”。最常见的用法是屏蔽不需要的输出find / -name *.conf 2/dev/null这里为什么单独重定向2因为find在遍历目录树时经常碰到权限不足的目录那些Permission denied刷屏毫无价值直接丢弃而正常结果保留在stdout上继续用。顺带提一个我踩过的冷门设备/dev/full。向它写入任何内容都会返回磁盘已满ENOSPC而且设备本身不会“变满”。在测试脚本有没有正确处理磁盘满错误时这是个非常好用的测试靶子。真实场景里日志盘满、重定向失败这类问题很常见提前用/dev/full演练过错误处理逻辑心里会踏实很多。4.2 tee既要落盘又要实时在终端看如果需要“既要留档又要实时看”tee比重定向更合适。它的语义是从stdin读入原样写一份给stdout同时写入文件。./run_tests.sh 21 | tee test.log这样终端能看到完整输出文件里也留了一份。注意运行完想追加而不是覆盖用tee -a。还有一个隐藏点管道会改变左边命令的输出归宿所以左边命令的stderr要提前合并到stdout21否则错误输出不会经过tee。像上面例子如果漏了21报错照样打在屏幕上而日志文件里缺了错误段整个日志就不完整了。4.3 日志轮转后的重定向失效问题生产服务常配合logrotate做日志切分。注意一个关键机制logrotate把旧日志改名后服务的文件描述符还指向旧文件的inode从外部看就是“日志不再增长了”。解决办法通常是让服务重新打开日志文件很多程序支持向它发送HUP信号重新初始化如果是shell脚本自己管理日志句柄就要用exec newfile 21手动重开描述符。实际中我见过有人依赖cron重新触发脚本“顺便”把文件重新打开但如果服务是常驻的daemon这样并不保险。最稳的做法是在脚本里设计好重新打开日志的入口或者干脆把日志托管给logrotate能直接HUP到的那个进程。5. 重定向的进阶形态heredoc、exec与pipefail的组合拳5.1 heredoc把多行文本整体喂给命令加分隔符可以把多行文本作为命令的标准输入。最简单的例子python3 EOF print(hello from heredoc) EOF更实用的是往MySQL或PostgreSQL里灌SQLmysql -u root -p mydb SQL SELECT * FROM users WHERE status1; SQL注意一个细节默认情况下heredoc的正文里变量和命令会展开。如果希望原样处理比如生成一段本身含$的配置模板就把定界符用单引号包起来cat EOF $HOME will not be expanded here EOF这在脚本里生成配置文件时非常常用能省掉一堆转义。5.2 exec配合重定向让脚本后面的所有输出自动进日志exec不仅能执行新命令还能直接修改当前shell进程的文件描述符。最有用的写法是在脚本开头一次性完成全局重定向exec (tee -a /var/log/app.log) 21这行的含义是把当前脚本包括后续所有子命令的标准输出重定向到一个进程替换tee在子进程中运行同时把内容写到文件和继承过来的shell stdout。效果是脚本里所有echo、命令输出之后都不需要再手动追加 log 21。这种写法在长时间运行的脚本里特别爽。我现在的做法是搭建好这个全局输出后剩下的业务逻辑里我可以专注写命令不用每一条都带着重定向尾巴脚本一下子清爽很多。进程替换还有另一个方向(...)把命令输出伪装成一个文件路径diff (sort file1) (sort file2)这在对比两个命令结果时极好用也免去了写临时文件的麻烦。5.3 pipefail管道与重定向组合后的退出码陷阱管道把前一个命令的stdout接到下一个命令的stdin但shell在管道结束后只看最后一个命令的退出码。这会造成一个非常隐蔽的问题grep pattern file.txt | wc -l即使grep因为文件不存在而报错wc -l也会正常输出0整条管道的退出码是wc的0。在脚本里用if判断就会误判为“一切正常”。解决办法是在脚本开头设置set -o pipefail开启后管道返回最左边失败命令的退出码如果后续还有失败命令则取决于最后那个。如果和我一样习惯写set -euo pipefail这条已经默认带上了。要提醒的是pipefail开启后某些命令“预期的非零退出”会被放大比如grep没匹配到任何内容时原本可能不想让脚本退出现在就会触发set -e这种场景需要酌情处理。6. 一套可复用的脚本日志方案从设计到落地的模板6.1 需求拆解一份日志要满足哪些基本要求在写任何带日志的脚本前先想清楚需求需要时间戳方便回溯stdout和stderr都要能进日志最好是追加而非覆盖能同时看到实时输出和留档脚本退出码不因管道而失真。简而言之完整、可查、不丢、不漏、退出码准确。这五条看着朴素真凑齐了并不容易。6.2 模板脚本基于前面的全部知识点一个中等复杂度的bash脚本开头可以长这样#!/usr/bin/env bash set -euo pipefail LOG_DIR/var/log/myapp LOG_FILE${LOG_DIR}/run_$(date %Y%m%d).log mkdir -p ${LOG_DIR} # 把后续所有输出同时导向终端和日志文件 exec (tee -a ${LOG_FILE}) 21 log_info() { echo [$(date %Y-%m-%d %H:%M:%S)] [INFO] $* } log_error() { echo [$(date %Y-%m-%d %H:%M:%S)] [ERROR] $* 2 } log_info backup job started # 业务逻辑... rc$? log_info backup job finished, exit$rc设计重点是几个部分分开set -euo pipefail让脚本在未定义变量、早期失败时及时停滞日志按天分文件避免单个文件无限膨胀exec配合进程替换后续命令零成本获得重定向能力log_error特意用2走stderr因为如果脚本被另一个程序捕获输出错误信息和普通日志不会混在同一个机器可读流里。6.3 边界情况全缓冲、unbuffered和崩溃丢日志写到日志文件时会因为缓冲问题出现“脚本都退出了日志才出现”的现象。stdout在非终端也就是重定向到文件时默认是全缓冲数据会先在缓冲区攒着攒满或进程正常结束时才批量写入。如果脚本中途被kill -9缓冲区里的内容会全部丢失。stderr则默认无缓冲立即输出。这在线排查“脚本被kill后为什么日志缺尾巴”时非常关键。对策有几种关键日志直接走stderr或者在自己的log函数里调用sync前显式打印标记用stdbuf -oL把stdout从全缓冲切成行缓冲更实用的做法是别在脚本里依赖最后一条日志作为“成功证据”多打几个阶段标记。我自己的习惯是把这套模板脚本保存好新脚本直接复制头部那几行比每次临时思考重定向写法要少踩很多坑。输出重定向这块知识初看零散一旦你理解了“描述符是指针重定向就是改指针指向”这个本质所有写法都能推导出来花一次时间弄透后面十年都受益。