基礎●○○○○

シェル

Shell ・ しぇる

コマンドでOSに仕事を頼む対話環境。プロセスの起動と、パイプで小さな道具をつなぐことが本質。

✦ 30秒で試す — 航海1973年、ある一晩の実装が、プログラムの作り方を変えたgrep ERROR ログ | head -3 で、grep が読む量は?予想してみる

概要

シェル(shell)は、キーボードから打ち込んだコマンドを受け取ってOSに仕事を頼み、その結果を返す対話環境です。名前の「殻(shell)」は、OSの中核を包む一番外側の皮、つまり人間とOSが接する窓口という意味からきています。黒い画面に文字を打ち込んでコンピュータを操作する、あの世界がシェルです。

シェルの真価は、単にコマンドを打てることよりも、「小さな道具を組み合わせて大きな仕事をこなす」ところにあります。1つ1つは単機能のコマンドを、パイプという仕組みで数珠つなぎにすることで、ログの集計やファイルの一括処理をその場で組み立てられます。サーバにログインして障害を調べるとき、デプロイの手順を自動化するとき、今もこの道具が一番の近道です。

代表的なシェルには bash や zsh、fish などがあります。普段のちょっとした操作から、決まった手順をまとめて実行する「シェルスクリプト」まで、対話と自動化の両方を1つの道具でこなせるのが特徴です。

なぜ生まれたか

画面をマウスで操作する GUI が当たり前になる前、コンピュータに仕事を頼む手段は文字による対話しかありませんでした。「このファイルを表示せよ」「このプログラムを動かせ」とOSに伝える口が必要で、そのために生まれた対話の窓口がシェルです。1971年の最初の UNIX には、Ken Thompson が書いたシェルがすでに付いていました。

シェルが本当に画期的になったのは、1973年に UNIX へパイプが入ってからです。「プログラムの出力を、ホースのように別のプログラムの入力へつなげられるべきだ」という Doug McIlroy の長年の提案を、Thompson が一晩で実装したと伝えられています。これで「1つのことをうまくやる小さなプログラムを作り、それらをつないで組み合わせる」という設計が現実のものになりました。何でもできる巨大なプログラムを用意しなくても、手持ちの道具のつなぎ方を変えるだけで新しい仕事が作れる。この考え方は「UNIX 哲学」と呼ばれ、1979年の Bourne シェル、1989年の bash を経て、今日のシェルに受け継がれています。

詳細

シェルの正体 — プロセスを起動するプログラム

シェルと聞くと特別なものに感じるかもしれませんが、その正体は「入力された文字列をコマンド名と引数に分解し、対応するプロセスを起動して、終わるのを待つ」だけの普通のプログラムです。たとえば ls と打つと、シェルは fork というシステムコールで自分自身を複製し、その複製を execve で ls という別のプログラムに置き換え、そのプロセスが終わるのを wait で待ちます。多くのコマンドはシェルに内蔵された機能ではなく独立した実行ファイルで、シェルはそれを見つけて起動する仲介役です。ただし cd のように、シェル自身の状態(現在のディレクトリ)を変えるものは、別のプロセスでは意味がないので内蔵コマンドとして実装されています。

標準入出力とパイプ — 3つの共通の口

シェルの組み合わせの力を支えているのが、標準入出力という約束事です。どのプロセスも起動した時点で3つの口を持っています。0番の標準入力(stdin)、1番の標準出力(stdout)、2番の標準エラー出力(stderr)です。コマンドは「0番から読んで、結果は1番に、エラーは2番に書く」とだけ決めておけば、その先が画面かファイルか別のプロセスかを知る必要がありません。口の先をつなぎ替えるのはシェルの仕事です。

あるコマンドの標準出力を、次のコマンドの標準入力に直結するのがパイプ(|)です。たとえば grep ERROR app.log | sort | uniq -c は、ログから ERROR を含む行だけを取り出し、並べ替え、隣り合う同じ行を数えて「どのエラーが何回出たか」を出します。3つのコマンドはお互いのことを何も知らず、ただ標準入出力を通じて行を受け渡すだけです。なお、入門書によく出る cat log | grep error は、grep error log と書けば cat のプロセスが1つ減ります。

grep ERROR app.logERROR の行だけ通すsort最後まで溜めて並べ替えるuniq -c隣り合う同じ行を数えるパイプパイプ1番 → 0番1番 → 0番標準出力画面(端末)標準エラー出力(2番)はパイプを通らず、そのまま画面へ
grep ERROR app.log | sort | uniq -c — 3つのプロセスが標準出力と標準入力でつながる。標準エラー出力はパイプに流れず、画面へ直接出る

見落とされがちなのは、パイプでつながったプロセスは同時に動いているという点です。シェルは3つのプロセスを一度に起動し、カーネルの中の小さなバッファ(Linux では既定で 64KiB)でそれぞれをつなぎます。grep が1行見つけるとすぐに sort へ流れ、前のコマンドの完了を待って一括で渡すのではありません。バッファが満杯になれば書き手が待たされ、空なら読み手が待たされるので、速さの違うプロセス同士でも自然に歩調が合います。ただし sort のように「最後の行を見るまで結果を出せない」コマンドは、入力の終わり(EOF)が来るまで溜め込みます。また head -3 のように途中で満足して終了するコマンドがいると、上流のプロセスは書き込み先を失って SIGPIPE というシグナルで止められ、巨大なファイルでも無駄に読み続けずに済みます。

