皆さん、シングルバイナリ サイコーですかー!(挨拶)
この記事は Go4 Advent Calendar 2019 の3日目の記事です。
今日は「ぼくのかんがえたさいきょうのシングルバイナリ生成ツール」を紹介したいと思います。
ことの発端
毎回言ってるのでもうご存知かもしれませんが、自分はシングルバイナリ大好きな人です。そして、コマンドをパイプでつなげるのが大好きな人でもあります。
そんな私が、あいも変わらずアクセスログの分析をしていました。
全体の処理はこの↓シェルスクリプトのようなかんじです:
#!/usr/bin/env bash
xz -cd "$@" \
| jsonize \
| jq -c 'select(.method == "GET")
| select(.user_agent != "-")' \
| paw-ua \
| insert-db
圧縮されたログを受け取って、解凍して、JSON形式にして、不要な行をjq
でフィルタして、UserAgentをごにょごにょ1して、DBにインサートするというだけです2。
上の jsonize
、paw-ua
、insert-db
は、もちろんGoで実装しています。
で、実装したコマンドたちを、本番環境にコピーして使うわけです。こんなふうに:
$ scp pipeline.bash jsonize paw-ua insert-db prod-server:~/bin
$ ssh prod-server
prod-server> pipeline.bash /var/log/archive/*.xz
コピーするだけで動くシングルバイナリまじサイコーですね!
さてここで、ログデータに位置情報を付加してほしいという要望がありました。
というわけで、GeoIp2を使ってサクッと ip2geo
コマンドを実装しました。
これをpipelineに追加しまして:
#!/usr/bin/env bash
xz -cd "$@" \
| jsonize \
| jq -c 'select(.method == "GET")
| select(.user_agent != "-")' \
| paw-ua \
| ip2geo -db ./GeoLite2-City.mmdb \
| insert-db
また本番環境にコピーして実行しましたらば・・・
$ scp pipeline.bash jsonize paw-ua insert-db prod-server:~
$ ssh prod-server
prod-server> pipeline.bash /var/log/archive/*.xz
pipeline.bash: ip2geo: command not found
あっ、ハイ、すみません・・・新しいコマンドをコピーするの忘れてました。。。
やっぱり、コマンド履歴にばかり頼ってちゃいけませんね・・・気を取り直しまして、
$ scp pipeline.bash jsonize paw-ua ip2geo insert-db prod-server:~/bin
今度はちゃんとコピーしましてから、実行してみますに~
$ ssh prod-server
prod-server> pipeline.bash /var/log/archive/*.xz
ip2geo: open GeoLite2-City.mmdb: no such file or directory
あゔぁゔぁゔぁゔぁゔぁゔぁ・・・
デスヨネー!GetLite2データベース3をコピるの忘れてましたよねー!!すいませんでしたよねー!!!
事ここに至るに当たり、気がついてしまったのです。おかしい、こんなはずじゃない、と。
僕らが夢見ていたシングルバイナリの世界はこんなはずじゃなかった!僕らが夢見ていたのは、バイナリ1つをコピーするだけで動くお手軽便利な世界ではなかったのか!?そうだ、今こそ取り戻そう!僕らの理想の世界を!!
というわけでWhole-In-One、略してWIO(わいおー)というのを作りました。
Whole-In-One なバイナリを作ってみよう
では、WIOの使い方の紹介です。まずはインストールから↓
Installation
ちょっと面倒なのですが、 go get
ではなく、次のようにビルドスクリプトを使ってビルドしてください:
$ git clone github.com/Maki-Daisuke/go-whole-in-one
$ cd go-whole-in-one/cmd/wio
$ ./make.sh install
なお、Windows用にPowerShell版もあります4:
> git clone github.com/Maki-Daisuke/go-whole-in-one
> cd go-whole-in-one\cmd\wio
> .\make.ps1 install
これで wio
コマンドが使えるようになりました。
Init
では、さっそく上の例をシングルバイナリにしていきましょう。
今回作成するシングルバイナリなコマンド名は mylog
とします。安直ですねー
なにはともあれ、プロジェクト用のディレクトリを作りましょう:
$ mkdir mylog
$ cd mylog
そして、このディレクトリで wio init
を実行します:
$ wio init
$ ls
main.go pack.go packing-list
すると、このように3つのファイルが出来上がりました。とりあえず、ファイルはこのままにしておきます。
Generate
次に、上の例にあった pipeline.bash
、jsonize
、paw-ua
、ip2geo
、insert-db
を、それぞれ次のように頭に mylog-
がつくようにリネームして、PATHの通っているところに置いておきます:
mv pipeline.bash mylog-pipeline
mv jsonize mylog-jsonize
mv paw-ua mylog-paw-ua
mv ip2geo mylog-ip2geo
mv insert-db mylog-insert-db
ファイル名の先頭に mylog-
とつけておくことで、これから作るバイナリに中に含まれるようになります5。
リネームしたのに合わせて、pipelineの中身もファイル名を書き換えました:
#!/usr/bin/env bash
xz -cd "$@" \
| mylog-jsonize \
| jq -c 'select(.method == "GET")
| select(.user_agent != "-")' \
| mylog-paw-ua \
| mylog-ip2geo -db $WIOPATH/GeoLite2-City.mmdb \
| mylog-insert-db
おっと、GeoLite2データベースもバイナリに含めるのをわすれないようにしましょう。packing-list
ファイルを開いて、末尾にファイル名を追記しておきます:
# This file was generated by wio-init.
mylog-*
./GeoLite2-City.mmdb # ←この行を追加
ここまでできたら、ビルドしましょう:
$ wio build
これで、mylog
コマンドができました。
Deploy
できたコマンドはこんなふうに使えます:
$ scp mylog prod-server:~/bin
$ ssh prod-server
prod-server> mylog pipeline /var/log/archive/*.xz
mylog
と pipeline
の間に-
(ハイフン)がない点にご注意ください。mylog-pipeline
が mylog
のサブコマンドのようにして呼び出されていますね。これは、mylog-jsonize
など他のコマンドについても同様です。
また、GeoLite2データベースも含まれているので、なんらかの外部ファイルに依存しているようなコマンドもシングルバイナリの中に押し込むことができます。なので、バイナリ1個コピーするだけでお手軽便利にコマンドが実行できる世界がやってきました!
やっぱり、シングルバイナリ サイコーですね!
しかしさらなる問題が…
もしかしたら、上のスクリプトを見た人の中には気づいてしまった人もいるかもしれませんね。そう、mylog-pipeline
スクリプトは jq
がインストールされている環境じゃないと動かないということを!!6
これでは真のシングルバイナリとは言えないのではないか!!!😨!!!
しかし案ずるでない、迷える子羊よ。そなたは如何なるファイルでもバイナリに埋め込むことができる!ほれ、このように packing-list
に追加するのじゃ:
# This file was generated by wio-init.
mylog-*
./GeoLite2-City.mmdb
jq # ←この行を追加
そしておもむろにビルドしてコピーすれば、あら不思議・・・
$ wio build
$ scp mylog prod-server:~/bin
$ ssh prod-server
prod-server> mylog pipeline /var/log/archive/*.xz
jq: error while loading shared libraries: libonig.so.2: cannot open shared object file: No such file or directory
エラーで怒られるのである・・・orz
デスヨネー。jq
さんには 鬼車 が必要ですもんねー!
気を取り直して
はいはい、鬼車がいるんなら、libonig.soを埋め込んであげればいいんでしょ7:
# This file was generated by wio-init.
mylog-*
./GeoLite2-City.mmdb
jq
/usr/lib/x86_64-linux-gnu/libonig.so.2 # ←さらに追加
というわけで、再チャレンジ:
$ wio build
$ scp mylog prod-server:~/bin
$ ssh prod-server
prod-server> mylog pipeline /var/log/archive/*.xz
今度は動きました!8
仕組み
ちょっとだけWIOの仕組みについて書いておきましょう。
と言っても、とても単純で、packing-list
で指定されたファイルを tar形式にかためて、バイナリに埋め込んでいるだけです。
mylog
コマンドが実行されたときに、tarファイルをtempディレクトリに展開して、そこにPATHを通すなどの下準備をしてからサブコマンドに execしているというわけです。ちなみに、tarの展開はコマンドの初回起動時のみで、2回目以降はtempディレクトリのキャッシュが使われます9。
wio init
は、デフォルトで コマンド名-サブコマンド名
という名前のコマンドをPATHから探索して実行する main関数を出力しますので、上記のように mylog-サブコマンド
という命名規則にしておくと、埋め込んだコマンドがサブコマンドのように呼び出せるようになるのです10
で、実際どうやってバイナリに埋め込んでんの?
それは、ソースコードのこのへんを見てもらえればわかると思うのですが、pack.go
のひな形の中に %q
というフォーマット文字列が見えるかと思います:
func WritePackGo(data []byte, codec string) error {
...(中略)...
_, err = fmt.Fprintf(out, `// DO NOT EDIT! This file was generated by wio-init.
package main
import(
...(中略)...
)
var(
data = %q
hash = "%X"
)
...(中略)...
`, data, md5.Sum(data), codec)
return err
}
で、ここにtarで固めたデータをドカドカっと流し込んで、Goコンパイラに文字列としてコンパイルしてもらっているだけです。
そうなんですねー。こんな乱暴な方法でも、ちゃんとGoコンパイラはコンパイルしてくれるんですねー。すごいですねー!(小並感)11
Let's enjoy シングルバイナリ
というわけで、皆さんもお手軽便利なシングルバイナリ・ライフを楽しんでください。
詳しい使い方は WIOのドキュメントを参照してくださいね。
それでは、シングルバイナリまじサイコー!(別れの挨拶)
-
ギョーム内容につき、お察しください。 ↩
-
ちなみに自分は、とりあえずログをJSONに変換してしまって、LDJSONをパイプラインでひとつひとつ変換処理かけていくのが好みです。こうするとパイプラインの各コマンドが並列で走るので、自然とマルチコアの恩恵を受けられますしね。 ↩
-
MaxMaind社の提供するIPアドレスと地点情報の紐付けデータベースです(https://dev.maxmind.com/geoip/geoip2/geolite2/ )。 GeoIp2 で使います。 ↩
-
ちなみに今のところ、Linux(Ubuntu 16.04)、macOS (Mojave)、Windows 10 (1909) で動作確認してあります。たぶん、BSDとかの他のUNIX系OSでも動くとは思いますが、確認環境がないためビルドタグはつけていません。どなたか興味がある人は、誰か試してみてほしいです。 ↩
-
あとで紹介しますが、ファイル名を変えなくてもバイナリに含む方法もあります。 ↩
-
まぁ、それを言ったら
xz
もなんですが、イマドキはxz
は標準で入っていることが多いので、横においておきます。 ↩ -
ちなみに、作業環境は開発環境・本番環境ともにUbuntuです。(ただし、開発環境はUbuntu on WSL) ↩
-
今回はたまたま
jq
の依存ライブラリが少なくてうまくいきましたが、現実にはこんなふうにうまくいくケースは少ないと思います。依存ライブラリが大量にある場合もありますし、そのライブラリがさらに他のライブラリに依存していたりするので、ただしく必要なライブラリのリストを抽出するのはかなり大変です。従って、シングルバイナリで動くGo製のコマンドか、シェルスクリプトを埋め込むのが現実的な範囲です。複雑な依存関係をデプロイしたいなら、素直にDockerみたいなコンテナで配布するのがいいと思います。だだし、Dockerを使うためにはroot権限が必要なんですよね。root権限なしに、ホームディレクトリにコピるだけで動くのが、シングルバイナリの利点ですよね! ↩ -
お気づきの方もいるかもしれませんが、これは古式ゆかしい(?)PAR::Packaer と同じ仕組みです。 ↩
-
ご想像のとおり、サブコマンドをPATHから探索して実行する様式はGitサブコマンドをマネたものです。このあたりのmain関数の挙動は
main.go
を書き換えれば好きなように変更できます。詳しくは WIOのドキュメントを参照してください。 ↩ -
ちなみにこの手法は、今一番主流のバイナリ埋め込みツールと目されるstatikを参考にしています。なので、こんな乱暴な方法でも問題はないと思います。 ↩