awkコマンド

本稿はログ分析などでよく利用するawkコマンドの使い方を自分用にまとめたものである。

awkの特徴

awkコマンドは、CSVのような複数の項目に分割できるテキストデータの加工や分析が得意なコマンドである。

以下のようなファイアウォールのログを模したデータを使ってawkコマンドでの特徴を見てみる。

2024/09/07 01:00:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,43256,443,allow,tcp-fin,1653,2456432
2024/09/07 01:01:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,31886,443,allow,tcp-rst-from-client,452,82673
2024/09/07 01:02:00,policy-2,INSIDE,192.168.1.11,DMZ,172.17.10.110,tcp,29243,443,allow,tcp-fin,328,2832
2024/09/07 01:03:00,policy-3,INSIDE,192.168.1.11,DMZ,172.17.10.120,tcp,41522,443,allow,ttcp-fin,9832,2398
2024/09/07 01:04:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,51102,443,allow,tcp-rst-from-client,23098797,913
2024/09/07 01:05:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.150,tcp,31810,80,allow,aged-out,10,0
2024/09/07 01:06:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.153,udp,48273,53,allow,aged-out,369,241
2024/09/07 01:07:00,policy-1,INSIDE,192.168.1.12,DMZ,172.17.10.100,tcp,41189,443,allow,ttcp-fin,947,8234
2024/09/07 01:08:00,policy-1,INSIDE,192.168.1.11,DMZ,172.17.10.100,tcp,25403,443,allow,ttcp-fin,654,2629
2024/09/07 01:09:00,policy-5,INSIDE,192.168.1.11,DMZ,172.17.10.150,tcp,61021,80,allow,aged-out,10,0
2024/09/07 01:10:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,13523,443,allow,tcp-rst-from-client,39,915
2024/09/07 01:11:00,policy-6,INSIDE,192.168.1.14,DMZ,172.17.10.153,udp,18292,53,allow,aged-out,823,432
2024/09/07 01:12:00,policy-1,INSIDE,192.168.1.11,DMZ,172.17.10.100,tcp,26714,443,allow,tcp-fin,1653,923479
2024/09/07 01:13:00,interzone-default,INSIDE,192.168.1.12,DMZ,172.17.10.150,tcp,41921,443,policy-deny,incomplete,42,0
2024/09/07 01:14:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,28907,443,allow,tcp-fin,568,5614
2024/09/07 01:15:00,policy-1,INSIDE,192.168.1.14,DMZ,172.17.10.100,tcp,39711,443,allow,tcp-fin,133,8031

この内容をsample.txtに保存した状態で、以下のコマンドを実行してみる。

awk -F , '$9==53 {print $2}' sample.txt

これは「9番目の項目が53である行は、2番目の項目を出力せよ」という意味を持っている。実行結果は以下のようになる。

policy-1
policy-6

続いて次のコマンドを実行してみる。

awk -F , '$4=="192.168.1.13" { sum += $12+$13 }; END { print sum }' sample.txt

これは「4番目の項目が192.168.1.13である行について、12番目と13番目の項目の総合計を出力せよ」という意味を持っている。実行結果は以下のようになる。

23189627

以上のように、awkは区切り文字で区切られたデータを処理することが得意なコマンドがawkである。

awkの基本

実行

awkコマンドの処理の流れは以下のようになる。

  1. データを行の区切りまで読み込む。行単位のデータをレコードと呼ぶ。
  2. レコードを区切り文字で分割する。分割したそれぞれの項目をフィールドと呼ぶ。
  3. ルールに従い処理する。ルールは処理対象のレコードを指定するパターンと、どのような処理をするか指定するアクションの組み合わせである。
  4. アクションの中でprintされたものが処理結果として出力される。

これを踏まえて、awkコマンドのよくある使い方を以下に示す。

awk [オプション] -F 区切り文字 '処理ルール' テキストデータ1 [テキストデータ2 ... ]

区切り文字が半角空白の場合は「-F 区切り文字」は省略できる。
例1:awk -F , '$2 > 100 {print $1}' sample.txt sample2.txt
例2:awk '$2 > 100 {print $1}' sample.txt sample2.txt

処理ルールはファイルに書いたものを読み込むこともできる。

awk [オプション] -f 処理ルールが書かれたファイル テキストデータ1 [テキストデータ2 ... ]
例:awk -f command.awk sample.txt sample2.txt

また、処理する対象はファイルだけで無く、パイプで渡すこともできる。

例1:cat sample.txt | awk '{print $1}'
例2:cat sample.txt | awk -f command.awk

レコードとフィールド

awkコマンドはデータを行単位で読み込む。行単位で読み込んだデータをレコードと呼ぶ。また、読み込んだレコードは区切り文字で分割する。分割したそれぞれのデータをフィールドとよぶレコードの内容は$0、フィールドの内容は$フィールド番号で参照できる。

末尾のフィールド

後述のNFを使用すれば一番最後のフィールドや最後から2番目のフィールドを取り出すことができる。一番最後のフィールドは$NFであり、最後の1つ前のフィールドは$(NF-1)である。

レコードの区切り

