世界を動かす技術を、日本語で。

TIL: Bashスクリプトにおけるタイムアウト

概要

  • Bashスクリプト でWebサーバー起動後の 状態確認 の問題点
  • until ループの無限待機リスク
  • timeoutコマンド の利用方法と制約
  • until とtimeoutの組み合わせ時の注意点
  • Bashプロセスや別スクリプト での解決策

BashスクリプトでのWebサーバー起動待機と無限ループ問題

  • Webサーバー起動確認 のためにBashスクリプトで until ループを使用
  • 例:until curl --silent --fail-with-body 10.0.0.1:8080/health; do sleep 1; done
  • サーバーが起動失敗 した場合、ループが 永久に継続 するリスク
  • sleep 1 が永遠に実行される状況発生

timeoutコマンドの活用法

  • timeout はコマンド実行に 制限時間 を設定するユーティリティ
  • 指定時間を超えると SIGTERM シグナルで強制終了
  • シグナルは --signal オプションで変更可能(例:timeout --signal=SIGKILL 1s foo
  • 例:timeout 1s sleep 5 → 1秒後にsleepへSIGTERM送信、終了コード124返却

untilとtimeoutの直接併用の課題

  • 直感的にはtimeout 1m until curl ...; do sleep 1; doneのように書きたくなる
  • しかし timeoutkill可能なコマンド を前提としており、 untilはシェルキーワード のため直接利用不可
  • timeout はシェル組み込みコマンドやキーワードには利用できない制約

解決策:Bashプロセスや外部スクリプトでラップ

  • until ループ全体を bash -c でラップし、timeoutの引数として渡す方法
    • 例:timeout 1m bash -c "until curl --silent --fail-with-body 10.0.0.1:8080/health; do sleep 1; done"
  • または untilループを外部スクリプト (例:until.sh)に分離し、
    • timeout 1m ./until.shのように実行

まとめと注意点

  • timeout はシェル組み込みには直接使えない制約
  • bash -c外部スクリプト でラップすることで 実用的にtimeoutを活用可能
  • 無限ループ防止障害時の自動停止 として有効なテクニック

Hackerたちの意見

リトライロジックが必要な時は、だいたいこんな感じでやってるよ。

for i in {0..60}; do
  true -- "$i" # shelleck suppression if eventually_succeeds; then break; fi
  sleep 1s
done

あんまり優雅じゃないけど、まあまあ正しいかな。次のステップは指数バックオフだね。ちょっとしたコンポーザビリティも残るし。

これ、アプリケーションによってはeventually_succeedsのためにtimeoutが必要になるから注意してね。Bashでは、POSIX/IO/プロセスを扱う時は、ディフェンシブコーディングのプラクティスを使う必要があるよ。何をやっても結果が伴うからね。

君次第だけど、shellcheckがその問題を解決する方法は、

for _ in

のように_を使うことだと思うよ。 リンク: https://github.com/koalaman/shellcheck/wiki/SC2034#intention...

どうやらtimeout(1)はGNU Coreutilsの一部らしい。読んだ後、Bash自体の一部かどうかはちょっと不明だった。

あと、注意してね。timeoutコマンドや引数は、/usr/bin/timeoutとBrewのgtimeoutで違うから(それが「g」プレフィックスの由来)。BSDを使ったことがないから、どうなってるのかは知らないんだ。

直接untilと一緒にtimeoutを使えないのは残念だね。 untilキーワードはPOSIX.2シェル仕様の一部で、タイムアウト機能は含まれていないんだ。bashで実装することはできるけど、他のシェルにはポータブルじゃない(Debian dashが主な懸念)。だから、別のユーティリティとして実装されているんだ。仕様を見たいなら、下の「The until loop」を検索してみて。 https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V...

あまり知られていないお気に入りのトリックは、straceの障害注入を使っていろんなsyscallの失敗をテストすること。例えば、

$ strace -e trace=clone -e fault=clone:error=EAGAIN random

リンク: https://medium.com/@manav503/using-strace-to-perform-fault-i...

これはすごい!もっと早く知っていればよかったな。失敗の分岐をテストできないのを知って、こういう関数をよくスタブアウトしてたけど、できるだけ狭い範囲に留めるようにしてたんだ。ありがとう!

これは素晴らしい!Windowsに同じようなものがあるか知ってる人いる?

新しいKubernetesのセットアップでコマンドタイムアウトを追加したばかりだよ。このawait-cmd.sh / await-http.sh / await-tcp.shのPOSIXシェルスクリプト実装は成熟してて、いくつかのシナリオではかなり便利だよ。 リンク: https://github.com/vegardit/await.sh

昔は、

timeout 1800 mplayer show.mp4 ; sudo pm-suspend

を使ってた。子供たちが小さい時に、30分間手動で監視せずにショーを見せるための貧乏人の親のコントロールとしてね。便利なコマンドだよ。

それは多分、最もよく説明されたユースケースだね!

変数をbash -cに渡す必要がある場合、最も良い方法はそれらを追加することだよ。例えば、bash -c 'some command "$1" "$2"' -- "$var1" "$var2" みたいにね。--を使うのは見た目が好きだからだけど、最初のパラメータはargv[0]に入るから、"$@"では展開されないんだよね。だから、個人的には分かりやすさのために、そこには引数以外のものを入れた方がいいと思う。bashには特にprintf %qがあって、代わりに使うこともできるけど、bashのバージョンが特にクリーンじゃないときは、bourne互換のものを使うのが好きなんだ。

Busyboxはargv[0]を使って何を実行するかを知るから、"ls"をargv[0]として渡すと、"ls"(または"mv"/"cp"/など)を実行するよ。

一般的に、コマンドをインラインで書いたり、サブプロセスにシグナルを送る必要があるからってローカルディレクトリを小さなスクリプトで散らかすのはあまり好きじゃないんだ。こんなラッパーを使ってるよ。これでタイムアウトさせたい複雑なロジックを含む関数をエクスポートするんだ。timeoutのbash -c引数の変なクオートは、ここでaidenn0が別のコメントで言ってたことの一般化されたバージョンだよ(サブプロセスに引数を安全に渡す)。

#!/usr/bin/env bash
long_fn () {
  # ここには何でも入れられるよ、OPのuntil curlループみたいに
  sleep $1
}
# TIMEOUT_DURATION BASH_FN_NAME BASH_FN_ARGS...
to () {
  local duration="$1"; shift
  local fn_name="$1"; shift
  export -f "$fn_name"
  timeout "$duration" bash -c "$fn_name"' "$@"' _ "$@"
}
time to 1s long_fn 5 # 1秒間実行されたと報告される

コマンドの最後には"$@"を使わないとダメだよ。そうしないと、スペースが含まれてる引数が分割されちゃうから。例えば、次のようにしてみて。

long_fn() {
  echo "$1"
  sleep "$2"
}

1秒で

long_fn "This has spaces in it" 5

ちなみに、curlには実際に--retry-connrefusedフラグがあって、シェルでこのループを完全に避けるのに役立つよ。

Macにtimeoutコマンドがないから、bashのビルトインだけでタイムアウトを作ろうと試行錯誤してるんだ。ビルトインだけではうまくいかなかったけど、sleepコマンドを使うなら(POSIXの最初のバージョンから標準化されてるから、POSIX準拠を目指す環境ならほぼどこでも使えるはず)、これでいけそうだよ。

# TIMEOUT SYSTEM
#
# タイムアウト関数を定義:
#
# 使い方: timeout
#
# スクリプトが終了していなければ、経過後に実行される。
_alarm() {
  local timeout=$1
  # $timeout秒間スリープしてからSIGALRMを送るサブシェルを生成
  ( sleep "$timeout"; kill -ALRM $$ ) &
  # タイムアウトが発生する前にこのシェルが終了したら
  # サブシェルを殺してクリーンアップ
  subshell_pid=$!
  trap _cleanup EXIT
}

_cleanup() {
  if [ -n "$subshell_pid" ]; then
    kill "$subshell_pid"
  fi
}

timeout() {
  local timeout=$1
  local command=$2
  trap "$command" ALRM
  _alarm "$timeout"
}

# メインプログラム
times_up() {
  echo 'TIME OUT!'
  subshell_pid=
  exit 1
}

timeout 10 times_up
for i in {1..20}; do
  sleep 1
  echo $i
done

純粋なbashで接続テストする別の面白い方法(過去15年の見直しが必要)は、次の通り。

timeout 5 bash -c 'cat /dev/tcp/google.com/80'

google.comとポート80を自分のウェブサーバーやTCPサーバー(SSHも!)に置き換えてね。サーバーがリッスンしていないか、ファイアウォールやプロキシが邪魔してると、コマンドはエラーになるかタイムアウトするよ。