購読中です 読者をやめる 読者になる 読者になる

Mackerelを支える時系列データベース技術

サーバモニタリングサービス Mackerel で採用している時系列データベース Graphite を用いたシステムの構築と運用事情を紹介します。Graphiteについては、プロビジョニングやアプリケーションからの使い方、Graphite自体のモニタリングなど様々なトピックがありますが、特に大規模ならではのトピックとして、Graphiteの内部アーキテクチャ、パフォーマンスチューニングおよびクラスタ構成についての知見を書こうと思います。

背景

Munin、Zabbix、Growthforecastなどに代表されるサーバモニタリングツール、特にグラフによるメトリック可視化機能をもつようなツールを運用する場合、時系列データの扱いが重要になってきます。サーバのメトリックはCPU利用率やメモリ使用量などのOSのメトリックに加えて、JVMやMySQLなどのミドルウェアのメトリックを加えると、 1ホストあたりのメトリック数は100を軽く超えます。仮想化したものも含めてネットワークカードやブロックデバイスが複数ある場合は、デバイスごとにメトリックを収集するので、さらに多くのメトリックを収集することになります。

仮にメトリック取得間隔を1分として、1ホストあたりのメトリック数を100、ホスト数を1,000台とすると、1分あたり最低でも100,000以上のメトリックの書き込みに耐える必要があります。 自社サーバのみをモニタリングするのであればホスト数が1,000以上になるような環境はそれほど多くはないと思いますが、MackerelのようにSaaSとしてシステムを一般提供する場合、10,000台規模でスケーラビリティを考える必要があります。

時系列データを格納するためにバックエンドとして、MySQLのようなRDBMSを使う場合もあれば、RRDtoolのような時系列データに特化したデータベースを用いる場合もあります。 後者については最近では、Goで書かれたInfluxDBやHBaseをバックエンドとしたOpenTSDBなども選択肢に入ります。

RDBMSを用いた例として、New Relic Architecture - Collecting 20+ Billion Metrics a Day - High Scalability - があります。2011年の情報なので今はどうなっているかわかりませんが、NewRelicではMySQLのテーブルをアカウントごとかつ1時間毎に分割しているようです。

RRDtoolについては今のMackerelを開発する以前に数年前から自社開発していた社内Mackerelの時系列DBとして用いていたことがあります。これについては以前YAPCでトークしたことがあるのでそちらの資料を参照してください。RRDtoolで消耗している様子がわかります。YAPC::Asia 2013ではてなのサーバ管理ツールの話のはなしをしました - ゆううきブログ

このように様々な選択肢がある中でGraphiteを選んだのは、知見を溜めていたRRDtoolへの不満をうまく解消していたというのと、RRDtoolと同じようなデータ構造を採用していることから、Better RRDtoolとして使えることを期待したからです。

一方で、前述のInfluxDBやOpenTSDB、Kairosなどの採用も考えましたが、そもそもInfluxDBは当時バージョン0.3でまだまだこれからのプロダクトだったということもあり、さすがにプロダクションで使うものではなかったと思います。今もまだバージョン0.8でプロダククションで使えるかはわかりません。 OpenTSDBとKairosはそれぞれHBaseとCassandraをバックエンドとして用いており、理屈上はスケーラビリティに優れていそうには見えますが、慣れ親しんだマスター・スレーブ型以外の分散DBを運用しきれるのかという問題がありました。

Graphiteは賢いシャーディングや冗長化の仕組みをもつわけではありませんが、個々のコンポーネントの仕組みはシンプルです。 仕組みがシンプルでわかりやすいならば、いざとなればコードを読んで詳細な挙動を把握することもパッチをあてることもやりやすいので。これならなんとか運用しきれるだろうと考えました。 採用事例もそれなりに豊富です。特にEvernoteの事例をみたのが最初にGraphiteを知ったきっかけでした。Evernote Tech Blog | The Care and Feeding of Elephants

Graphiteシステム概観

