データラングリング

あるフォーマットのデータを、他のフォーマットに変換したいと思ったことはありませんか?もちろんありますよね!すごくざっくり言うと、この講義ではそのフォーマット変換を取り上げます。具体的には、テキスト形式かバイナリ形式かを問わず、自分の欲しいフォーマットが得られるまで、データをマッサージする (処理する) ことを取り上げます。

これまでの講義で、基本的なデータの扱い方を見てきました。パイプ演算子 | を使うときは、いつも何らかのデータラングリングを行っています。journalctl | grep -i intel というコマンドを考えてみましょう。このコマンドは Intel (大文字小文字は区別しません) という単語を含む、全てのシステムログエントリを抽出します。データラングリングには見えないかもしれませんが、これはあるフォーマット (システムログ全体) から、より有用なフォーマット (intel ログエントリだけを抽出したもの) に変換しています。このように、データラングリングとは多くの場合、自分が自由に使えるツールを知り、それらの良い組み合わせ方を知ることです。

最初から始めましょう。データラングリングには次のふたつ: 扱うデータと、そのデータを使ってやることが必要です。ログは、良いユースケースとなり得ます。なぜなら、調査をしたい対象ではあるが、全部を読むことは不可能なことが多いからです。私のサーバーのログを見て、誰がログインしようとしているか調べてみましょう。

ssh myserver journalctl

これでは、出力があまりにも多すぎます。ssh関連に限定しましょう:

ssh myserver journalctl | grep sshd

パイプを使い、 リモート のファイルを、ローカルコンピュータ上の grep を通して、リモートからローカルへストリームして (送って) いることに注目してください! ssh はまるで魔法のようです。次回の「コマンドライン環境」講義で、もっと詳しく取り上げます。しかし、この出力はまだ不要な情報がたくさんあり、とても読みにくいです。もっとうまくやりましょう:

ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' | less

なぜ引用符部分を追加したのでしょうか。ログはかなり大量かもしれず、すべてローカルコンピュータに送り、それからフィルタをするのは無駄が多いからです。代わりに、リモートサーバでフィルタを行った後、ローカルでデータを処理することができます。 less は、長い出力を上下にスクロールする「ページャ」を提供します。さらに、コマンドラインのデバッグ中に発生する通信を減らすため、現在のフィルタされたログをローカルのファイルに保存し、ネットワークにアクセスせず作業を進めることもできます:

$ ssh myserver 'journalctl | grep sshd | grep "Disconnected from"' > ssh.log
$ less ssh.log

この出力にも、まだ不要な情報が山ほど残っています。取り除く方法はたくさんありますが、あなたのツールキット中で最も強力なコマンドのひとつ、 sed を見てみましょう。

sed は、古い ed エディタの上に開発された「ストリームエディタ」です。基本、短いコマンドを使って、ファイルの内容を直接は編集せず(編集もできますが)、ファイルの内容に手を加えられます。コマンドはたくさんありますが、最もよく使うものの一つは s: substitution(置換)です。例えば、次のように書きます:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed 's/.*Disconnected from //'

今書いたのはシンプルな正規表現です。正規表現は、テキストをパターンに一致させられる強力な構文です。s コマンドは s/REGEX/SUBSTITUTION/ という形式で書かれています。 REGEX は検索したい正規表現、SUBSTITUTION はパターンに一致したテキストを置き換えるテキストです。

(この構文は、Vimの講義ノートの「検索と置換」セクションで見られたかもしれません。実際、Vim は検索と置換に sed の置換コマンドに似た構文を使います。一つのツールを学ぶと、他のツールを使いこなせるようになるのは、よくあることですね。)

正規表現

正規表現は、よく知られ、かつ有用なので、時間をかけて仕組みを理解する価値は十分にあります。まず、上で使ったものを見てみましょう: /.*Disconnected from / です。正規表現は、通常(必ずではありませんが) / で囲まれています。ほとんどのASCII文字はその文字そのものの意味を持ちますが、中には「特別な」一致動作をする文字があります。どの文字が何をするかは、正確には正規表現の実装により多少異なるため、大きなフラストレーションの原因となります。よく使われるのは以下です:

