症状:通知の本文がまるごと空になった

私たちは運用の通知を送るシェルスクリプトを使っていますが、通知の本文が空のまま送られ、知らせそのものが消えてしまうことがありました。記録を調べると、同じ現象が2回起きていました。

厄介だったのは、スクリプトの終了コードが0のままだったことです。パイプの終了コードは最後のコマンドのものになるため、途中で何が起きていても気づけません。実際に組み立てられるJSONは文法として正しく、"text":""という形で送信されてしまいます。成功と失敗が同じ見た目になるので、気づけるのは送り先の返事だけでした。起きてから見つかるまでに27日かかっています。

原因:壊れたUTF-8の1バイトとsedの無言失敗

記録に残っていた文をodで見ると、bc 88という2バイトが、全角文字の途中から切れた形で残っていました。これが壊れたUTF-8のバイト列です。

macOSのsedは、日本語のlocale(ja_JP.UTF-8)でこの壊れたバイトに当たると、sed: RE error: illegal byte sequenceというエラーを出し、何も出力しません。先ほど触れた通り、パイプの終了コードはこのエラーを隠してしまいます。ちなみにCのlocaleでは壊れたバイトがそのまま通ってしまうため、片方のlocaleだけで試すと問題なく動いて見えるのもつまずきやすい点でした。

壊れたバイトの出どころも特定できました。bashのスクリプトで変数の直後に全角文字を書いた行があり、macOSに入っている古いbashは、日本語のlocaleで全角文字の1バイト目を変数名の続きとして読み込んでしまいます。結果として、残りのバイトだけが文に取り残される形になっていました。変数を${VAR}のように括っていれば、この問題は起きませんでした。

直し方:iconv -cの癖を踏まえて壊れたバイトだけを落とす

直し方の基本方針は、壊れたバイトだけを落として送ることです。そのためにiconv -cを使いますが、macOSのiconv -cにはいくつか癖があります。

文の末尾が文字の途中で切れている場合、そのバイトは落としてくれるものの、終了コードはrc=1を返します。さらに、先頭のバイトの後ろが足りないケースでは、後ろにある正しいASCII文字まで一緒に消してしまいます。逆方向の落とし穴もあり、U+10FFFFを超える4バイトの形や、5・6バイトの形は「正しい」文字として素通りさせてしまいます。

そこで私たちは、文の後ろに8バイトの印を足してからiconv -cに通す方法を取りました。こうすることで、どの壊れたバイトも文の「途中」として扱われるようになります。処理のあとで印が戻ってこなければ、何か想定外のことが起きたと判断し、元の文のまま送るようにしています。最後にCのlocaleのsedを使い、iconv -cが素通りさせてしまう形(U+10FFFFを超える4バイトの形や5・6バイトの形)を落とす処理を加えました。正しい文であれば1バイトも変わりません。

確かめたこと

この直し方は、Pythonの厳密なUTF-8の読み方(壊れたバイトだけを落とす方式)と3000通りのデータで突き合わせ、3000/3000で一致することを確かめました。正しい文だけを集めた600通りでは、1バイトも変わらないことも確認済みです。処理時間は1回あたり約11ミリ秒増えるという結果でした。

今回の一連の出来事から分かったのは、locale違いで挙動が変わるコマンドは、片方のlocaleだけの確認では動いて見えてしまうということです。そして、bashで変数の直後に全角文字を書くときに${VAR}で括っていなかったことが、壊れたバイトが紛れ込む直接の経路になっていました。パイプの終了コードが最後のコマンドのものだけになるという性質も、本文が空のまま文法上は正しいJSONが組み上がってしまう今回の現象を見えにくくしていました。