こんにちは、SREチームの成田です。 皆さんはどれくらいシェル(ワンライナー)を使ってますでしょうか? 僕は年々使う頻度が減ってきてはいますが、ログ調査したいときなどはシェルでちょちょっとやっちゃうことが多いです。
例えば、とあるサーバのアクセスが集中しているという状況で、サーバのアクセスログを集計してアクセスが集中しているエンドポイント(リクエスト)を特定したいとします。 エディタを立ち上げて、お好みのプログラミング言語で普段使わないファイル入出力のやり方をググっている間、一方シェルではもう集計作業を始めることができます。 シェルは入出力をパイプで渡して次々に処理していくという性質上、ログ調査のような作業をやるには結構便利です。
はじめに
今回は使用するシェルはBashを想定しています。 Linux外部コマンド(jq, awkなど)も使用します、これらは使用頻度が高いコマンドなのでインストールしておくと良いでしょう。 シェルのワンライナーで表現できるものはシェルとしています。
ぱっと見何しているかわからないシェルはどうせ忘れてしまうので、シェル芸っぽくならないように心掛けます。
入出力
シェルはパイプでコマンドをつないでいくことで入出力データを加工していくという性質があります。 そのため、シェルの最初のコマンドはファイルの読み込みや、入力データの受け取りをする必要があります。
基本的にファイルの処理であればcat
、コマンドライン上でのデータ入力であればecho
を使うと良いでしょう。
ファイルの読み込みをするコマンドでless
がありますが、less
はフォアグラウンドを奪ってしまうのでパイプで繋いでいく処理には向かないですし、編集もやろうと思えばできてしまうので基本的には使わないほうが良いでしょう。
大きなファイルを処理する場合、head
やtail
を使用して処理対象のデータを絞ってからパイプ処理のトライアンドエラーをし、全体のパイプラインが完成してからファイル全体を処理するのが良いでしょう。
文字列抽出
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
を使用して出力できます。
正規表現
grep
とawk
の基本的な機能だけでも大体の文字列抽出はできますが、時には正規表現で抽出したい場合もあるでしょう。
grep
もawk
も、正規表現を使えるのですが、grep
はGNU版と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
正規表現でマッチした行の特定の部分文字列だけを抽出したいことが少なからずあるのですが、そういったことはgrep
やawk
だと難しいのですが、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のデータとして欲しい場合はどうしたら良いでしょうか?
考え方としては、JSONをCSVに変換したらよさそうです。jq
では、JSON→CSV変換は以下のように簡単にできます。
$ 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 /
降順で集計できましたね。
このようにgrep
、awk
、時には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から取得したデータをもとにシェルでゴニョゴニョしたい場合は便利ですね。
まとめ
文字列抽出のテクニックは知っておいて損は無いと思いますし、障害対応などのいざというときにシェルでサクッと集計するテクニックは役に立つと思うし実際役に立ってます。 今回紹介した内容は、基本的なものが中心でそんなにハードルが高くはないと思うので、この機会にシェルと仲良くなってみてはいかがでしょうか!