アプリケーションエンジニアも開発やログ解析がはかどる! シェルスクリプトTIPS

こんにちは! エンジニアの臼井です。
Pairs のサーバサイドで、Go言語と戯れる毎日を送っています。
前職ではシェルスクリプト(bash)でjsonを吐くAPIサーバを作る、世にも奇妙な業務をしていました。
途中からPHPに移行させてもらえましたが、そのお話はみなさまとどこかでお会いした時に別途……。

今回は、その世にも奇妙な業務を通して得られた、アプリケーションエンジニアにとって有用なシェルスクリプトの知見について共有できればと思います。

シェルスクリプトは手続き的に書くと遅い

シェルスクリプトを使う時は、普通のプログラミング言語と比べて大きく異なる特性を頭の片隅に置く必要があります。
細かくは色々ありますが、通常の手続き型言語のように書くと著しくパフォーマンスが出ないことは比較的ぶつかりがちな壁なので、押さえておいて下さい。
代表的なハマりどころとしては、for や whileで大量の繰り返し処理が向かないというのがあります。
例として、100万回の何もしないループで、Go言語とシェルスクリプトのfor, while の処理時間を比較してみます。

cat<<-EOF > blank_loop.go
package main

func main() {
    for i := 0; i < 1000000; i++ {
    }
}
EOF

go build blank_loop.go
time ./blank_loop

real    0m0.005s
user    0m0.002s
sys 0m0.002s

time (for i in $(seq 1000000);do :;done)

real    0m1.301s
user    0m1.236s
sys 0m0.065s
time (seq 1000000 | while read i;do :;done)

real    0m3.467s
user    0m2.461s
sys 0m1.004s

Go の空の for と比べ、100倍以上の差があることがわかると思います。
複数回実行してもこれらの値はほぼ同程度になります。

while はreadコマンドの呼び出しがあるとはいえ、forよりも明白に重いことが見て取れます。
これは、while do; ... done の中身は、別プロセスのシェル(サブシェル)として実行されるためであり、その起動コストが実行回数分かかってくることの寄与が大きいです。

この程度のことを気にするのであれば、別のプログラミング言語で書けばよいのでは? と思われるかもしれませんが、敢えてそこでシェルスクリプトを使い倒してゆき、シェルスクリプトに親しむというスタンスで参ります。どうかお付き合い下さい。

便利なコマンドのインストール

OpenUSP-Tukubai という、大袈裟に言えばシェルスクリプトをテキストファイルの操作に特化したDSLに変貌させるコマンドを一部で使用します。
以下の手順でインストールが可能です。詳しくはリポジトリの README.md をご覧下さい。
本稿では一部の使用に留めておりますが、使用していないコマンドにも有用なものが多くありますので、一度ご覧になって下さい。

# インストール 
git clone https://github.com/usp-engineers-community/Open-usp-Tukubai.git ~/Open-usp-Tukubai
# カレントユーザでのみ使用
echo "export PATH=~/Open-usp-Tukubai/COMMANDS:${PATH}" >> ~/.bash_profile
source ~/.bash_profile
# グローバルにインストールする
sudo make install

Webサーバのログ集計

LTSVで出力されている、以下の様なWebサーバのログをざっくり集計したい! という時、色々な方法があります。
多くの場合、各自で好きなスクリプト言語で書き捨てのスクリプトを書いたりすると思いますが、今回はシェルスクリプトのワンライナーでやってみましょう。

host:10.255.1.131   vhost:xxx   port:80 time:2016-08-29T03:33:02+00:00  user_id:-   method:GET  uri:/1.0/xxx    protocol:HTTP/1.1   status:200  size:136    ua:xxx  referer:-   forwardedfor:xxx.xxx.xxx.xxx    apptime:0.005   resptime:0.005  upstream_size:13

何らかのエラーが検知され、まずHTTPステータスコードの全体的な傾向を調べたくなった時、ログの中に含まれる単純なステータスコード毎の数の集計は、以下で行うことができます。

egrep -o "status:\d{3}" access.log | sort | count 1 1

出力結果は以下になります。

status:200 33624
status:201 1018
status:204 550
status:301 277
status:400 618
status:401 302

上記のワンライナーで使用しているコマンドを、パイプラインの1つ目から順に解説します。

  • egrep -o "status:\d{3}" : 正規表現にマッチしたもののみ標準出力する。-o オプションで、マッチした部分のみ出力する。
  • sort : 標準入力を辞書順ソート。
  • count 1 1 : 標準入力を半角スペース区切りでソート済みの文字列を前提とし、1列目から1列目をキーとみなして、キーとその数を集計した値を標準出力する。

