Bash
systemd
シェルスクリプト
journald
coproc

コプロセスによる出力フィルタ

More than 1 year has passed since last update.

はじめに

シェルスクリプトは、様々なコマンドの出力を次々パイプでつないで変換 ( フィルタ ) をかけていき、目的の結果を得るのが醍醐味のひとつです。

しかし、単純なパイプで処理できないような場合で、bash4のコプロセスのチュートリアルで取り上げたコプロセスが有用なことがありそうだということでまとめてみました。

場面

例えば、コマンドの出力にプロセス名等の固定の ( 時刻のように可変でない ) 情報を各行頭に埋め込みたいとします。
これは次のように、sedを使って実現できます。

$ commandX | sed -e 's/^/commandX: /'  ← コマンド名を行頭に挿入

しかし、この方法の場合、標準エラーを別個に加工することはできません。| によるシェルでのパイプは、あくまで一方の標準出力ともう一方の標準入力を仲介する1本分しかOSのパイプを生成しないからです。

では、標準出力・標準エラー両方加工したい場合どうするか、を考えてみます。

従来の方法

では今までどうしていたかというと、プロセス置換 >() を使っていました。次のような使い方になります。

$ commandX 2> >( sed -e 's/^/stderr: /' >&2 ) | sed -e 's/^/stdout: /'

これで概ね上手く行くのですが、ただ若干の不満がありました。cronジョブでこの方法を使っているスクリプトを実行すると、プロセス置換を使っている、この場合標準エラー側の出力が欠けるときがあるのです。

どうやら、プロセス置換によって起動されたフィルタ(この場合はsed)がジョブではなく管理外のプロセスと扱われているようで、実行完了を待って貰えてない、なのでメインの処理が終わった時点でcronデーモンから動作を止められてしまうからだ、と分かりました。
おそらく、cronでなくてもバッチ処理的に実行するような場面では似たようなことが起り得ます。

もちろん、最後にsleepを入れるなどしてフィルタの完了の猶予を与えてあげる手はあるのですが…。それは流石にダサいのではないかと思っていました。

コプロセスによる対処

ここである時ふと気付きました。フィルタをコプロセスとして起動すれば、これはジョブの一種として扱われますから、フィルタの完了をwaitで待ち合わせることができます。
その時まで、コプロセスは対話的な遣り取りの目的のためと見ていたわけですが、対話しなくてもジョブとして扱えるだけで十分メリットがあるのでは、と考えたわけです。

単純版

ということで、まずはフィルタをコプロセスとして実行してみます。
フィルタの出力は単に垂れ流しにさせたいので、次のような使い方になります。単純に行頭に何かを追加するフィルタの例です。

filter処理
exec 3>&1
coproc filterjob { sed -e 's/^/xxx/' >&3; }
commndX >&${filterjob[1]}
eval "exec ${filterjob[1]}>&-"
wait
exec 3>&-

手順がやや複雑ですが、次のようなことをやっています。

  • フィルタが出力を垂れ流すためにディスクリプタ3番をコピーとして使う。コプロセスの中で、出力はこの3番へ流してしまう。( コプロセスの出力を読み込む、ということは行わない想定 )
  • リダイレクトによりフィルタのコプロセスに出力を渡す
  • 最後、出力がなくなったことを、ディスクリプタを閉じる>&-で指示する。閉じるディスクリプタを変数で直接的に指定することはできないので、evalを挟む。

パイプの時のように、フィルタにデータを供給するプロセスとフィルタプロセスが一蓮托生の関係になっていませんから、フィルタをいつ終了させるかこれをディスクリプタを閉じるという操作で伝える必要があるのが、大きな違いです。
ただ逆に言えば、ディスクリプタを閉じるまで、様々なコマンドで使いまわしができるということで、()等でグループ化しなくて済むというメリットも生まれています。

複数のフィルタ

さてじゃあ、これで個別にフィルタをかけることができるな…と思うわけですが、実はここでちょっと落とし穴があります。何かと言うと、

  • 今のバージョンのbash ( 少なくとも4.3 ) では、複数のコプロセスを起動すると警告が出る
    どうやら非推奨なようなのです。標準エラーを/dev/nullへリダイレクトすれば警告も消せますが、あんまりそれも気が引けます。
  • commndX 2>${filterjob[1]} | filtercommand のようにパイプと併用すると上手く動かない
    深い事情があるのですが、まあ、そういう仕様だと諦めるしかありません。

ということで、もう一段階工夫が必要です。次のようにします。

filter処理(改)
exec 3>&2
coproc filterjob { sed -e 's/^/stderr: /' >&3; }
exec 2>&${filterjob[1]}
commndX | sed -e 's/^/stdout: /'
exec 2>&3
eval "exec ${filterjob[1]}>&-"
wait

つまり、パイプの中でリダイレクトを書くのがN.G.なのであれば、予めリダイレクトをしておいてからパイプを実行しようということです。
これでグローバルに2番の出力先がコプロセスに切り替わってしまいますから、後で対比していたディスクリプタを使って切り替え直します。

適用例

さて、なぜこのような個別のフィルタを考えたかというと、それはsystemdで動かすサービス等の出力を工夫するためです。

systemd制御下のプロセスの出力は、特に設定をしなければそのままjournaldへと渡されログとして記録されるのですが、実はこの時ログレベルも指定できるようになっています。
どうするかというと、出力の先頭に<数字>を付与するだけです。例えば 4 であれば warning相当、3であればerr相当というように調整できます。なお、省略すると 6 の info 相当です。詳しくはこちらprintf()のあたりをご覧ください。
これを利用して、各プロセスの出力に一括でログレベルを指定したかった、というのが今回の主な動機でした。

実際には次のような実装をしています。

systemd配下の処理
s=hoge # プロセス名等付与
exec 3>&2
coproc filterjob { stdbuf -o L sed -e "s/^/<4>$s: /">&3; }
exec 2>&${filterjob[1]}
# メインのコマンド
commandX | stdbuf -o L sed -e "s/^/$s: /"
exec 2>&3
eval "exec ${filterjob[1]}>&-"
wait

こうすることで、標準出力は標準のログレベルで、標準エラーはwarningレベルでログに残るということです。なお、ログのリアルタイム性を確保するためにstdbufを使ってバッファリングを行バッファリングにとどめています。

終わりに

幾分書き方がややこしくなりますが、個別にフィルタをかけるためにコプロセスを利用するというのはアリなんじゃないでしょうか。