設定によりレコードを行単位では無く、指定の区切り文字で分割することができる。

処理ルール

awkではレコードに対してどのような処理を行うかを指定する。どのレコードに対して処理を行うかを指定するものをパターンレコードに対してどのような処理を行うかを指定するものをアクションと呼び、pattern { action } のように書く。処理ルールはいくつも書くことができ、複数書いた場合は以下のようになる。

pattern1 { action1 }
pattern2 {
    action2-1
    action2-2
    action2-3
    action2-4
}
pattern3 { action3 }
...

パターン

パターンはアクションを適用するレコードを指定するものである。パターンの書き方は大きく4通りに分けて考えるとよい。

処理の対象パターンの書き方
すべてのレコード{ action } *パターンを記載しない
条件を満たしたレコード条件 { action }
範囲内のレコード開始条件,終了条件 {action}
レコード読み込み前、読み込み後の特定のタイミング最初 ... BEGIN { action }
ファイルの最初 ... BEGINFILE { action }
ファイルの最後 ... ENDFILE { action }
最後 ... END { action }

この中で使う条件の主な書き方を示す。

条件の書き方意味
/regex/レコードが正規表現にマッチする
$n == "string"n番目のフィールドが文字列と一致する
$n != "string"n番目のフィールドが文字列と一致しない
$n ~ /regex/n番目のフィールドが正規表現とマッチする
$n !~ /regex/n番目のフィールドが正規表現とマッチしない
$n < value
$n <= value
$n >= value
$n > value
(数値のフィールドに対して) n番目のフィールドが
・値より小さい
・値以下である
・値以上である
・値より大きい
FNR == n(ファイルの先頭から数えて) n番目のレコードである
NR == n(全ファイルを通して) n番目のレコードである
!conditionNOT条件(conditionが真でない)
condition1 && condition2AND条件(condition1もcondition2も真である)
condition1 || condition2OR条件(すくなくともcondition1かcondition2のどちらかは真である)

アクション

アクションはレコードを加工したり、集計したりする処理を書くところである。また、printを使用してその結果を出力する。

print

処理した結果はprintを使ってレコードとして出力する。

print field1, field2, field3, ..., fieldN

これらのフィールドは組み込み変数OFSの値で区切られて出力され、末尾には組み込み変数ORSが出力される結果となる。

BEGIN {
        OFS="<OFS>"
        ORS="<<ORS>>"
}

END {
        print "11", "12", "13"
        print "21", "22", "23"
}
11<OFS>12<OFS>13<<ORS>>21<OFS>22<OFS>23<<ORS>>

なお、フィールドを1つも指定しなかった場合、$0が指定されているものと見なされる。

{
        print        # これは print $0 と同じ
}
アクションの省略

アクションを省略した場合、{ print }が指定されたものと見なされる。

NR==3,NR==5          # これは NR==3,NR==5 { print } と同じ

パターンとアクションの例

ここではパターンとアクションを組み合わせた、実際に動作する処理内容の例を示す。

BEGIN {
        print "action-BEGIN", FNR
}

/^2024\/09\/07 01:1/ {
        print "action-1:", FNR
}

{
        print "action-2:", FNR
}

END {
        print "action-END:", FNR
}

この例では4つの処理が書かれている。上から、処理が始まるとき、レコードが2024/09/07 01:1で始まるとき、すべてのレコード、処理が終わるときの処理である。各処理ではFNRを出力している。FNRとは現在処理中のファイルのレコード番号のことである。

これを上述のファイアウォールログsample.txtに対して実行してみる。すると、action-2がすべてのレコードに対して実行されていて、11行目からはaction-1も実行されていることが分かる。これは11行目からaction1の実行条件/^2024\/09\/07 01:1/を満たすようになったためである。

$ awk -F , -f command.awk sample.txt
action-BEGIN 0
action-2: 1
action-2: 2
action-2: 3
action-2: 4
action-2: 5
action-2: 6
action-2: 7
action-2: 8
action-2: 9
action-2: 10
action-1: 11
action-2: 11
action-1: 12
action-2: 12
action-1: 13
action-2: 13
action-1: 14
action-2: 14
action-1: 15
action-2: 15
action-1: 16
action-2: 16
action-END: 16

変数

awkでは変数に値を記憶することができる。これを利用して値を集計したり、あるワードの出現回数を数えたりできる。

awkが定義する変数(組み込み変数)

awkにはあらかじめ定義された、すなわちawkが処理で使用する変数がある。これを組み込み変数とよぶ。組み込み変数は、awkの動作に影響するものとawkの状態を表すものの2つがある。

awkの動作を決める組み込み変数

アクションの例では以下のコマンドを実行した。

awk -F , -f command.awk sample.txt

これは別の書き方で以下のように書ける。

awk -v FS=, -f command.awk sample.txt

-vオプションは変数の値を指定するもので、この例ではFSという変数の値を“,”に指定している。このFSは動作を決める組み込み変数の一つであり、フィールドの区切り文字(フィールドセパレータ)を意味している。-Fオプションは区切り文字を指定するコマンドオプションであり、これらは書き方は違うが同じことを意味している。

