
パイプとリダイレクトの仕組み - 2>&1の意味をファイルディスクリプタから理解する
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
command > log.txt 2>&1という書き方は、シェルを使っていれば毎日のように目にします。ただ、「なぜ2>&1は後ろに書くのか」「&は何なのか」と聞かれて説明できる人は多くありません。順番を間違えると標準エラー出力だけファイルに入らず、エラーが残っていないログが出来上がります。
リダイレクトとパイプは、シェルの独自の魔法ではなく、OSのファイルディスクリプタを繋ぎ替える処理です。この仕組みが分かると、2>&1の順番も、sudo echo > fileが失敗する理由も、パイプにすると出力の順番が変わる理由も、同じ考え方で説明できます。
NOTE
仕様は2026年11月10日に、POSIX.1-2024のShell Command Language、pipe(7)、dup(2)、setvbuf(3)、zshのマニュアルと、手元のbash 5.3.9のmanページで確認しました。コマンドの実行結果は、特に断りがなければmacOS 27.2のbash 5.3.9のものです。lsのエラーメッセージなど細かい文言は、Linuxでは少し変わります。
先に結論: 2>&1の読み方
2>&1は次のように読みます。
2>: ファイルディスクリプタ2番(標準エラー出力)の行き先を変える&1: 行き先は「ファイルディスクリプタ1番が今指している先」
&は「この後ろの数字はファイル名ではなくファイルディスクリプタの番号だ」という印です。&を付け忘れた2>1は、1という名前のファイルを作ります。
ls missing.txt 2>1
ls 1
cat 11
ls: missing.txt: No such file or directory2と>の間に空白を入れてもいけません。echo a 2 >&1はa 2と表示され、2はただの引数になります。
大事なのは「今」の部分です。2>&1は「fd 2をfd 1の別名にする」ものではなく、その瞬間のfd 1の行き先をfd 2にコピーするものです。プログラムの代入文でfd2 = fd1と書くのと同じで、あとからfd1を変えてもfd2は変わりません。
標準入力・標準出力・標準エラー出力とファイルディスクリプタ
0番、1番、2番
プロセスがファイルや端末、パイプ、ソケットを扱うときに使う番号がファイルディスクリプタ(file descriptor、以下fd)です。read(2)やwrite(2)といったシステムコールには、この番号を渡します。
0、1、2の3つは、慣習で役割が決まっています。
| fd | 名前 | Cの定数 | stdioの名前 | 用途 |
|---|---|---|---|---|
| 0 | 標準入力 | STDIN_FILENO | stdin | プログラムへの入力 |
| 1 | 標準出力 | STDOUT_FILENO | stdout | プログラムの本来の出力 |
| 2 | 標準エラー出力 | STDERR_FILENO | stderr | エラーや進捗など、本来の出力以外 |
標準出力と標準エラー出力が分かれているのは、結果をファイルやパイプに流しているときでも、エラーは画面に出せるようにするためです。端末で実行すると、どちらも同じ端末に繋がっているので区別がつきません。違いが見えるのは、片方だけを繋ぎ替えたときです。
ls exists.txt missing.txt 2>/dev/null | wc -l
ls exists.txt missing.txt 2>&1 | wc -l 1
2fd、open file description、inodeの3層
fdはただの番号です。番号の先には2段階の構造があります。
| 層 | 持ち主 | 持っている情報 |
|---|---|---|
| fdテーブル | プロセスごと | 番号と、close-on-execのフラグ |
| open file description | カーネル全体のテーブル | ファイルオフセット(今どこまで読み書きしたか)、O_APPENDなどの状態フラグ |
| inode | ファイルシステム | ファイルの実体(サイズ、データの場所など) |
open file descriptionはPOSIXの用語で、open(2)の説明の通り、カーネル全体の「開いているファイルの表」の1行のことです。macOSのdup(2)のmanページにも、別のファイル位置が欲しければopen(2)をもう一度呼ぶ必要があると書かれています。inodeについてはファイルシステムの記事で詳しく扱っています。
command > out.txt 2>&1を実行したときの状態を図にすると、次のようになります。
fd table (per process) open file description (kernel) inode
+------+ +----------------------------+ +---------+
| fd 0 | ---------------> | offset, flags (terminal) | -----> | tty |
+------+ +----------------------------+ +---------+
| fd 1 | ---+
+------+ +-----------> +----------------------------+ +---------+
| fd 2 | ---+ | offset, flags (out.txt) | -----> | out.txt |
+------+ +----------------------------+ +---------+lsofで、実際のプロセスのfdを確かめられます。-Ffnは、fd番号(f)と名前(n)を1行ずつ出力する形式です。
bash -c 'lsof -a -p $$ -d 0,1,2 -Ffn' < /dev/null > lsof.txt 2>&1
cat lsof.txtp32040
f0
n/dev/null
f1
n/private/tmp/demo/lsof.txt
f2
n/private/tmp/demo/lsof.txt複製と、2回openするのとの違い
dup2(2)でfdを複製すると、2つのfdは同じopen file descriptionを指します。dup(2)にある通り、ファイルオフセットと状態フラグは共有され、片方でlseekするともう片方の位置も変わります。共有されないのはclose-on-execのフラグだけです。
これが効いてくる例を見てみます。同じファイルへ、2>&1で出す場合と、2>で同じファイル名をもう一度開く場合です。
{ echo out; echo err >&2; } > dup.txt 2>&1
cat dup.txt
{ echo out-long-long-line; echo err >&2; } > two.txt 2> two.txt
cat two.txtout
err
err
long-long-line複製したdup.txtでは、オフセットを共有しているのでoutの続きにerrが書かれます。
2回開いたtwo.txtでは、fd 1とfd 2のオフセットが別々です。fd 1がout-long-long-lineを書いても、fd 2のオフセットは先頭のままなので、errと改行の4バイトが先頭を上書きします。その結果、out-がerr\nに置き換わり、long-long-lineだけが残りました。> file 2> fileは、一見同じ意味に見えますが、ログが壊れる書き方です。
リダイレクトはforkとexecの間で行われる
シェルが行っていること
シェルが外部コマンドを実行するときの流れは、おおまかに次の通りです。
1. シェルがコマンドラインを解釈する
2. fork で子プロセスを作る(fd テーブルは親からコピーされる)
3. 子プロセスの中で、リダイレクトを左から順に処理する
open でファイルを開く
dup2 で fd 1 などに付け替える
不要になった元の fd を close する
4. exec でコマンドのプログラムに置き換わる(fd はそのまま引き継がれる)
5. コマンドは fd 1 や fd 2 に write するだけこの流れが成り立つのは、forkとexecの性質のおかげです。macOSのfork(2)のmanページには、子プロセスは親のディスクリプタのコピーを持ち、同じオブジェクトを参照すると書かれています。execve(2)には、close-on-execが設定されていないディスクリプタは新しいプログラムでも開いたままだと書かれています。
つまり、コマンドの側は自分の出力先を知りません。lsは常にfd 1へ書くだけで、その先がファイルか端末かパイプかを決めるのはシェルです。これが、Unixでリダイレクトがどんなコマンドにも効く理由です。
同じ手順をCで書くと次のようになります。ls exists.txt missing.txt > out.txt 2>&1に相当するプログラムです。
#include <fcntl.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
/* 子プロセス: exec する前に fd を繋ぎ替える */
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); /* > out.txt */
close(fd);
dup2(1, 2); /* 2>&1 */
execlp("ls", "ls", "exists.txt", "missing.txt", (char *)NULL);
_exit(127);
}
waitpid(pid, NULL, 0);
return 0;
}ls: missing.txt: No such file or directory
exists.txtシェルのコマンドと同じ結果になりました。
NOTE
このCのプログラムは、シェルと同じ手順を自分で書いて結果を確かめたものです。bashやzsh自体のシステムコールをトレースしたわけではありません。POSIXが定めているのはリダイレクトの結果で、実際のコードはシェルごとに異なります。
主なリダイレクトと、開き方
| 書き方 | 意味 | 開き方(代表的なフラグ) |
|---|---|---|
> file | fd 1へ、ファイルを切り詰めて書く | O_WRONLY、O_CREAT、O_TRUNC |
>> file | fd 1へ、ファイルの末尾に追記する | O_WRONLY、O_CREAT、O_APPEND |
< file | fd 0へ、ファイルを読む | O_RDONLY |
2> file | fd 2へ、ファイルを切り詰めて書く | > fileと同じ |
n<> file | fd nで、読み書き両用に開く | O_RDWR、O_CREAT |
2>&1 | fd 2をfd 1の複製にする | 開かない(dup2相当) |
3>&- | fd 3を閉じる | 開かない(close相当) |
番号を省略すると、>はfd 1、<はfd 0が対象になります。
>>が本当にO_APPENDで開いているかは、子プロセスから状態フラグを読めば確かめられます。
P='import os, fcntl; print("O_APPEND:", bool(fcntl.fcntl(1, fcntl.F_GETFL) & os.O_APPEND))'
python3 -c "$P" > a1.txt
python3 -c "$P" >> a2.txt
cat a1.txt a2.txtO_APPEND: False
O_APPEND: Trueリダイレクトはコマンドより先に処理される
リダイレクトは、コマンドが動き始める前に済んでいます。このことから2つの性質が分かります。
1つ目は、コマンドが存在しなくても、ファイルは作られる(切り詰められる)ことです。
nonexistent_command_xyz > created.txt
echo "rc=$?"
ls -l created.txt | awk '{print $5, $NF}'bash: line 1: nonexistent_command_xyz: command not found
rc=127
0 created.txt2つ目は、リダイレクトに失敗するとコマンドは実行されないことです。次の例では、出力先のディレクトリがないので、touchは動かずmarker.txtも作られません。
touch marker.txt > /nonexistent-dir/x
echo "rc=$?"
ls marker.txtbash: line 1: /nonexistent-dir/x: No such file or directory
rc=1
ls: marker.txt: No such file or directoryexecでシェル自身のfdを変える
コマンドなしのexecにリダイレクトを付けると、シェル自身のfdを変更できます。bashのmanページには「execと使うと、現在のシェルの実行環境のファイルハンドルを変更する」と書かれています。スクリプトの途中から全部の出力をログに流したいときに使えます。
exec 3> fd3.txt # fd 3を開く
echo hello >&3 # fd 3へ書く
exec 3>&- # fd 3を閉じる
cat fd3.txthello2>&1は複製である: fdの表で追う
リダイレクトは左から順に処理されます。POSIXには「複数のリダイレクト演算子が指定された場合、評価の順序は先頭から末尾へ」(If more than one redirection operator is specified with a command, the order of evaluation is from beginning to end.)と書かれています。これを踏まえて、順番が違う2つの書き方を追います。
cmd > file 2>&1の場合
| 段階 | fd 1の行き先 | fd 2の行き先 |
|---|---|---|
| 開始時 | 端末 | 端末 |
> fileを処理 | file | 端末 |
2>&1を処理(fd 1の今の行き先をコピー) | file | file |
標準出力も標準エラー出力もファイルに入ります。
cmd 2>&1 > fileの場合
| 段階 | fd 1の行き先 | fd 2の行き先 |
|---|---|---|
| 開始時 | 端末 | 端末 |
2>&1を処理(fd 1の今の行き先をコピー) | 端末 | 端末 |
> fileを処理 | file | 端末 |
2>&1を処理した時点では、fd 1はまだ端末を指しています。そのためfd 2には端末がコピーされ、あとでfd 1をファイルへ向けてもfd 2は端末のままです。
代入文に直すと、違いがはっきりします。
# cmd > file 2>&1
fd1 = open(file)
fd2 = fd1 # fd2 は file を指す
# cmd 2>&1 > file
fd2 = fd1 # fd1 はまだ端末なので、fd2 も端末を指す
fd1 = open(file) # fd1 だけが file に変わる実際に実行すると、manページの説明の通りになります。
ls exists.txt missing.txt > out.txt 2>&1
echo '--- out.txt:'; cat out.txt
ls exists.txt missing.txt 2>&1 > out.txt
echo '--- out.txt:'; cat out.txt--- out.txt:
ls: missing.txt: No such file or directory
exists.txt
ls: missing.txt: No such file or directory
--- out.txt:
exists.txt2回目は、エラーメッセージが端末に出て、out.txtにはexists.txtだけが入っています。
パイプと組み合わせたときの順番
パイプはリダイレクトより先に繋がります。POSIXは、パイプラインによる標準入出力の割り当ては、そのコマンドのリダイレクトより前に行われるとしています。bashのmanページにも同じことが書かれています。
このためcmd 2>&1 | grep fooは次のように動きます。
| 段階 | fd 1の行き先 | fd 2の行き先 |
|---|---|---|
| 開始時 | 端末 | 端末 |
| パイプを接続 | パイプ | 端末 |
2>&1を処理 | パイプ | パイプ |
fd 1がすでにパイプに変わっているので、2>&1でfd 2もパイプに繋がります。> file 2>&1の順番と同じ形です。
順番の違いをわざと利用すると、標準エラー出力だけをパイプに流すことができます。
ls exists.txt missing.txt 2>&1 >/dev/null | tr a-z A-ZLS: MISSING.TXT: NO SUCH FILE OR DIRECTORY2>&1の時点でfd 1はパイプなので、fd 2はパイプになります。その後>/dev/nullでfd 1だけを捨てています。
標準出力と標準エラー出力を入れ替えることもできます。一時的な保管場所としてfd 3を使い、変数の値を入れ替えるのと同じ操作をします。
{ echo to-stdout; echo to-stderr >&2; } 3>&1 1>&2 2>&3 3>&- | tr a-z A-Zto-stdout
TO-STDERR&>と|&はbashの拡張
> file 2>&1を短く書くために、bashには次の書き方があります。bashのmanページによると、&>wordと>&wordのうち&>wordが推奨で、>word 2>&1と同じ意味です。
&> file: 標準出力と標準エラー出力をファイルへ。> file 2>&1と同じ&>> file: 両方をファイルへ追記。>> file 2>&1と同じ|&: 両方をパイプへ。2>&1 |の省略形
ls exists.txt missing.txt &> both.txt
cat both.txt
ls exists.txt missing.txt |& tr a-z A-Zls: missing.txt: No such file or directory
exists.txt
LS: MISSING.TXT: NO SUCH FILE OR DIRECTORY
EXISTS.TXTただ、これらはPOSIXのシェルにはありません。POSIXの範囲で動かすスクリプトで&>を書くと、問題が起きます。dashで試すと次のようになります。
dash -c 'ls missing.txt &> both.txt; wait; wc -c < both.txt'ls: missing.txt: No such file or directory
0dashは&>を1つの演算子と解釈しません。ls missing.txt &(バックグラウンドで実行)と> both.txt(空のコマンドでファイルを空にする)の2つの文として読みます。構文エラーにならず、エラーは端末に出て、ファイルは0バイトになります。気付きにくい失敗です。
|&は、dashではSyntax error: "&" unexpectedという構文エラーになります。また、macOSに最初から入っている/bin/bashは3.2.57で、|&と&>>は構文エラーでした。bashのNEWSによると、この2つが追加されたのはbash 4.0です。
| 書き方 | POSIX | bash 4.0以降 | zsh | 手元の結果 |
|---|---|---|---|---|
> file 2>&1 | 使える | 使える | 使える | どのシェルでも同じ |
&> file | ない | 使える | 使える | dashは別の意味になる |
&>> file | ない | 使える | 使える | bash 3.2は構文エラー |
シェルスクリプトを#!/bin/shで書くなら、> file 2>&1と書くのが安全です。/bin/shの実体はOSによって違い、bashとは限りません。手元の環境はls -l /bin/shなどで確かめられます。macOSの/bin/shでは&>が使えました。
パイプの実体
pipe(2)が作るカーネル内のバッファ
パイプは、pipe(2)というシステムコールが作る、カーネル内のバッファです。読み出し用と書き込み用の2つのfdが作られ、書き込み側に書いたデータを読み出し側から順に読めます。
command1 | command2は、次のように組み立てられます。
1. pipe で fd を2つ作る(読み出し用 r、書き込み用 w)
2. fork して command1 用の子プロセスを作る
dup2(w, 1) で fd 1 を書き込み側にする
3. fork して command2 用の子プロセスを作る
dup2(r, 0) で fd 0 を読み出し側にする
4. 使わない r と w を close する
5. それぞれ exec する図にすると次のようになります。ファイルと違って、途中のlseekはできず、データは読まれると消えます。
command1 kernel command2
fd 1 ---> write() ---> [ pipe buffer ] ---> read() ---> fd 0pipe(7)によると、パイプはバイトストリームで、メッセージの区切りという概念はありません。
容量と、書き手が速いときのブロック
Linuxの既定のパイプ容量は、pipe(7)によると16ページ(ページサイズが4096バイトのシステムで65,536バイト)です。Linux 2.6.11からこの値で、それより前はページサイズと同じでした。Linux 2.6.35からは、fcntlのF_GETPIPE_SZとF_SETPIPE_SZで確認と変更ができます。
パイプがいっぱいになると、書き手のwrite(2)はブロックします(O_NONBLOCKを付けている場合はEAGAINで失敗します)。逆に、読み手はパイプが空だとread(2)でブロックします。これによって、速い書き手と遅い読み手の間で、自然に速度が調整されます。これがパイプの背圧(バックプレッシャー)です。データが無限にメモリへ溜まることはありません。
ノンブロッキングのfdで複数のパイプやソケットを同時に扱う方法は、I/O多重化の記事で扱っています。
macOSで、パイプにどれだけ書けるかを測ってみます。
import os, fcntl
r, w = os.pipe()
fcntl.fcntl(w, fcntl.F_SETFL, os.O_NONBLOCK)
n = 0
try:
while True:
n += os.write(w, b"x" * 1024)
except BlockingIOError as e:
print("wrote", n, e.errno, e.strerror)
print("PIPE_BUF", os.fpathconf(w, "PC_PIPE_BUF"))wrote 65536 35 Resource temporarily unavailable
PIPE_BUF 512読み手が先に閉じた場合: SIGPIPEとEPIPE
パイプの終わり方は2通りあります。pipe(7)によると、次の通りです。
| 状況 | 起きること |
|---|---|
| 書き込み側のfdがすべて閉じられた | 読み手のread(2)は、残りのデータを読んだあとEOF(0バイト)を返す |
| 読み出し側のfdがすべて閉じられた | 書き手のwrite(2)でSIGPIPEが発生する |
上記で書き手がSIGPIPEを無視している | write(2)がEPIPEエラーで失敗する |
SIGPIPEの既定の動作はプロセスの終了です。シグナルの詳しい話はUnixシグナルの記事にあります。よくある例がheadです。headは必要な行を読むと終了するので、書き続けている左側のコマンドがSIGPIPEで終わります。
yes | head -n 1
echo "PIPESTATUS=${PIPESTATUS[*]}"
kill -l 13y
PIPESTATUS=141 0
PIPE141は128 + 13です。bashのmanページの通り、シグナルnで終了したコマンドの終了ステータスは128+nになり、SIGPIPEのシグナル番号は13です。
Pythonは起動時にSIGPIPEを無視する設定になっており(signal.getsignal(signal.SIGPIPE)がSIG_IGNでした)、writeがEPIPEで失敗してBrokenPipeErrorになります。
python3 -c 'for i in range(1000000): print(i)' | head -n 1
echo "PIPESTATUS=${PIPESTATUS[*]}"0
Traceback (most recent call last):
File "<string>", line 1, in <module>
for i in range(1000000): print(i)
~~~~~^^^
BrokenPipeError: [Errno 32] Broken pipe
Exception ignored while flushing sys.stdout:
BrokenPipeError: [Errno 32] Broken pipe
PIPESTATUS=120 0スクリプトで| headの左側がエラーを出して落ちるのは、このためです。
PIPE_BUFと書き込みの原子性
複数のプロセスが同じパイプに書き込むと、データが混ざるのか。これを決めるのがPIPE_BUFです。pipe(7)によると、次の通りです。
PIPE_BUF以下の書き込みはアトミック(途中に他のプロセスのデータが割り込まない)PIPE_BUFを超える書き込みはアトミックとは限らず、他の書き手のデータと混ざることがある- POSIXでは
PIPE_BUFは512バイト以上、Linuxでは4096バイト
先ほど測ったように、手元のmacOSではfpathconfの値は512でした。短い1行ずつのログなら混ざりませんが、大きなデータを同時に書くと混ざることがあります。
パイプラインは同時に起動する
a | b | cは「aが終わったらbを実行する」ものではありません。bashのmanページによると、パイプラインの各コマンドはそれぞれ別のプロセス(サブシェル)で実行されます。全部が同時に動いていることは、時間を測ると分かります。
time ( sleep 2 | sleep 2 | sleep 2 )real 0m2.230s
user 0m0.005s
sys 0m0.013s順番に実行されていれば6秒かかるはずですが、2秒ほどで終わっています。並行処理と並列処理の記事で扱ったプロセスの話そのものです。
起動する順番も決まっていません。次の例では、右側の表示が先に出ました。
{ echo "left start $(date +%S)" >&2; sleep 1; echo "left end $(date +%S)" >&2; } |
{ echo "right start $(date +%S)" >&2; sleep 1; echo "right end $(date +%S)" >&2; }right start 16
left start 16
right end 17
left end 17パイプの中で変数を変えても残らない
各コマンドが別プロセスなので、パイプの右側でreadした変数は、シェルに戻ると消えています。これはbashでよくあるつまずきです。
n=0; echo x | read n; echo "n=[$n]"
n=0; read n < <(echo x); echo "procsub n=[$n]"n=[0]
procsub n=[x]プロセス置換を使うか、bashならshopt -s lastpipe(ジョブ制御が無効のとき)で、最後のコマンドを現在のシェルで実行させれば値が残ります。bashのmanページによると、ジョブ制御が既定で有効なのは対話シェルです。
終了ステータス: PIPESTATUSとpipefail
パイプライン全体の終了ステータスは、既定では最後のコマンドの終了ステータスです。途中で失敗しても、最後が成功すれば成功扱いになります。
false | true; echo "$?"
true | false; echo "$?"0
1個々のコマンドの結果は、bashの配列変数PIPESTATUSで見られます。zshでは小文字のpipestatusです。
false | true; echo "PIPESTATUS=${PIPESTATUS[*]}"
false | true; echo "first: ${PIPESTATUS[*]}"; echo "second: ${PIPESTATUS[*]}"PIPESTATUS=1 0
first: 1 0
second: 0PIPESTATUSは次のコマンドで上書きされます。echoを1回挟むとechoの結果に変わるので、必要なら先に変数へコピーしておきます。
set -o pipefailを有効にすると、右端の失敗したコマンドのステータスがパイプライン全体の結果になります。全部成功した場合は0です。
set -o pipefail
false | true; echo "$?"
exit3() { return 3; }; exit5() { return 5; }
exit3 | exit5 | true; echo "$?"1
5pipefailは長くbashなどの拡張でしたが、POSIX.1-2024で標準に入りました。ただし実装の対応はまちまちで、手元のmacOSのdashではset: Illegal option -o pipefailというエラーでした。
注意: set -o pipefailとset -eを組み合わせると、yes | head -n 1のようにSIGPIPEで左側が終わる普通のパイプラインでも、失敗扱いでスクリプトが止まることがあります。set -euo pipefailの注意点は別の記事にまとめています。
バッファリング: 出力の順番が入れ替わる理由
stdioの既定の動作
Cのprintfなどのstdio関数は、write(2)をすぐに呼ばず、ユーザー空間のバッファに溜めてからまとめて書きます。setvbuf(3)によると、既定の動作は次の通りです。
| ストリーム | 既定のバッファリング | 書き出すタイミング |
|---|---|---|
| 端末に繋がったstdout | 行バッファ | 改行のとき |
| ファイルやパイプに繋がったstdout | ブロック(完全)バッファ | バッファがいっぱいになったとき、終了時 |
| stderr | 非バッファ | すぐ |
つまり、同じプログラムでも、出力先が端末かパイプかで動きが変わります。
次のプログラムで確かめます。標準出力と標準エラー出力へ交互に書くだけです。
#include <stdio.h>
int main(void) {
printf("out 1\n");
fprintf(stderr, "err 1\n");
printf("out 2\n");
fprintf(stderr, "err 2\n");
return 0;
}端末に繋いだ場合(scriptコマンドで疑似端末を用意して確認)と、パイプに繋いだ場合の結果です。
out 1
err 1
out 2
err 2./buf 2>&1 | caterr 1
err 2
out 1
out 22>&1で同じパイプに繋いでいても、順番が変わります。stdoutはバッファに溜まったまま終了時に書き出され、stderrはすぐに書かれるからです。fdの繋ぎ替えが間違っているわけではなく、ユーザー空間のバッファが原因です。ログで「エラーが先に出ているように見える」原因は、たいていこれです。
出力が遅れて届く
同じ理由で、パイプの先へのデータが遅れることもあります。1秒ごとに1行出力するtickというプログラムをパイプに繋ぎ、受け取った時刻を付けて表示します。
./tick | while IFS= read -r l; do echo "$(date +%S) $l"; done22 tick 1
22 tick 2
22 tick 3stdbufとpython -uで対処する
GNU coreutilsのstdbufは、対象のコマンドのstdioのバッファリングを変更します。-oLで標準出力を行バッファにします。
stdbuf -oL ./buf 2>&1 | cat
stdbuf -oL ./tick | while IFS= read -r l; do echo "$(date +%S) $l"; doneout 1
err 1
out 2
err 2
22 tick 1
23 tick 2
24 tick 3パイプの途中のコマンドも同じようにバッファします。途中にgrepがあると、grepの所で溜まって遅れます。grepには行バッファにする--line-bufferedがあります。
stdbuf -oL ./tick | grep tick | while IFS= read -r l; do echo "$(date +%S) $l"; done
stdbuf -oL ./tick | grep --line-buffered tick | while IFS= read -r l; do echo "$(date +%S) $l"; done14 tick 1
14 tick 2
14 tick 3
14 tick 1
15 tick 2
16 tick 3stdbufのmanページには、teeのように自分でバッファリングを調整するコマンドではstdbufの指定が上書きされること、ddやcatのようにストリームを使わないコマンドには効かないことが書かれています。
Pythonは独自のI/O層を持っているので、手元ではstdbuf -oLが効かず、errが先に出たままでした。Pythonでは-uオプションか、環境変数PYTHONUNBUFFEREDを使います。
python3 p.py 2>&1 | cat
python3 -u p.py 2>&1 | caterr 1
err 2
out 1
out 2
out 1
err 1
out 2
err 2/dev/null、ヒアドキュメント、tee
/dev/null
/dev/nullは、書き込まれたデータを捨て、読むと常に空(長さ0)のデバイスです。fdの繋ぎ先の1つなので、これまでのリダイレクトがそのまま使えます。
command > /dev/null # 標準出力を捨てる
command > /dev/null 2>&1 # 両方捨てる
command < /dev/null # 標準入力を空にする(入力待ちで止まらないようにする)ヒアドキュメントとヒアストリング
ヒアドキュメントは、コマンドの標準入力に複数行のテキストを渡す書き方です。区切りの語を引用符で囲むと、変数やコマンド置換が展開されません。<<-にすると、行頭のタブが取り除かれます。
name=world
cat <<EOT
hello $name $(echo cmd)
EOT
cat <<'EOT'
hello $name $(echo cmd)
EOThello world cmd
hello $name $(echo cmd)<<<はヒアストリングで、1つの文字列を標準入力に渡します。bashのmanページの通り、末尾に改行が付きます。POSIXにはなく、bashとzshで使えます。
cat <<< "hello" | od -c | head -10000000 h e l l o \nプロセス置換
<(list)は、コマンドの出力をファイル名のように渡すbashとzshの機能です。POSIXにはありません。
diff <(sort l.txt) <(sort r.txt)
echo <(true)2c2
< b
---
> c
/dev/fd/63<(list)は/dev/fd/63のようなファイル名に展開されます。fdを開いたまま、そのパスをコマンドの引数として渡しているわけです。
tee
teeは、標準入力を標準出力とファイルの両方へ書き出すコマンドです。パイプの途中の様子をファイルに残せます。-aで追記になります。
echo hello | tee t1.txt t2.txt | tr a-z A-Z
cat t1.txt t2.txt
ls exists.txt missing.txt 2>&1 | tee all.log | wc -lHELLO
hello
hello
2よくある間違いとトラブル対応
sudo echo > fileが権限エラーになる
sudo echo 'hello' > /etc/demo.confこのとき、sudoが権限を与えているのはechoだけです。> /etc/demo.confは、sudoを起動する前にシェルが処理します。シェルは一般ユーザーの権限で動いているので、openが失敗します。
bash -c 'echo x > /etc/zzz-demo; echo rc=$?'bash: line 1: /etc/zzz-demo: Permission denied
rc=1対処は、ファイルを開く処理自体をsudoの中で実行することです。teeをsudoで動かすか、シェルごとsudoで動かします。
echo 'hello' | sudo tee /etc/demo.conf > /dev/null # 上書き
echo 'hello' | sudo tee -a /etc/demo.conf > /dev/null # 追記
sudo sh -c "echo 'hello' > /etc/demo.conf"> /dev/nullは、teeの標準出力が画面に出ないようにするためです。
sort file > fileでファイルが空になる
printf 'c\na\nb\n' > f.txt
sort f.txt > f.txt
wc -c f.txt 0 f.txtシェルはsortを起動する前に> f.txtを処理して、ファイルを0バイトに切り詰めます。sortが読むときにはもう中身がありません。catやgrep、sedでも同じです。
対処は、一時ファイルを経由するか、sortなら-oオプションを使うことです。POSIXのsortは、-oの出力先は入力ファイルと同じでもよいとしています。
sort -o f.txt f.txt
sort f.txt > f.txt.tmp && mv f.txt.tmp f.txtWARNING
cat file >> fileも危険です。手元のmacOSの/bin/catでは止まらずファイルが増え続け、timeoutで打ち切る必要がありました。GNU coreutils 9.9のcatは、input file is output fileというエラーで止まります。試すときは必ずtimeoutを付けてください。
cronのログ取りは>> log 2>&1
cronでコマンドの出力を残すときの定番は次の形です。
30 4 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1>>で追記し、2>&1でエラーも同じファイルに残します。cronのSHELLの既定は/bin/shなので、&>>のようなbashの書き方は避けるのが無難です。また、O_APPENDで開いているため、複数のジョブが同じログへ書いても追記の位置は末尾になります。
noclobberと>|
set -o noclobber(POSIXのset -C)を有効にすると、既存のファイルを>で上書きしようとするとエラーになります。うっかり上書きを防ぐ設定です。>|を使うと、この設定を無視して上書きできます。
echo one > nc.txt
set -o noclobber
echo two > nc.txt; echo "rc=$?"
cat nc.txt
echo three >| nc.txt
cat nc.txtbash: line 3: nc.txt: cannot overwrite existing file
rc=1
one
three>>での追記は、noclobberでもbashなら問題なく動きます(ファイルがなくても作られます)。
シェルごとの違い
確認できた範囲で、POSIX sh、bash、zshの違いをまとめます。
&>と&>>: bashとzshにはあるが、POSIXにはない。dashでは別の意味になる|&: bash 4.0以降とzshにあるが、POSIXにはないpipefail: POSIX.1-2024で標準に入った。bash、zshにはあるが、手元のdashにはなかったPIPESTATUS: bashの配列変数。zshは小文字のpipestatus- ヒアストリング
<<<とプロセス置換<(list): bashとzshの機能 > fileだけの行: bashではファイルを空にする。zshではNULLCMD(既定はcat)が動き、標準入力を読みに行く
zshで特に違うのが、既定で有効なMULTIOSです。zshのマニュアルによると、1つのfdを複数の行き先へリダイレクトすると、zshはパイプと、入力をすべての行き先へコピーするプロセスを用意します。
echo hello > a.txt > b.txt| シェル | a.txtの中身 | b.txtの中身 |
|---|---|---|
| bash | 空 | hello |
| zsh(既定のMULTIOS) | hello | hello |
zsh(unsetopt multios) | 空 | hello |
パイプも暗黙のリダイレクトなので、zshではecho hello > c.txt | wc -cとしたときにファイルとパイプの両方にデータが流れます。手元の実行結果は、bashがパイプへ0バイト、zshが6バイトでした。入力の側でも、cat < a < bは両方のファイルを順に読みます。
なお、順番による2>&1の違いはzshでも同じです。ls exists.txt missing.txt 2>&1 > out.txtは、zshでもエラーが端末に出ました。zshのマニュアルには、&>はMULTIOSが有効な場合に> word 2>&1と同じとは限らないという注意があります。
zshでnoclobberにあたるのはCLOBBERオプションの無効化です。setopt noclobberのあとは>|のほかに>!でも上書きできます。また、手元のzsh 5.9では、noclobberを有効にすると>>で存在しないファイルに追記できず、no such file or directoryになりました(setopt append_createで作成できます)。.zshrcでnoclobberを設定している人は注意が必要です。
まとめ
- fdはプロセスごとの番号で、その先にカーネルのopen file description、さらにその先にinodeがある
2>&1は別名ではなく複製。「fd 1の今の行き先をfd 2へコピーする」という代入- リダイレクトは左から順に処理されるので、
> file 2>&1と2>&1 > fileは結果が違う - シェルはforkしたあと、execの前にopenとdup2でfdを繋ぎ替える。コマンドは自分の出力先を知らない
- パイプはカーネル内のバッファ。Linuxの既定の容量は65,536バイト(ページサイズ4096バイトの場合)で、いっぱいになると書き手はブロックする
- 読み手が先に閉じると、書き手には
SIGPIPEが届く。無視している場合はEPIPE - パイプラインの各コマンドは同時に動く。終了ステータスは最後のコマンドのもので、
PIPESTATUSやpipefailで途中の失敗を拾える - stdioのstdoutは、端末では行バッファ、パイプやファイルではブロックバッファ。stderrは非バッファなので順番が入れ替わる
sudo echo > fileとsort file > fileの失敗は、どちらも「リダイレクトはコマンドより先にシェルが処理する」ことが原因#!/bin/shで書くなら、&>や|&は使わず> file 2>&1と書く


