Mackerelプラグインパッケージのファイルサイズを9割削減したBusyBoxテクノロジー

チーフエンジニアでMackerelのプロダクトマネージャーを務めている id:Songmu です。

このエントリは、はてなエンジニアアドベントカレンダーの12日目の記事です。

もう昨年の話になるのですが、Mackerelのプラグインパッケージの容量削減を実施しました。

上記エントリから抜粋すると、rpmやdebパッケージのファイルサイズについてmackerel-agent-pluginsが56.6MBのところを4.23MB、mackerel-check-pluginsが20.5MBのところを2.75MBと、実に1/10程度にまで削減に成功しました。このエントリでは、それの技術的解説をします。

タイトルにBusyBoxと書いてあるのでピンと来た方はいるかも知れません。テクノロジーと大げさに書きましたが、ローテクで枯れた手法です。

プラグインパッケージについて

mackerel-agent-pluginsやmackerel-check-pluginsはMackerelの公式監視エージェントであるmackerel-agentのためのプラグイン集です。これらについてrpmとdebパッケージをMackerelでは公式に提供しています。mackerel-agent-pluginsはメトリックプラグイン、mackerel-check-pluginsはチェックプラグインという位置づけです。ソースコードはそれぞれ、GitHub上で公開されています。

mackerel-check-pluginsのソースコードリポジトリ名がgo-check-pluginsになっているのは少し混乱するかもしれませんが、チェックプラグインは別にMackerelに限らず、例えば、NagiosやConsulでも利用できるものなので、このような命名にしています。

mackerel-agentのプラグインというのは実は単なるコマンドです。例えばmackerel-plugin-accesslogやmackerel-plugin-apache2といった実行ファイルです。そして、mackerel-agent-pluginsの場合50個以上、mackerel-check-pluginsの場合20個以上のコマンドがパッケージに同梱されています。

さて、mackerel-agentの公式プラグインはすべてGoで書かれています。そして、Goでプラグインを書いて普通にビルドするとバイナリのファイルサイズは数MBになります。先程も書いたようにmackerel-agentのプラグインは単なるコマンドなので、普通に考えると、プラグイン毎に一個一個ビルドをしてパッケージに同梱する必要があります。ですので、プラグインを1個追加する毎に数MBの容量増になります。

プラグインパッケージはもともとその様に素朴に新規プラグインの追加をおこなっていたのですが、プラグイン数が数十個を超えてくると、ファイルサイズは数十MBになり、パッケージを展開すると100MBを超えてしまうようになりました。流石にそのサイズは見過ごせません。

BusyBoxという解決案

そこで、BusyBoxという手法でそれを解決することにしました。BusyBoxはエポックメイキングなテクニックです。Wikipediaには以下のように書かれています。

BusyBox は、Coreutilsなど標準UNIXコマンドで重要な多数のプログラムを単一の実行ファイルに「詰め込んで」提供する、特殊な方式のプログラムである(その詰め込み方法を指して呼ぶこともある)。

今回BusyBoxと言っているのは、システムとしてのBusyBoxではなく、「詰め込み方式」としての方法を指しています。

私が自宅利用しているQNAPがBusyBoxシステムで動いていることや、Nagiosのcheck_tcpがBusyBoxになっていることからこの着想を得ました。

BusyBox方式を使うと、パッケージ内の実行ファイルのバイナリは一つだけになり、シンボリックリンクによって、処理が分岐する形になります。

BusyBoxの正体

では実際にどうなっているか見てみましょう。mackerel-agent-pluginsがインストールされている環境で以下のコマンドを実行してみましょう。

% ls -lF /usr/bin | grep mackerel-plugin
-rwxr-xr-x 1 root root    20727456 Nov 12 13:02 mackerel-plugin*
lrwxrwxrwx 1 root root          15 Nov 12 13:02 mackerel-plugin-accesslog@ -> mackerel-plugin*
lrwxrwxrwx 1 root root          15 Nov 12 13:02 mackerel-plugin-apache2@ -> mackerel-plugin*
lrwxrwxrwx 1 root root          15 Nov 12 13:02 mackerel-plugin-aws-cloudfront@ -> mackerel-plugin*
... (以下50プラグインが並ぶ)

mackerel-pluginと言う実行ファイルが一個だけあり、mackerel-plugin-accesslogなどはそのファイルに対するシンボリックリンクであるということがわかります。mackerel-pluginはシンボリックリンク経由で実行されることにより、実際に動かすプラグインの挙動を決め、実行を行います。

そのあたりの分岐処理はリポジトリ直下のmackerel-plugin.goに記述されています、処理を一部端折って、抽出したものが以下です。

