LLVMのauto-vectorizationとc2goasmによるGo Plan9 Assemblyの生成によるSIMD最適化
InfluxDBの開発チームはApache Arrowの技術に注目していて、ArrowのGo実装の開発にも積極的に参加しています。Stuart Carine (InfluxDBの開発チームメンバー)がApache ArrowのGo実装に取り入れたc2goasmとLLVMを使った最適化が以前話題になりました。
c2goasm はClangで生成したアセンブリをGo Plan9 Assemblyに変換できる汎用的なコマンドラインツールです。しかしその活用事例はほとんどが、Goコンパイラーが行っていないSSEやAVX命令を用いたSIMD最適化です。この記事ではAVX2によるSIMD演算を実現するための c2goasm の使い方を整理します。最終的にどれくらい速くなるかでいうとfloat64のsum演算は10x以上高速化しました。
$ go test -bench . goos: darwin goarch: amd64 pkg: github.com/c-bata/sample-c2goasm BenchmarkSumFloat64_256-4 5000000 282 ns/op BenchmarkSumFloat64_1024-4 1000000 1234 ns/op BenchmarkSumFloat64_8192-4 200000 10021 ns/op BenchmarkSumFloat64_AVX2_256-4 50000000 23.5 ns/op BenchmarkSumFloat64_AVX2_1024-4 20000000 95.9 ns/op BenchmarkSumFloat64_AVX2_8192-4 2000000 904 ns/op PASS ok github.com/c-bata/sample-c2goasm 10.911s
c2goasm
- Github: https://github.com/minio/c2goasm
- 作者minioによる解説: https://blog.minio.io/c2goasm-c-to-go-assembly-bb723d2f777f
c2goasm の使い方は次のとおりです。
ツールとしての作り込みが少し雑な印象はありハマりどころもありますが、この3つのステップを踏めばSIMDを使って最適化したC/C++のコードを少ないオーバーヘッドでGoから呼び出せます。
CgoによるCの関数呼び出し
Goにおいてデファクトスタンダードとなっている cgo は性能面において優れたソリューションではありません 1 。c2goasmで生成されたGo Plan9 Assemblyのサブルーチン呼び出しは、他のGoの関数呼び出しと同程度の効率で実行できるようです。
GithubリポジトリのREADMEにはcgoとの性能比較が紹介されています。 https://github.com/minio/c2goasm#benchmark-against-cgo
Cのコードの記述
float64のarrayに入った値の合計値を計算する関数を用意します。 SIMDによる演算で合計値の計算をするのは少しアルゴリズム的な工夫が必要です。
#include <immintrin.h> void sum_float64_avx_intrinsics(double buf[], size_t len, double *res) { __m256d acc = _mm256_set1_pd(0); for (int i = 0; i < len; i += 4) { __m256d v = _mm256_load_pd(&buf[i]); acc = _mm256_add_pd(acc, v); } acc = _mm256_hadd_pd(acc, acc); // a[0] = a[0] + a[1], a[2] = a[2] + a[3] *res = _mm256_cvtsd_f64(acc) + _mm_cvtsd_f64(_mm256_extractf128_pd(acc, 1)); }
このようにintrinsicsを使って自分でかくこともできますが、実装が少し大変です。 LLVMのAuto-Vectorization にこの辺の最適化は任せられるなら楽ができます。
void sum_float64(double buf[], int len, double *res) { double acc = 0.0; for(int i = 0; i < len; i++) { acc += buf[i]; } *res = acc; }
これをClangでコンパイルしてみます。
$ clang -S -mavx2 -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -c sum_avx_intrinsics.c
オプションはこの辺を参考にしてください。
実行すると ~.s
とアセンブリファイルが生成されるので中を見るとこのような感じです。
.section __TEXT,__text,regular,pure_instructions .build_version macos, 10, 14 .intel_syntax noprefix .globl _sum_float64 ## -- Begin function sum_float64 .p2align 4, 0x90 _sum_float64: ## @sum_float64 ## %bb.0: ... vxorps xmm0, xmm0, xmm0 mov qword ptr [rsp + 40], rdi mov dword ptr [rsp + 36], esi mov qword ptr [rsp + 24], rdx vmovsd qword ptr [rsp + 16], xmm0 ...
vmovsdといった命令やレジスタ xmm0 を利用していることからLLVMがSIMD命令を活用していることがわかります。サブルーチン名 _sum_float64
はGoの関数定義で必要なので覚えておきます。
※ xmm0レジスタは128 bitsしかないレジスタのため、64 bitsを消費するdoubleの演算は2要素しか一度に演算できません。おそらくSSEの命令を利用しています。ここで ymm0 など128 bitsのレジスタを使い AVX2の命令を呼び出してほしいのですが、Clangのバージョンや最適化オプションによりうまくいったりイカなかったりするようです。詳細はGithubをご覧ください。
Goの関数定義
package c2goasm_sample import "unsafe" //go:noescape func __sum_float64(buf, len, res unsafe.Pointer) func SumFloat64Avx2(a []float64) float64 { var ( p1 = unsafe.Pointer(&a[0]) p2 = unsafe.Pointer(uintptr(len(a))) res float64 ) __sum_float64(p1, p2, unsafe.Pointer(&res)) return res }
シグネチャはアセンブリ内のサブルーチン名に _
を追加したものです。今回のようにもしサブルーチン名が _
から始まっていたら、 __
で始まることに注意してください。
https://github.com/minio/c2goasm/blob/0325a40cfd1fc6a5097e69eaf0292990eb6cee6a/arguments.go#L85
c2goasm実行
$ go get -u github.com/minio/asm2plan9s $ go get -u github.com/minio/c2goasm $ go get -u github.com/klauspost/asmfmt/cmd/asmfmt $ c2goasm -a -f _lib/sum_avx_intrinsics.s sum_avx.s
これでGoアセンブリが生成されます。第2引数が出力ファイルですが、Goのファイル名を sum_avx.go
としたときは拡張子を .s
に変えただけの sum_avx.s
を指定します。
https://github.com/minio/c2goasm/blob/0325a40cfd1fc6a5097e69eaf0292990eb6cee6a/c2goasm.go#L252
ここまでが一連の流れです。
ベンチマーク
Pure Goで実装した次の関数とベンチマークにより比較します。
package c2goasm_sample func SumFloat64(a []float64) float64 { var sum float64 for i := range a { sum += a[i] } return sum }
ベンチマークのコードは次の通り。
package c2goasm_sample_test import ( "math/rand" "testing" ) func init() { rand.Seed(0) } func initializeFloat64Array(n int) []float64 { var max float64 = 1024 var min float64 = 0 x := make([]float64, n) for i := 0; i < n; i++ { x[i] = rand.Float64() * (max - min) + min } return x } func benchmarkFloat64Sum(b *testing.B, n int) { x := initializeFloat64Array(n) b.ResetTimer() for i := 0; i < b.N; i++ { SumFloat64(x) } } func benchmarkFloat64SumAvx2(b *testing.B, n int) { x := initializeFloat64Array(n) b.ResetTimer() for i := 0; i < b.N; i++ { SumFloat64Avx2(x) } } func BenchmarkSumFloat64_256(b *testing.B) { benchmarkFloat64Sum(b, 256) } func BenchmarkSumFloat64_1024(b *testing.B) { benchmarkFloat64Sum(b, 1024) } func BenchmarkSumFloat64_8192(b *testing.B) { benchmarkFloat64Sum(b, 8192) } func BenchmarkSumFloat64_AVX2_256(b *testing.B) { benchmarkFloat64SumAvx2(b, 256) } func BenchmarkSumFloat64_AVX2_1024(b *testing.B) { benchmarkFloat64SumAvx2(b, 1024) } func BenchmarkSumFloat64_AVX2_8192(b *testing.B) { benchmarkFloat64SumAvx2(b, 8192) }
実行結果
$ go test -bench . goos: darwin goarch: amd64 pkg: github.com/c-bata/sandbox-go/c2goasm BenchmarkSumFloat64_256-4 5000000 277 ns/op BenchmarkSumFloat64_1024-4 1000000 1205 ns/op BenchmarkSumFloat64_8192-4 100000 10401 ns/op BenchmarkSumFloat64_AVX2_256-4 2000000 768 ns/op BenchmarkSumFloat64_AVX2_1024-4 500000 2872 ns/op BenchmarkSumFloat64_AVX2_8192-4 100000 23946 ns/op PASS ok github.com/c-bata/sandbox-go/c2goasm 10.474s
遅くなっちゃった...
追加調査
Apache Arrowでの利用事例がある以上、なにかコンパイラーオプションとかが理由で性能が十分に引き上げられていない。気になってる点は次のあたり
- xmm0とか使ってるし、vxorpsとかvmovsdとかもAVX2じゃなくてSSEとかの命令を使っていそう
- LLVM Auto-Vectorizationのドキュメント読みつつ、Clangのコンパイルオプション見直したほうが良い。
- https://github.com/apache/arrow/blob/master/go/arrow/math/float64_avx2_amd64.s とかはちゃんとAVX2使ってることが確認できる
- LLVMの最適化に頼らずintrinsicsを使って実装した処理で性能がちゃんとあがるのかも試してみたけど、実行時にセグフォで死んだ
- https://github.com/c-bata/sample-c2goasm/blob/master/_lib/add_float32_avx2_intrinsics.s を見る限りアセンブリ生成まではおそらく問題なくて、Go側の呼び出し時のポインタの扱いに問題がある。
- Apache Arrowのパフォーマンスがちゃんとあがってるのかベンチマークとってみる。
追記: うまくいきました。
ひとまずClang 7.0.1+O2オプション+pragmaヒントでうまくいった。Clangのバージョンとか最適化オプションによって結構動かなくなってしまうみたい。細かい調査はまた今度。
— Masashi Shibata (@c_bata_) 2019年3月17日
試しにClangでアセンブリだしてみてだめなら自分でAVX2のイントリンシック使って書くとかになりそうhttps://t.co/zp22jGNEAo
Clang-7.0.1を利用する際には、次のコンパイルオプションを使用してください。
$ /usr/local/Cellar/llvm/7.0.1/bin/clang -S -O2 -mavx2 -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -c sum_float64.c
追記: SSEのコードはなぜ遅くなっていたのか。
SSEでも遅くはならないんじゃないかなというのが気になっていたけれど、なんとなく原因がイメージついた気がします。 InfluxDataの記事にあるとおりsumの計算を最適化してCで直接書くとAVX2のコードはこんな感じになります。
void sum_float64_avx_intrinsics(double buf[], size_t len, double *res) { __m256d acc = _mm256_set1_pd(0); for (int i = 0; i < len; i += 4) { __m256d v = _mm256_load_pd(&buf[i]); acc = _mm256_add_pd(acc, v); } acc = _mm256_hadd_pd(acc, acc); // a[0] = a[0] + a[1], a[2] = a[2] + a[3] *res = _mm256_cvtsd_f64(acc) + _mm_cvtsd_f64(_mm256_extractf128_pd(acc, 1)); }
重要なのはこの _mm256_hadd_pd
の処理で こちらの説明にあるように詰め込まれている要素の加算 を担当します。128 bitsしかないSSE用のレジスタ xmm0 とかだと8 bytes 必要な double の値を詰め込んでも2つしかのらないので、それにたいして hadd_pd みたいな処理(これはAVX2の処理ですがそれに相当する処理がSSEにもあるのかと思います) をすると全く並列化されてないことになります。
細かいところはアセンブリが使ってる命令をみればはっきりしそうです。
-
GoのポインタをCに渡せないようにポインタのバリデーション処理が走るなど、GC側の都合による性能劣化が発生するようです。詳細は Why cgo is slow @ CapitalGo 2018 - Speaker Deck をご覧ください。↩
Github Actionsでbranch作成/削除にフックしてFeature環境を構築する
最近Github Actionsを触る機会があったのですが、まだ自分のgithub accountはbetaのwait list待ちで業務で使ってるrepostioryでしか使えないので、使い方とかポイントを忘れないようにメモ。ついでにいくつか公式のactionにPR送ったり、KubernetesのIngress Rulesを編集するためのActionを公開していたりもするので、こちらも紹介します。
- Add deleted_branch and deleted_tag filters by c-bata · Pull Request #42 · actions/bin · GitHub
- Add github action for kubectl by c-bata · Pull Request #9 · actions/gcloud · GitHub
- GitHub - abema/github-actions-ingress-rules-editor: Edit ingress rules to build feature environments automatically on Github Actions.
- GitHub - c-bata/go-actions: go-actions provides the utilities for Github Actions.
Feature環境の自動作成
業務では開発中の機能を手軽にDev環境で確認するために、特定の命名規則に従ったブランチ名でGithubにpushすると、自動でdev環境のKubernetesクラスターにリリースしIngressでエンドポイントを用意して閲覧できるようにしています。これ自体はそんなに珍しくなくて検索するといくつか同じような記事が見つかります。
- ECSを使ってPR毎に確認環境を構築する社内ツールをOSSで開発してます! - Speee DEVELOPER BLOG
- GitHubへのpush時に、featureブランチ環境を自動作成する - LCL Engineers' Blog
これまでもSpeeeさんの事例のようにwebhook eventを監視してoperationを行うサーバーを用意して解決することはできました。弊社ではCircle CIなどでfeature環境を作成したりもしています。 ただSpeeeさんの事例では自分たちでサーバーを用意して運用しないといけません。また弊社がこれまでやっていたようにCIサービスでfeature環境を作成する場合にはbranchの削除にトリガーできません。社内の別のチームではbotを立ててbranchの削除を監視してたりもしたみたいですが、これだけのためにbotたてるのも少し手間になります。
GithubのあらゆるイベントにトリガーできるGithub Actionsを使えば、branchがpushされたときにfeature環境を作成し、branchが削除されたときにfeature環境を削除するといったオペレーションを、自分たちでサーバーを管理することなく実現できます。業務ではKubernetesを使っているので、全体像としては次のような感じになります。
feature-abc
のようにfeature-*
の命名規則に従ってbranchを作成しGithubにPush- Kubernetes Deploymentをbranch用に作成
- Kubernetes Serviceをfeatureブランチ用に新規で作成 1
- Ingress (ingress-gce) でエンドポイント作成 2
- Google Cloud DNSのRecordsetsの作成
https://feature-abc-webapp.foo.com
でアクセスして動作確認
Github Actions
基本的な使い方は公式ドキュメントをみてください。
https://developer.github.com/actions/
いくつか悩んだり調べた中でメモしておきたいポイントを中心に残します。
credential情報の管理
外部に漏れては困る情報は Secret によりGithub RepositoryのSettingsで指定できます (参照 https://developer.t.com/actions/creating-workflows/storing-secrets/)。 Actionsを追加する際にも「Secret」というフィールドがありますが、そこから指定してもやってることは同じです。
実は1月頃に一度Github Actionsの利用を検討したことがあったのですが、当時はまだLimited Public Beta期間中でProduction Secretsを保存してはいけませんでした。 今回はLimited Public Betaがとれたため、改めてGithub Actionsを調査することにしました。
ブランチ名のフィルター
pushイベントに対してすべてトリガーしてほしいわけではなく、特定の命名規則に従ったbranchでのみ実行してほしいものです。GITHUB_REFS
という環境変数の中に refs/head/feature-A
のような形式でブランチ名やタグ名が入っています。 refs/head/
のprefixを削除して利用すればOKです。公式で用意されている↓のactionがこの操作をしてくれているのでこちらを利用しましょう。
bin/filter at master · actions/bin · GitHub
ただbranch削除時のfilterにはこの方法が使えません。 delete
triggerは GITHUB_REF
にdefault branchつまりmasterを指定が指定されています。環境変数からbranch名を取り出すことはできません。そのかわり GITHUB_EVENT_PATH
環境変数が示す場所にWebhookのevent情報がそのままjson形式で入っています。
delete
でtriggerしたときは DeleteEvent の形式なので、 ref
フィールドよりブランチ名が取り出せます。公式で用意してほしい機能なので↓にPRをだしました。
まだマージされていないので c-bata/bin/filter@master
を指定して使っています。 deleted_branch feature-*
のようにargsを指定すれば使えます。
マージされたので公式の actions/bin/filter@master
を使用してください。そちらには deleted_tag
フィルターも追加しています。
GCPのService Accountからgcloudの認証を行う
公式で用意されている↓のactionを用いることで実現できます。Service AccountはSecret GCLOUD_AUTH
にbase64 encodeしたservice accountのjsonファイルを与えればOKです (ex: base64 ./service-account.json
)。
gcloud/auth at master · actions/gcloud · GitHub
少し驚いたのですがgcloudコマンドの実行は別のactionとして定義し、↓を利用して実行します。
gcloud/cli at master · actions/gcloud · GitHub
gcloudコマンドのcredential情報は、Homeディレクトリ以下に作成されます。Github Actionsは裏側で /github/workspace
を常にマウントしそこをHomeディレクトリに設定しているようです。このディレクトリは次のactionでもそのままの状態で引き継がれます。gcloudの認証とgcloudコマンドの実行は別のactionでやるのがGithub Actionらしいやり方なようです。
kubectlの実行
gcloud authができるようになったので、kubernetes clusterのcredentials情報を取得してkubectlを実行します。既存でよさそうなものがなかったのですが、https://github.com/actions/gcloud で管理されるのがみんな幸せかと思うので PRを出しました。
Add github action for kubectl by c-bata · Pull Request #9 · actions/gcloud · GitHub
gcloudコマンドにならって、PROJECT_IDやZONE、K8S clusterをセットするactionとkubectlの実行用actionを分割しました。まだマージはされていないので c-bata/gcloud/kubectl-config@master
および c-bata/gcloud/kubectl@master
を指定して使っています。
ingress rules書き換えツールの実行
deploymentsやserviceをfeature環境ごとに個別に作っていたようにingressもfeature環境ごとにつくることもできるのですが、大きいチームだったので大量にFeature環境が立ち上がりLoadbalancerの作成上限に引っかかったことがありました。そのため全てのfeature環境で1つのingressを使いまわし、Spec.Rulesに振り分け設定を追加して webapp.foo.com
や feature-a-webapp.foo.com
を振り分けています。管理の都合上もその方がいいかなと思います。IngressのSpec.Rulesの編集にはもともとnodeで書かれたscriptが社内で使われていたのですが、kubectlのwrapperになっていてclient-goが使えるGoで書いたほうが色々楽だったので今回書き直しました。↓で公開しています。
c-bata/go-actions
github actionsの調査もかねてutilityライブラリ作りました。 正直使うほどでもないシーンが多いと思いますが、よければ使ってみてください。
GitHub - c-bata/go-actions: go-actions provides the utilities for Github Actions.
面倒だったこと
branchを削除したときにはmasterブランチのmain.workflowが参照され、実行されます。そのためbranchの削除にtriggerして何らかの処理を行いたいとき、一度そのbranchをmasterにマージして削除しないと動作確認ができません。
Add deleted_branch and deleted_tag filters by c-bata · Pull Request #42 · actions/bin · GitHub みたいな機能はとりあえず書いてmasterにマージしてbranchを削除して、問題があればまたbranchを作ってmasterにマージしてbranchを削除しないと確認できずmasterのcommit logが結構汚れます。仕事のrepositoryでそれをやることになったので申し訳ないなと思いながら開発してました。
おわりに
はやく自分のrepositoryでも使ってみたい
-
執筆時点では ingress-gce がClusterIPへのヒモ付に対応してないのでServiceTypeはNodePortを使用しています。NodePortの番号は特に指定していないのでKubernetes側にrandomに割り振ってもらっています。↩
-
次の手順でGoogle Cloud DNS Recordsetsを作りますが、もしそちらをterraformで管理して消し忘れとかをなくしたいのであれば、Google compute address(静的IPアドレス)の払い出しもTerraformで行って、Ingress側の
metadata.annotations.kubernetes.io/ingress.global-static-ip-name
で指定して使うのが管理の都合上いいかと思います。↩
RAW Socket / BPF(Berkeley Packet Filter)を用いたパケットキャプチャーツールの実装
パケットキャプチャーツールは、ネットワークを流れるすべてのパケットを受け取り解析します。 NIC(Network Interface Card)のほとんどはプロミスキャスモードとよばれるモードをサポートしており、これを有効にすることでアドレスにかかわらずNICはすべてのパケットをホストに渡します。 ソフトウェアとハードウェアが連携して動作するため、扱っているレイヤーが低く環境によってInterfaceに差異があります。
tcpdumpの開発者によってつくられたlibpcapというライブラリはUNIXのシステムの差異を吸収します。またWindowsにもWinPcapという名前で移植されています。 もしパケットキャプチャーを作る際にはlibpcapを利用することが一般的かと思いますが、今回は勉強も兼ねて Linux と macOS で動作するパケットキャプチャーをlibpcapを使わずに1からC言語で実装してみました。
※ BPF VM(Berkeley Packet Filter Virtual Machine)によるFilteringの仕組みには今回は触れません。
目次
- xpcap のソースコード(Github)
- RAW SOCKETを用いたキャプチャー (Linux)
- BPF(Berkeley Packet Filter)によるキャプチャー (macOS, BSD系)
- 実行方法
- 参考ソースコード
xpcap のソースコード(Github)
最近作りたいなと思っているパケットキャプチャー関連のソフトわがありそちらはGo言語で実装しているのですが、せっかくなら移植性を考えてPure Goで実装したいと思っています。こういったレイヤーのプログラムをいきなりCのAPIがベースにあってそれをGoで書くとドキュメントを追うのも大変なので、C言語でまずは書いてみたものがこちらです。
プロトコルは今のところARPやIPv4、IPv6、TCP、UDP、ICMPに対応していて、Ethernet Frameからパースした結果を標準出力に書き出します。
RAW SOCKETを用いたキャプチャー (Linux)
LinuxでMACアドレスやEthernet Frameのヘッダー情報までプログラムで扱うには、RAW Socketが必要です。
ソケットディスクリプタを取得する際には、アドレスファミリーとして AF_PACKET
、ソケットタイプとして SOCK_RAW
そして第3引数のprotocolには htons(EATH_P_ALL)
を指定します。全部を説明すると長くなるので手順と呼び出さないといけない関数を次に示します。
xpcapのソースコードと合わせてご覧ください。
socket()
ディスクリプタの取得int soc = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)))
en0
などのインターフェイス名を指定してインターフェイスの情報を取得ioctl(soc, SIOCGIFINDEX, &if_req)
- ソケットディスクリプターをインターフェイスにバインド
bind(soc, (struct sockaddr *) &sa, sizeof(sa))
- インターフェイスのフラグを取得
ioctl(soc, SIOCGIFFLAGS, &if_req)
- プロミスキャスモードを有効にし、インターフェイスをUP(動作中)にする
ioctl(soc, SIOCSIFFLAGS, &if_req)
これで準備が完了です。あとは select
や epoll
でソケットディスクリプターへの書き込みを監視しready担った状態で recv(2)
で読み出せばOKです。
struct timeval timeout; fd_set mask; int width, len, ready; while (g_gotsig == 0) { FD_ZERO(&mask); FD_SET(soc, &mask); width = doc + 1; timeout.tv_sec = 8; timeout.tv_usec = 0; ready = select(width, &mask, NULL, NULL, &timeout); if (ready == -1) { perror("select"); break; } else if (ready == 0) { fprintf(stderr, "select timeout"); break; } if (FD_ISSET(sniffer->fd, &mask)){ if ((len = recv(soc, buffer, >buf_len, 0)) == -1){ perror("recv:"); return -1; } } }
自分は Linuxネットワークプログラミングバイブル で勉強しましたが、この書籍以外にもLinuxで動くRAW SOCKETを使ったシンプルなパケットキャプチャーの作り方を解説している資料は多くあります。一方でBSD系のOSではアドレスファミリーとして AF_PACKET
を指定できません。BSD系のOSでEthernet frameを読み出す方法を確認しましょう。
BPF(Berkeley Packet Filter)によるキャプチャー (macOS, BSD系)
これらはBPF(Berkeley Packet Filter)という仕組みを使う必要があります。 BPFにはBPF Virtual Machineという仕組みを使ってパケットをKernel側でフィルタリングすることで必要ないものまでユーザー空間に移さずオーバーヘッドを減らす仕組みのようです。読み出しには BPFデバイスというのを用います。ひとまずすべてキャプチャーするならBPF VMについては気にする必要はありません。
BPFデバイスは、 /dev/bpf*
に存在します。これらを順にopenしながら、使用可能なBPFデバイスを探さなくてはいけません。
$ ls /dev/bpf? /dev/bpf0 /dev/bpf1 /dev/bpf2 /dev/bpf3 /dev/bpf4 /dev/bpf5 /dev/bpf6 /dev/bpf7 /dev/bpf8 /dev/bpf9
手元では bpf255 ぐらいまで存在しますが、google/gopacketなどの実装では99までチェックしているようです。 NICの数以上に必要になるケースはほとんどなさそうなので99個は十分に余裕を持った値なんだと思います。
gopacket/bsd_bpf_sniffer.go at a35e09f9f224786863ce609de910bc82fc4d4faf · google/gopacket · GitHub
BPFデバイスが決まったらOpenします。その後次のような手順が準備に必要になります。
- bpfデバイスのopen
fd = open(params.device, O_RDWR)
- バッファ長の設定 or 取得。
BIOCSBLEN
の変更は、BPFデバイスにNICをアサインするBIOCSETIF
より先に呼び出される必要があるので注意してください。これになかなか気づかず結構はまってしまいました。ioctl(fd, BIOCSBLEN, ¶ms.buf_len)
: 設定ioctl(fd, BIOCGBLEN, ¶ms.buf_len)
: 取得
- BPFデバイスとネットワークインターフェイスをバインド
ioctl(fd, BIOCSETIF, &if_req)
- プロミスキャスモードの有効化
ioctl(fd, BIOCPROMISC, NULL)
こちらはデバイスファイルなので recv(2)
ではなく read(2)
で読み出します。
読み出すとイーサネットのフレームではなくBPFパケットというものにくるまれています。
ヘッダーをパースするとデータ長が乗っているため、それをもとに次のBPFパケットの位置を求めてパースを繰り返していきます。
typedef struct { int fd; char device[11]; unsigned int buf_len; char *buffer; unsigned int last_read_len; unsigned int read_bytes_consumed; } Sniffer; int parse_bpf_packets(Sniffer *sniffer, CapturedInfo *info) { if (sniffer->read_bytes_consumed + sizeof(sniffer->buffer) >= sniffer->last_read_len) { return 0; } info->bpf_hdr = (struct bpf_hdr*)((long)sniffer->buffer + (long)sniffer->read_bytes_consumed); info->data = sniffer->buffer + (long)sniffer->read_bytes_consumed + info->bpf_hdr->bh_hdrlen; sniffer->read_bytes_consumed += BPF_WORDALIGN(info->bpf_hdr->bh_hdrlen + info->bpf_hdr->bh_caplen); return info->bpf_hdr->bh_datalen; }
あとはごりごりパースしていくのですが、そこはプラットフォームに変わらず同じです。 ゴリゴリ実装していくだけで解説してもしかたないのでマスタリングTCP/IPなどを頼りにソースコードを読んでみてください。
実行方法
build.sh
でビルドできます。Vagrantfileも用意しているのでLinuxで試したいmacOSユーザーの方はご利用ください。実行結果は次のような感じです。
$ ./build.sh $ ./xpcap en0 -v device = en0, verbose = 1, port = 0 ================================================================================ [TCP6] ether_header-------------------------------------------------------------------- ether_dhost = XX:XX:XX:XX:XX:XX ether_shost = XX:XX:XX:XX:XX:XX ether_type = 86DD(IPv6) ip6----------------------------------------------------------------------------- ip6_vfc = 96 ip6_flow = 2363892320 ip6_plen = 15104 (TCP), ip6_hlim = 56 ip6_src = xxxx:xxxx:xxxx:x::xxxx:xxxx ip6_dst = yyyy:yy:yyyy:yyyy:yyyy:yyyy:yyyy:yyyy tcphdr-------------------------------------------------------------------------- source: 47873 destination: 59083 sequence number: 1148644729 ack number = 2897299570 data offset = 5, control flag = 24, window = 49152, checksum = 54057, urgent pointer = 0 data---------------------------------------------------------------------------- 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ..something..data.. ================================================================================ ================================================================================ [ARP] ether_header-------------------------------------------------------------------- ether_dhost = XX:XX:XX:XX:XX:XX ether_shost = XX:XX:XX:XX:XX:XX ether_type = 806(Address resolution) ether_arp----------------------------------------------------------------------- arp_hrd = 1(Ethernet 10/100Mbps.), arp_pro = 2048(IP) arp_hln = 6, arp_pln = 4, arp_op = 1(ARP request.) arp_sha = 34:76:C5:77:5D:4C arp_spa = 192.168.0.1 arp_tha = 00:00:00:00:00:00 arp_tpa = 192.168.0.8 ================================================================================ ================================================================================ [UDP] ether_header-------------------------------------------------------------------- ether_dhost = XX:XX:XX:XX:XX:XX ether_shost = XX:XX:XX:XX:XX:XX ether_type = 800(IP) ip------------------------------------------------------------------------------ ip_v = 4, ip_hl = 5, ip_tos = 0, ip_len = 149 ip_id = 29282, ip_off = 0, 0 ip_ttl = 255, ip_p = 17(UDP), ip_sum = 42831 ip_src = yyy.yyy.yyy.yyy ip_dst = xxx.xxx.xxx.xxx udphdr-------------------------------------------------------------------------- source = 5353, dest = 5353 len = 129, check = 38825 data---------------------------------------------------------------------------- 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ..something..data.. ================================================================================
参考ソースコード
困ったときは次のコードが参考になりました。
- GitHub - bpk-t/packet_capture
- gopacket/bsd_bpf_sniffer.go at master · google/gopacket · GitHub
- net/bpf.h Source
またLinuxネットワークプログラミングバイブルはかなりおすすめの書籍です。
- 作者: 小俣光之,種田元樹
- 出版社/メーカー: 秀和システム
- 発売日: 2014/10/07
- メディア: Kindle版
- この商品を含むブログ (1件) を見る
実践 パケット解析 第3版 ―Wiresharkを使ったトラブルシューティング
- 作者: Chris Sanders,高橋基信,宮本久仁男,岡真由美
- 出版社/メーカー: オライリージャパン
- 発売日: 2018/06/16
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (1件) を見る