このようなawkの動作を決める組み込み変数の主なものを示す。

組み込み変数意味
FS入力データのフィールド区切り文字、または区切りとして使う正規表現。
""(空文字)の場合は1文字ごとにフィールドを分割する。
デフォルト値は” ”(半角空白)。
RS入力データのレコード区切り文字。
OFS出力データのフィールド区切り文字。
ORS出力データのレコード区切り文字。
IGNORECASEこの値が非0のとき、文字列を比較するときに大文字小文字を区別しない。

awkの状態を表す組み込み変数

すでに登場したFNRやNRのように、awkの状態を表す変数として以下のようなものがある。

組み込み変数意味
NRawkを開始してから処理したレコードの総数。
FNR現在のファイルの処理を開始してから処理したレコードの総数。
NF現在処理中のレコードのフィールド数。
FILENAME処理中のファイル名。
入力がパイプで与えられた場合、ファイル名がないため"-"となる。

独自変数

組み込み変数でなく、ifなどのキーワードでもなければ、自由にユーザ定義変数として利用できる。以下はABCという名前の変数の内容を出力する。

{
    print ABC
}

awkでは変数を宣言すること無く利用でき、全ての独自変数は初期値が数値であれば0、文字列であれば""(空文字列)として扱われる。この例では変数ABCを初期化しないで出力しているが、awkではエラーにならない。

$ awk -F , -f command.awk sample.txt


(省略)

変数の型

awkでは変数は配列か非配列(スカラー)かのどちらかである。変数がどちらかは最初にその変数をどちらとして使用するかで決まる。一度スカラーとして初期化した変数を、配列として使うことはできない。例示はしないが逆も同様である。