sed の正規表現は少し変わっています。ほとんどの特別な意味を持たせる文字の前には \ を置く、または -E を渡します。

/.*Disconnected from / を改めて考えると、任意の文字数で始まるテキストにマッチし、その後リテラル文字列 “Disconnected from” が続くとわかります。私たちが求めていたものですね。しかし正規表現は一筋縄ではいきませんから、注意してください。誰かがもし、ユーザ名”Disconnected from”でログインを試みたらどうなるでしょう?すると:

Jan 17 03:13:00 thesquareplanet.com sshd[2631]: Disconnected from invalid user Disconnected from 46.97.239.16 port 55920 [preauth]

何が起こるでしょう。*+ は、通常「貪欲」に動作します。つまり、できるだけ多くの文字に一致するよう振る舞います。そのため、さきほどのログは以下に変換されます:

46.97.239.16 port 55920 [preauth]

これは求めた結果ではなさそうです。正規表現の実装によっては、*+ の後ろに ? を付けて「貪欲でない(non-greedy)」 動作ができます。残念ながら sed はこの表現をサポートしていません。この表現をサポートする、perlのコマンドラインモードに切り替えても良いでしょう:

perl -pe 's/.*?Disconnected from //'

ここでは sed を使い続けます。なぜなら、この手の仕事には現状 sed の方がよく使われるからです。 sed は他にも、指定のテキスト一致に続く行を表示する、呼び出しごとに複数の置換を行う、検索するなどの便利なことができます。しかし、ここではあまり取り上げません。 sed は、それ自体がひとつの独立したトピックになり得ます。ただ、同じ目的には、より良いツールがあることも多いです。

さて、ここで削除したい接尾辞があるとしましょう。どうすれば良いのでしょうか?ユーザ名の後ろに続くテキストのみ一致させるのは、少し厄介です。特にユーザ名がスペース名等を含むとしたら!必要なのは、行 全体 を一致させることです:

 | sed -E 's/.*Disconnected from (invalid |authenticating )?user .* [^ ]+ port [0-9]+( \[preauth\])?$//'

正規表現のデバッガで、何が起こっているか見てみましょう。スタートはこれまでと同じです。次に、”user”の変種(ログには2種類の接頭辞があります)のいずれかに一致します。次に、ユーザ名が含まれる文字列の中から任意の文字列に一致します。次に、任意の単一の単語([^ ]+; 空白でない空白文字の連続)に一致します。次に、”port”という単語の後に数字の列が続きます。そして、行末の前に、接尾辞 [preauth] が付くことと、付かないことがあります。

この方法なら、ユーザ名”Disconnected from”が使われたとしても、もう混乱が起きませんね。なぜだかわかりますか?

しかし、この方法にはひとつ問題があります。それは、一致した全部が消去されてしまうことです。最終的に、ユーザ名は 残して おきたいのです。そのためには「キャプチャグループ」を使います。括弧で囲まれた正規表現でマッチしたテキストは、番号付きのキャプチャグループに保存されます。これらは、\1, \2, \3 等で呼び出せます。(いくつかのエンジンでは、一致パターン自体にも使えます!)ですから:

 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

ご想像の通り、 とっても 複雑な正規表現を作れてしまいます。例えば、ここでは Eメールアドレスを一致させる方法 についての記事を紹介します。これは 簡単ではありません。そして、多くの議論があります。テストコードを書いた人がいます。テスト結果の表を書いた人もいます。与えられた数が素数かどうか判定する正規表現も書けます。

正規表現を正しく書くことは非常に難しいですが、あなたのツールボックスに持っておくと、とても便利に使えます。

データラングリングに戻る

さて、ここまで揃ってきました。

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'

sed は、テキストの上書き (i コマンド)、明示的な行の表示 (p コマンド)、インデックスによる行の選択など、他にも色々と面白い使い方ができます。man sed をご確認ください。

ともかく、これでログインしようとした全ユーザ名リストが表示されます。しかし、これではまだ扱いづらいです。一般的な他の処理を考えてみましょう:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c

