9
3

More than 3 years have passed since last update.

GitHub Actions で CI したら Broken pipe や I/O Error がでるようになった話(または SIGPIPE の罠)

Last updated at Posted at 2020-11-10

はじめに

Travis CI で動かしていたシェルスクリプトを GitHub Actions に変更したら Broken pipe(または printf: I/O error 等)というエラーメッセージが出るようになってしまいました。結論から言うとシグナル SIGPIPE のトラップ状況が異なっているからのようです。

※ 今回エラーメッセージがでるようになったのはシェルスクリプトを使ったテストですが、CI から直接または間接的にシェルスクリプトや外部コマンドが実行される場合にも当てはまるはずです。

ローカルで再現

まずローカルで再現させる方法です。1行100文字で1000行(おそらく Linux のパイプのバッファサイズである 64KB 以上)ぐらいのファイルを用意してください。そして以下のシェルスクリプトを作って実行します。

#!/bin/sh
cat 1000.txt | head -n 5

この状態では「エラーはでない」はずです。実はこの処理の中でシグナル SIGPIPE がこっそり発生しています。なぜなら cat が 1000 行出力している途中で head は 5 行読み込んだタイミングで終了してしまうため cat の出力先がなくなってしまうからです。しかしそれでもエラーがでないのは SIGPIPE が発生したときのデフォルトの処理が Terminate だからです。そのため cat はエラー出力をする間もなく強制終了します。

catSIGPIPE で終了したのであればエラーになるのでは?と思った人、半分正しいです。実際 cat はエラー終了しています。しかしシェルスクリプトの仕様上、パイプの左側(つまり cat ) でエラーになっても無視されます。

bash 等では set -o pipefail を実行することでパイプの途中でエラーになった場合にエラー終了させることができるので以下のようにスクリプトを書き換えると、見事に SIGPIPE のエラー(Linuxの場合は終了ステータス 13 (SIGPIPE) + 128 = 141)で停止します。

#!/bin/bash
set -o pipefail
cat 1000.txt | head -n 5

つまり、普段何気なく cat でかいファイル.txt | head -n 5 などとやると実はエラーが発生しているのです。

では上記のスクリプトをこう書き換えてみましょう。

#!/bin/sh
trap '' PIPE # PIPEシグナルの扱いを Ignore (SIG_IGN) に変更する
cat 1000.txt | head -n 5

cat: 書き込みエラー: Broken pipecat: write error: Broken pipe)というエラーが出力されたはずです。(ただし set -o pipefail を実行してないのでスクリプト自体は正常終了です。)

SIGPIPE を無視 (Ignore) に設定したらエラーメッセージも表示されないような気もしますが、単にシグナル発生時に何もせず続行する進むというだけなので、標準出力に出力ができなかった cat はエラーを出力します。

このエラー出力を回避する簡単な方法は標準エラー出力を /dev/null に捨てるという方法ですが、これだとその他のエラーもわからなくなってしまうので最後の手段にしたいところです。そもそもこのエラーは出力先 head が早期に終了してしまうことで、cat の出力先が無くなってしまうために発生します。そこで cat の出力を全て受け取るようにすることで回避が可能です。例えば以下のような方法です。

#!/bin/sh
trap '' PIPE
cat 1000.txt | { head -n 5; cat >/dev/null; }

head は早期に終了しますが、残りの出力を後続の cat >/dev/null が受け取るため cat 1000.txt の出力先が無くなることはなくエラーになることもありません。他にも wc など出力をすべて受け取るコマンドを利用することで回避可能です。ただし本来はすべての行を処理することなく終了していたものを、すべての行を処理するわけなので遅くなってしまいます。遅くせずに回避する方法は標準エラー出力を捨てるしかないかもしれないです。(head 終了直後に catSIGKILL を送って強制終了させるという方法を考えてみましたが、プロセスIDの取得方法とか面倒なコードが必要になりそうです。)

GitHub Actions の問題

さて GitHub Actions の話に戻りますが、私が書いていたシェルスクリプトのコードは trap '' PIPESIGPIPE を無視するようなコードは書いていません。すなわち Terminate で静かにエラー終了するはずですが、GitHub Actions で実行するとなぜかエラーメッセージが表示されるようになってしまいました。どうも GitHub Actions は SIGPIPE をデフォルトの Terminate ではなく Ignore に設定してからワークフローに書いたシェルスクリプトを実行するようなのです。