BEGIN {
        abc = "text"        # これで変数abcはスカラーになる。
        abc["key"] = "text" # これはスカラー変数abcを配列のように使用しているためエラーになる
}
$ awk -f command.awk sample.txt
awk: command.awk:3: fatal: attempt to use scalar `abc' as an array
スカラー

配列でない単一の値を持つ変数をスカラーという。awkでは変数の値は文字列である。しかし必要に応じて数値としても振る舞う。数値でない文字列や空文字列が数値として振る舞うとき、値は0になる。

BEGIN {
    NAME = "12"
    print NAME + 1
    NAME = "a12"
    print NAME + 1
}
$ awk -F , -f command.awk sample.txt
13
1

この例では、はじめにNAME変数を「12」という文字列で初期化して、それに1を足している。そのとき、awkはNAMEを自動で数値に変換するため、計算結果は13になる。つぎにNAMEを「a12」という文字列で同じことを行う。同様にawkはNAMEを数値に変換しようとするが、「a12」は数値ではないため、0として取り扱われる。そのため計算結果は1となる。

別の例として、初期化していない変数に演算を行う例を示す。これは数値として扱われているため初期値は0となる。

BEGIN {
    print NAME + 1
}
$ awk -F , -f command.awk sample.txt
1
配列

awkの配列は連想配列である。他言語でも良くある[]で要素を取得でき、キーとして文字列が使われる。配列変数の初期値は空の配列である。存在しない要素にアクセスするとスカラーと同様に空文字列で初期化される。

BEGIN {
        capital["日本"] = "東京"
        capital["アメリカ"] = "ワシントンD.C."
}

END {
        print capital["アメリカ"], capital["イギリス"], capital["日本"]
}
$ awk -f command.awk sample.txt
ワシントンD.C.,,東京

この例ではcapital["日本"] = "東京"の時点で、capitalが配列変数として初期化される。後に初期化していないcapital["イギリス"]へアクセスしているが、エラーとはならずcapital["イギリス"]は空文字列で初期化される。実際にcapital["イギリス"]を参照した後で、すべてのcapitalを表示させてみる。

BEGIN {
        capital["日本"] = "東京"
        capital["アメリカ"] = "ワシントンD.C."
}

END {
        print capital["アメリカ"], capital["イギリス"], capital["日本"]
        for( nation in capital ) {
                print nation, capital[nation]
        }
}
$ awk -f command.awk sample.txt
ワシントンD.C.,,東京
アメリカ ワシントンD.C.
イギリス
日本 東京

イギリスが配列capitalに追加されていることが分かる。

delete

deleteを使うと配列の要素を削除できる。

BEGIN {
        capital["日本"] = "東京"
        capital["アメリカ"] = "ワシントンD.C."
        delete capital["日本"]
        for( nation in capital ) {
                print nation, capital[nation]
        }
}
$ awk -f command.awk sample.txt
アメリカ ワシントンD.C.

配列のインデックスを指定しないと配列を空にできる。

BEGIN {
        capital["日本"] = "東京"
        capital["アメリカ"] = "ワシントンD.C."
        delete capital
        for( nation in capital ) {
                print nation, capital[nation]
        }
}
$ awk -f command.awk sample.txt
(何も表示されない)
変数の初期化

変数の初期値が""または0で問題無いなら、変数を初期化しなくても動作する。初期化するときはBEGINまたはBEGINFILEのアクションで行うことがある。

BEGIN {
        NAME = "text"
}

{
        print NAME
}
$ awk -F , -f command.awk sample.txt
text
text
(省略)
変数のスコープ

変数の初期化の例を見ると分かるように、変数はawk内のどこでも使用できる。基本的にawkの変数は大域変数である。ただし後述する関数の引数はローカル変数となる。

アクションの記法

文字列の結合

文字列を結合するときは" "(半角空白)で区切って書く+では数値として演算が行われてしまう

BEGIN {
    ABC = "text"
    print ABC ABC
}
$ awk -F , -f command.awk sample.txt
texttext

awk特有の制御構文

処理は条件がBEGINやENDなどのときを除いて上から順に行われる。もしBEGINやENDが複数回指定されている場合は、その中で上から実行される。

この処理の流れを途中で中断するawk特有の制御構文を示す。

構文意味
next現在のレコード処理を終了し、次のレコードに移る
nextfile現在のファイル処理を終了し、次のファイルに移る
exitawkを終了する
現在のファイルは強制終了の扱いとなりENDFILEが実行されない。
ただし、ENDは実行される。

一般的な制御構文

awkはよくあるプログラミング言語の構文におおむね対応しており、柔軟に処理が指定できる。ここではアクションで利用できる主な構文を示す。

条件分岐(if)if ( condition1 ) {
condition1が真のときの処理
} else if ( condition2 ) {
condition1が偽かつcondition2が真のときの処理
} else {
すべての条件が偽のときの処理
}
条件分岐(switch...case)switch( 変数名 ) {
case 条件1:
条件1のときの処理
break
case 条件2:
条件2のときの処理
break
default:
いずれの条件も満たさないときの処理
break
}
繰り返し(for)for( 初期化; 反復条件; 再設定){
繰り返す処理
}
繰り返し(while)while( 反復条件 ){
繰り返す処理
}
繰り返し(do...while)do {
繰り返す処理
}while( 反復条件 )
配列に対しての繰り返し(for in)for( 変数名 in 配列 ){
繰り返す処理
}

関数

独自関数

functionで関数を作成できる。

function add1(x) {
    return x+1
}

{
    print add1(FNR)
}

関数の引数は大域変数では無くローカル変数となる。

文字列を操作する関数

文字列を操作する関数を紹介する。主な文字列操作関数の説明を以下に示す。関数によって値が書き換えられる引数に<out>と記載している。

関数意味備考
index( text, searchtext )textの中でsearchtextを検索し、インデックスを返す。
length( text )textの文字数を返す。
split( text, <out> result, separator )textをseparatorで分割したものをresultに格納する。
分割した個数を返す。
resultは結果が格納できる変数で無ければならない。
sub( regex, replace, <out> text )
gsub( regex, replace, <out> text )
textでregexにマッチしたものをreplaceに置換し、textに格納する。
置換した個数を返す。
subは一番左でマッチしたものを置換し、gsubはマッチしたものを全て置換する。
textは入力であり、出力でもある。
gensub( regex, replace, index, text)textでregexにindex番目にマッチしたものをreplaceに置換して返す。
indexに"g"を指定した場合、全て置換する。
これはsub、gsubとは異なり、textを書き換えない。
substr( text, start, length)textの部分文字列を取り出す。
toupper( text )
tolower( text )
textを大文字/小文字にしたものを返す。

実際に文字列操作を行う例を示す。

BEGIN {
    text="a,ab,abc,abcd,abcde,abcdef,abcdefg"
    OFS="	:"

    print "text", text

    print "index", index(text, "cde")

    print "length", length(text)

    print "split", split(text, result, ",")
    for(r in result){
            print "  result[" r "]", result[r]
    }

    target = text
    print "sub", sub( /.cd?e?,/, "!", target)
    print "  result", target

    target = text
    print "gsub", gsub( /.cd?e?,/, "!", target)
    print "  result", target

    target = text
    print "gensub", gensub( /.cd?e?,/, "!",   1, target)
    print "gensub", gensub( /.cd?e?,/, "!",   2, target)
    print "gensub", gensub( /.cd?e?,/, "!", "g", target)
    print "  after", target

    print "substr", substr(text, 5, 3)

    print "upper", toupper(text)
    print "lower", tolower("UpperText")
}
text    :a,ab,abc,abcd,abcde,abcdef,abcdefg
index   :17
length  :34
split   :7
  result[1]     :a
  result[2]     :ab
  result[3]     :abc
  result[4]     :abcd
  result[5]     :abcde
  result[6]     :abcdef
  result[7]     :abcdefg
sub     :1
  result        :a,ab,a!abcd,abcde,abcdef,abcdefg
gsub    :3
  result        :a,ab,a!a!a!abcdef,abcdefg
gensub  :a,ab,a!abcd,abcde,abcdef,abcdefg
gensub  :a,ab,abc,a!abcde,abcdef,abcdefg
gensub  :a,ab,a!a!a!abcdef,abcdefg
  after :a,ab,abc,abcd,abcde,abcdef,abcdefg
substr  :,ab
upper   :A,AB,ABC,ABCD,ABCDE,ABCDEF,ABCDEFG
lower   :uppertext

続いて正規表現でパターンマッチする関数matchを紹介する。

関数意味備考
match( text, regex )textをregexでマッチする。最初にマッチしたインデックスを返す。
また、RSTARTとRLENGTHにマッチしたインデックスとマッチした長さが格納される。これを利用してsubstrで一致した文字列を取り出すことができる。
match( text, regex, <out> group )上のmatchの動作に加え、groupにキャプチャグループでキャプチャされた内容、インデックス情報が格納される。groupは配列でなければならない。
groupにキャプチャされる情報は以下である。
・group[キャプチャグループ番号]:キャプチャした文字列
・group[キャプチャグループ番号, "start"]:キャプチャした文字列のインデックス
・group[キャプチャグループ番号, "length"]:キャプチャした文字列の長さ
BEGIN {
        text="a,ab,abc,abcd,abcde,abcdef,abcdefg"
        OFS="   :"

        print "text", text

        print "match", match(text, /,a.*[ef],/)
        print "  RSTART",  RSTART
        print "  RLENGTH", RLENGTH
        print "  substr",  substr(text, RSTART, RLENGTH)

        print "match", match(text, /,ab?([^,]{3}),/, group)
        print "  group[1]",             group[1]
        print "  group[1, \"start\"]",  group[1, "start"]
        print "  group[1, \"length\"]", group[1, "length"]
}
text    :a,ab,abc,abcd,abcde,abcdef,abcdefg
match   :2
  RSTART        :2
  RLENGTH       :26
  substr        :,ab,abc,abcd,abcde,abcdef,
match   :9
  group[1]      :bcd
  group[1, "start"]     :11
  group[1, "length"]    :3

その他の関数

awkには他にも数値に関する関数、入出力に関する関数、時刻に関する関数などがある。これらについてはドキュメントを参照すること。

シェルとの連携

ワンライナーでの記述

簡単な処理であればシェルコマンドの引数で指定する。このとき、1行に複数の処理を書くときは;で処理を区切る

cat sample.txt | awk -F , '{ print }; NR % 2 == 0 { print $1; print $2 }'

このコマンドは以下の内容をワンライナーで書いたものである。

{
        print
}

NR % 2 == 0 {
        print $1
        print $2
} 

セミコロンの位置

C言語のように、すべての行の末尾に;を付けても動作する。ただし、アクション区切りのセミコロンも必要なことに注意すること。

cat sample.txt | awk -F , '{ print; }; NR % 2 == 0 { print $1; print $2; }'

また誤ってパターンの後ろにセミコロンを付けるとエラーにならず意味が変わるため注意すること。

cat sample.txt | awk -F , 'NR % 2 == 0; { print $1 }'

なぜなら、これは以下のようなアクションを省略した処理とパターンを省略した処理の2つの処理の組み合わせと解釈されるためである。

cat sample.txt | awk -F , 'NR % 2 == 0 { print }; 1 { print $1 }'

パイプでawkを使用するときの注意点

ファイル名

当然ながらパイプで渡された内容はファイル名を取得できない。

$ cat sample.txt sample2.txt | awk -F , 'BEGINFILE { print FILENAME }'
-

$ awk -F , 'BEGINFILE { print FILENAME }' sample.txt sample2.txt
sample.txt
sample2.txt

nextfile

ファイル名の取得の例を見て分かるように、パイプで渡される内容は1つのファイルのように扱われる。そのため、nextfileで次のファイルへ移ることはできない。単にパイプで渡されるデータをこれ以上処理しないことになる。

シェルコマンドを実行する

getlineを使うとシェルコマンドをawkから実行できる。lsコマンドを実行する例を示す。

BEGIN {
        "ls" | getline var
        print var
}
$ awk -F , -f command.awk sample.txt
command.awk

この方法ではgetlineは1行目の出力結果しか得ることができない。whileと組み合わせると全ての結果が得られる。

BEGIN {
        while (("ls" | getline var) > 0) {
                print var
        }
}
$ awk -F , -f command.awk sample.txt
command.awk
sample.txt
sample2.txt

getlineには他にも使い方がある。詳細はマニュアルを参照すること。

シェルからパラメータを受け取る

vオプションで変数に値を設定する。たとえば条件に一致した行だけ出力するが、その条件自体はコマンドラインで指定するような場合である。

$0 ~ keyword {
    print
}
$ awk -v keyword=192.168.1.10 -F , -f command.awk sample.txt
2024/09/07 01:00:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,43256,443,allow,tcp-fin,1653,2456432
2024/09/07 01:05:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.150,tcp,31810,80,allow,aged-out,10,0
2024/09/07 01:10:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,13523,443,allow,tcp-rst-from-client,39,915

エスケープシーケンス

この例が"192.168.1.10"という文字列を含む行だけを出力したい、という意味であれば実は正しく動作しない。サンプルデータでは偶然うまくいっただけである。

2024/09/07 01:00:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,43256,443,allow,tcp-fin,1653,82673
2024/09/07 01:01:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,31886,443,allow,tcp-rst-from-client,452,192116871410

このデータで実行してみよう。2行目のデータに192.168.1.10は入っていないが2行目も出力される。

$ awk -v keyword=192.168.1.10 -F , -f command.awk sample2.txt
2024/09/07 01:00:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,43256,443,allow,tcp-fin,1653,82673
2024/09/07 01:01:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,31886,443,allow,tcp-rst-from-client,452,192116871410

これはkeywordを正規表現として検索しているためであり、2行目の"192116871410"が/192.168.1.10/にマッチしている。となればkeyword=192\.168\.1\.10とエスケープすると期待した結果になるはずだが、実際はkeyword=192\\\\.168\\\\.1\\\\.10とすると正しく動作する。これはawkが使うまでに2回エスケープ処理が行われるためである。これを確認するには以下のようにしてawkが使うkeywordの値を出力してみると良い。

BEGIN {
        print keyword
}
$ awk -v keyword=192.168.1.10 -F , -f command.awk sample2.txt
192.168.1.10

$ awk -v keyword=192\.168\.1\.10 -F , -f command.awk sample2.txt
192.168.1.10

$ awk -v keyword=192\\.168\\.1\\.10 -F , -f command.awk sample2.txt
awk: warning: escape sequence `\.' treated as plain `.'
192.168.1.10