Graphiteは、タイムスタンプ、メトリック名、メトリック値、これらの値の組を連続的に受け取り、グラフ化するというシンプルな機能をネットワークサービスとして提供しています。ネットワークサービスというのが重要で、RRDtoolの場合、それ単体ではネットワーク経由でデータの出し入れが難しくありました。GrowthForecastでは、HTTPインタフェースを提供するために、RRDtoolをバックエンドとしたWebアプリケーションという形式をとっています。

Graphiteではデータの書き込みと読み込みでプロトコルが異なり、TCPベースの独自のテキストプロトコルで時系列データを書き込み、書き込んだ時系列データをHTTPで取得します。 時系列データは、グラフ画像もしくはJSON形式で取得できます。単に取得できるだけでなく、時間範囲指定やアグリゲーションなどの機能を備えています。(詳細は公式ドキュメント http://graphite.readthedocs.org/en/latest/render_api.html , http://graphite.readthedocs.org/en/latest/functions.html ) Mackerelのようにグラフの描画はクライアントサイドJavaScriptで行う場合、JSON形式で取得します。

Graphiteは主に以下の3つのコンポーネントで構成されています。

  • whisper: ラウンドロビンデータベースファイルを作成・更新するためのライブラリ
  • carbon: 書き込み要求を受け付けるためのデーモン。厳密にはcarbon-cache。
  • graphite-web: 読み込み要求を受け付けるためのWebアプリケーション

f:id:y_uuki:20150425224002p:plain

まず、whisperはラウンドロビンデータベースとしてRRDtoolと似たようなデータ構造で時系列の数値データのみを格納します。(The Whisper Database — Graphite 0.10.0 documentation ) whisperは、サーバとして動作しクライアントとソケット通信するようなインタフェースはもたず、ただのPythonのライブラリとしてwhisper専用のフォーマットのファイルに保存されたデータを扱います。基本操作としてwhisper形式のデータファイルを作成するcreate、whisper形式のファイルに対して時系列データポイントを更新するupdate、時系列データポイント列を取得するfetchなどがあります。

次に、carbonはネットワークごしの書き込み要求を受けて、whisperライブラリを使ってデータファイルの作成と更新をします。( The Carbon Daemons — Graphite 0.10.0 documentation ) carbonに対する書き込み要求が多くなりがちなため、パフォーマンスを考慮して、ヘッダのパースなどのオーバヘッドの大きいHTTPではなく独自のテキストベースの単純なプロトコルを使います。具体的には、<metric path> <metric value> <metric timestamp>で表現された形式の文字列をTCPで送信すればよいだけです。 例えば、以下のようなコマンドを叩けばデータを書き込めます。( Feeding In Your Data — Graphite 0.10.0 documentation

PORT=2003
SERVER=graphite.your.org
echo "local.random.diceroll 4 `date +%s`" | nc -q0 ${SERVER} ${PORT}

テキスト形式だけでなく、Pythonのpickle形式でシリアライズすることもできます。pickleの方がcarbonデーモンにテキストパース処理が不要になるため、CPU効率がよくなるはずです。 クライアントからみて非同期書き込みとなっている点に注意が必要です。 MySQLのバイナリログのような仕組みもないため、絶対にデータをロストしてはいけないようなシステムには向いていません。

最後に、graphite-webはDjangoで書かれたWebアプリケーションです。 クライアントからのリクエストに応じて、whisperライブラリを通して該当ファイルのデータを読み出し、グラフをレンダリングします。 Webサーバとしてgunicornやuwsgiが使われることが多いようです。

データ構造とアーキテクチャ

パフォーマンス特性を明らかにするにはデータ構造とアーキテクチャについて知る必要があります。

whisperのデータ構造

時系列データを保存する上で重要なのはディスクサイズをどれだけ節約できるかです。素朴に考えると、1分おきにやってくるデータポイントを年単位で保存するとなると相当なディスク使用量になってしまいます。

そこで、古いデータについては一定期間で平均化するなり最大値を残すなりして丸めてしまってディスク使用量を節約するというのがラウンドロビンデータベースの考え方です。 例えば、1分精度のデータは1日分だけでよいが、5分精度のデータは1週間残すというようなイメージです。

precision(精度)とretention(データ保持期間)の組をarchiveと呼びますが、whisperのラウンドロビンデータベースはarchiveを複数定義し、あるprecisionのretentionを過ぎたら次点のprecisionに丸めて(Rollup Aggregation)、最も荒いprecisionのretentionを過ぎたらそれ以前のデータポイントを完全に削除するというような仕組みです。 (http://graphite.readthedocs.org/en/latest/whisper.html#archives-retention-and-precision) 。 データを取得するときは、指定した時間範囲に応じて適切なarchiveが選択されます。

whisperファイルの構造は以下の図のようになっており、先頭にMetadataとArchiveへのオフセットを格納するHeader領域があり、後続に複数のArchive領域が並びます。

(The Architecture of Open Source Applications - Graphite http://www.aosabook.org/en/graphite.html)

より詳細な情報はwhisperのコードに書いてあります。時系列データそのものはArchive領域に保存されていることがわかれば十分です。

# https://github.com/graphite-project/whisper/blob/0.9.12/whisper.py#L19-25
File = Header,Data
Header = Metadata,ArchiveInfo+
    Metadata = aggregationType,maxRetention,xFilesFactor,archiveCount
    ArchiveInfo = Offset,SecondsPerPoint,Points
Data = Archive+
    Archive = Point+
        Point = timestamp,value

データ書き込み時は、Headerを参照して最も高いprecisionをもつArchiveへのオフセットを取得して、該当Archiveの末尾へseekして書き込みます。 ただし、Rollup Aggregationする必要があるため、次点以下のprecisionをもつArchiveを必要があれば丸めて更新します。

carbon-cacheのアーキテクチャ

先に述べたように、carbon-cacheによりファイルシステム上にメトリックごとのwhisperファイルが作成されます。 メトリック数が膨大になるため、大量のwhisperファイルに対して1分ごとにデータポイントを書き込むことになります。

実装にはPythonのイベント駆動フレームワークのTwistedが使われており、書き込み要求をlistenするスレッドとwhisperを使ってデータを書き込むスレッドがそれぞれ独立して動作します。書き込み要求はlistenスレッドによりバッファリングされて、writerスレッドがバッファからデータポイントを取り出して、ディスクに書き込むという仕組みです。

ディスクのI/O性能低下などにより、同じメトリックだがtimestampの異なるデータポイントがバッファに貯まっても、whisperのupdate_manyでまとめて書き込んでくれるます。 (https://github.com/graphite-project/carbon/blob/0.9.12/lib/carbon/writer.py#L128)

ただしこのとき、whisperファイルへの反映は当然遅れるので、graphite-webには読み出し時にcarbon-cacheのメモリ上のデータを取得して、whisperファイルのデータとマージするという機能(CARBON_LINK)があります。

パフォーマンス特性

以上のアーキテクチャから、ディスクI/O、CPU効率などの観点からパフォーマンス特性について考察します。

まず、ディスクI/Oですが、carbon-cacheレベルでみると大量のファイルに小さな書き込みを頻繁に書き込むことになります。 さらに、whisperレベルでみると1つのファイルに対して、Archiveサイズ分離れた位置で複数のwrite I/Oが発生するということが言えます。 carbon-cacheレベルでみてもwhisperレベルでみても、ファイルシステム上の異なるブロックに対して同時に書き込むため、I/Oスケジューラによるwrite mergeが効きづらいように思えます。HDDのような低速なディスクの場合では致命的かもしれません。 Graphiteのアーキテクチャ上避けられない問題なので、SSDないしioDriveのようなフラッシュストレージを使って、高IOPSを捌けるようにするなどの力技が必要だと思います。

一方、CPU利用率という観点でみると、carbon-cacheとcarbon-relayは2スレッドでしか動作しないためマルチコアスケールしません。 carbon-relayの場合は、ロードバランサにぶら下げて横に並べれば垂直にスケールしますが、carbon-cacheはローカルのディスクに書き込むため、同じホスト上に複数のcarbon-cacheプロセスを動かす必要があります。

以上は書き込み時のパフォーマンス特性ですが、サービスの性質上人間がグラフをみるときだけ読み込みが発生するため、それほどスケールを気にする必要はないと思います。carbon-cacheの全方位書き込みのおかげで、大半のデータがOSのページキャッシュに載っているため、read I/Oが少ないということもあります。

パフォーマンスチューニング

Graphiteのパフォーマンス特性を踏まえて、ミドルウェアレイヤとカーネルレイヤでのチューニング方法を書きます。

ミドルウェアレイヤ

ミドルウェアレイヤといっても、主にcarbon-cacheのチューニングですが、パラメータの説明はcarbon.confのexampleにあります。

https://github.com/graphite-project/carbon/blob/0.9.12/conf/carbon.conf.example

この中でパフォーマンスに影響するのは以下のパラメータです。 パフォーマンス特性でみたように、CPUをなるべく使わないようにするということとディスクは高速なものを使うという方針でパラメータを決定します。

  • MAX_CACHE_SIZE
  • MAX_UPDATES_PER_SECOND
  • MAX_CREATES_PER_MINUTE
  • CACHE_WRITE_STRATEGY
  • WHISPER_AUTOFLUSH
  • WHISPER_FALLOCATE_CREATE

まず、MAX_CACHE_SIZEはcarbon-cache上のバッファサイズ(キャッシュサイズ)の上限です。バッファサイズが大きいと、その分ソートなどによるCPUコストが高くなります。 指定した方がよさそうにみえますが、MAX_CACHE_SIZEを指定してしまうとスレッド間でリソース競合を起こして、CPU使用率が跳ね上がるバグ?があるので、よほどメモリが少ない環境でない場合はinfを指定します。 バージョン0.9.13(未リリース)だと既に直っているかもしれません。https://github.com/graphite-project/carbon/issues/167

次に、MAX_UPDATES_PER_SECONDはwhisperへの書き込みレートを制限します。 書き込みレートを制限することにより、細々とディスクに書き込まずにバッファにデータポイントをためて、まとめて書き込むようになります。 ディスクI/O効率がよくなりますが、高速なディスクを使用しているので、今のところ特に制限不要なのでinfにしています。 バッファに溜め込みすぎるとcarbon-cacheが落ちたときのデータロストが大きくなるので、なるべく使わないようにして、I/Oで詰まったら試すぐらいがよいと思います。

MAX_CREATES_PER_MINUTEはwhisperファイルの新規作成のレートを制限します。パラメータの意図はMAX_UPDATES_PER_SECONDと同じでファイル作成時のI/Oを抑えるというものです。whisperは指定されたprecisionとretentionにしたがって、未来のデータ領域も最初に作成するので、I/Oインパクトが大きいです。 ただし、これも高速なディスクを使用しているので、今のところinfにしています。

CACHE_WRITE_STRATEGYはwriterスレッドがデータポイント列をディスクにフラッシュするときのオーダーを決定する方針を指定します。 sortedmaxnaiveの3つを選択できますが、SSDかつCPU利用率を節約したいときはnaiveを選びます。

WHISPER_AUTOFLUSHはwrite(2)後にfsync(2)するかどうかを指定します。クライアントからみればどのみち非同期書き込みであるというのと、CPU利用率を節約したいので、iowaitが増えそうなオプションは切ったほうがよいと思います。

WHISPER_FALLOCATE_CREATEfallocate(2)を使うことによりwhisperのファイル作成が高速化されます。fallocateが使用可能なファイルシステムあれば使ったほうがよいでしょう。高速な理由は、空のarchive領域を確保するのに、writeシステムコールでゼロフィルするより、事前に連続領域をOSに予約してからゼロフィルしているためのようです。(https://github.com/graphite-project/whisper/blob/0.9.12/whisper.py#L384-397)

実際はパラメータについて試行錯誤しましたが、全体としてはcarbon-cacheに何もさせないようなチューニングになっています。 ioDriveのような高性能なディスクを使う場合は、I/Oスケジューラもnoopを選んで何もさせないようにすることが多いと思いますが、carbon-cacheも同様で下手にI/O性能の管理をさせるよりは、何もさせないほうがよいようです。

カーネルレイヤ

カーネルレイヤではメモリ管理まわりとファイルシステムまわりでチューニングを試しました。

メモリ管理

まずメモリ管理まわりでは、スワップしたわけでもないのに、ページインとページアウトが頻繁に発生(スラッシング)し、read I/Oが増えるという問題がありました。 これは、carbon-cacheがファイルシステム上の全方位に定期的に書き込みをかけるため、whisperファイルの更新時のページキャッシュによりメモリを圧迫したためです。 whisperファイルへの更新はwriteだけなくMetadataの参照などでreadも実行されるので、おそらくキャッシュから追い出されたページに対してread I/Oが走っていたものと考えています。

スラッシングを防ぐ手段を3つ考えました。

まず参照することのない無駄なページキャッシュが大量にあるということに着目して、posix_fadviseによりwriteしたページのキャッシュを落としておくという方法があります。posix_fadviseはwriteしたページに対して、POSIX_FADV_DONTNEEDにより該当ページへは将来アクセスされないことをOSに伝えるという仕組みです。 これはRRDtoolではファイル作成時のみ使われています。更新時はなぜかコメントアウトされていました。https://github.com/oetiker/rrdtool-1.x/blob/72147e099cb655c1db5aca9b3c450aedbc0825ee/src/rrd_update.c#L952 whisperにパッチをあてて試していたのですが、writeしたページだけという判定が難しく、read対象のページのキャッシュも落としてしまったりしたので、うまくいきませんでした。 https://github.com/yuuki1/whisper/commit/42a662dbfeae9849e0824f4ecdd154446f32a176

次に、MySQLでも使われているI/Oダイレクトによりそもそも書き込み時にはページキャッシュしないという方法です。 I/Oダイレクトはopen(2)にO_DIRECTフラグを渡せばよいのですが、出力バッファを512バイト単位でアライメントしておく必要があるという制限があります。 昔CでSIMD演算やってたときはalignedアトリビュートでアライメントとったりしていましたが、Pythonだとposix_memalignを使えば良さそうに思ったものの、そこで諦めました。

結局、メモリ増やせばよいだけなので、数十GBくらいのメモリを積んで金で解決しました。

ファイルシステム

ファイルシステムまわりでは、ファイルシステムをext4かxfsのどちらを使うかという話があります。 大量のファイルを探索するという要求と、大量のファイルに同時に書き込むという要求があるので、ディレクトリツリーをB+treeで探索できて、並列I/O性能の優れたxfsが有利だと思いました。 念のため、同じサーバスペック(ioDrive)でnoatime, nobarrierでマウントしてブロックサイズは4KB、ioDriveなのでI/Oスケジューラをnoopにした2つのノードにcarbon-cacheをたてて、同じ量の書き込みをさせたところ、xfsのほうがCPU効率が1.07倍ほどよく、I/O timeも1.2倍ほど大きいという結果になりました。 IOPSはなぜかext4のほうが大きく、あまり考察ができていないという状態です。 思った以上に差がでなかったのはwhisperファイル数がまだ差が出るほどの数ではなかったというのと、carbon-cacheは同一ファイルに対して1スレッドしか書き込まないため、ext4でもそれほど並列性が悪くなかったのではないかと予想しています。

他には、whisperファイルが固定長であるという特徴を利用して、ブロックサイズをwhisperファイルの固定長に合わせるとI/O効率がよくなるかもしれないなど、まだ試していないこともあります。

クラスタ構成

Graphiteはcarbon-relayという仕組みを使って、冗長化または負荷分散のためにクラスタを構築できるようになっています。 もちろん、ロードバランサやDRBDも組み合わせて、クラスタを組むこともあります。 クラスタといっても、バイナリログを使ったマスター・スレーブ型でもRaftのような分散アルゴリズムを使ったものでもなく、非常に素朴です。

carbon-relayの仕組み

The Carbon Daemons — Graphite 0.10.0 documentation

carbon-relayはcarbon-cacheの前段で書き込み要求を複数のcarbon-cacheインスタンスにシャーディングもしくはレプリケーションします。 carbon-relay自体はcarbon-cacheとは別のインスタンスで動作し、carbon-cacheと同じくTwistedを使って実装されたデーモンです。 carbon-relayのシャーディング方式はconsistent-hashingとrulesの2つがあります。 シャーディングにより別々のノードにデータを分散保存できるため、carbon-cacheのCPU利用率、IOPS、ディスク容量などが分散できます。

まず、consistent-hashing方式はメトリック名をキーとしたconsistent-hashingで複数のcarbon-cacheに書き込み要求をシャーディングします。 一方、rules方式はメトリック名に対して、正規表現マッチングで分散先のノードを選択できます。 これにより、先頭一文字がaならノード1、bならノード2といった分散ルールを書くことができます。

consistent-hashing方式を使えば何もルールを決めなくても均等に分散される一方で、分散先のノードを増やしたときにシャードをrebalanceさせなければなりません。 ここでいうシャードのrebalanceとは、ノードを増やしたことにより同じキー名であっても分散先が変更されたために、同じキー名に紐づく既存のデータを新しい分散先に移動させることです。 consistent-hashingはノードの増減時に分散先のノードがなるべく変わらないようなアルゴリズムですが、それでも同じキー名に対して別のノードに分散することはあります。 この仕組みはキャッシュデータを格納するMemcachedなどに対しては使いやすいです。ノードの増減時になるべくキャッシュミス仮にキャッシュミスしたとしても、オリジンデータを引いて埋め直せばよいだけです。 もしconsistent-hashingを使うなら、carbonはシャードのrebalanceをサポートするような仕組みはないので、自前で仕組みを作る必要があります。

carbon-relayのもうひとつの機能であるレプリケーションはcarbon-cacheインスタンスを冗長化するための仕組みです。 レプリケーションといっても、MySQLのようなバイナリログを用いたものではなく、単純にcarbon-relayが複数のレプリケーション先のcarbon-cacheにそれぞれ書き込み要求を投げるだけです。 結構素朴な仕組みなので、carbon-relayインスタンスが落ちたときに全てのレプリケーション先でデータの一貫性は保証されません。そもそもcarbon-relayのバッファ上のデータをロストする可能性もあります。 素朴な仕組みだからといって、使えないかといえばそうでもなく、carbon-relayインスタンスがそんなに頻繁に落ちることはない、サーバのメトリックデータは絶対に欠けてはいけない性質のものではない、たとえ欠けたとしてもwhisperのRollup Aggregationにより過去のデータは丸められるため時間経過によりデータロストが気にならなくなる、という3点を考慮して使ってもよいと考えています。

クラスタ設定

クラスタの設定方法についてはClustering Graphite - bitprophet.orgThe architecture of clustering Graphite | Jamie Alquiza が参考になります。

Mackerelにおける構成

前述のGraphiteにおけるクラスタ構成を踏まえて、Mackerelにおける構成を紹介します。 Mackerelで構成を組むときに、Web上に公開されている様々な構成に目を通して研究しました。

初期構成

最初期の構成は本当に単純で1台のホストにcarbon-cacheとgraphite-webを立てておくだけでした。 もちろん、これでは1台落ちたら終わりなので冗長化を考えます。

carbon-relayにより冗長化

carbon-cacheの載ったインスタンスをtsdb-masterと呼んでいますが、tsdb-masterを2台用意し、carbon-relayをその前段におきます。 carbon-relayはreplicationモードで動作しており、書き込み要求を2台のtsdb-masterに複製します。

さらに、carbon-relayをSPOFにしないために、ロードバランサ以下にcarbon-relayを複数台並べます。 ロードバランサはkeepalivedで冗長化したLVSを使ってますが、ELBやHAProxyでもよいでしょう。

graphite-webはcarbon-cacheが書き込み先ファイルシステムと同じファイルシステムを参照する必要があるため、同じノードで両方動いています。 graphite-webもLVS越しに参照されます。 先に述べたようにtsdb-master間のデータの一貫性が保証されないことを考えると、tsdb-masterをVIPで参照すべきなような気はします。今のところはcarbon-relayがダウンしたときに、2つのtsdb-masterそれぞれにランダムにグラフを大量に描画させて、データロストを目視で確認してロストしてる方をLVSから外すということでよしとしています。

バックアップ

参照はされないものの、2台では不安なのでバックアップ用のtsdb-masterを作っています。carbon-relayにぶら下げるだけなので構築は簡単です。

マルチcarbon-cache

秒間書き込みデータポイント数が増えてくるとcarbon-cacheのCPUが1コア使い切るようになりました。 そこで、なるべくマルチコアスケールさせるために、tsdb-masterへの書き込みをcarbon-relayで受けてそこからconsitent-hashingで複数のcarbon-cacheに分散させました。 carbon-relayを挟むのではなくL4ロードバランサでも良かったのですが、consistent-hashingを使うことにより同じメトリックは同じcarbon-cacheインスタンスに分散させることで、update_manyによるまとめ書き込みを期待できます。

tsdb-relay-lb導入

さらに秒間書き込みデータポイント数が増えると、tsdb-master上のcarbon-relayのCPU利用率であたるので、carbon-relayを外に出すことを考えました。 外に出したcarbon-relayをスケールさせるために、LVSにぶら下げます。

1つのcarbon-relayでreplicationさせつつ、consistent-hashingするといったことができないので、carbon-relayが2段必要になってしまうのが難点ですね。 多段になればなるほど全体としての可用性やメンテナンス性は落ちるので、1段で完結させる方法を考えてはいます。 例えば、前段のcarbon-relayを無くしてアプリケーションに複製させたり、試したことはないもののcarbon-cacheはAMQPプロトコルをしゃべることもできるので、carbon-relayの代わりにRabbitMQを使うことも考えられます。

かなり複雑ですが、大雑把にみると冗長ペアを2台1組構築しているだけです。

ほとんど落ちることはないのですが、tsdb-masterノードが落ちたあとの復旧は、復旧側にcarbon-cacheの書き込みをさせつつ、rsyncで片肺からファイル同期します。 世の中的にもほとんどrsyncを使っているようです。 一旦どこかのレイヤで書き込み要求を貯めこんでその間にデータ同期するということができないと一貫性のあるデータ同期はかなり難しいです。

もちろんDRBDを使って同期するという方法はあります。試したものの、DRBDは更新のあったブロックを同期する仕組みなので、carbon-cacheのような全方位書き込みをすると、大量のブロックを同期しようとするため、ネットワーク帯域であたるということがありました。

Graphite開発状況

https://github.com/graphite-project/

Graphiteは2006年から開発が始まったプロダクトです。 最近では大きなリリースはないものの、開発はゆるやかに進んでおり、プルリクエストもそこそこ活発というような状況です。 ただ、中の人のメンテナンスが追いついてないというか、結構放置されたプルリクエストが多いですね。 たまにissueがまとめて大量closeされたりしています。 whisper以外テストコードがない状態なので、いきおいよくマージできる状態じゃないのかもしれません。

現在のstableバージョンは0.9.12でMackerelでもこのバージョンを使っています。

一方で、次世代のCarbonとWhisperの実装として、MegacarbonとCeresというものもあります。 今ひとつ開発状況をつかめていないですが、Yahoo!で大規模に使われている事例もあります。

参考

まとめ

今回ははてなにアルバイトにきて初めてRRDtoolを触ってからの2年半にわたって蓄積した時系列DB、特にGraphiteに関する知見を紹介しました。 今の構成に落ち着いたのは1年ほど前ですが、改善する余地は多々あるものの正式リリース以降もそれほど大したトラブルもなく安定して動作しています。

とはいえ、現状安定しているシステムでも、サービスの成長にあわせてスケールさせていく必要があります。 特にトラヒックの桁が1つ2つ上のスケーラビリティを達成するためにはシステムのアーキテクチャを大きく変えることもあるでしょう。

はてなでは、計算機、OS、ネットワーク、ミドルウェアの知識を駆使して、日々成長していくサービスのトラヒックに耐えるシステムを構築・運用することに興味のあるエンジニアを募集しています。

Monitoring With Graphite

Monitoring With Graphite

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すごかったよ。