HDE BLOG

コードもサーバも、雲の上

SMTPサーバに負荷を掛けたい話

クラウドプロダクト開発部のたなべです。 最近、日経コンピュータのDocker特集 で私のコメントが載りちょっとうれしかったです。

2014年の弊社アドベントカレンダーでも紹介 したように、現在GoでSMTPサーバを実装しています。最近STARTTLS拡張も実装を終えて、 そろそろ本格的に負荷を掛けてパフォーマンスやリソース消費具合を見てみたくなりました。

今回は

  • SMTPサーバのベンチマークツール smtp-source とSMTPテストサーバ smtp-sink について
  • 拙作のsmtp-sourceのGo版go-smtp-sourceについて

それぞれご紹介します。

smtp-sourceとsmtp-sink

smtp-{source,sink}はPostfixの配布物に含まれる小さなツールで、 smtp-source はSMTP/LMTPテストジェネレーター、 smtp-sink はSMTP/LMTPテストサーバです。

Postfixのtarball内の src/smtpstone に実装があります。 大量のコネクションを効率よく処理できるように、 非同期I/Oを使い、イベントループを回す実装になっています。

TLS上でわざわざ負荷を掛けたがる人がいないからかもしれませんが、 smtp-sourceはSTARTTLS拡張に対応していません。

我々のSMTPサーバはpublicなサーバではなく、特定組織からのみリレーを受けつける関係上、 必ずTLS経由で受信しなければなりません。

アプリケーション固有の性能検証はTLS抜きでやりますが、最終的な性能測定はTLS経由で実施したいところです。

さっそく、意気揚々と smtp-source.c を見る→うん、Goで再実装しよう……

smtp-sinkは今回、smtp-sourceとgo-smtp-sourceの性能検証のために使用しました。 smtp-sinkはSMTP版/dev/nullなのでSMTPベンチマーカーためのSMTPサーバとしても最適です。

go-smtp-source

https://github.com/nabeken/go-smtp-source

さて、拙作のgo-smtp-sourceについて少し紹介します。

smtp-source 自体はシンプルなツールですが、 当面は自分の欲しい機能さえあればいいので、以下のコマンドラインと同等の処理がTLS経由でできることをまず目指しました。

# 送信者 from@example.com 受信者 to@example.com のメールを
# 127.0.0.1:10025 に対して同時100セッションで1万通送信
smtp-source -s 100 -m 10000 -f from@example.com -t to@example.com 127.0.0.1:10025

これをgo-smtp-sourceを使い、TLS経由で配送する場合:

# -tls を付けただけ
go-smtp-source -tls -s 100 -m 10000 -f from@example.com -t to@example.com 127.0.0.1:10025

go-smtp-source実装上のポイント

SMTPクライアント機能はnet/smtpに一通り揃っていたためそのまま使いました。

最初に書いた時はまず同時接続数分だけワーカーgoroutineを起動させ、その中でSMTPサーバへの接続からメール送信までを実施していました。 しかし、スループットが出ず、ふと smtp-source.c に書かれていたコメントを思い出しました。

postfix-3.0.0/src/smtpstone/smtp-source.c:156

 /*
  * Per-session data structure with state.
  * 
  * This software can maintain multiple parallel connections to the same SMTP
  * server. However, it makes no more than one connection request at a time
  * to avoid overwhelming the server with SYN packets and having to back off.
  * Back-off would screw up the benchmark. Pending connection requests are
  * kept in a linear list.
  */

大量のコネクション確立要求を同時に送ったとしても、すべてが1度のラウンドトリップで処理されるとは限りません。 参考。 smtp-sourceに倣い、コネクション確立部分は直列に処理し、確立後にワーカーgoroutineに処理を引き渡すようにしたところ、 パフォーマンスが改善されました。

ここまでで、time(1)でsmtp-sourceとgo-smtp-sourceの実行時間とメモリ消費量を比較してみました。 その結果、コマンド実行時間の違いはもちろんながら、 並列度の上げた場合のメモリ消費量に大きな変化がありました

smtp-sourceは並列度を上げてもメモリ消費量に大きな変化はありませんでしたが、 go-smtp-sourceは上げた分だけメモリを消費していました。

すぐに思いつくのはgoroutineを大量に起動させたからですが、「推測するな、計測せよ」に習って https://blog.golang.org/profiling-go-programs を参考にメモリ割り当てのプロファイルを取ってみました。

その結果、 net/textprotoが内部で確保しているbufio がメモリを消費していることが判明しました。

bufioの件は以前net/httpのコードを読んでいた時に bufioを使い回している のを思い出し、さっそく net/{smtp,textproto} をvendoringし、 sync.Poolで使い回す修正 をしてみました。

修正後の比較結果

1000並列の場合を比べてみましょう。

<sync.Poolなし>
smtp-source:
0.60user 2.31system 0:02.93elapsed 99%CPU (0avgtext+0avgdata 3028maxresident)k
0inputs+0outputs (0major+216minor)pagefaults 0swaps

go-smtp-source:
0.58user 4.30system 0:04.45elapsed 109%CPU (0avgtext+0avgdata 31828maxresident)k
0inputs+0outputs (0major+7125minor)pagefaults 0swaps


<sync.Poolあり>
smtp-source:
0.50user 1.93system 0:02.44elapsed 99%CPU (0avgtext+0avgdata 2872maxresident)k
0inputs+0outputs (0major+194minor)pagefaults 0swaps

go-smtp-source:
0.37user 4.01system 0:03.91elapsed 111%CPU (0avgtext+0avgdata 12244maxresident)k
0inputs+0outputs (0major+2401minor)pagefaults 0swaps

メモリ消費量が31MBから12MBへ大幅に削減できています。また、コマンド実行時間も改善されていました。 ここまでで、Postfixのsmtp-sourceとGoでざっくり書いたgo-smtp-sourceでは1.6倍程度の性能劣化で済みました。

ベンチマークに使用したスクリプトは https://github.com/nabeken/go-smtp-source/blob/master/bench.sh で確認できます。

まとめ

  • Postfixにはsmtp-sourceとsmtp-sinkという小さなツールがあり、smtp-sinkはベンチマーカーのためのSMTPサーバとしても便利。
  • smtp-sourceをGoで書きなおしたら、性能劣化は1.6倍程度で済んだ。また、Goなので今後の機能追加もやりやすくなった。
  • net/textprotoで実験的に内部のbufioをsync.Poolで使い回すようにしたら実行速度、メモリ消費量に効果があった。

今後について

go-smtp-sourceは今後、

  • 時間に関する詳細な統計情報の出力 (全セッション分保持した上で異常値がないか、とかみたい)
  • 複数のベンチマーカーを束ねて大量のトラフィックを発生させるクラスタ機能

なんかを追加していく予定です。