cronジョブやバッチ処理を設計したことがある人なら、一度は感じたことがある悩みです。
bash には、スクリプト起動直後にたった2行書くだけで、スクリプト全体のstdout(標準出力)とstderr(標準エラー出力)をまとめてログファイルにリダイレクトできる仕組みがあります。それが、bashの組み込みコマンド
exec によるファイルディスクリプタ(FD)操作です。この記事では、bashのexecを使ったFD操作の基礎から、cronジョブのログ集約・バッチ処理への応用・trapとの組み合わせまで、実務で使える設計パターンを具体的なコード例とともに解説します。
この記事のポイント
・exec 1>>logfile 2>&1 でスクリプト全体の出力を一括ログ保存できる
・exec はbashの組み込みコマンドであり、docker exec とは別ツール
・exec 3>&1 でFDを複製しておけば、途中でstdoutを元に戻せる
・trap EXIT と組み合わせてFDを確実に閉じる設計が現場の定石
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
ファイルディスクリプタ(FD)の基礎知識
bashでリダイレクトを理解するには、まずファイルディスクリプタ(File Descriptor、以下FD)の概念を押さえておく必要があります。LinuxのプロセスはI/Oのやりとりを整数値(FD番号)で管理します。プロセスが起動した時点で、以下の3つのFDが自動的にオープンされます。
・FD 0(stdin):標準入力(キーボード入力など)
・FD 1(stdout):標準出力(通常は端末に表示される)
・FD 2(stderr):標準エラー出力(エラーメッセージの出力先)
コマンドラインで
command > file と書いたとき、シェルは内部でFD1の接続先を file に差し替えています。つまりリダイレクトとは、FDの接続先を変更する操作にほかなりません。通常のリダイレクト(
command > file)は、そのコマンド1つだけに適用されます。スクリプト内の全コマンドにまとめて適用するには、別のアプローチが必要です。それがexecによるFD操作です。FDの状態は、Linuxでは
/proc/$$$/fd/ ディレクトリ($$$はシェル自身のPID)で確認できます。# 現在のシェルのFD接続先を確認する ls -la /proc/$$/fd/ # 出力例(RHEL 9 / bash 5.1 環境) # lrwx------. 1 user user 64 Jul 22 10:15 0 -> /dev/pts/0 # lrwx------. 1 user user 64 Jul 22 10:15 1 -> /dev/pts/0 # lrwx------. 1 user user 64 Jul 22 10:15 2 -> /dev/pts/0 # FD 0, 1, 2 すべてが端末(/dev/pts/0)に向いている
bash の exec コマンドとは何か
exec はbashの組み込みコマンド(shell builtin)です。使い方が2種類あるため、混乱しやすい点があります。使い方①:プロセスの置き換え
exec /usr/bin/python3 myscript.py のように引数にコマンドを渡すと、現在のシェルプロセスを指定コマンドで置き換えます。新しいプロセスは生成されず、PIDはそのまま引き継がれます。使い方②:FDの操作(コマンド引数なし)
exec 1>logfile のようにリダイレクト記号だけを指定すると、現在のシェルのFDを操作します。プロセスの置き換えは発生せず、スクリプトはそのまま続行します。本記事で扱うのは②の使い方です。これを使うことで、以降のすべての出力先をまとめて変更できます。
注意:docker exec とは別ツールです
Dockerを使っている方は「exec」という文字列に反応してしまうかもしれませんが、docker exec(実行中のコンテナ内でコマンドを実行する機能)とbash組み込みの exec は完全に別物です。本記事のexecはあくまでbashシェルの組み込み機能であり、Dockerとは無関係です。
exec によるFD操作の基本構文
1. exec 1>logfile でstdoutを切り替える
最も基本的な使い方です。スクリプト冒頭に以下を書くだけで、以降のすべての標準出力がlogfileに書き込まれます。#!/bin/bash # スクリプト全体のstdoutを切り替える exec 1>/tmp/test-exec.log echo "ここからの出力はすべてlogfileに書かれる" echo "端末には何も表示されない" date hostname
# /tmp/test-exec.log の内容(RHEL 9 実行例) ここからの出力はすべてlogfileに書かれる 端末には何も表示されない 2026年 7月 22日 水曜日 10:30:00 JST web01.example.local
2. exec 2>&1 でstderrをstdoutに統合する
cronジョブなどでは、stdout(FD1)とstderr(FD2)を同じログファイルに記録したい場合がほとんどです。以下の組み合わせが定番パターンです。#!/bin/bash # stdout と stderr を同じlogfileに追記する exec 1>>/var/log/myscript.log 2>&1 echo "[INFO] スクリプト開始" ls /nonexistent_dir # stderr が出るコマンド echo "[INFO] スクリプト終了"
2>&1 は「FD2をFD1と同じ出力先につなぐ」という意味です。この順番(先にFD1を切り替えてからFD2をFD1に向ける)が重要で、逆に書くと意図どおりに動きません。・正しい順序:
exec 1>>logfile 2>&1(先にFD1をlogfileに向け、次にFD2をFD1に向ける)・誤った順序:
exec 2>&1 1>>logfile(FD2をFD1に向けた時点ではFD1がまだ端末のまま)実行後のログ例(/var/log/myscript.log):
# /var/log/myscript.log(RHEL 9 実行例) [INFO] スクリプト開始 ls: '/nonexistent_dir' にアクセスできません: そのようなファイルやディレクトリはありません [INFO] スクリプト終了
3. exec 3>&1 でFDを複製・保存する
「ログに書きながら、特定の出力だけターミナルにも表示したい」という場合は、FD3(ユーザー定義FD)を使って元のstdoutを保存しておきます。FD0~2はシステム予約ですが、FD3以上はユーザーが自由に使えます(慣習的に3~9を使用)。#!/bin/bash # 元の stdout(端末)をFD3に保存 exec 3>&1 # stdout をログファイルに切り替え exec 1>/var/log/batch.log 2>&1 echo "[LOG] これはログに書かれる" # FD3(元の端末)に直接書く echo "[CONSOLE] これは端末にも表示される" >&3 echo "[LOG] 後続処理もログに書かれる" # 後始末:FD3を閉じる exec 3>&-
4. exec N>&- でFDを明示的にクローズする
exec N>&- でFD番号Nを閉じることができます。ファイルへの書き込みを完了してフラッシュしたい場合や、後続プロセスへのFD引き渡しを防ぎたい場合に使います。# FD1(stdout)を閉じる exec 1>&- # FD2(stderr)を閉じる exec 2>&- # FD3(ユーザー定義)を閉じる exec 3>&-
実務パターン:スクリプト全体のログを一括保存する
1. cronジョブのログ集約パターン
cronで実行するスクリプトにexecを組み込む最も汎用的なパターンです。日付付きのログファイルに、stdout・stderrを追記モードで一括保存します。#!/bin/bash # cronジョブのログ集約テンプレート # /usr/local/bin/daily-batch.sh SCRIPT_NAME="$(basename "$0")" LOG_DIR="/var/log/batch" LOG_FILE="${LOG_DIR}/${SCRIPT_NAME%.sh}_$(date +%Y%m%d).log" # ログディレクトリがなければ作成(exec より前に必ず実行) mkdir -p "$LOG_DIR" # スクリプト全体のstdout/stderrをログに向ける(追記モード) exec 1>>"$LOG_FILE" 2>&1 echo "==========================================" echo "[$(date '+%Y-%m-%d %H:%M:%S')] START: $SCRIPT_NAME" echo "==========================================" # --- ここから本処理 --- echo "[INFO] データ処理を開始します" # ... 実際の処理 ... echo "[INFO] データ処理が完了しました" # --- 本処理ここまで --- echo "[$(date '+%Y-%m-%d %H:%M:%S')] END: $SCRIPT_NAME"
# crontab -e の設定例 # 毎日午前3時に実行(cron自体のメール通知は MAILTO= で無効化) MAILTO="" 0 3 * * * /usr/local/bin/daily-batch.sh
2. バッチスクリプト:stdout/stderrを別ファイルに保存する
エラー調査を容易にするため、stdoutとstderrを別々のファイルに保存したい場合は以下のパターンを使います。実行ごとにタイムスタンプ付きのファイル名が生成されるため、どの実行のログか一目でわかります。#!/bin/bash LOG_DIR="/var/log/batch" RUN_ID="$(date +%Y%m%d_%H%M%S)" mkdir -p "$LOG_DIR" # stdout と stderr を別ファイルに振り分ける exec 1>>"${LOG_DIR}/out_${RUN_ID}.log" exec 2>>"${LOG_DIR}/err_${RUN_ID}.log" echo "[INFO] バッチ処理を開始" ls /nonexistent_dir # stderrに記録される echo "[INFO] バッチ処理を終了" # 確認方法: # tail /var/log/batch/out_20260722_030000.log # 正常出力 # tail /var/log/batch/err_20260722_030000.log # エラーのみ
3. /dev/null で出力を捨てる設計
cronジョブで「出力は不要・エラーだけメールで受け取りたい」場合や、デーモン化スクリプトで完全サイレントにしたい場合には /dev/null を使います。#!/bin/bash # パターン1: stdout のみ捨てる(stderr はそのまま → cron がメール通知) exec 1>/dev/null # パターン2: stdout も stderr も捨てる(完全サイレント) exec 1>/dev/null 2>&1 echo "この出力は /dev/null に捨てられる" ls /nonexistent # パターン1では stderr → cron 経由でメール通知される
2>&1 でstderrもstdoutに統合してから /dev/null に向ければ、完全サイレントモードになります。
シェルスクリプト講座を見る >>
trap との組み合わせ:終了時にFDを確実に閉じる
スクリプトが途中でエラー終了した場合でも、ログに「スクリプト終了」を記録したい・FDを確実に閉じたいという要件があります。そのためにtrap コマンドと組み合わせます。#!/bin/bash LOG_FILE="/var/log/batch/$(date +%Y%m%d).log" mkdir -p "$(dirname "$LOG_FILE")" # stdout/stderr をログに向ける exec 1>>"$LOG_FILE" 2>&1 # EXIT シグナルで終了ログを記録してFDを閉じる cleanup() { local exit_code=$? echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script ended (exit code: ${exit_code})" exec 1>&- 2>&- } trap cleanup EXIT echo "[$(date '+%Y-%m-%d %H:%M:%S')] Script started" # --- 本処理 --- echo "[INFO] 処理を実行中" # エラーが起きても trap EXIT が必ず呼ばれる # set -e を使う場合、コマンドが失敗してもcleanupは実行される
trap cleanup EXIT は、スクリプトが正常終了・エラー終了・set -e によるエラー終了のいずれでも cleanup 関数を呼び出します。ログへの終了記録と後始末を一か所にまとめられるため、長期運用するcronジョブで特に有効です。FDを元に戻す(stdout の復元)
「特定の関数内だけログに書き、関数が終わったら端末表示に戻したい」という場合の設計パターンです。FD3を「保存ソケット」として利用します。#!/bin/bash LOG_FILE="/tmp/function_log.log" # 元の stdout(端末)をFD3に保存 exec 3>&1 log_section() { # 関数内だけログファイルに切り替え exec 1>>"$LOG_FILE" 2>&1 echo "[LOG] 関数内の処理" hostname date # FD3(元の端末)に戻す exec 1>&3 } echo "この出力は端末に表示される(FD1=端末)" log_section echo "ここも端末に表示される(FD1が端末に復元済み)" # 後始末:FD3を閉じる exec 3>&-
トラブルシュート
【問題1】exec 後の出力がログに書かれない
exec 実行後にサブシェル(( ) で囲んだブロック)や外部スクリプトを呼んでいると、FDが引き継がれていない場合があります。・確認方法:問題箇所の直前に
ls -la /proc/$$/fd/1 を入れてFD1の接続先を確認する・解決方法:サブシェルを使わずスクリプトをフラット構成にするか、子スクリプト内にも同様のexec設定を入れる
【問題2】ログファイルに何も書かれない
ログディレクトリが存在しない場合、execの実行が失敗します(シェルは終了せず、その後の出力が消えます)。# NG: ディレクトリがなければ exec が失敗して以降の出力が消える exec 1>>/var/log/newdir/batch.log 2>&1 # OK: execより前に必ずディレクトリを作成する mkdir -p /var/log/newdir exec 1>>/var/log/newdir/batch.log 2>&1
【問題3】FD3を使ったら「bad file descriptor」エラー
FD3を閉じた後(exec 3>&-)に再度 >&3 で書こうとするとエラーになります。また、exec でFD3を開く前に >&3 を実行するのも同様です。・対処:FDの開閉順序を設計段階で整理する。閉じたFDを再利用する場合は
exec 3>&1 で再オープンしてから使う【問題4】2>&1 の順序が逆でエラーが端末に出てしまう
exec 2>&1 1>>logfile と書いてしまうと、「FD2をFD1に向けた時点ではFD1がまだ端末」のため、stderrが端末に流れます。・正しい順序:
exec 1>>logfile 2>&1(FD1をlogfileに変更してからFD2をFD1に統合)本記事のまとめ
bashのexecコマンドによるFD操作は、シェルスクリプトの品質を大きく左右するテクニックです。毎行にリダイレクトを書く代わりに、スクリプト先頭の2行でログ設計を完結できます。| やりたいこと | exec の書き方 |
|---|---|
| stdout をログに上書き保存 | exec 1>/path/to/logfile |
| stdout をログに追記 | exec 1>>/path/to/logfile |
| stdout と stderr を同じログに記録 | exec 1>>/path/to/logfile 2>&1 |
| stdout を捨てる | exec 1>/dev/null |
| すべての出力を捨てる | exec 1>/dev/null 2>&1 |
| 元の stdout を保存しておく | exec 3>&1 |
| 保存した stdout を復元する | exec 1>&3 3>&- |
| FD を閉じる | exec N>&- |
シェルスクリプト講座を見る >>
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
登録10秒/合わなければ解除3秒 / 詳細はこちら
- 前のページへ:bashのサブシェルと変数スコープの罠|()と{}の違い・パイプ先で変数が消える問題の対処
- この記事の属するカテゴリ:シェルスクリプトへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は1分、解除もいつでも可。
登録無料・いつでも解除できます