それシェルでできるよ

こんにちは、SREチームの成田です。 皆さんはどれくらいシェル(ワンライナー)を使ってますでしょうか? 僕は年々使う頻度が減ってきてはいますが、ログ調査したいときなどはシェルでちょちょっとやっちゃうことが多いです。

例えば、とあるサーバのアクセスが集中しているという状況で、サーバのアクセスログを集計してアクセスが集中しているエンドポイント(リクエスト)を特定したいとします。 エディタを立ち上げて、お好みのプログラミング言語で普段使わないファイル入出力のやり方をググっている間、一方シェルではもう集計作業を始めることができます。 シェルは入出力をパイプで渡して次々に処理していくという性質上、ログ調査のような作業をやるには結構便利です。

はじめに

今回は使用するシェルはBashを想定しています。 Linux外部コマンド(jq, awkなど)も使用します、これらは使用頻度が高いコマンドなのでインストールしておくと良いでしょう。 シェルのワンライナーで表現できるものはシェルとしています。

ぱっと見何しているかわからないシェルはどうせ忘れてしまうので、シェル芸っぽくならないように心掛けます。

入出力

シェルはパイプでコマンドをつないでいくことで入出力データを加工していくという性質があります。 そのため、シェルの最初のコマンドはファイルの読み込みや、入力データの受け取りをする必要があります。

基本的にファイルの処理であればcatコマンドライン上でのデータ入力であればechoを使うと良いでしょう。 ファイルの読み込みをするコマンドでlessがありますが、lessはフォアグラウンドを奪ってしまうのでパイプで繋いでいく処理には向かないですし、編集もやろうと思えばできてしまうので基本的には使わないほうが良いでしょう。

大きなファイルを処理する場合、headtailを使用して処理対象のデータを絞ってからパイプ処理のトライアンドエラーをし、全体のパイプラインが完成してからファイル全体を処理するのが良いでしょう。

文字列抽出

grep

基本はやはりgrepです。 grepをパイプで繋ぐことでAND検索、-eで複数条件指定することでOR検索、-vでNOT検索になります。

$ echo '埼玉県
千葉県
東京都
神奈川県' | grep ''
埼玉県
千葉県
神奈川県

$ echo '埼玉県
千葉県
東京都
神奈川県' | grep '' | grep ''
埼玉県

$ echo '埼玉県
千葉県
東京都
神奈川県' | grep -e '' -e ''
埼玉県
千葉県

$ echo '埼玉県
千葉県
東京都
神奈川県' | grep -v '' | grep -v ''
東京都
神奈川県

ちなみに、-A -Bというオプションもあって、これはgrepでヒットした行の前後の行も出力できるオプションになるのですが、たまに使うので覚えておくと良いことがあるかもしれません。

awk

awkは多機能なコマンド(というかプログラミング言語)ですが、今回使う機能は、区切り文字で区切られた文字列の抽出と、抽出した文字列を使用した出力だけです。

例えば、こんなCSVがあるとします。

都道府県,県庁所在地
埼玉県,さいたま市
千葉県,千葉市
東京都,東京
神奈川県,横浜市

県庁所在地のデータのみ欲しい場合はawkでこのようにします。

$ echo '都道府県,県庁所在地
埼玉県,さいたま市
千葉県,千葉市
東京都,東京
神奈川県,横浜市' | awk -F ',' '{print $2}'
県庁所在地
さいたま市
千葉市
東京
横浜市

-Fで区切り文字を指定でき(デフォルトでは空白文字)、区切り文字で分割された文字列は$1,$2という変数でアクセスする事できます。 $1,$2という変数はprintを使用して出力できます。

正規表現

grepawkの基本的な機能だけでも大体の文字列抽出はできますが、時には正規表現で抽出したい場合もあるでしょう。 grepawkも、正規表現を使えるのですが、grepGNU版とBSD版があってそれぞれ正規表現などの挙動が異なり、awkは一気に難易度が上がる印象があります。

そこでおすすめしたいのはperlワンライナーで使う方法です。 perlはもともと文字列処理を目的に作られた言語なので、強力な正規表現処理を簡単に実行できます。

例えば、以下の様なパスがあったとして