$ awk -v keyword=192\\\.168\\\.1\\\.10 -F , -f command.awk sample2.txt
awk: warning: escape sequence `\.' treated as plain `.'
192.168.1.10

$ awk -v keyword=192\\\\.168\\\\.1\\\\.10 -F , -f command.awk sample2.txt
192\.168\.1\.10

サンプル

awkの使用例をいくつか示す。以下のテキストデータsample.txtを処理する。

2024/09/07 01:00:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,43256,443,allow,tcp-fin,1653,2456432
2024/09/07 01:01:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,31886,443,allow,tcp-rst-from-client,452,82673
2024/09/07 01:02:00,policy-2,INSIDE,192.168.1.11,DMZ,172.17.10.110,tcp,29243,443,allow,tcp-fin,328,2832
2024/09/07 01:03:00,policy-3,INSIDE,192.168.1.11,DMZ,172.17.10.120,tcp,41522,443,allow,ttcp-fin,9832,2398
2024/09/07 01:04:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,51102,443,allow,tcp-rst-from-client,23098797,913
2024/09/07 01:05:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.150,tcp,31810,80,allow,aged-out,10,0
2024/09/07 01:06:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.153,udp,48273,53,allow,aged-out,369,241
2024/09/07 01:07:00,policy-1,INSIDE,192.168.1.12,DMZ,172.17.10.100,tcp,41189,443,allow,ttcp-fin,947,8234
2024/09/07 01:08:00,policy-1,INSIDE,192.168.1.11,DMZ,172.17.10.100,tcp,25403,443,allow,ttcp-fin,654,2629
2024/09/07 01:09:00,policy-5,INSIDE,192.168.1.11,DMZ,172.17.10.150,tcp,61021,80,allow,aged-out,10,0
2024/09/07 01:10:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,13523,443,allow,tcp-rst-from-client,39,915
2024/09/07 01:11:00,policy-6,INSIDE,192.168.1.14,DMZ,172.17.10.153,udp,18292,53,allow,aged-out,823,432
2024/09/07 01:12:00,policy-1,INSIDE,192.168.1.11,DMZ,172.17.10.100,tcp,26714,443,allow,tcp-fin,1653,923479
2024/09/07 01:13:00,interzone-default,INSIDE,192.168.1.12,DMZ,172.17.10.150,tcp,41921,443,policy-deny,incomplete,42,0
2024/09/07 01:14:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,28907,443,allow,tcp-fin,568,5614
2024/09/07 01:15:00,policy-1,INSIDE,192.168.1.14,DMZ,172.17.10.100,tcp,39711,443,allow,tcp-fin,133,8031

