2 ポイント 投稿者 GN⁺ 2024-03-03 | 1件のコメント | WhatsAppで共有
  • Bashスクリプトが想定と違う動きをするときは、実行されるコマンドをそのまま見る方法だけでも原因を追跡しやすくなる
  • set -x は各行を変数展開後に出力し、スクリプトが実際にどのコマンドを実行しているか確認できるようにする
  • コマンドラインで bash -x script.sh として実行すると、script.sh の先頭に set -x を入れたのと同じ効果がある
  • DEBUG トラップread を併用すると、各行の実行前に停止して、ファイル名、行番号、次のコマンドを確認できる
  • die() { echo $1 >&2; exit 1; } 関数は、失敗したコマンドの後に付けて、標準エラーにメッセージを出力して終了する流れをシンプルにする

実行フローを目で確認する

  • set -x はスクリプトが実行する行を出力し、変数は展開後の値で表示する
  • スクリプトの先頭に set -x を入れて使える
  • 同じ動作はコマンドラインからも実行できる
    • $ bash -x script.sh
    • これは script.sh の先頭に set -x を入れるのと同じ

行ごとに止めて確認する

  • DEBUG トラップは各コード行が実行される前に動作する
  • スクリプトの冒頭に以下のコードを入れると、次のコマンドを実行する前にEnter入力を待つ
    • trap '(read -p "\[$BASH_SOURCE: $LINENO] $BASH_COMMAND")' DEBUG
    • read -p はメッセージを出力し、Enter入力を待つ
    • $BASH_SOURCE はスクリプトファイル名
    • $LINENO は行番号
    • $BASH_COMMAND は次に実行されるコマンド

失敗時にメッセージを残して終了する

  • die 関数は、コマンド失敗時にメッセージを出力してプログラムを終了するために使える
    • die() { echo $1 >&2; exit 1; }
    • 失敗する可能性のあるコマンドの後に some_command || die "oh no!" のように付ければよい
  • この関数はメッセージを標準エラーへ送り、exit 1 で終了する

1件のコメント

 
GN⁺ 2024-03-03
Hacker News の意見
  • ZFSBootMenu ではデバッグを助けるために、いくつかのなかなか良い独自関数を使っている
    コードのあちこちに zdebug ロギング関数が入っている: https://github.com/zbm-dev/zfsbootmenu/blob/master/zfsbootme...
    デバッグロギングを有効にした状態でメインメニューから Ctrl-T を押すと、このような画面が出る: https://i.imgur.com/Ge75zkP.png
    さらに https://github.com/zbm-dev/zfsbootmenu/blob/master/zfsbootme... で有効にできるフレームグラフ・プロファイリングもあり、シリアルポートにダンプしたデータを再構成すると https://raw.githubusercontent.com/zbm-dev/zfsbootmenu/master... のようなグラフを作れる
    Bash は意外と柔軟
  • set -x を使うときは、PS4='+ ${BASH_SOURCE:-}:${FUNCNAME[0]:-}:L${LINENO:-}: ' がとても便利
    こうするとファイル名、関数名、行番号が表示されるので、大きな Bash スクリプトをデバッグするときにかなり役立つ
  • shellcheck もおすすめ。問題を直接見つけられなくても、潜在的な問題を教えてくれる
    それから、スクリプトを別の言語で書き直すこともおすすめする。会社では Bash スクリプトを Rust に置き換えているが、導入コストは高いものの、結果としてできるコードはずっと保守しやすく信頼性も高い
    Bash は手早いスクリプトには今でも向いているが、100行くらいを超えたら、より強い保証を与えてくれる言語を使う価値がある
    • 同意。ただ、その話はCI/CD エンジニアリングや YAML パイプラインの方面にもするべき
  • 終了コードをこのように使うと、デバッグをさらに改善できる
    die() はエラーメッセージを標準エラーに出力してから、指定したエラーコードで終了するヘルパー関数
    さらに多くのシェルスクリプト終了コードとヘルパー関数はここにある: https://github.com/SixArm/unix-shell-script-kit/blob/main/un...
    • 良い一覧だし、熟練ユーザーならみんな自分なりのヘルパー関数を持っていそう
      ただ、その die の考え方には少し物足りなさがある。die 関数は基本的に失敗したコマンドの終了コードを伝え、そのコマンドのエラー出力も隠すべきではない
      大きなスクリプトでコマンド失敗に自分で意味を持たせたいなら、もっと特化した die を別に使うだろう。自分の die はおおよそ __errex "$?" "${LINENO}" "$0" の形で、致命的エラー、行番号、スクリプト名、メッセージを出力し、その終了コードで終わる
  • Bash 関数を多用しているなら、一種のスタックトレースを作ることもできる
    実装例のひとつはここにある: https://github.com/TritonDataCenter/sdc-headnode/blob/master...
    • 別のスタックトレース実装もある: https://github.com/runag/runag/blob/main/lib/fail.sh
      some-command || fail "message" のように使うと、some-command が 0 以外の終了ステータスを返したときにスタックトレースを作成してシェルを終了する
      関数内でスタックトレースを作成して返したいなら、some-command || softfail "message" || return $? のように書ける
  • Bash がいまだに事実上のシェルスクリプト言語である理由は、レガシーな慣性以外にもあるのか気になる
    必要なことはできるが不格好で、構文もひどい。スクリプトがある程度の大きさや複雑さに達すると、まともな言語へ移るよう強制してくるので、もしかすると意図された設計なのかもしれないと思う
    • レガシーな利用量が人気の大きな部分を占めているのは確か
      現代のディストリビューションなら、たいていかなり新しい Bash が入っているし、配列のようなものを使おうとしているのでなければ、バージョンもそれほど気にしなくてよい
      Bash の魅力は、他の言語やツールの間で占めている位置にある。他のツールをつなぎ合わせるのに理想的で、OS に十分近いので便利だし、Python のようにライブラリのインストールを要求することもない
      より複雑なスクリプティングは Python のような言語に移せとよく言われるが、長期的には助けにならないかもしれない複雑さの層が追加される。20年前に書いた Bash スクリプトは今でも問題なく動くが、20年前の Python プログラムはバージョン問題に遭遇している可能性が高い
    • Bourne シェルスクリプティングは十分に良いので、ほとんど置き換え不可能
      Plan 9 の rc はもう少しきれいだが、「似ているけれどよりきれいなもの」だけでは誰も乗り換えない。今すぐ https://pkgsrc.se/shells から似ているがより良いものをインストールできるのに使っていないし、他人にとっても実行方法は変わらない
      定着した技術を置き換えるには、核心的な面で何倍も優れていなければならない。Plan 9 も UNIX 系より優れているが、置き換えるほど十分に優れてはいなかった
      Bourne シェルスクリプティングのニッチを置き換えるほど十分に良いものを作るのは難しい。そこまで良くなる前に、すでに Perl、Python、Ruby のような本物のスクリプト言語の生態的地位や問題領域へ移ってしまう
      狭い問題領域では局所最適解が空気をすべて吸い尽くし、理論上の大域最適解に近い競争相手が出てきにくくなる
    • 本当にレガシーと慣性のせいだと思う