/aaa/aaa/index
/aaa/bbb/index
/aaa/bbb/ccc/index
/aaa/aaa/show
/bbb/bbb/index

/aaaで始まって/indexで終わるパスの抽出はこのように抽出できます。

$ echo '/aaa/aaa/index
/aaa/bbb/index
/aaa/bbb/ccc/index
/aaa/aaa/show
/bbb/bbb/index' | perl -nle 'print $1 if /(^\/aaa\/.*\/index$)/'
/aaa/aaa/index
/aaa/bbb/index
/aaa/bbb/ccc/index

/aaa/indexの間のパスの抽出はこのようにできます。

$ echo '/aaa/aaa/index
/aaa/bbb/index
/aaa/bbb/ccc/index
/aaa/aaa/show
/bbb/bbb/index' | perl -nle 'print $1 if /^\/aaa\/(.*)\/index$/'
aaa
bbb
bbb/ccc

正規表現でマッチした行の特定の部分文字列だけを抽出したいことが少なからずあるのですが、そういったことはgrepawkだと難しいのですが、perlだと簡単にできてしまいます。

コマンド実行

文字列抽出を応用することで、シェルコマンドを作成して実行するということができます。

とあるディレクトリにこのようなファイルがあったとして、

$ ls -l
total 0
-rw-r--r--  1 kazuma-narita  wheel  0 10 14 21:29 aaa_1.txt
-rw-r--r--  1 kazuma-narita  wheel  0 10 14 21:29 aaa_2.txt
-rw-r--r--  1 kazuma-narita  wheel  0 10 14 21:32 aaa_3.txt
-rw-r--r--  1 kazuma-narita  wheel  0 10 14 21:29 bbb_1.txt

aaa_[0-9]+.txtのファイル名をaaa_[0-9]+.jsonへ変更したいとします。 今回は3ファイルだけが対象ですが、1000ファイルくらいあったら1つ1つmvコマンドでリネームしようと思わないですよね。

そんな場合に、先程紹介した文字列抽出の手法で解決できます。 とりあえす、以下のコマンドでmvコマンドを生成できます。

$ ls -l | grep aaa | awk '{print $9}' | perl -nle 'print $1 if /(aaa_\d).txt/' | awk '{print "mv " $1 ".txt " $1 ".json"}'
mv aaa_1.txt aaa_1.json
mv aaa_2.txt aaa_2.json
mv aaa_3.txt aaa_3.json

後は、生成したコマンドをパイプでシェルに渡して実行するだけです。

$ ls -l | grep aaa | awk '{print $9}' | perl -nle 'print $1 if /(aaa_\d).txt/' | awk '{print "mv " $1 ".txt " $1 ".json"}' | bash
$ ls -l
total 0
-rw-r--r--  1 kazuma-narita  wheel  0 10 14 21:29 aaa_1.json
-rw-r--r--  1 kazuma-narita  wheel  0 10 14 21:29 aaa_2.json
-rw-r--r--  1 kazuma-narita  wheel  0 10 14 21:32 aaa_3.json
-rw-r--r--  1 kazuma-narita  wheel  0 10 14 21:29 bbb_1.txt

こんな事ができて何が嬉しいのかと思うかもしれないですが、一例を出すと、AWS S3のファイルをaws cliで一括ダウンロードすることに応用できたりします。

$ aws s3 ls s3://narita-techblog
2021-10-14 21:49:28          0 aaa.txt
2021-10-14 21:49:32          0 bbb.txt
2021-10-14 21:49:37          0 ccc.txt

$ aws s3 ls s3://narita-techblog | awk '{print "aws s3 cp s3://narita-techblog/" $4 " ./"}' | bash
download: s3://narita-techblog/aaa.txt to ./aaa.txt
download: s3://narita-techblog/bbb.txt to ./bbb.txt
download: s3://narita-techblog/ccc.txt to ./ccc.txt

要するに、文字列操作でシェルコマンドを出力できれば、それをパイプでシェルに渡して実行できるのです。

JSON操作

最近はログがJSONってこともよくありますし、JSON APIを叩いてゴニョゴニョするシェルを作ったりすることもありますよね。 シェルでJSONを扱うをなるとjqですね。 jqも多機能なコマンドですが、最終的にシェルで集計処理などの処理をすることを目的とするとパイプで渡せるような状態にする必要があるので、どうやってJSONから必要な情報を抽出してrowとして出力するかを考えます。