例:指定された行範囲のレコードを出力する

あらかじめ処理したい行番号の範囲が分かっている場合、以下のように行範囲を指定できる。

FNR==6,FNR==10{
        print $0
}
$ awk -F , -f command.awk sample.txt
2024/09/07 01:05:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.150,tcp,31810,80,allow,aged-out,10,0
2024/09/07 01:06:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.153,udp,48273,53,allow,aged-out,369,241
2024/09/07 01:07:00,policy-1,INSIDE,192.168.1.12,DMZ,172.17.10.100,tcp,41189,443,allow,ttcp-fin,947,8234
2024/09/07 01:08:00,policy-1,INSIDE,192.168.1.11,DMZ,172.17.10.100,tcp,25403,443,allow,ttcp-fin,654,2629
2024/09/07 01:09:00,policy-5,INSIDE,192.168.1.11,DMZ,172.17.10.150,tcp,61021,80,allow,aged-out,10,0

例:指定された時間の範囲のレコードを出力する

今回のデータでは1番目のフィールドが時間を表している。1番目のフィールドが調査したい時間のものだけを出力する例を示す。

$1 ~ "2024/09/07 01:03",$1 ~ "2024/09/07 01:09"{
        print $0
}
$ awk -F , -f command.awk sample.txt
2024/09/07 01:03:00,policy-3,INSIDE,192.168.1.11,DMZ,172.17.10.120,tcp,41522,443,allow,ttcp-fin,9832,2398
2024/09/07 01:04:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,51102,443,allow,tcp-rst-from-client,23098797,913
2024/09/07 01:05:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.150,tcp,31810,80,allow,aged-out,10,0
2024/09/07 01:06:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.153,udp,48273,53,allow,aged-out,369,241
2024/09/07 01:07:00,policy-1,INSIDE,192.168.1.12,DMZ,172.17.10.100,tcp,41189,443,allow,ttcp-fin,947,8234
2024/09/07 01:08:00,policy-1,INSIDE,192.168.1.11,DMZ,172.17.10.100,tcp,25403,443,allow,ttcp-fin,654,2629
2024/09/07 01:09:00,policy-5,INSIDE,192.168.1.11,DMZ,172.17.10.150,tcp,61021,80,allow,aged-out,10,0