下の部品で確かめてください。コマンドの札をパイプに並べると、ログの各行が粒になって流れ、それぞれのプロセスが同時に通したり捨てたり溜めたりします。札を外したり、出力先をファイルに向けたりすると、流れの行き先が変わります。

⚡ 体験: 札を並べてパイプを組む
出力先
コマンドの札(ドラッグしてパイプの空き場所へ・タップでも置ける)grep -v DEBUGsortuniq -chead -3wc -lapp.loggrep ERRORERROR の行だけ通す空き(素通し)空き(素通し)画面$ grep ERROR < app.log
ERROR の行WARNINFODEBUG集計の結果

上の札をドラッグして、パイプの空き場所(破線の枠)に置いてください。置くたびに app.log の各行が粒になって流れ直します。札はタップでも置けます。

リダイレクトと標準エラー出力

パイプと並ぶつなぎ替えがリダイレクトです。> は標準出力をファイルに向け(ls > list.txt、既存の中身は上書き)、>> は追記し、< はファイルの中身を標準入力に流し込みます。数字を付けると別の口も向けられ、2> err.log は標準エラー出力だけをファイルに、> out.log 2>&1 は両方を同じファイルにまとめます。この 2>&1 は「2番を、いま1番が向いている先に向ける」という意味なので、順番を逆にして 2>&1 > out.log と書くと、エラーは元の画面に残ったままになります。ログを取るつもりでエラーだけ取りこぼす、定番の落とし穴です。

標準出力と標準エラー出力が分かれているのは、パイプで次のコマンドへ流すデータに、エラーメッセージを混ぜないためです。自分でコマンドやスクリプトを書くときも、結果は1番、診断メッセージは2番に書くと、ほかの道具と気持ちよくつながります。

終了ステータスと && ・ ||

コマンドは終わるときに、0〜255 の終了ステータスを返します。0 が成功、それ以外が失敗の約束で、直前のコマンドの値は $? で見られます。make && ./deploy.sh のように && でつなぐと前のコマンドが成功したときだけ次を実行し、|| なら失敗したときだけ実行します。テストに失敗したらデプロイを止める、という自動化の基本はこの仕組みの上に成り立っています。

注意したいのは、パイプ全体の終了ステータスが最後のコマンドの値になることです。curl URL | tar xz で curl が失敗しても、tar が正常に終われば成功扱いになります。bash では set -o pipefail を指定すると、途中のどれかが失敗したらパイプ全体を失敗にできます。

クォートと単語分割の落とし穴

シェルは、変数を展開した結果を空白で単語に分け、さらに * のような記号をファイル名に展開します。そのため rm $file と書いたとき、変数の中身が my notes.txt なら my と notes.txt という2つのファイルを消そうとします。中身が空なら rm に何も渡らず、中身が * ならカレントディレクトリのすべてが対象になります。変数は原則として "$file" のように二重引用符で囲む、が鉄則です。

スクリプトの冒頭に set -euo pipefail と書くのも定番です。-e は失敗したコマンドがあればそこで止め、-u は未定義の変数を使ったら止め、pipefail はパイプの途中の失敗を拾います。ただし -e には「if の条件や && の左辺では止まらない」などの例外が多く、万能の安全装置ではありません。静的解析ツールの ShellCheck を通すと、クォート漏れのような典型的な誤りの大半を事前に見つけられます。

環境変数と PATH

シェルは「環境変数」という名前付きの値をいくつも抱えており、起動したプロセスにそれを引き継ぎます。なかでも重要なのが PATH です。ls と短く打っただけでコマンドが見つかるのは、シェルが PATH に列挙されたディレクトリを順に探しているからです。「command not found」の多くは、目的のディレクトリが PATH に入っていないことが原因です。どのファイルが実行されるかは command -v ls で確かめられます。

シェルスクリプトと自動化

対話で打つコマンドの並びは、そのままファイルに書き出してシェルスクリプトにできます。条件分岐やループ、関数も使えるため、ビルドしてテストして配置する、といった一連の作業をまとめて実行できます。この自動化はサーバの運用や CI/CD(変更を自動でビルド・テスト・デプロイする仕組み)の土台で、パイプラインの各ステップの実体はたいていシェルコマンドの列です。

まず対話で1行ずつ試しながら組み立て、うまくいったらスクリプトに書き写す——探索と定着が地続きなのがシェルの魅力です。一方で、数十行を超えて分岐やデータ構造が増えてきたら、Python などのプログラミング言語に移すほうが安全です。シェルは「プロセスをつなぐ」ことに最も強い道具だと覚えておくと、使いどころを誤りません。

試問

ログファイル app.log には、ERROR timeout や ERROR disk full のような行が、ほかの行に混ざってばらばらの順に出てきます。パイプ(|)は、左のコマンドの出力を右のコマンドの入力へ流すつなぎ目です。下の5つのコマンドを1本のパイプにつなぎ、いちばん多く出たエラー上位3種類を、回数つきで表示させます。

✦ 命題挑戦中

出た回数が多いエラー上位3種類を回数つきで表示するように、5つのコマンドをパイプでつなぐ順に並べよ

↑ パイプの最初

↓ パイプの最後

2つを順に選ぶと入れ替わります(↑↓でも動かせます)