sort は、入力を並べ替えます。uniq -c は、連続する同じ内容の行を一行にまとめ、その前に出現回数を付けます。今回の全ユーザ名リストも並べ替えて、頻出のユーザ名だけを残すと良さそうですね:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10

sort -n は、(字句解析ではなく) 数値順にソートします。-k1,1 は「空白で区切られた最初の列だけでソートする」という意味です。,n は、n番目のフィールドまで使ってソートします。デフォルトは行末まで使います。 今回の 例では、行全体でソートしても問題ないでしょう。でも、ここは学びの場ですからね!

最も 一般的で ない ものを並べたい場合は、tail の代わりに head を使うことができます。また、逆順にソートする sort -r もあります。

これはとてもクールですが、ユーザ名だけをコンマで区切ったリストとして抽出し、例えば設定ファイルに使いたい場合はどうでしょうか?

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10
 | awk '{print $2}' | paste -sd,

paste から見ていきましょう: これは、与えられた一文字の区切り文字 (-d; この場合は ,) で行 (-s) を結合してくれます。では、この awk とは何者でしょうか?

awk – もうひとつのエディタ

awk は、テキストストリームを処理するのが得意なプログラミング言語です。 awk を正しく学ぶなら話すことは たくさん ありますが、ここで扱う他のトピック同様、基本的なことだけを説明します。

まず、{print $2} は何をするのでしょう? awk のプログラムは、任意で記述できるパターンと、パターンが指定行にマッチした場合の振る舞いを示すブロック、という並びで記述されます。デフォルトのパターン (上で使ったもの) はすべての行にマッチします。ブロック中では、$0 はその行の内容全体に設定され、$1 から $n はその行の n 番目のフィールドに設定され、 awk フィールドセパレータ (デフォルトでは空白、-F で変更) で区切られます。この場合は、すべての行について2番目のフィールド内容を表示する、つまりユーザ名を表示する、ということです。

では、もっと簡単なことができるか見てみましょう。c で始まり e で終わる、一度しか現れないユーザ名の数を計算してみましょう:

 | awk '$1 == 1 && $2 ~ /^c[^ ]*e$/ { print $2 }' | wc -l

ここは、たくさん説明することがあります。まず、パターン({...}の前の記述)がありますね。このパターンは、行の最初のフィールドは1に等しいこと(これは uniq -c からのカウントです)、また2番目のフィールドは与えられた正規表現に一致すること、と指定しています。そして、ブロックはユーザ名を表示せよ、と言っているだけです。そして、出力行数を wc -l でカウントします。

しかし、awk はプログラミング言語でしたね。覚えていますか?

BEGIN { rows = 0 }
$1 == 1 && $2 ~ /^c[^ ]*e$/ { rows += $1 }
END { print rows }

BEGIN は入力の開始にマッチするパターンです(END は終了にマッチします)。さて、行単位のブロックは最初のフィールドからのカウントを追加し(この場合は常に1)、最後にそれを出力します。実際は、grepsed を完全に削除することもできます。 なぜなら、awkなんでもできるからです。ただ、これは読者の皆さんの練習問題としておきましょう。

データの分析

STDIN! から読み込める電卓 bc を使えば、シェル内で直接計算ができます。例えば、各行の数字を + で区切り、連結することで、足し算ができます:

 | paste -sd+ | bc -l

または、もう少し詳しい表現を試してみましょう:

echo "2*($(data | paste -sd+))" | bc -l

計算には色々な方法があります。st はもとても良いですし、もしR言語が使えるなら:

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c
 | awk '{print $1}' | R --slave -e 'x <- scan(file="stdin", quiet=TRUE); summary(x)'

R言語は、データ分析とプロットに優れた別の(奇妙な)プログラミング言語です。ここではあまり詳細に立ち入りませんが、summary は配列の要約統計量を表示するということに触れれば十分でしょう。これで、数値の入力ストリームを含む配列を作成しました。R言語が、私たちのほしい統計計算をしてくれたということですね。

もし単純なプロット(グラフ描画)がしたいなら、gnuplot がお勧めです。