例:条件を満たすレコードを出力する

12番目のフィールドが1000以上の場合に処理する例を示す。

{
        if( $12 > 1000 ){
                print FNR, $0
        }
}
$ awk -F , -f command.awk sample.txt
1 2024/09/07 01:00:00,policy-1,INSIDE,192.168.1.10,DMZ,172.17.10.100,tcp,43256,443,allow,tcp-fin,1653,2456432
4 2024/09/07 01:03:00,policy-3,INSIDE,192.168.1.11,DMZ,172.17.10.120,tcp,41522,443,allow,ttcp-fin,9832,2398
5 2024/09/07 01:04:00,policy-1,INSIDE,192.168.1.13,DMZ,172.17.10.100,tcp,51102,443,allow,tcp-rst-from-client,23098797,913
13 2024/09/07 01:12:00,policy-1,INSIDE,192.168.1.11,DMZ,172.17.10.100,tcp,26714,443,allow,tcp-fin,1653,923479

if文ではなく、パターンを使っても同じ結果が得られる。状況に応じて使いやすい方を使えば良い。

$12 > 1000 {
        print FNR, $0
}

例:値の出現回数を数える

今回のデータは2番目のフィールドにはファイアウォールのポリシー名が出力されている。どういうポリシーがどれだけ使用されたか集計する例を示す。

{
        key = $2
        count[key] += 1
}

END {
        for(c in count) {
                print c, count[c]
        }
}
$ awk -F , -f command.awk sample.txt
interzone-default 1
policy-5 1
policy-6 1
policy-1 11
policy-2 1
policy-3 1

countという連想配列で出現回数を数える。数えたい値をキーとして使うことで、目的を実現している。そしてENDのアクション内でfor ... inを使用して結果を表示している。

もし複数のフィールドをキーとして件数を数えたい場合、以下のようにフィールドを結合したものをキーとすれば良い。

{
        key = $6 ":" $9
        count[key] += 1
}

END {
        for(c in count) {
                print c, count[c]
        }
}
$ awk -F , -f command.awk sample.txt
172.17.10.100:443 9
172.17.10.153:53 2
172.17.10.150:443 1
172.17.10.150:80 2
172.17.10.120:443 1
172.17.10.110:443 1

例:合計数を求める

12番目と13番目のフィールドの合計値をそれぞれ求める例を示す。

{
        sent += $12
        received += $13
}

END {
        print sent, received
}
$ awk -F , -f command.awk sample.txt
23116310 3494823

例:平均値を求める

12番目のフィールドの平均値を求める例を示す。

{
        count += 1
        sent += $12
}

END {
        print sent, count, sent/count
}
$ awk -F , -f command.awk sample.txt
23116310 16 1.44477e+06

条件別に合計値や平均値を求める。

例:合計値や平均値を条件別に求める

今回のデータは4番目に通信の送信元IPアドレスが、12番目に送信したバイト数が入っている。送信元IPアドレスごとに何バイト送信したか集計する例を示す。

{
        key = $4
        count[key] += 1
        sent[key] += $12
}

END {
        print sent, count, sent/count
}
$ awk -F , -f command.awk sample.txt
192.168.1.10 1702 3 567.333
192.168.1.11 12477 5 2495.4
192.168.1.12 989 2 494.5
192.168.1.13 23100186 4 5.77505e+06
192.168.1.14 956 2 478

例:CSVを読み込む(簡易)

フィールドの区切り文字を","にすれば良い。

BEGIN {
        FS=","
        OFS=","
}

{
        print $4,$6
}
$ awk -f command.awk sample.txt
192.168.1.10,172.17.10.100
192.168.1.13,172.17.10.100
192.168.1.11,172.17.10.110
(省略)

ただし、このサンプルは読み込むCSVデータによって正しく処理できないことがある。次の例で補足する。

例:CSVを読み込む(精緻)

簡易版ではフィールドの値に「,」が含まれるCSVで処理がおかしくなる。以下の表データをCSV化したものをcsverror.txtとして保存した。

