Linuxでロードバランサやキャッシュサーバをマルチコアスケールさせるためのカーネルチューニング

【追記】コメントいただいたように、ソフトウェア割り込みという言葉を使うと、CPUのソフトウェア割り込みと混同していますので、Linux カーネルの softirq を「ソフト割り込み」と呼ぶように修正しました。(http://www.slideshare.net/syuu1228/10-gbeio の資料などから softirq をソフトウェア割り込みと呼ぶこともあるようです)

【追記2】C社の喜びの声

HAProxy や Nginx などのソフトウェアロードバランサやリバースプロキシ、memcached などの KVS のような高パケットレートになりやすいネットワークアプリケーションにおいて、単一の CPU コアに負荷が偏り、マルチコアスケールしないことがあります。 今回は、このようなネットワークアプリケーションにおいて CPU 負荷がマルチコアスケールしない理由と、マルチコアスケールさせるための Linux カーネルのネットワークスタックのチューニング手法として RFS (Receive Flow Steering) を紹介します。

Redis や Nodejs のような1プロセス1スレッドで動作するアプリケーションをマルチコアスケールさせるような話ではありませんのでご注意ください。

問題と背景

前述のように高負荷なネットワークアプリケーションにおいて、下記のように他のコアが空いているにも関わらず、CPU0 の softirq(%soft) に負荷が集中した結果、CPU0 のみ idle(%idle) が著しく低いということがよくあります。

CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest   %idle
all   31.73    0.00    1.47    0.13    0.00    0.96    0.06    0.00   65.64
  0   70.41    0.00    5.10    0.00    0.00   15.31    0.00    0.00    9.18
  1   68.04    0.00    3.09    0.00    0.00    0.00    0.00    0.00   28.87
  2   53.06    0.00    3.06    0.00    0.00    0.00    0.00    0.00   43.88
  3   47.47    0.00    2.02    0.00    0.00    0.00    1.01    0.00   49.49
  4   49.45    0.00    1.10    0.00    0.00    0.00    0.00    0.00   49.45
  5   44.33    0.00    2.06    0.00    0.00    0.00    0.00    0.00   53.61
  6   38.61    0.00    2.97    0.99    0.00    0.00    0.00    0.00   57.43
  7   32.63    0.00    1.05    0.00    0.00    0.00    0.00    0.00   66.32
  8   29.90    0.00    1.03    1.03    0.00    0.00    0.00    0.00   68.04
  9   10.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   90.00
 10    8.08    0.00    1.01    0.00    0.00    0.00    0.00    0.00   90.91
 11    6.12    0.00    0.00    0.00    0.00    0.00    0.00    0.00   93.88
 12   10.00    0.00    2.00    0.00    0.00    0.00    0.00    0.00   88.00
 13   11.00    0.00    1.00    0.00    0.00    0.00    0.00    0.00   88.00
 14   17.71    0.00    0.00    0.00    0.00    0.00    0.00    0.00   82.29
 15   11.22    0.00    1.02    0.00    0.00    0.00    0.00    0.00   87.76

softirq というのはソフト割り込みと呼ばれますが、ソフト割り込みの負荷が高いというのはどういうことかというのと、なぜ CPU0 に負荷が集中するのかについて議論してみます。

ソフト割り込みの負荷が高いとはどういうことか

Linux のソフト割り込みについては、2.2 Linuxカーネルの割り込み処理の特徴 - Linux Kernel Documents Wiki - Linux Kernel Documents - SourceForge.JP を参照してください。

まず、Linux における割り込みを用いたパケットの受信フローをみてみます。Linux 2.6 からは割り込みとポーリングを組み合わせた NAPI という仕組みでパケットを受信します。NAPI についてはこの記事の最後のおまけの章をみてください。

f:id:y_uuki:20150330235452p:plain

  1. 【NICハードウェア受信】NIC はパケットを受信すると NIC の内部メモリにパケットを置く。
  2. 【ハードウェア割り込み】パケットを受信したことを知らせるために、NIC からホストの CPU にむけてハードウェア割り込みをかける。ハードウェア割り込みがかけられた CPU (NIC ドライバ) は NIC 上のパケットをカーネル上のリングバッファに置く 。移行のパケット処理は同期的に処理しなくてよいため、ソフト割り込みをスケジュールして、そちらに任せる。(厳密には、リングバッファに渡しているのはソケットへのポインタ的なもので、パケットデータは DMA で CPU を介さずに、カーネルのメモリ領域に渡されるはず)
  3. 【ソフト割り込み】スケジュールされたソフト割り込みが発生し、リングバッファからパケットを取り出し、ソフト割り込みハンドラでプロトコル処理したのち、ソケットキューにパケットが積まれる。
  4. 【アプリケーション受信】readrecvrecvfrom などが呼ばれるとソケットキューからアプリケーションへ受信データがコピーされる。

一回一回のハードウェア割り込みおよびソフト割り込み負荷は当然大したことはないが、64 バイトフレームで 1Gbps のワイヤーレートでトラヒックを流したとすると、約 1,500,000回/sec もの割り込みが発生することになります。ソフト割り込みは優先度の高いタスクなので、割り込みを受けている CPU は割り込みハンドラの処理以外何もできなくなります。

CPU のクロック周波数が頭打ちになり、10 Gbps イーサネットなどのワイヤーレートが向上すると、このように CPU 処理のうち割り込み処理の割合が大きくなっていきます。

パケット受信の流れについては、下記の文献が詳しいです。

なぜマルチコアスケールしないのか(なぜ CPU 負荷が特定コアに偏るのか)

まず、マルチキュー対応(MSI-X 対応、RSS 対応) NIC でない場合、NIC からのハードウェア割り込み先は特定の CPU に固定されます。 もしランダムに複数の CPU に割り込みをかけたとすると、パケットを並列処理することになり、TCP のような一つのフロー(コネクション)中のパケットの順序保証があるプロトコルの場合、パケットの並べ直しが必要になります。 これを TCP reordering 問題といいますが、reordering によりパフォーマンスを下げないために、同じ CPU にハードウェア割り込みをかけています。

さらに、ハードウェア割り込みハンドラでソフト割り込みをスケジュールしますが、このとき Linux ではハードウェア割り込みを受けた CPU と同じ CPU にソフト割り込みを割り当てるようになっています。これは、ハードウェア割り込みハンドラでメモリアクセスした構造体をソフト割り込みハンドラにも引き継ぐので、CPUコアローカルな L1, L2キャッシュを効率よく利用するためです。

したがって、特定の CPU にハードウェア割り込みとソフト割り込みが集中することになります。

実環境では、割り込み負荷に加えて、アプリケーション処理の CPU負荷 (%user) も割り込みがかかっている CPU に偏っています。 これは、accept(2) で待ち状態のアプリケーションプロセスが、データ到着時にプロセススケジューラにより、プロトコル処理した CPU と同じ CPU に優先してアプリケーションプロセスを割り当てているような気がしています。 これも、L1, L2キャッシュの効率利用のためだと思いますが、ちゃんと確認できていません。

ネットワークスタックをマルチコアスケールさせるための技術

ネットワークスタックをマルチコアスケールさせるための技術は、NIC(ハードウェア)の機能によるものか、カーネル(ソフトウェア)の機能によるものか、その両方かに分類できます。 今回紹介するのは、NICの機能で実現する RSS(Receive Side Scaling) と RPS/RFS です。 RPS の発展した実装が RFS なので、実質 RSS と RFS ということになります。

RSS/RPS/RFS については、Scaling in the Linux Networking Stack が詳しいです。 RPS/RFS は Linux カーネル 2.6.35 以降で実装されていますが、RHLE 系は 5.9 ぐらい以降でバックポートされていたと思います。

他にも、論文ベースでは、[1][A
Transport-Friendly
NIC
for
Multicore/Multiprocessor
Systems] や [2][mTCP: a Highly Scalable User-level TCP Stack for Multicore Systems] など、ネットワークスタックを高速化させるための様々な手法が提案されています。

RSS(Receive Side Scaling)

マルチコアスケールしないそもそもの原因は、特定の CPU にのみハードウェア割り込みが集中するからです。 そこで、NIC に複数のパケットキューを持たせて、キューと CPU のマッピングを作り、キューごとにハードウェア割り込み先 CPU を変えます。

TCP reordering を回避するために、"フィルタ"により同じフローのパケットは同じキューにつなげるようにします。 フィルタの実装は大抵、IPヘッダとトランスポート層のヘッダ、例えば src/dst IPアドレスと src/dst ポート番号の4タプルをキーとして、キュー番号をバリューとしたハッシュテーブルになります。

ハッシュテーブルのエントリ数は 128 で、フィルタで計算したハッシュ値の下位 7 bit をキーとしているハードウェア実装が多いようです。

RPS (Receive Packet Steering)

RSS は NIC の機能なので、NIC が対応していなければ使えません。 RSS 相当の機能をソフトウェア(Linuxカーネル)で実現したものが RPS です。

RPS はソフト割り込みハンドラで NIC のバッファからパケットをフェッチした後、プロトコル処理をする前に、他の CPU へコア間割り込み(IPI: Inter-processor interrupt)します。そして、コア間割り込み先の CPU がプロトコル処理して、アプリケーション受信処理をします。

他の CPU の選択方法は RSS とよく似ており、src/dst IPアドレスと src/dst ポート番号の4タプルをキーとして、Consistent-Hashing により分散先の CPU が選択されます。

f:id:y_uuki:20150330235500p:plain

RPS のメリットは以下の3点が考えられます。

  • ソフトウェア実装なので、 NIC に依存しない
  • フィルタの実装がソフトウェアなので、新しいプロトコル用のフィルタも簡単に追加できる
  • ハードウェア割り込み数を増やさない
    • ハードウェア割り込みを CPU 間で分散させるために、通常1回で済むハードウェア割り込みを全ての CPU に対して行い、ロックを獲得した CPU のみソフト割り込みをスケジュールするという非効率なやり方もある? (RSS で MSI-X が使えないとこうなる?)

RPS の使い方は簡単で、NIC のキューごとに下記のようなコマンドを叩くだけです。シングルキュー NIC なら rx-0 のみで、マルチキュー NIC ならキューの数だけ rx-N があります。

# echo "f" > /sys/class/net/eth0/queues/rx-0/rps_cpus

rps_cpus は分散先のの CPU の候補をビットマップで表しています。 "f" の2進数表現は 1111 となり、各ビットが下位から順番に CPU0 ~ CPU3 まで対応しています。ビットが 1 ならば、対応する CPU は分散先の候補となるということです。 つまり、"f" なら CPU0,1,2,3 が分散先の候補となります。 通常は全てのコアを分散先に選択すればよいと思います。 RPS を有効にしても、ハードウェア割り込みを受けている CPU に分散させたくないときは、その CPU の対応ビットが 0 になるような16進数表現にします。 詳細は https://www.kernel.org/doc/Documentation/IRQ-affinity.txt に書かれています。

RFS (Receive Flow Steering)

RPS はアプリケーションプロセスまで含めた L1, L2キャッシュの局所性の観点では問題があります。 RPS ではフローとフローを処理する分散先のCPUのマッピングはランダムに決定されます。 これでは、accept(2)read(2) を呼んでスリープ中のアプリケーションプロセスがスリープ前に実行されていた CPU とは異なる CPU に割り当てられる可能性があります。

そこで、RFS は RPS を拡張して、アプリケーションプロセスをトレースできるようになっています。 具体的には、フローに対するハッシュ値からそのまま Consistent-Hashing で分散先CPU を決めるのではなく、フローに対するハッシュ値をキーとしたフローテーブルを用意して、テーブルエントリには分散先の CPU 番号を書いておきます。 該当フローを最後に処理した CPU が宛先 CPU になるように、recv_message などのシステムコールが呼ばれたときに、フローテーブルの宛先 CPU を更新します。

RFS の設定は、RPS の設定の rps_cpus に加えて、rps_flow_cntrps_sock_flow_entries を設定するだけです。

# echo "f" > /sys/class/net/eth0/queues/rx-0/rps_cpus
# echo 32768 > /sys/class/net/eth0/queues/rx-0/rps_sock_flow_entries
# echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt

rps_sock_flow_entries はシステムグローバルなフローテーブルのエントリ数を設定します。 ローカルポート数(最大接続数)以上を設定しても意味はないので、65536 以下の数値を設定すればよいはずです。 32768 が設定されている例をよく見かけます。

rps_flow_cnt は NIC キューごとのフロー数を設定できます。 16 個のキューをもつ NIC であれば、rps_sock_flow_entries を 32768 に設定したとすると、rps_flow_cnt は 2048 に設定するのが望ましいと思います。 シングルキュー NIC であれば、rps_flow_cntrps_sock_flow_entries と同じ設定でよいです。

設定の永続化については、CentOS6で/sys/の変更を永続化する方法 が非常に参考になります。

実験

ベンチマークツール( iperf )によるベンチマークと実アプリケーションへの適用をやってみました。 いずれも 10GBps NIC を使用しています。

iperfによるベンチマーク

ベンチマーク環境は以下の通りです。

  • CPU: Intel Core i5 3470 3.2GHz 2コア (Hyper Threading有効)
  • NIC: Mellanox ConnectX-3 EN 10GbE PCI Express 3.0
  • OS: CentOS 5.9

CPU クロック周波数を BIOS で 1.6 GHz に制限して、無理やりCPUネックな環境を作っています。 iperfのプロセス数(コネクション数)を4として、パケットサイズを64バイトにしてトラヒックを流すと、iperfサーバ側では、下記のように CPU0 の softirq コンテキストの使用率が 100% になります。

CPU   %user   %nice    %sys %iowait    %irq   %soft  %steal   %idle
all    0.74    0.00   16.75    0.00    0.00   27.83    0.00   54.68
  0    0.00    0.00    0.00    0.00    0.00  100.00    0.00    0.00
  1    2.00    0.00   52.00    0.00    0.00    9.00    0.00   37.00
  2    0.00    0.00   14.95    0.00    0.00    3.74    0.00   81.31
  3    0.00    0.00    1.00    0.00    0.00    0.00    0.00   99.00

そこで、下記の設定で RFS を有効にすると、CPU1,CPU2,CPU3にも system と softirq が分散される形になりました。

# echo "f" > /sys/class/net/eth0/queues/rx-0/rps_cpus
# echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
# echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
CPU   %user   %nice    %sys %iowait    %irq   %soft  %steal   %idle
all    0.24    0.00   11.86    0.00    0.00   23.00    0.00   64.89
  0    0.00    0.00   11.11    0.00    0.00   38.38    0.00   50.51
  1    0.95    0.00   12.38    0.00    0.00   19.05    0.00   67.62
  2    0.00    0.00   13.21    0.00    0.00   18.87    0.00   67.92
  3    0.00    0.00   12.50    0.00    0.00   16.35    0.00   71.15

実アプリケーション(Starlet)への適用

次に、Perl の Starlet で動作しているプロダクション環境のアプリケーションサーバに RFS を適用してみました。 アプリケーションサーバ環境は以下の通りです。

  • EC2 c3.4xlarge SR-IOV有効
  • CPU: Intel Xeon E5-2680 v2 @ 2.80GHz 16コア
  • NIC: Intel 82599 10 Gigabit Ethernet Controller
  • NIC driver: ixgbevf 2.7.12
  • OS: 3.10.23 Debian Wheezy

RFS の設定は標準的です。

# echo "ffff" > /sys/class/net/eth0/queues/rx-0/rps_cpus
# echo 32768 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
# echo 32768 > /proc/sys/net/core/rps_sock_flow_entries

RFS 有効前は下記の通り、CPU0 以外の CPU コアはがら空きなのにもかかわらず、CPU0 の softirq(%soft) が 15% を占めており、idle が 9% しかないという状況です。

CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest   %idle
all   31.73    0.00    1.47    0.13    0.00    0.96    0.06    0.00   65.64
  0   70.41    0.00    5.10    0.00    0.00   15.31    0.00    0.00    9.18
  1   68.04    0.00    3.09    0.00    0.00    0.00    0.00    0.00   28.87
  2   53.06    0.00    3.06    0.00    0.00    0.00    0.00    0.00   43.88
  3   47.47    0.00    2.02    0.00    0.00    0.00    1.01    0.00   49.49
  4   49.45    0.00    1.10    0.00    0.00    0.00    0.00    0.00   49.45
  5   44.33    0.00    2.06    0.00    0.00    0.00    0.00    0.00   53.61
  6   38.61    0.00    2.97    0.99    0.00    0.00    0.00    0.00   57.43
  7   32.63    0.00    1.05    0.00    0.00    0.00    0.00    0.00   66.32
  8   29.90    0.00    1.03    1.03    0.00    0.00    0.00    0.00   68.04
  9   10.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00   90.00
 10    8.08    0.00    1.01    0.00    0.00    0.00    0.00    0.00   90.91
 11    6.12    0.00    0.00    0.00    0.00    0.00    0.00    0.00   93.88
 12   10.00    0.00    2.00    0.00    0.00    0.00    0.00    0.00   88.00
 13   11.00    0.00    1.00    0.00    0.00    0.00    0.00    0.00   88.00
 14   17.71    0.00    0.00    0.00    0.00    0.00    0.00    0.00   82.29
 15   11.22    0.00    1.02    0.00    0.00    0.00    0.00    0.00   87.76

RFS 有効後は、softirq(si) が他のコアに分散され、ついでに、user(%usr) や system(%sys) の負荷も分散されています。

CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest   %idle
all   27.41    0.00    3.07    0.00    0.00    0.70    0.13    0.00   68.69
  0   36.08    0.00    8.25    0.00    0.00    6.19    0.00    0.00   49.48
  1   30.43    0.00    3.26    0.00    0.00    0.00    0.00    0.00   66.30
  2   31.96    0.00    4.12    0.00    0.00    2.06    0.00    0.00   61.86
  3   35.64    0.00    3.96    0.00    0.00    0.00    0.99    0.00   59.41
  4   44.12    0.00    1.96    0.00    0.00    0.98    0.00    0.00   52.94
  5   37.00    0.00    9.00    0.00    0.00    0.00    0.00    0.00   54.00
  6   38.78    0.00    1.02    0.00    0.00    1.02    0.00    0.00   59.18
  7   39.00    0.00    6.00    0.00    0.00    1.00    1.00    0.00   53.00
  8   19.59    0.00    3.09    0.00    0.00    0.00    0.00    0.00   77.32
  9   23.16    0.00    3.16    0.00    0.00    0.00    0.00    0.00   73.68
 10   17.17    0.00    4.04    0.00    0.00    0.00    0.00    0.00   78.79
 11   18.00    0.00    3.00    0.00    0.00    0.00    0.00    0.00   79.00
 12   16.49    0.00    0.00    0.00    0.00    0.00    0.00    0.00   83.51
 13   16.33    0.00    0.00    0.00    0.00    1.02    0.00    0.00   82.65
 14   16.00    0.00    1.00    0.00    0.00    0.00    0.00    0.00   83.00
 15   16.67    0.00    0.00    0.00    0.00    0.00    0.00    0.00   83.33

softirq だけでなく、user や system も分散されているというのが重要で、前述したようにプロトコル処理が実行される CPU が分散されると、アプリケーション処理負荷も分散されます。

過去には Starlet 以外に HAProxy、pgpool、Varnish などのアプリケーションで RFS を試したことがありますが、特に副作用なく動いています。

識者の皆様方は memcached や LVS、Linux ルータに適用されているようです。

参考資料

RFS は直接関係ないですが @ten_forward さんに良い資料教えていただきました。

まとめ

Linuxカーネルのネットワークスタック処理の最適化について紹介しました。 その中でとくに RFS に着目して、ベンチマークと実アプリケーションに適用してみて、マルチコアスケールさせられることを確認できました。 RFS はハードウェア依存がなく、カーネルのバージョンさえそれなりに新しければ使えてしまうので、結構手軽な上に効果が高いので、コスパがよいチューニングといえると思います。

間違っている記述などがあれば斧ください。

はてなでは、実際の本番環境を相手にチューニングしてハードウェア性能を使い切りたいエンジニアを募集しています。

おまけ: ネットワークスタック処理の CPU 負荷を最適化する技術

10GbE時代のネットワークI/O高速化 に全て書いてあります。

ネットワークスタックのCPU負荷がボトルネックと言われるようになって久しいですが、その間ネットワークスタック処理のCPU負荷を低減させるための手法が登場しています。(代表的なものだけ抜粋)

まず、チェックサム計算などのネットワークスタック処理の中で比較的重い処理をCPU(カーネル)でやらずに、NICのASICにオフロードするという手法があります。 NICが対応している必要がありますが、オンボードNICでなければだいたい対応している気がします。 ethtool -k eth0 rx-checksummingなどのコマンドで有効化できます。 スタック処理の一部だけでなく、TCPスタック処理のほとんどをNICにやらせる TCP Offload Engine (TOE) などもあります。

次に、NICからの割り込み回数が多いなら、1パケットごとにNICからCPUへハードウェア割り込みをかけるのではなく、複数パケットの受信を待ってから、1つの割り込みにまとめてしまえばよいという方法です。これは、Interrupt Coalescing と呼ばれています。これもNICオフロードの一種といえるかもしれません。 Interrupt Coalescing は負荷を下げられる一方で、後続パケットの受信待つ分、レイテンシは上がってしまうというデメリットもあります。 Interrupt Coalescing は今も普通に使われており、去年、EC2環境で割り込み頻度などのパラメータチューニングをしていたりしました。 EC2でSR-IOVを使うときのNICドライバパラメータ検証 - ゆううきブログ

NIC へのオフロードは実際は問題が起きやすく、無効にすることが多いです。

ソフトウェアベースの手法として、Linux 2.6 から使える NAPI というカーネルが提供する NIC ドライバフレームワークがあります。 NAPI は Interrupt Coalescing と同様、1パケットごとにNICからCPUへハードウェア割り込みをかけることを防ぎますが、CPU から NIC へポーリングをかけるところが異なります。 具体的には、一旦パケットを受信したらハードウェア割り込みを禁止して、CPU が NIC の受信バッファ上のパケットをフェッチします。 パケットレートが高ければ、禁止からフェッチまでの間にNIC受信バッファに複数のパケットが積まれるはずなので、1回のポーリングでそれらをまとめてフェッチできます。 e1000 や igb などの主要なNICドライバは NAPI に対応しているはずなので、典型的な Linux 環境であれば、NAPI で動作していると思って良いと思います。

詳解 Linuxカーネル 第3版

詳解 Linuxカーネル 第3版

  • 作者: Daniel P. Bovet,Marco Cesati,高橋浩和,杉田由美子,清水正明,高杉昌督,平松雅巳,安井隆宏
  • 出版社/メーカー: オライリー・ジャパン
  • 発売日: 2007/02/26
  • メディア: 大型本
  • 購入: 9人 クリック: 269回
  • この商品を含むブログ (70件) を見る

Linuxカーネル Hacks ―パフォーマンス改善、開発効率向上、省電力化のためのテクニック

Linuxカーネル Hacks ―パフォーマンス改善、開発効率向上、省電力化のためのテクニック

  • 作者: 池田宗広,大岩尚宏,島本裕志,竹部晶雄,平松雅巳,高橋浩和
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2011/07/26
  • メディア: 単行本(ソフトカバー)
  • 購入: 4人 クリック: 50回
  • この商品を含むブログ (4件) を見る

Ansible + Mackerel APIによる1000台規模のサーバオペレーション

Ansible と Mackerel API を組み合わせて、1000台規模のサーバ群に対して同時にパッケージの更新やその他のサーバオペレーションのための方法を紹介します。 タイトルに Mackerel とありますが、それほど Mackerel に依存しない話です。

背景

社内では、サーバ構成管理ツールとして Chef を使用しています。 Chef Server は運用が大変なので使用しておらず、knife-solo と Mackerel APIを組み合わせてホストと Chef role とのマッピングに Mackerel のロール情報を用いています。 また、Mackerel の Ruby クライアントを利用して recipe 内で API を叩いて、Mackerel から動的にホスト情報を参照するといったこともやっています。

今も構成管理は全て Chef でやっているのですが、Chef Server を用いていないため、cookbook の変更を基本的には1台1台適用することになります。(頻繁に変更するミドルウェアのクラスタ設定などは Capistrano を用いて該当設定ファイルのみ配っています。) これでは、例えば mackerel-agent のようなパッケージを全てのホストに一斉に更新をかけるといったことができません。

そこで、エージェントレスな、並列実行に優れたサーバ構成管理ツール Ansible に注目しました。 並列実行だけでなく、後述するようにDynamic Inventoryを使ってサーバ管理ツールとの連携もしやすいことも重要です。

1000台規模で Ansible を使う

1000台規模で Ansible を使うために、いくつかのパフォーマンスチューニングを行います。 パフォーマンスチューニングについては、Ansibleの公式ブログが詳しいです。

Ansible Performance Tuning (for Fun and Profit)

まず、forks で並列度を上げます。デフォルトは 5 ぐらいなので、100 とかにしてみます。 local_action とか使ってると詰まるので、手元のファイルを送信するのではなく、どこかのファイルサーバに置いて、各ホストから落としてくるほうがよさそうです。

次に、SSH接続を高速化します。 OpenSSH の ControlPersist を使うと、SSH のコネクションを維持するようになり、再接続のオーバヘッドを軽減できます。 さらに、pipelining を有効にすると、かなりのパフォーマンスが改善されます。sudo を使う場合、/etc/sudoersで requiretty を無効にする必要があります。 以前は、Accelerated Modeを使えばよかったようですが、今では SSH pipelining を使うほうがよいようです。 ただし、RHEL 5,6環境では OpenSSH のバージョンが古くて、paramiko という pure PythonでのSSH実装にフォールバックします。paramiko は ControlPersist 機能がないため、毎回接続が発生するので、これを回避するために、Accelerated Mode を使うとよいようです。

リポジトリルートに下記のような設定を書いた .ansible.cfg を設置して、他のチームメンバーも同じ設定を使えるようにしておきます。

[defaults]
transport=ssh
pipelining=True
forks=100

[ssh_connection]
ssh_args=-o ControlMaster=auto -o ControlPersist=30m
scp_if_ssh=True 
control_path=%(directory)s/%%h-%%r

Mackerel APIと組み合わせる

通常、Ansible では静的な inventory ファイルに実行対象のホストを記述する必要があります。 特に1000台以上もサーバを持っているとファイルで管理はしていられません。 普段、Mackerel などのサーバ管理ツールを使っている場合、API経由でホスト情報がとれるので、なるべくホスト情報を別のファイルを管理したくありません。 そこで、Ansible の Dynamic Inventory を使います。 Dynamic Inventory は EC2 や Zabbix のホスト情報を inventory として使用することができる機能です。 実体は、EC2ならEC2のAPIを用いて、定められたフォーマットのJSONを出力するスクリプトです。 Dynamic Inventory スクリプトの書き方は Developing Dynamic Inventory Sources — Ansible Documentation に書かれています。

Mackerel API Dynamic Inventory

http://docs.ansible.com/developing_inventory.html#tuning-the-external-inventory-script によると、JSON出力に_meta キーを含めるフォーマットのほうが実行が高速らしいです。 つまり、下記のように、ロール名やサービス名のようなグループ名をキーとして、グループ内のホスト識別子(ホスト名に限らない)をバリューとしたJSONを出力するスクリプトを書けばよいです。 各ホストの情報は、_meta => hostvars のキーの中にいれておく。hostvars は playbook の中で参照することができる。例えば、Mackerel の status に応じた task を書くことができます。

{
  "Example-Blog_app": ["blogapp001.host.h", "blogapp002.host.h"],
  "Example-Blog_proxy": ["blogproxy001.host.h", "blogdproxy002.host.h"],
  ...
  "Example-Blog": ["blogapp001.host.h", "blogapp002.host.h", "blogproxy001.host.h", "blogdproxy002.host.h"]
  ...
  "_meta" => { 
    "hostvars" => {
      "blogapp001.host.h" => {
        "status": "working",
        "roleFullnames": ["Example-Blog::app"]
        ...
      },
      "blogapp002.host.h" => {
        ...
      },
      ...
    }
  }
}

簡単な Mackerel 用の Dynamic Inventory スクリプトを書いてみました。 Ansible は Python で書かれているので、本当は Python で書くのが筋がよさそうですが、Python クライアントがないので、とりあえず Ruby で書きました。 言語による大した違いはないと思います。

実行方法は簡単で、-i オプションに実行権限をつけてスクリプトを渡します。 パターンを all にすると、inventory 内の全ホストが対象になります。

$ ansible -i ./bin/mackerelio_inventry all --list-hosts

playbook

playbooks リポジトリのディレクトリ構成

Ansible の公式ドキュメントに構成のベストプラクティスが書かれています。 Best Practices — Ansible Documentation 今回は、そんなに複雑な構成管理をするわけではないので、シンプルなディレクトリ構成にしています。

  • 普通のフルプロビジョニング用途とは思想が異なり、単発のオペレーション用途なので、playbook ファイルはオペレーション単位で作る。 mackerel-agent.ymlmkr.ymljq.ymlなど。
  • script/ 以下に Dynamic Inventory スクリプト、bin/以下に直接実行するファイルを置く。bin/mackerelio_inventryscript/mackerelio.rb を bundle exec でラップしたもの
  • roles 以下に使用する Ansible Role を置く。これは普通。Ansible Galaxy | Find, reuse, and share the best Ansible content
.
├── Gemfile
├── Gemfile.lock
├── bin
│   ├── ansible-install-simplejson
│   ├── ansible-pssh
│   └── mackerelio_inventry
├── mackerel-agent.yml
├── mkr.yml
├── jq.yml
├── requirements.yml
├── roles
│   └── mackerel.mackerel-agent
├── script
   └── mackerelio.rb
└── vars
     └── mackerel-agent-plugin

jq のインストール

例として、実際に jq を配布してみます。jq.yml に下記のような設定を書きます。jq は apt リポジトリはありますが、yum リポジトリはない?ようなので、実行ファイルをそのまま get_url モジュールでダウンロードするだけです。サーバのディストリ情報などは使わないため、gather_facts は不要なので切っておきます。

---
-
  hosts: all
  sudo: yes
  gather_facts: no
  tasks:
  - name: install jq
    get_url: url=http://stedolan.github.io/jq/download/linux64/jq dest=/usr/local/bin/jq mode=0755

下記コマンドで実行します。

$ ansible-playbook --ask-sudo-pass -i ./bin/mackerelio_inventry ./jq.yml

だいたい20分くらいで数千台のサーバに配り終えました。それなりに時間はかかりますね。 失敗したホストに対してのみリトライしたければ上記コマンドに --limit @/Users/y_uuki/jq.retry をつけて実行してやります。

jq は all を指定して全てのホストに配りましたが、Mackerel のサービスやロール単位で task を実行することができます。 Patterns — Ansible Documentation に、対象ホストを絞り込むためのパターン指定方法があります。ワイルドカードやOR条件、AND条件、NOT条件などでそれなりに柔軟に指定できます。

補足

Capistrano などの並列sshツールとの違い

Capistrano でも複数ホストに同時にコマンド実行することは可能です。 ただし、実際に 1000 台に対して実行すると、手元のsshで詰まったり、実行に失敗したホストの情報がよくわからなかったりするので、複数回実行します。 途中で詰まったりして1回の実行に1時間以上かかるので、結構大掛かりになります。 Capistrano v2 を使用していますが、Capistrano v3 からSSHのバックエンドが sshkit になっているので、もう少しはマシかもしれません。

Ansible では、仮に失敗したホストがあっても、失敗したホストのリストをファイルに残してくれます。次回は失敗したホストのみ適用したり、失敗したホストのみ cssh などを使って、手動でオペレーションすることも可能です。 一方実行時間は Capistrano ほどではないですが、それなりに時間はかかります。この辺りは後述する Ansible v2 の free strategy を使うか、gather_facts no を指定して各ホストから情報収集ステップをスキップして、代わりに Mackerel の Inventory から取得した情報だけでホスト情報を賄うなどの高速化の可能性があります。

わざわざ Ansible や Capistrano のようなレシピ的なものに記述するタイプではなく、単純にコマンド実行するツールで十分かもしれません。 Parallel Distributed Shell(pdsh)を使って複数ホストでコマンドを同時実行する - えこ日記 に Parallel ssh や Cluster ssh など複数のリモートホストに同じコマンドを一斉実行するためのツールがまとめられています。 しかし、誰がいつどのようなオペレーションをやったのか記録が残らないかつ、適用前にPull Requestにしてレビューすることができないため、レシピとして記述するタイプのツールのほうが Infrastructure As Code の観点からみても優れていると思います。 (ワンタイムな操作の場合は日付を付けた playbook を用意するとよいかもしれません)

さらに、前述の get_url モジュールのように Ansible は標準モジュールが充実しており、ある程度冪等性を期待できるオペレーションがやりやすいのでそのあたりも加点ポイントです。

ansible-pssh

本当に単純にコマンドを実行したい場合、ansible-pssh というスクリプトを用意して、shellモジュールを使って実行させる。

#!/bin/bash

set -ex

ANSIBLE_INVENTORY_SCRIPT=./bin/mackerelio_inventry

PATTERN=$1 # Example-Bookmark
if [ -z $PATTERN ]; then
    echo 2>&1 "role required: ansible-pssh ROLE COMMAND"
    exit 1
fi

COMMAND="${@:2:($#-1)}"
if [ -z $COMMAND ]; then
    echo 2>&1 "role command: ansible -pssh ROLE COMMAND"
    exit 1
fi

exec ansible --ask-sudo-pass -i $ANSIBLE_INVENTORY_SCRIPT $PATTERN -m shell -a "$COMMAND"
$ ./bin/ansible-pssh all 'curl -sSfL https://raw.githubusercontent.com/mackerelio/mkr/master/script/install_linux_amd64 | sudo bash'

python-simplejson

CentOS 5 環境だとプリインストールされている Python のバージョンが古くて、ansible のモジュールに必要な python-simplejson がインストールされていない。 そこで、あらかじめ下記のようなスクリプトを実行しておく。raw モジュールだと python-simplejson を使わないので、実行できる。

#!/bin/bash

set -ex

ANSIBLE_INVENTORY_SCRIPT=./bin/mackerelio_inventry

PATTERN=$1 # Example-Bookmark
if [ -z $PATTERN ]; then
    echo 2>&1 "role required: ansible-install-simplejson PATTERN COMMAND"
    exit 1
fi

exec ansible --ask-sudo-pass -s -i $ANSIBLE_INVENTORY_SCRIPT $PATTERN -m raw -a "[ -e /usr/bin/yum ] && yum install -y python-simplejson || true" # https://github.com/ansible/ansible/issues/1529

Ansible v2

What's New in v2 - AnsibleFest London 2015

先日の AnsibleFest London 2015 で Ansible v2 の発表がありました。 内部実装の設計変更やエラーメッセージの改善などの変更がありますが、Execution Strategy 機能に注目しています。 Execution Strategy は task の実行方式を変更できる機能で、従来の liner 方式に加えて、他のホストの task 実行をまたずになるべく速く task を実行できる free 方式が実装されるようです。 これにより、高速実行できることを期待できます。

関連

以前にMackerel APIの利用例を書いていました。

1年以上前に Chef と Ansible について書いていました。

tagomoris さんのスライドは非常に参考になりました。かなり近い思想で運用されているようにみえます。

まとめ

若者なので大量に ssh しまくっています。

Ansible と Mackerel API を組み合わせたサーバオペレーションを紹介しました。 また、1000台規模で使えるツールであることを確認しました。

Mackerel の思想の一つとして、APIによるホスト情報の一元管理が挙げられます。Ansible の静的Inventoryファイルではなく、Dynamic Inventory により、Ansible 側でホスト情報を管理しなくてすむようになります。 さらに、Mackerel に登録したサービス、ロールやステータスなどのホスト情報を扱えるようになるのが便利なところです。

本当は1台のホストから多数のホストに接続する push 型ではなく、Gossipプロトコルなどのアドホックなネットワーク通信を用いた Serf、Consul のような pull 型のほうが圧倒的にオペレーション速度は速いはずですが、そもそも pull を実行するソフトウェアを各ホストにインストール/アップデートしなければならないため、このような仕組みは必要だと思っています。

積極採用!!!

1000台規模のサーバを抱えていると普通は発生しないような課題がたくさんあります。 はてなでは、そんな大規模環境特有の課題に取り組みたいWebオペレーションエンジニア(いわゆるインフラエンジニア)を積極採用中です。

Twitter

それから君のOSSすごかったよ

オンプレはみんな溶けるし、はてなの勉強してるし、Perl感だし、書いてる内容はinnodb、彼女はMySQLのことを考えてる。それから君のOSSすごかったよ。

パフォーマンスの観点からみるDockerの仕組みと性能検証 #dockerjp

Docker Meetup Tokyo #4 にて「Docker Performance on Web Application」という題で発表しました。 発表内容は、下記の2つの記事をまとめたものに加えて、最新バージョンの Docker 1.4 での ISUCON ベンチマークと、storage-driver として Device Mapper + Docker 1.4 から実装された [OverlayFS:title=https://github.com/docker/docker/pull/7619] を試しました。

この記事は、上記2記事で、いくつか難しいポイントがあったとフィードバックをいただいたので、Docker Meetup での発表内容を少し詳しめに説明したものになります。

1. Dockerのパフォーマンスについて重要な事はなにか

Docker のパフォーマンス検証に関する IBM の Research Report である An Updated Performance Comparison of Virtual Machines and Linux Containersの内容などから Linux Containers、UNION Filesystem、Volume、Portmapper、Host Networking が重要な要素であることがわかりました。

Docker Items

Linux Containers

まず、Linux Containers については、コンテナという機能があるのではなく、カーネルの各リソース(ファイルシステム、ネットワーク、ユーザ、プロセステーブルなど)について実装されている Namespace によって区切られた空間のことをコンテナと呼んでいます。つまり、Namespace で隔離された空間でプロセスを生成するというモデルになります。(普通のプロセスと扱いが変わらないので、Dockerコンテナの起動が速いのは当然)全ての Namespace を同時に使う必要はなく、一部の Namespace を使うことも当然可能です。(例えば、docker run コマンドの --net=hostオプションは、Network Namespace を使っていないだけのはず) Linux Containers は単体のカーネルで動作するので、親と子で別々のカーネルをもつ Hypervisor による仮想化と比べて、CPU命令をトラップしたり、メモリアクセスやパケットコピーの二重処理をしなくていいので、オーバヘッドがありません。(もちろん、VT-xやSR-IOVなど、ハードウェア支援による高速化手法はある)

@ten_forward さんの記事 コンテナの歴史と Linux カーネルのコンテナ関連機能についての割とどうでも良い愚痴 - TenForwardの日記 を読むとよいと思います。

Linux Containersについて実装レベルで理解されている方々にとっては、普通のプロセスと対して変わらないし、わざわざ検証するまでもないかと思いますが、production でのサービスインを考える上で一応見ておかないといけないと思いました。

UNION Filesystem

次に UNION Filesystem については、下記の公式画像をみるとだいたいわかった気になれます。

UNION Mount という手法でファイルシステムの層が実現されており、要は既にマウントされているポイントに対して重ねて別のブロックデバイス(ディレクトリ)をマウントし、最上位層のみを read-write 属性に、それ以外の層を read-only にするようなイメージです。複数のブロックデバイス(ディレクトリ)を同じマウントポイントからアクセスできます。 基本的に、任意のファイルシステムの状態から新規書き込みの分だけ上位層に書くようにすれば、最下層にベースファイルシステムがあり、その上に差分データだけを持つファイルシステム層が乗っていくようになります。

このような仕組みを実装するにあたって、ブロックデバイスレベルでの実装とファイルシステムレベルの実装があります。 Docker では storage-driver というオプションにより、UNION Filesystem の実装を切り替えることができます。 aufs,btrfs,devicemapper,vfs,overlayfs を使用可能です。 devicemapper がブロックデバイスレベルでの実装であり、aufs,btrfs,overlayfs がファイルシステムレベルでの実装となります。(vfs は docker側で無理やり層を作ってる?) Device Mapper は特定のファイルシステムに依存しないかつ、カーネル標準の機能なので気軽に使いやすいというメリットがあります。(LVM にも使われている) 一方で、Device Mapperの場合、イメージ層の作成・削除の性能は落ちるという検証結果もあります。(Comprehensive Overview of Storage Scalability in Docker | Red Hat Developer Blog) 汎用的でプリミティブな機能を持ったDevice Mapperを使って逐一、層となる仮想ブロックデバイスの作成や削除をするより、専用の機能を実装したファイルシステムレベルの実装が速そうというのはなんとなくわかる話ではあります。

発表内でデフォルトが Device Mapper とか言っていましたが、RHEL/CentOSでは事実上 Device Mapper がデフォルトであるというのが正しいです。 お詫びして訂正します。(ISUCON ベンチマークで使った Ubuntu 14.04 では、modprobe aufsした状態でデフォルトが devicemapper になっていたはずなんだけど、カーネルバージョン変えてたし、なんかミスってたのかもしれない) ちゃんとコードを読んでみると https://github.com/docker/docker/blob/5bc2ff8a36e9a768e8b479de4fe3ea9c9daf4121/daemon/graphdriver/driver.go#L79-84 となっており、aufs,btrfs,devicemapper,vfs,overlayfs の順になっているようで、デフォルトが AUFS というのが正しいです。

Volume

UNION Filesystem を使うと複数の層に対して、I/O要求もしくはその他の処理が多重に発行されるはずで(最適化はされているとは思いますが)、オーバヘッドが気になるところです。Docker には Volume という機能があり、これを使うと指定したディレクトリを UNION Mount しないようになります。したがって、そのディレクトリ以下のファイルへのI/O効率がよくなる可能性があります。

Volume 自体はパフォーマンス目的で使うものではなく、コンテナ間もしくはホスト・コンテナ間でデータを共有するためのものです。

Portmapper

コンテナ間通信やホスト・コンテナ間通信では、ホスト側の iptables によるNAPTで実現されています。(172.17.0.3がコンテナのIP)

-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER
-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
-A POSTROUTING -s 10.0.3.0/24 ! -d 10.0.3.0/24 -j MASQUERADE
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 80 -j DNAT --to-destination 172.17.0.3:8000

ただし、iptablesが利用できない環境のために、コンテナ間通信のみ docker-proxy というユーザランドのプロキシが使用されます。docker-proxy自体はiptablesを使っている使っていないに関わらず起動しているようです。

docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 80 -container-ip 172.17.0.3 -container-port 8000

iptables、つまりカーネルランドの netfilter で NAPT できるところをユーザランドのプロキシを経由すれば明らかにオーバヘッドが大きくなるという予想がつきます。

Host Networking

Docker では Network Namespace を使わずに、ホストと同じ Namespace を利用する Host Networking 機能があります。 Host Networking は --net=host で使えます。 ネットワークについては、ホストでプロセスを起動するのと変わらないことになります。 これならば、先ほどの Portmapper が必要なくなるため、NAPTのオーバヘッドがなくなります。

Host Networking については @deeeetさんの DockerのHost networking機能 | SOTA が詳しいです。

2. Docker化したISUCONアプリケーションのベンチマーク

ベンチマークは、Nginx と MySQL をこれまで紹介したオプションを切り替えて Docker化 して、それぞれのスコアを比較しました。 環境は前回との差分はより新しい Linux カーネル 3.8.0、Docker 1.4.1 を使っている点です。 詳しい内容は下記のスライドを参照していただくとして、結果は Nginx を Docker 化したときに Host Networking を使わずNAPTさせたときに、15%程度スコアが落ちるというものでした。それ以外の、VolumeのOn/Off や storage-driver の切り替えによるパフォーマンスの変化は ISUCON4予選の環境では起きませんでした。

Host Networking と Volume ON の状態で、性能が変わらないのは予想通りですが、storage-driver の切り替えによりパフォーマンスに変化がないのは意外でした。 これはおそらく、今回の環境では、データが全てメモリにのっているため、Read I/Oはほぼ発生していないということと、Write I/Oは UNION FS の最上層のみに適用すればよいので、複数の層があることによるオーバヘッドがあまりないのではないかと考えています。

NAPTのオーバヘッドが顕著であり、これは docker-proxy プロセスがCPU を 50% ほど使用しているためです。 iptables を有効にしているのになぜ docker-proxy が使われるのかと思いましたが、iptablesのルールに宛先がループバックアドレスの場合はコンテナへルーティングされないようです。

-A OUTPUT ! -d 127.0.0.0/8 -m addrtype --dst-type LOCAL -j DOCKER

benchmarker はデフォルトでは 127.0.0.1:80 へ接続するため、benchmarker - Nginx 間での接続に、ホストの 0.0.0.0:80 で LISTEN してる docker-proxy が使われてしまうという事態になっています。 benchmarker のオプションで --host <host eth0 ipaddr> としてやると、iptables でルーティングされるようになるため、スコアはDocker化していない状態とほぼ同じになりました。

なぜループバックアドレスだけ外されるのか

宛先アドレスが 127.0.0.1 のままだと、コンテナがパケットを受信して返信するときに、宛先アドレスを 127.0.0.1 にしてしまい、コンテナ自身にループバックします。 ループバックを避けるため、以下の様なPOSTROUTINGルールでNAPTする設定が必要なようです。 127.0.0.1 がコンテナのIPに書き換わり、コンテナからホストへの返信時に宛先アドレスがコンテナのIPになり、結局自分に戻ってくるようにみえます。しかし、Docker は仮想ブリッジ経由でホスト側のネットワークとコンテナ側のネットワークを接続しているので、仮想ブリッジ(docker 0)のヘアピンNAT(NATループバック)を有効にすることで、ホスト側へNATしてくれるようです。(この辺りすこし怪しい)

-A POSTROUTING -p tcp -s <container ipaddr>/28 -d <container ipaddr>/28 --dport <container port> -j MASQUERADE

ただ、RHEL/CentOS 6.5環境下で /sys以下が readonly でマウントされており、 /sys/class/net/{ifname}/brport/hairpin_mode に書き込めないため、仮想ブリッジ環境でヘアピンNATモードを有効にできないようです。(RHEL/CentOS 6.5環境のみかどうかはちゃんと調べてないです) ヘアピンNAT サポートが一旦、マージされてリバートされたのもこのためです。

発表スライド

さらに詳しい情報は下記スライドを参照してください。

Keynote テーマは弊社のデザイナ @murata_s さんが作ったテーマを使わせてもらっています。

関連情報

RedHatの @enakai さんの必読のスライド。コンテナとVMM、Dockerのファイルシステム、ネットワークについて詳しく書かれていて非常に参考になりました。 26枚目の、iptables でループバックアドレス宛のパケットだけ外されている理由がわからないという点についての回答は前述の仮想ブリッジでのヘアピンNATの話かなと思います。

Linux Containers については、LWN の記事と @ten_forward さんの記事が参考になると思います。

Device mapper については、下記スライドが参考になりました。

UNION Mount については、Oreilly の Programmer's High のブログが参考になりました。

まとめ

Docker化したWebアプリケーションにおけるパフォーマンス研究の成果について書きました。 IBMのレポートの内容から、Linuxカーネルとの接点となるUNION Filesystem や、その他 Host Networking、Volume などがパフォーマンスにおける重要な要素であることがわかりました。そこから、自分で検証してみて、ISUCON4予選問題の範疇では、iptables を使わずに docker-proxy というユーザランドのプロキシの使用を回避さえすれば、いずれのパターンでも性能の変化はないことがわかってきました。

iptablesを切って、nf_conntrack を切ってチューニングするような環境ではそもそもまともにDockerは動かせないので、ギリギリまでリソースを使い切るようなホストの場合はさすがにI/Oまわりのパフォーマンスが問題となってくると思います。 Linuxカーネル、特にUNION Filesystem周りでパフォーマンスに関する知見があればぜひ教えていただけると助かります。

パフォーマンスの観点から Docker を支える技術を調査してきてましたが、だいたい満足しました。1年半Dockerを触ってきて、知見もかなりたまってきたので、Production で Docker を投入できそうな頃合いだと思っています。

会場を提供していただいた Recruit Technologies の皆様、イベントを企画運営していただいた皆様、どうもありがとうございました。 非常に有意義なイベントでした。

はてなではWebオペレーションエンジニア(いわゆるインフラエンジニア)を募集しています。

Webオペレーションエンジニア職 - 株式会社はてな