ssh myserver journalctl
 | grep sshd
 | grep "Disconnected from"
 | sed -E 's/.*Disconnected from (invalid |authenticating )?user (.*) [^ ]+ port [0-9]+( \[preauth\])?$/\2/'
 | sort | uniq -c
 | sort -nk1,1 | tail -n10
 | gnuplot -p -e 'set boxwidth 0.5; plot "-" using 1:xtic(2) with boxes'

引数を作るためのデータラングリング

長いリストに基づき、インストールしたり削除したりする対象を見つけるため、データを検索したいこともあるでしょう。これまでに説明したデータ検索と xargs の組み合わせは強力なコンボです。

例えば以前の講義で見たように、古いビルド名をデータラングリングツールで抽出し、xargs を使ってアンインストーラーに渡すことで、システムから古いナイトリービルドの Rust をアンインストールすることができます:

rustup toolchain list | grep nightly | grep -vE "nightly-x86" | sed 's/-x86.*//' | xargs rustup toolchain uninstall

バイナリデータのラングリング

ここまでは、ほぼテキストデータの扱いを話してきました。パイプはバイナリデータにも同じように使えます。例えば、ffmpeg を使ってカメラから画像をキャプチャし、それをグレースケールに変換して圧縮、SSHでリモートマシンに送り解凍した後、コピーを作成して表示することができます。

ffmpeg -loglevel panic -i /dev/video0 -frames 1 -f image2 -
 | convert - -colorspace gray -
 | gzip
 | ssh mymachine 'gzip -d | tee copy.jpg | env DISPLAY=:0 feh -'

演習

  1. この短くインタラクティブな正規表現チュートリアルを試しましょう
  2. 少なくとも3つの a を持ち、 's を語尾に持たない ( /usr/share/dict/words に含まれる) 単語の数を求めましょう。それらの単語で、最後の2文字として最もよく使われる3つの文字は何ですか?大文字小文字を区別したくない場合、 sedy コマンド、または、trプログラムが便利です。これらの2文字の組み合わせはいくつありますか?チャレンジ課題として:逆に、どの組み合わせが起こり得ないでしょうか?
  3. in-placeな置換を行うには、sed s/REGEX/SUBSTITUTION/ input.txt > input.txt のようにしたくなるものです。しかし、これは筋の悪い方法です。なぜでしょうか?これは sed 固有でしょうか。 man sed で、どうしたら良いか調べましょう。
  4. 過去10回のシステム起動時間の平均値、中央値、最大値を求めます。ブート時間の平均値、中央値、最大値を調べてください。Linuxではjournalctlを、macOSではlog showを使用して、開始時刻に近いログのタイムスタンプを探してください。各ブートの最初と最後にログのタイムスタンプを探します。Linuxの場合、次のようなものがあります:
    Logs begin at ...
    

    そして、

    systemd[577]: Startup finished in ...
    

    macOSでは、ここを見てみましょう:

    === system boot:
    

    と、

    Previous shutdown cause: 5
    
  5. 過去3回のリブートで共通しないブートメッセージを探してください(journalctl-b フラグを参照してください) この作業を複数のステップに分けます。まず、過去3回のブートからのログだけを取得する方法を見つけます。 ブートログ抽出に使うツールに、該当するフラグがあるかもしれません。 または、sed '0,/STRING/d' を使って、STRING に一致する行より前の、全行を削除できます。 次に、 常に変化する 行の部分(タイムスタンプなど)を削除します。 次に入力行の重複を取り除き、それぞれの行を数えます (uniq があなたの友人です)。 最後に、カウントが3の行を削除します。なぜなら、その行は全ブートで共通だったと言えるからです。
  6. これや、これのようなもの、またはここからオンラインデータセットを探します。 それを curl を使って取得し、数値データの2列だけを抽出します。 もしHTMLデータを取得するなら、pupが役に立つかもしれません。 JSONデータの場合は、jqを試してみてください。 ワンコマンドで1つの列の最小値と最大値を出す方法、そして各列の合計の差を出す別コマンドを見つけましょう。

このページを編集する

Licensed under CC BY-NC-SA.