csv,testcsvtest
"csv","test"csvtest
csvtestcsvtest
csverror.txtの内容
"csv,test",csv,test
"""csv"",""test""",csv,test
csvtest,,csvtest

これをawkでカンマ区切りとして処理すると、csv,testや"csv","test"が2つのフィールドに分かれてしまう。

BEGIN {
        FS=","
        OFS="    "
}

{
        print "1:" $1, "2:" $2, "3:" $3
}
$ awk -f command.awk csverror.txt
1:"csv    2:test"    3:csv
1:"""csv""    2:""test"""    3:csv
1:csvtest    2:    3:csvtest

FPAT

FPATという組み込み変数を使うとより正確にCSVを読み込むことができる。ただ、完全ではない。

これまでフィールドを区切る文字をFSで指定することでレコードをフィールドに分割していた。考え方を逆にして、フィールドがどのようなフォーマットかを正規表現で指定して、それに従ってレコードを分割する。このときに使用する組み込み変数がFPATである。FPATを使用してCSVを読み込む例を示す。

BEGIN {
        FPAT="[^,]*|\"[^\"]*\""
        OFS="    "
}

{
        print "1:" $1, "2:" $2, "3:" $3
}
$ awk -f command.awk csverror.txt
1:"csv,test"    2:csv    3:test
1:"""csv""    2:""test"""    3:csv
1:csvtest    2:    3:csvtest

この例で使用した正規表現FPAT、[^,]*|\"[^\"]*\"はREGEXPERでは以下のように図示される。この正規表現は「,を含まない文字列」「空文字列」「二重引用符で囲まれている文字列」を意味しており、awkはこれに一致したものを1つのフィールドとして扱う。この正規表現は"csv,test"という値を正しく1つのフィールドとして扱うことができるが、"""csv"",""test"""は正しく分割できない。

非貪欲マッチ

awkの正規表現は非貪欲マッチをサポートしていない。そのため [^,]*|\".*?\" の代わりに [^,]*|\"[^\"]*\" としている。

そこで2つ連続する二重引用符をフィールドの値として許容するようにFPATを変更する。

BEGIN {
        FPAT="[^,]*|\"([^\"]|\"\")*\""
        OFS="    "
}

{
        print "1:" $1, "2:" $2, "3:" $3
}
$ awk -f command.awk csverror.txt
1:"csv,test"    2:csv    3:test
1:"""csv"",""test"""    2:csv    3:"test"
1:csvtest    2:    3:csvtest

今回のcsverror.txtはこれで正しく処理できたが、値に改行が含まれる場合など全てのCSVを取り扱えるものではない。さらなる工夫をすればawkで扱えるかもしれないが、高度な内容であればCSVを専門に扱えるツールを利用することを検討した方が良い。

例:ヘッダ行を飛ばす

データにヘッダ行が含まれていて、1行目だけ処理したくないことがある。いくつか方法が考えられる。

  • パターンで1レコード目以外を条件として指定する
    • FNR !=1 { action }
  • パターンで2レコード目以降を条件として指定する
    • FNR >= 2 { action }
    • FNR == 2,0 { action }
  • 1行目なら次のレコードへ移る
    • FNR==1 { next }; { action }
    • FNR==1,1{ next }; { action }
    • { if(FNR==1){ next } action }

自分の環境では速度差はほとんど無かったので、処理したい内容に書き方があっているものを選べば良い。おそらくヘッダ行を飛ばす処理よりも、他アクションの処理に圧倒的に時間がかかるためだろう。

例:ダミーデータの生成

入力データを与えないで、なんらかのテストで使うためのCSVデータを生成することもできる。

BEGIN {
        if(number == 0){
                number = 1000000
        }
        srand()
        for(i=1;i<=number;++i){
                value1 = i
                value2 = "ABC" int(number*rand())
                value3 = int(1000*rand())
                value4 = number-i
                print value1, value2, value3, value4
        }
}
$ awk -f genRecord.awk -v number=1000000 -v OFS=, > data.csv
$ less data.csv
1,ABC398203,99,999999
2,ABC187966,973,999998
3,ABC73386,630,999997
4,ABC624966,334,999996
(省略)

strftimeと組み合わせれば時系列順のログを模したダミーデータも作れる。

BEGIN {
        maxvalue = number == 0 ? 1000000 : number
        unixtime = start == 0 ? systime() : start
        if( step <= 2 ){ step = 2 }

        srand()

        for(i=1;i<=maxvalue;++i){
                unixtime += int(step*rand())
                value1 = strftime("%Y-%m-%d %H:%M:%S", unixtime)
                value2 = "ABC" int(number*rand())
                value3 = int(1000*rand())
                value4 = i
                print value1, value2, value3, value4
        }
}
$ awk -f genRecordTime.awk -v number=100 -v start=1725721200 -v OFS=,
2024-09-08 00:00:03,ABC20,644,1
2024-09-08 00:00:05,ABC85,474,2
2024-09-08 00:00:08,ABC9,504,3
2024-09-08 00:00:11,ABC97,497,4
2024-09-08 00:00:12,ABC34,508,5
(省略)

参考文献

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です