ポイントとしては、

  • count コマンドの簡潔さ
  • egrep(及びgrep)コマンドの、-o オプション

です。
countコマンドなど、便利コマンドで入るコマンドは表形式のテキストファイルに対する演算が可能なコマンドがたくさんあります。
ですので、わざわざ集計操作の為にDBセットアップして、テキストからETLしてSQL発行するのが手間だという場合に効率が急上昇します。
JOIN も内部結合と外部結合どちらもできるコマンドがあるので、複数のテキストファイルを使用しての集計も可能です。

ちなみに、このログがRDBMSに格納されていた場合、上記集計に相当するSQLは以下になります。

SELECT status, COUNT(status) FROM access_log
GROUP BY status

次に、時間別のステータスコード集計をしてみます。

awk -F"\t" '{print $4,$9;}' access.log | self 1.1.18 2 | sort | count 1 2
time:2016-08-29T03 status:200 510
time:2016-08-29T03 status:201 14
time:2016-08-29T03 status:204 4
time:2016-08-29T03 status:301 6
time:2016-08-29T03 status:401 16
time:2016-08-29T04 status:200 450
time:2016-08-29T04 status:201 83
time:2016-08-29T04 status:301 2
time:2016-08-29T04 status:400 6
...
time:2016-08-29T23 status:200 64
time:2016-08-30T00 status:200 467
time:2016-08-30T00 status:204 2
time:2016-08-30T00 status:301 3
time:2016-08-30T00 status:400 10
time:2016-08-30T00 status:401 1
time:2016-08-30T01 status:200 1118
time:2016-08-30T01 status:201 47
time:2016-08-30T01 status:204 4
time:2016-08-30T01 status:301 4
time:2016-08-30T01 status:400 14
time:2016-08-30T01 status:401 33

awk も bashのように多くの環境でデフォルトで入っており、かつ仕様がほぼ固まっています。個々のawk処理系に依存しない簡潔な書き方をすればどの環境でも問題無く動作します。

簡易なETL

では、前節で言及した、

わざわざ集計操作の為にDBセットアップして、テキストからETLしてSQL発行する

という操作についてもやってみましょう。
ETLというとおおごとの様な感じがしますが、あるシステムから得たCSVファイルを加工して別のシステムに入れることも、十分にETLの一種と言えます。

なんちゃって個人情報

上記サイトで生成した、顧客情報のダミーデータをCSVファイルで生成し、ローカルのMySQLに投入するというタスクを行ってみます。
外部サービス連携などでCSVでしかデータが得られないなどのケースはよくあります。
// LOAD DATA INFILE (MySQL) や \copy (PostgreSQL) を使用すればよいのですが、シェルでも出来るというところを強調させて頂く、ということで。

#!/bin/bash

# Transformation
# from sjis csv to utf8 sql.
iconv -f SJIS -t UTF-8 dummy_data_utf8.csv | # 文字コード変換
tail +2 | # 1行目にあるヘッダはスキップ
awk -F"," 'BEGIN{OFS=",";} # カンマ区切りで出力
{
  # 一部のフィールドのみ
  buf = "\"" $1 "\"";
  for (i = 2;i <= 10;i++) {buf = buf "," "\"" $i "\"";} # 簡単のため、今回は全フィールド文字列とする。
  print "(" NR, buf, ")"; # NR(行番号)でシーケンシャルID発行
  buf = ""; # awkは次の行になっても変数のスコープが続いているので、空文字で初期化しておく。
}' |
sed -e 's/,)$/)/' |
# 発行可能なSQL文の長さに限界があるので適切に分割する。ここでは100レコード単位とする。
awk '{print int(1 + (NR / 100)), $0;}' |
# 1フィールド目の数値ごとに、VALUES に含まれるレコードに相当するテキストが入った1ファイルを生成する。
keycut -d sql/bulk_insert_dummy_data.%1.sql.values