最近追加された機能は sh/Bash の上にうまく載ってはいるものの、結局シェルスクリプティングは目的のための手段であり、一般的なプログラミング言語よりはるかにゆっくり進化すべきもの
Bash/sh の核心的な特徴は、反エントロピー的である点にある。開発や進化がほとんどないため、依存関係や新機能で面倒が起きる可能性が低く、20年前に使えたものが今も基本ツールとして残り続けている
その設計上、変化に抵抗するシステムになっており、限界にぶつかると人々をその外へ出ていかせる誘因が生まれる

  • それが本当に Bash なのかは確信がない
    FreeBSD のスクリプトの大半は sh 用に書かれており、sh は POSIX 標準の一部なので、はるかに広くサポートされていると感じる。Bash は単に人気がある方だと思う
  • どこにでもあるという点が大きい
    ただし Bash があまりにひどいので、Groovy スクリプトを使うために名前空間を短くしたユーティリティを大量に作った。IDE で開発でき、ライブラリシステムも安全で、Groovy が Java の不便さをほぼ全部ならしてくれるので、ずっと良かった
  • かなり強力な gdb スタイルの本物のデバッガ もある: https://bashdb.sourceforge.net/
  • 少し関連する宣伝をすると、以前、中間出力を保持する Bash パイプラインデバッガ を作った
    いくつか制限はあるが、一般的に役立つかもしれない: https://github.com/ketancmaheshwari/pd
  • die() テクニックは良いが、Bash には腹立たしい特性がある。サブシェル内で exit しようとすると、サブシェルだけが終了し、残りのスクリプトは実行され続ける
    例えば cat myfile | while read line; do ... die "Found match" ... done のようなパイプライン内で die を呼び出すと、後続の echo "I don't want this line" が依然として出力される
    サブシェルを避けられる場合は多く、この例では shellcheck が UUOC を指摘するのは正しく、それを直せばサブシェル内の die 問題も解決する
    しかし時にはサブシェルを避けられなかったり、避けようとするとスクリプトが複雑になりすぎたりする。そういう場合は、スクリプトの開始部分で MYPID=$$ と PID を保持しておき、die() { echo "$1" >&2; kill -9 $MYPID; exit 1; } のように kill できる
    もちろん、これにもトレードオフがある。この kill の仕方はかなり乱暴で、理由は分からないが完全に信頼できるものでもなかった
    • set -e を追加するだけで、サブシェルが 0 以外のエラーコードで終了したときにスクリプトも一緒に終了する
      どんなシェルスクリプトでも set -e を外すべき理由はあまり思い浮かばない
    • そのように PID を kill すると、ゾンビプロセス が生じる可能性があるのでは?
  • Bash スクリプトの先頭には常に set -euxo pipefail を入れている
    条件テストは少し難しくなるが、特に pipefail だけでも何度も元を取った
    • https://mywiki.wooledge.org/BashPitfalls#set_-euo_pipefail
    • 複数行に対して有効にしてから set +x で無効にすることもできる
      ずっと有効にしておくと、かなり退屈になる
    • 命の恩人のような設定
      ただし -x は、本当にあらゆる汚いデバッグ出力を見る必要があるときまで取っておく