シグナルハンドラはデフォルト(SIG_DFL) または Ignore (SIG_IGN) であれば親プロセスの状態を継承します。そのためシェルスクリプトおよびそこから呼び出した catSIGPIPE が Ignore に設定された状態で実行してしまいます。

trap - PIPE では SIG_DFL に戻せない

GitHub Actions に問題があるとはいえ、シェルスクリプト内でデフォルト (SIG_DFL) に戻せば回避可能・・・と思うかもしれませんができません。一応シェルスクリプトの trap にはデフォルトに戻す方法があります。

#!/bin/sh
trap - PIPE
cat 1000.txt | head -n 5

しかしこれはシェルスクリプト開始時に SIG_IGN に設定されているものに対しては効果がありません。その挙動は strace を使用するとわかります。

まず普通に上記のスクリプトを実行した場合です。SIG_DFL に設定しているのがわかります。

$ strace ./test.sh 2>&1 | grep SIGPIPE
rt_sigaction(SIGPIPE, NULL, {sa_handler=SIG_DFL, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7ff3003fef20}, 8) = 0
rt_sigaction(SIGPIPE, {sa_handler=SIG_DFL, sa_mask=~[RTMIN RT_1], sa_flags=SA_RESTORER, sa_restorer=0x7fcde743ef20}, NULL, 8) = 0

次に SIG_IGN に設定してから実行した場合です。SIG_DFL への設定は行われません。

$ trap '' PIPE
$ strace ./test.sh 2>&1 | grep SIGPIPE
rt_sigaction(SIGPIPE, NULL, {sa_handler=SIG_IGN, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7ff3003fef20}, 8) = 0

この挙動は POSIX でも規定されています。
https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#trap

Signals that were ignored on entry to a non-interactive shell cannot be trapped or reset, although no error need be reported when attempting to do so. An interactive shell may reset or catch signals ignored on entry. Traps shall remain in place for a given shell until explicitly changed with another trap command.

Debian Bug report で bash の バグとして報告されていますが、正しい挙動であるとのことです。
"trap - SIGNAL" should reset SIGNAL to SIG_DFL not to initial value

おまけ参考リンク
Don't fear SIGPIPE!

env --default-signal で SIG_DFL に戻す

実はシェルスクリプトで SIG_IGN から SIG_DFL へ戻せない事への対応として、GNU Core Utilities の env コマンドに --default-signal オプションが追加されているので、cat コマンドを env コマンド経由で呼び出せば回避できます。

‘--default-signal[=sig]’
Unblock and reset signal sig to its default signal handler. Without sig all known signals are unblocked and reset to their defaults. Multiple signals can be comma-separated. The following command runs seq with SIGINT and SIGPIPE set to their default (which is to terminate the program):

従来のシェルでは SIG_DFL に戻すことが不可能であることも書かれています。

In the following example, we see how this is not possible to do with traditional shells. Here the first trap command sets SIGPIPE to ignore. The second trap command ostensibly sets it back to its default, but POSIX mandates that the shell must not change inherited state of the signal - so it is a no-op.

trap '' PIPE && sh -c 'trap - PIPE ; seq inf | head -n1'

ただし、このオプションは比較的最近の GNU Core Utilities 8.31 からの機能なので、現時点で最新の Debian buster や Ubuntu focal 等ではまだ使うことができません。

参考までですが cat コマンドの呼び出しを全て、env --default-signal=PIPE cat に変更するのは大変ですよね?こういう場合には cat シェル関数を定義することで cat コマンドの呼び出しを上書きすることができます。環境変数を使うよりも便利なテクニックなので覚えておくと良いと思います。

#!/bin/sh

cat() {
  env --default-signal=PIPE cat "$@"
}

cat 1000.txt | head -n 5

Perl(他言語)で SIG_DFL に戻す

SIG_DFL に戻せないのはシェルスクリプトの制限なので他の言語を使って戻すことができます。例えば Perl を使った場合は以下のようなコードで戻すことができます。

#!/usr/bin/env perl
$SIG{'PIPE'} = 'DEFAULT';
# system(); 等で実行したいシェルスクリプトを呼び出す

さいごに

ということで、GitHub Actions に乗り換えたら Broken Pipe が出るようになったのでいろいろと調べてみました。CI サービスは便利ですが、GitHub Actions に限らずサービスによって微妙に環境が違っていたりして予期せぬ所でハマったりして困りますね。検証するにもコードを push しないとわからないので時間がかかってしまうので大変です。(さて、あとは GitHub Actions に報告を・・・どこにすればいいんだろう?)

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3