このようなJSONについて考えます(県庁所在地がcityなのはさておき)。

[
  {
    "prefecture": "埼玉県",
    "city": "さいたま市"
  },
    {
    "prefecture": "千葉県",
    "city": "千葉市"
  },
    {
    "prefecture": "東京都",
    "city": "東京"
  },
    {
    "prefecture": "神奈川県",
    "city": "横浜市"
  }
]

都道府県の抽出はこれでできます。この状態でrowのデータになっているのでパイプで処理できます。

$ echo '[
  {
    "prefecture": "埼玉県",
    "city": "さいたま市"
  },
    {
    "prefecture": "千葉県",
    "city": "千葉市"
  },
    {
    "prefecture": "東京都",
    "city": "東京"
  },
    {
    "prefecture": "神奈川県",
    "city": "横浜市"
  }
]' | jq -r .[].prefecture
埼玉県
千葉県
東京都
神奈川県

都道府県と県庁所在地の両方をrowのデータとして欲しい場合はどうしたら良いでしょうか? 考え方としては、JSONCSVに変換したらよさそうです。jqでは、JSONCSV変換は以下のように簡単にできます。

$ echo '[
  {
    "prefecture": "埼玉県",
    "city": "さいたま市"
  },
    {
    "prefecture": "千葉県",
    "city": "千葉市"
  },
    {
    "prefecture": "東京都",
    "city": "東京"
  },
    {
    "prefecture": "神奈川県",
    "city": "横浜市"
  }
]' | jq -r '.[] | [.prefecture, .city] | @csv'
"埼玉県","さいたま市"
"千葉県","千葉市"
"東京都","東京"
"神奈川県","横浜市"

他にもjqにはJSONの抽出、加工をするのに便利な機能があります。jqで色々JSONデータを加工して最終的にCSVのようなrowのデータとして出力したら、後はシェルのパイプで処理できます。 これで、JSONフォーマットのログデータでも問題なくシェルで処理できますね。

集計

集計については、とりあえず実行しとけ的なこちらのキラーコンテンツがあります。

sort | uniq -c | sort -r

実際に使うには、文字列抽出で紹介したテクニックを使って必要なデータのみに絞り込んだ後にパイプで渡して実行します。

以下はとあるNginxのログです。このログから、リクエストがあったパスを集計してみます。

172.17.0.1 - - [14/Oct/2021:13:36:38 +0000] "GET / HTTP/1.1" 200 612 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
2021/10/14 13:36:38 [error] 31#31: *1 open() "/usr/share/nginx/html/favicon.ico" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /favicon.ico HTTP/1.1", host: "localhost:3333", referrer: "http://localhost:3333/"
172.17.0.1 - - [14/Oct/2021:13:36:38 +0000] "GET /favicon.ico HTTP/1.1" 404 556 "http://localhost:3333/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
172.17.0.1 - - [14/Oct/2021:13:36:42 +0000] "GET /aaa HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
2021/10/14 13:36:42 [error] 31#31: *1 open() "/usr/share/nginx/html/aaa" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /aaa HTTP/1.1", host: "localhost:3333"
2021/10/14 13:36:46 [error] 31#31: *1 open() "/usr/share/nginx/html/bbb" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /bbb HTTP/1.1", host: "localhost:3333"
172.17.0.1 - - [14/Oct/2021:13:36:46 +0000] "GET /bbb HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
2021/10/14 13:36:48 [error] 31#31: *1 open() "/usr/share/nginx/html/ccc" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /ccc HTTP/1.1", host: "localhost:3333"
172.17.0.1 - - [14/Oct/2021:13:36:48 +0000] "GET /ccc HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
172.17.0.1 - - [14/Oct/2021:13:36:51 +0000] "GET /aaa HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
2021/10/14 13:36:51 [error] 31#31: *1 open() "/usr/share/nginx/html/aaa" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /aaa HTTP/1.1", host: "localhost:3333"
2021/10/14 13:36:54 [error] 31#31: *1 open() "/usr/share/nginx/html/aaa" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /aaa HTTP/1.1", host: "localhost:3333"
172.17.0.1 - - [14/Oct/2021:13:36:54 +0000] "GET /aaa HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
2021/10/14 13:37:00 [error] 31#31: *1 open() "/usr/share/nginx/html/aaa/bbb" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /aaa/bbb HTTP/1.1", host: "localhost:3333"
172.17.0.1 - - [14/Oct/2021:13:37:00 +0000] "GET /aaa/bbb HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
2021/10/14 13:37:08 [error] 31#31: *1 open() "/usr/share/nginx/html/bbb/bbb" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /bbb/bbb HTTP/1.1", host: "localhost:3333"
172.17.0.1 - - [14/Oct/2021:13:37:08 +0000] "GET /bbb/bbb HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
172.17.0.1 - - [14/Oct/2021:13:37:15 +0000] "GET /aaa HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"
2021/10/14 13:37:15 [error] 31#31: *1 open() "/usr/share/nginx/html/aaa" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /aaa HTTP/1.1", host: "localhost:3333"
2021/10/14 13:37:20 [error] 31#31: *1 open() "/usr/share/nginx/html/bbb/bbb" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /bbb/bbb HTTP/1.1", host: "localhost:3333"
172.17.0.1 - - [14/Oct/2021:13:37:20 +0000] "GET /bbb/bbb HTTP/1.1" 404 556 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36" "-"