for v in $(ls sql/bulk_insert_dummy_data.*.sql.values)
do
  # 個々の VALUES ファイルと INSERT INTO table の部分を結合して、完全なINSERT 文を生成する。
  cat <(echo '
INSERT INTO user 
(
    id,
    name,
    name_reading,
    email,
    gender,
    age,
    birthday,
    marital,
    blood_type,
    pref_name,
    pref_code
)
VALUES') $v > ${v/.values/} # sql/bulk_insert_dummy_data.[0-9]+.sql という名前の、bulk insert 用のSQLファイル
  rm -f $v
done

# Load
# user テーブルは作成済みとし、CREATE文は省略。
for q in $(ls sql/bulk_insert_dummy_data.*.sql)
do
  mysql -h ${MYSQL_HOST_NAME} -u${USER_sNAME} -p${PASSWORD} ${DB_NAME} < $q
done

上記例のポイントは テキストファイルをキーとなるフィールドで分割する、 keycut コマンドです。
MySQL であれば max_allowed_packet により、1度に発行可能なSQL文のサイズが規定されているので、そのサイズが長過ぎない程度になるようファイルを分割しています。
レコードを半角スペース区切りのテキストファイルとしてみた場合の、1列目をファイル分割のキーとして参照し、ファイル分割後に削除しています。
今回は100件単位でID順に分割しましたが、他にも例えばIDの剰余の値をキーとして、A/B テスト用のフラグを更新するUPDATE文を生成して発行することも可能です。
こういったケースではSQLに複雑なCASEを記述するより、ファイル単位で分割した方が見通しが良くなる可能性があります。

パイプライン処理で注意すべき点

前々節で述べ前節で例示したコードのように、シェルスクリプトは手続き的に書くより、テキストのストリームをパイプラインで処理する書き方が望ましいとされています。
みなさんがUNIX系OSで端末から使用できるコマンドは、標準入力(あるいはファイル)を受け取って処理し、標準出力及び標準エラー出力に出力するものがほとんどであることにお気づきかと思います。
いわゆるUNIX哲学に基づく考え方です。

さて、パイプライン処理を行うのはシェルスクリプトの言語仕様としても、マルチプロセス処理が行われるというパフォーマンス上の観点からも良いことではあります。ですが、こういった処理が同時に発生しすぎないようにコントロールする必要があります。
過剰なプロセス数の増大によりOSの割込やコンテキストスイッチが増大し、各々のプロセスの処理コストの合計を大きく超える負荷をOSが抱えることになります。
例示したコードであれば問題はあまりありませんが、シェルスクリプトは & をコマンド末尾に付けてバックグラウンド実行することが可能です。
つまりカジュアルに並行あるいは並列処理が出来てしまいますので、ちょっとfor文の中身を同時に実行したいという考えでバックグラウンド実行をする際は、特に処理コストに注意を払う必要があります。
手元の開発環境であれば、最悪強制シャットダウンなどで済みますが、共用のサーバー環境で意図せぬ大量のプロセスが起動されると大変なことになります……。
また、イベントドリブンな処理にパイプライン処理を多用したシェルスクリプトを使用する場合も同様の懸念を抱えるため、基本的には控えるべきと言えます。

任意のタイミングで実行するバッチやタスクの処理であれば、処理量がコントロール可能であるためあまり問題とはなりませんが、同時実行数が読みづらいケースには特に注意して使用を検討して下さい。

おわりに

いかがでしたでしょうか。
端末は使用するがシェルスクリプトは書いたことがないという方に、シェルスクリプトでこんなことも出来てしまうのか、と思って頂けたら幸いです。
最初に述べたように、json を返却するAPIサーバを構築することも出来てしまいます。

シェルスクリプト自体は言語としても古く、構文もクラシックで学ぶには食指が動かない方も多いとは思いますが、身につけることでちょっとしたタスクや簡易なデータ処理を定型化したりすることが出来ます。
また、古い言語であるということにより、UNIX系環境ならばどの環境でもほぼそのまま動作させることが出来るという可搬性があることも利点です。
最近では Windows 10 においても、 Windows Subsystem for Linux によって、シェルスクリプトの実行が容易になっています。
MSYS+MinGWやCygwin使わなくてもbashが使えるのですね。隔世の感があります。

Python や Perl など普通のプログラミング言語との使い分けですが、既存の単機能コマンドの組み合わせにより実現可能な処理であれば、シェルスクリプトが選択肢に入ってくるでしょう。
端末から実行できる既存のコマンド全てが、他言語における外部ライブラリのように扱える点は、さすが元祖グルー言語といった所でしょうか。
好みの問題もありますが、エンジニアであればターミナルを触っているはずなので、シェルスクリプトは全員が触らざるを得ない共通言語であるはずです。
この点においては、ちょっとしたタスクはシェルスクリプトで書く方が良いという考えを持っています。

それではみなさま、シェルスクリプトを正しく使って良い開発ライフを!

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

Android Activityのテストを上手く書くコツ

エウレカのマイクロサービスデザイン – Pairsフルスクラッチを通して