func run(args []string) int {
    var plug string
    f, _ := exec.LookPath(args[0])
    fi, _ := os.Lstat(f)
    base := filepath.Base(f)
    // シンボリックリンク経由で実行された場合に処理を分岐している
    if fi.Mode()&os.ModeSymlink == os.ModeSymlink && strings.HasPrefix(base, "mackerel-plugin-") {
        // if mackerel-plugin is symbolic linked from mackerel-plugin-memcached, run the memcached plugin
        plug = strings.TrimPrefix(base, "mackerel-plugin-")
    ...

実は、別にこういうトリッキーな挙動をさせる必要はなく、単に、 mackerel-plugin accesslog のようなサブコマンドでも良いとは思いますが、もともと、Mackerelのユーザーには mackerel-plugin-accesslog という形式でプラグイン設定を記述していてもらっていたため、互換性のためにBusyBox方式を採用することにしました。

互換性維持のための移行戦略

BusyBox方式に移行するとしてもいろいろ考慮が必要な点がありました。例えば、後方互換やシンボリックリンクのないWindows環境のために、既存の挙動は維持したい、つまり、go buildで単体バイナリをビルド可能で、go getで単体コマンドがインストールできる状態は保ちたいと言う要求がありました。

具体的には、以下の go get で mackerel-plugin-uptime コマンドがインストールされる既存の挙動は維持したいというところです。

% go get github.com/mackerelio/mackerel-agent-plugins/mackerel-plugin-uptime

つまり、移行の戦略は以下のようになります。

  • 既存の各プラグインをコマンドとしてもビルド可能な状態は維持しつつ、ライブラリとしても利用可能にする
  • それらのライブラリをBusyBox本体であるmackerel-pluginコマンドの内部から利用する

もともとMackerelのプラグインは、当然ながら単なるコマンドとして実装されていたため、プラグイン名に対応するディレクトリが切られ、その配下に main パッケージが配置され、処理がべた書きされている状況でした。

それを以下のように変更をおこなうことにしました。

Before

mackerel-plugin-uptime/
└── uptime.go # mainパッケージに処理べた書き

After

mackerel-plugin-uptime/
├── lib/
│   └── uptime.go # mputimeパッケージとして切り出し
└── main.go # mainパッケージからmpuptimeパッケージの処理を呼び出すだけに

この対応を入れることで、mackerel-plugin-uptime/main.goは以下の中身だけになります。

package main

import "github.com/mackerelio/mackerel-agent-plugins/mackerel-plugin-uptime/lib"

func main() {
    mpuptime.Do()
}

mainパッケージのmain関数では単に、mpuptimeパッケージのDo関数を呼び出すだけとなり、処理の中身はごっそりそちらに移しました。これにより、コマンドとしてのビルドは維持され、処理を同じくして、パッケージとしても再利用可能となりました。

lib/ ディレクトリを掘ってパッケージを配置するのはGoっぽくは無く、気が進まない部分もあったのですが、互換性や、機械的な置き換え容易性のためにこのディレクトリ構成をとることにしました。

Perlによる機械的なパッケージ化処理

上記のパッケージ化の書き換えを手でちまちまやりたくなかったため、スクリプトで変換しました。

それにあたってpackagize.plというPerlスクリプト(!)を書き、一斉変換をおこないました。そう、私はPerlハッカーなのです。

その対応が以下のpull requestです。"166 files changed, 821 insertions(+), 362 deletions(-)" というボチボチ豪快なpull requestになっています。

github.com

これで、既存の各プラグインをパッケージとしても利用可能な状態になりました。

BusyBox本体であるmackerel-pluginコマンドの作成

さて、次は本丸である、mackerel-pluginコマンドを見ていきましょう。mackerel-pluginコマンドは、以下の2ファイルによって構成されています。

mackerel-plugin.goには、シンボリックリンク経由で実行されたときの分岐処理などが記述されているということは既に書きました。ここでは、もう一つのmackerel-plugin_gen.goを見ていきます。

_gen.go という命名になっていることから察せられる通り、このファイルは go generate で自動生成されたファイルです。中身を見ると以下のように同じようなコードが反復されており、いかにも自動生成されたファイルという風体です。実際、このソースファイルは、パッケージに同梱するプラグイン一覧を元に自動生成されています。

func runPlugin(plug string) error {
    switch plug {
    case "accesslog":
        mpaccesslog.Do()
    case "apache2":
        mpapache2.Do()
    case "aws-cloudfront":
        mpawscloudfront.Do()
    case "aws-dynamodb":
        mpawsdynamodb.Do()
...

go generate の定義は、mackerel-plugin.goの中に以下のように書かれています。

//go:generate sh -c "perl tool/gen_mackerel_plugin.pl > mackerel-plugin_gen.go"

またPerlか!と言う感じですね。仕方がありません、私はPerlハッカーなのですから。まあ、こういった自動生成用のスクリプトもGoのプロジェクトである以上Goで書くのが筋ではあると思うのですが、ササッと書く分にはPerlの方が当時の僕には早かったのでPerlでやりました。実際、50行程度のスクリプトで、それほど書き直すことも無いだろうし、その気になればGoで書き直すことも容易だという判断もありました。

go generate での自動生成用のスクリプトを比較的多くの環境に入っているスクリプト言語であるPerlやPythonなどでサクッと書いてしまうのはアリだと個人的には考えています。とはいえも僕自身もだいぶGoに慣れたため、Goでやればいいと思うことも増えました。

そうしてできたのが以下のpull requestです。

github.com

これで、一つの mackerel-plugin というバイナリの中に、数十個のプラグインをBusyBox方式で押し込めるようになったのです。

まとめ

これで冒頭に述べたように劇的なパッケージ容量サイズの削減に成功しました。ここでは、mackerel-agent-pluginsについて取り上げましたが、mackerel-check-pluginsについても同様の作業をおこない容量を削減しました。これにより、Mackerelのユーザーが新規サーバーを構築する時のプラグインのダウンロード時間が削減でき、すばやくサーバーを構築できるようにもなり、ユーザー体験の地味な向上につながっています。

はてなではこの様に知恵を絞ってサービスのインクリメンタルな開発、改善に取り組み、ユーザーに価値を届けることに喜びを見出すエンジニアを募集しています。アプリケーションエンジニア、SRE(Site Reliability Enginner)、CRE(Customer Reliability Engineer)全方位に大募集しています。ご応募お待ちしています!

hatenacorp.jp