grep の「バイナリファイル (標準入力) に一致しました」が出る条件を調べていたらそれは長い旅路の始まりだった。

公開:2020/10/06
更新:2020/10/07
7 min読了の目安(約6800字TECH技術記事
Likes59

はじめに

昨今では1行につき、1つの JSON を出力する様なログファイル形式も珍しくはありません。

  • grep しやすい
  • データベース化しやすい

これらの理由で各所で多く使われています。僕も仕事で普通に使っているのですが、ある日突然そのログファイルを集計するスクリプトで以下の様なエラーが出始めました。

parse error: Invalid numeric literal

実行している bash スクリプトはおおよそ以下の通り。

zgrep keyword foo.log* | while read -r line; do
  echo $line | jq ...
done

ログファイルを tail で見ても特におかしい所はありません。どうやら jq がおかしな行をパースしている様なので echo をそのまま出して確認してみました。

バイナリファイル (標準入力) に一致しました

どうやら grep が行をバイナリ扱いし、それをメッセージとして出力し、jq がそれを食ったというのが原因でした。その場は grep の -a オプション(バイナリであってもテキスト扱いする)を付けて回避しました。まさかあんな事があるとは思いもよりませんでしたが。

原因が知りたい

しかしなぜこんなメッセージが出てしまうのか、プログラマにとってこういう話は「よっちゃんいか」です。どうしても気になってしまい調べてみる事にしました。

そう、「エラーを標準出力に吐くなよ!この○○がぁっ!」という気持ちを抑えながら。

grep のソースを調べる

grep のリポジトリ情報は以下にあります。

https://savannah.gnu.org/git/?group=grep

git で clone してソースを確認します。まず grep コマンドがこのメッセージを出力しているのは grep.c の 1643 行目

  if (binary_files == BINARY_BINARY_FILES && ! (out_quiet | suppress_errors)
      && (encoding_error_output
          || (0 <= nlines_first_null && nlines_first_null < nlines)))
    error (0, 0, _("%s: binary file matches"), input_filename ());
  return nlines;

binary_files-a フラグが使う値、out_quiet-q フラグが使う値、suppress_errors-s フラグが使う値。この条件でバイナリと判定したのは encoding_error_output であるのが分かりました。次に encoding_error_output を更新している個所を調べると関数 print_line_head で更新されているのが分かりました。

grep.c(1157)

static bool
print_line_head (char *beg, size_t len, char const *lim, char sep)
{
  if (binary_files != TEXT_BINARY_FILES)
    {
      char ch = beg[len];
      bool encoding_errors = buf_has_encoding_errors (beg, len);
      beg[len] = ch;
      if (encoding_errors)
        {
          encoding_error_output = true;
          return false;
        }
    }

  ......

  return true;
}

buf_has_encoding_errors が何かを判定している様です。

static bool
buf_has_encoding_errors (char *buf, size_t size)
{
  if (! unibyte_mask)
    return false;

  mbstate_t mbs = { 0 };
  size_t clen;

  buf[size] = -1;
  for (char const *p = buf; (p = skip_easy_bytes (p)) < buf + size; p += clen)
    {
      clen = mbrlen (p, buf + size - p, &mbs);
      if ((size_t) -2 <= clen)
        return true;
    }

  return false;
}

skip_easy_bytes は以下の通り。

static char const * _GL_ATTRIBUTE_PURE
skip_easy_bytes (char const *buf)
{
  /* Search a byte at a time until the pointer is aligned, then a
     uword at a time until a match is found, then a byte at a time to
     identify the exact byte.  The uword search may go slightly past
     the buffer end, but that's benign.  */
  char const *p;
  uword const *s;
  for (p = buf; (uintptr_t) p % sizeof (uword) != 0; p++)
    if (to_uchar (*p) & unibyte_mask)
      return p;
  for (s = CAST_ALIGNED (uword const *, p); ! (*s & unibyte_mask); s++)
    continue;
  for (p = (char const *) s; ! (to_uchar (*p) & unibyte_mask); p++)
    continue;
  return p;
}

つまり mbrlen(3) がエラーを返したらこの関数が true を返し、その呼び出し元である部分で encoding_error_output を true に更新、そして今回のエラーメッセージ(といいながら標準出力やないか)を出力している様です。

あれ、おかしいな。ログに日本語なんか出力しとらんのだけど。

ログファイルを確認してみた

気になったのでログファイルを再度確認してみました。するとファイルの先頭に大量の NUL バイトが詰められている事に気付きました。(勘のいい方はここで気付く)

$ xxd foo.log | head -n 20
00000000: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000090: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000b0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000c0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000d0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000e0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000f0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000100: 0000 0000 0000 0000 0000 0000 0000 0000  ................

(NUL で埋まる件は最後に原因が書いてあります)

一瞬出た言葉は「うそやん」でした。これはおかしいと、アプリのソースをくまなく調べましたがこんな事をやる様なコードは見当たりません。

色々と頭を悩ませていた中、なんとなく logrotate が怪しいんじゃないかと思い始め、logrotate のソースを見てみる事にしました。そこで見つけたコードが以下の処理

https://github.com/logrotate/logrotate/blob/5c6b370de432d8a97c0e5575d2c1d612cc923a81/logrotate.c#L1203-L1208
※このコードは logrotate 3.9.0 (2015/04/03)で入っています。

ログファイルに書き込むプロセス側はファイルポインタを保持しながらファイルに書き込む訳ですが、外部のプロセスがログファイルを移動し、truncate してファイルサイズが 0 になったとしても、その後のログファイルには続きのオフセットから書いてしまう事になります。つまりリネームした時点で書き込み側のプロセスが持っているファイルオフセットまでのコンテンツが不正になってしまいます。そこで logrotate はこの領域に 0x00 を埋めるという処理をしています。

これがログファイルの先頭に大量についた NUL バイトの原因です。なるほどなぁ。

でもそれプロセスにシグナルで HUP 飛ばしたりログ吐いてる側が適度にフラッシュしてないのが悪いんやで。

原因が分かったはいいが

原因が分かったはいいけど、そもそも僕が作ったアプリはログファイルを直接出力していないのです。このログファイルを出力しているのは systemd で実行してるリダイレクトです。

[Service]
Type=simple
PIDFile=/var/run/myapp.pid
ExecStart=/bin/sh -c '/opt/myapp/myapp >> /opt/myapp/myapp.log 2>&1'

でもそれプロセスにシグナルで HUP 飛ばしたりログ吐いてる側が適度にフラッシュしてないのが悪いんやで。

だったらそれ、ワシやないか。

本来ならば、最近はこういった物は journal で吐くのが正しい。でもシステムの都合上、どうしてもログファイルが必要なのです。systemd 236 に入っている StandardOutput=file を付ける事でログファイルを出力できるのですが、ログファイルは再起動で上書きされてしまいます。これを回避する為に append: という接頭語を使い追加モードをサポートできる様になったらしいのですが

StandardOutput=append:/tmp/test.log
StandardError=append:/tmp/test.log

append: が使えるのは systemd 240 からです。そして残念ながら CentOS 8 に入っている systemd は 239 なのです。致し方なく、シェルのリダイレクトで代用したという事です。

つまり前述のファイルポインタを持っているのは /bin/sh なのです。

どうやって解決したか

ここまでを纏めると

  • スクリプトファイルがエラーを吐いた
  • jq がパースエラーを出していた
  • grep がバイナリ扱いしてエラーを出していた
  • logrotate が起因して NUL バイトが吐かれていた
  • /bin/sh がオフセットを保持している

ではオフセットを持たない様にすればいいはずですね。そこで便利なのが stdbuf(1) です。stdbuf はストリーム上のバッファリングを変更する為のプログラムです。引数にコマンドを指定すると、そのコマンドの出力バッファを変更できます。systemd では以下の様に書きました。

[Service]
Type=simple
PIDFile=/var/run/myapp.pid
ExecStart=/bin/sh -c '/bin/stdbuf -o0 /opt/myapp/myapp >> /opt/myapp/myapp.log 2>&1'

-o0 は「バッファしない」という意味になります。これで logrotate がファイルを truncate したとしても stdbuf によりゼロ位置から書き始められるのでファイルの先頭に NUL バイトが埋められるという事も無くなるという事です。

めでたしめでたし。

追記1

NUL バイトが書き込まれる直接的な原因は > を使っているからでした。>> を使う事で O_APPEND が使われ、それにより truncatecopy の問題が回避できる様です。

logrotateでcopytruncateしてもサイズが大きいまま – K blog

たしかにこの調査をしている最中に >>> に直した気がする...
古い logrotate では同じ設定で起きなかったので 0x00 で埋まる件については後日調べる。
おそらく見間違いの可能性があります。原因は >> にしてなかった、だけになります。

追記2

おわりに

ログファイルは難しい。logrotate を仕込んだらかならず2日目に確認しろ。以上。