とりあえずこのログはアクセスログとエラーログが混ざっているので、エラーログを除外します。 その後パスを抽出します。

echo '~~ログ~~' | grep -v '\[error\]' | awk '{print $7}'
/
/favicon.ico
/aaa
/bbb
/ccc
/aaa
/aaa
/aaa/bbb
/bbb/bbb
/aaa
/bbb/bbb

パスが抽出できたので、集計してみます。

echo '~~ログ~~' | grep -v '\[error\]' | awk '{print $7}' | sort | uniq -c | sort -r
   4 /aaa
   2 /bbb/bbb
   1 /favicon.ico
   1 /ccc
   1 /bbb
   1 /aaa/bbb
   1 /

降順で集計できましたね。 このようにgrepawk、時にはperlで必要な情報を抽出した後に、とりあえず集計コマンドを実行するだけで集計できてしまいます。 ログを集計するくらいであれば、シェルでサクッとできてしまう感覚を理解してもらえると幸いです。

netcat

今までとコンテキストが異なるのですが、netcat(nc)が便利でたまに使うのでついでに紹介します。 netcatはネットワーク系のサーバorクライアントとして使えるコマンドです。 NginxなどのWebサーバは入ってないけど一時的なWebサーバをサクッと立てたい時や、ネットワーク接続するミドルウェアのクライアント(Redisなど)の代わりとして有用です。

Webサーバ

index.htmlというファイルが(中身はどうであれ)とあるサーバにあったとして、ブラウザで表示確認したい時、Nginx等のWebサーバが無い場合以下のコマンドでポート8000でWebサーバが立ちます。 やってることとしては、netcatでポート8000をListenし、レスポンスとしてパイプで渡されたHTTPレスポンスを返しているだけです。

( echo "HTTP/1.1 200 ok"; echo; cat index.html ) | nc -l 8000

もちろん本番環境で使用してはいけませんが、ちょっとした表示確認に便利です。

Redisクライアント

Redisクライアントとして一般的にredis-cliが使用されると思いますが、redis-cliが無い環境でも以下のコマンドでRedisの操作が可能です。

echo 'keys *' | nc localhost 6379

この方法のメリットとして、redis-cliを実行するとシェルのフォアグラウンドがredis-cliに奪われてしまいますが、netcatを使用した通信によりフォアグラウンドを奪われなくなります。 つまり、Redis操作の結果は単なる標準出力なので、シェルのパイプで処理することができます。 Redisから取得したデータをもとにシェルでゴニョゴニョしたい場合は便利ですね。

まとめ

文字列抽出のテクニックは知っておいて損は無いと思いますし、障害対応などのいざというときにシェルでサクッと集計するテクニックは役に立つと思うし実際役に立ってます。 今回紹介した内容は、基本的なものが中心でそんなにハードルが高くはないと思うので、この機会にシェルと仲良くなってみてはいかがでしょうか!