ウェブアプリケーション開発に新言語を採用したときにインフラで考えたこと

この文章は、サーバサイドのウェブアプリケーション開発において、社内実績の少ない新しい言語を採用したときにインフラ面で考慮したことを社内向けにまとめたものです。

はてなでは、長らくPerlでウェブアプリケーション開発を続けてきた一方、ここ数年で社内でScalaまたはGoの採用事例も増えてきました。 今後開発が始まるプロダクトにおいても、Perl、Scala、Goもしくは他の言語を採用するかどうかを開発開始時に選ぶことになるでしょう。

新言語を採用するときに、考慮すべきことの一つとして、「インフラ」への影響があります。 新言語に関する雑談をしていると、ウェブアプリケーションエンジニアに「インフラ」への影響について聞かれます。 もしくは、ウェブオペレーションエンジニアから考慮するポイントを伝えることもあります。 ScalaやGo以外に、Node.jsやサーバサイドSwiftはどうかというのも雑談レベルで話をしたりもしました。

そのような流れがあったため、ScalaまたはGoで書かれたプロダクトを2年ぐらい運用した結果をまとめておきます。

ウェブアプリケーション開発の言語として何を選ぶのか、といった技術選択そのものについては、はてなでの10年戦える新技術採用戦略の話 - Hatena Developer Blog を参考にしてください。

ウェブサーバアーキテクチャ

1つ目は、ウェブサーバアーキテクチャです。新言語を採用したときに、アプリケーションが動作するウェブサーバのアーキテクチャが変わる可能性があります。

ウェブサーバアーキテクチャ自体については、下記のエントリに書きました。詳しい内容はこのエントリを参照してください。

まず、採用しようとしている言語のウェブサーバが、上記エントリでいうところの、どのモデルなのかを確認します。

  • マルチプロセス(prefork)型
  • マルチスレッド型
  • イベント駆動型
  • ハイブリッド型
  • 軽量プロセス型

マルチプロセス(prefork)型

prefork型の場合、リクエスト処理コンテキストの単位がプロセスなので、コンテキストあたりのメモリ空間が独立しているという特徴があります。 MaxReqsPerChildのようなN回リクエストを処理しなおしたらforkし直すというようなこともできます。 したがって、運用観点でいうと、メモリリークや地雷リクエストへの対処がしやすいと言えます。

よほど1台あたりのパフォーマンスを求められない限りは比較的運用しやすいモデルといえます。

Perlの世界ではprefork型がデファクトになります。Rubyの場合はUnicornなどが相当するでしょう。 JVM上で動作する言語では普通やらない仕組みだと思います。

マルチスレッド型(スレッドプール)

マルチスレッド型は、リクエスト処理コンテキストの単位がスレッドなので、各コンテキストがメモリ空間を共有しているという特徴があります。 したがって、スレッド間でリソース競合を起こす可能性が高いため、プログラマが意識して競合を避ける必要があります。

意識といってもビジネスロジックのコードが問題になるというよりは、言語処理系の実装やライブラリの実装側に問題があることが多いと思います。 以前、Scalaのobjectのメソッド内のローカル変数の lazy valを使っている箇所のリソース競合問題により、著しくアプリケーションサーバのスループットが落ちるという現象がありました。

Javaの世界でよくみるモデルだと思います。他には、RubyのPumaなど。(追記: Pumaについては現在はマルチプロセスとマルチスレッドのハイブリッドモデルであるとコメントをいただきました。)

ウェブアプリケーション開発に新言語を採用したときにインフラで考えたこと - ゆううきブログ

Rubyのマルチスレッド型として紹介されるとMRIのGILが気になってしまいそうですが、Pumaは2以降のclustered modeだとマルチプロセス・マルチスレッドのハイブリッドなのでMRIでもコアを使い切れます。

2016/03/02 20:53
b.hatena.ne.jp

イベント駆動型

イベント駆動型は、イベントループにより1つのスレッドで複数のコンテキストを並行に処理します。 1スレッドで動作するので、マルチスレッド型以上にリソース競合に気をつける必要があります。

特にI/O処理をする場合、基本はノンブロッキングI/Oを使うことになります。 「ミドルウェアクライアント」の項にもでてきますが、意図せずブロッキングI/Oを使ってしまうと他のI/O処理をすべてブロックしてしまうので注意が必要です。

Node.js、RubyのThinなどで有名なモデルです。

ハイブリッド型

ハイブリッド型は上記の3種類のモデルの混合モデルを指します。 例えば、Play2のデフォルトはマルチスレッド+イベント駆動型になります。アプリケーションサーバにはあまり使われませんが、Nginxはマルチプロセス+イベント駆動型になります。

マルチプロセス/マルチスレッド+イベント駆動型の場合、たとえブロッキングI/Oを使っていても、ワーカープロセス/スレッド数分の並列度を維持できるので、あまり気にせずブロッキングI/Oを使うという選択肢もあると思います。

軽量プロセス型

Goのgoroutineベースのウェブサーバなどがこのモデルに相当します。 インフラ運用視点では、基本的にマルチスレッド型に近いと思います。

前述の3つのモデルはOSの仕組みを直接利用するものでしたが、軽量プロセスは、言語処理系上に実装されるため、言語ごとに特性を持っているといえます。 Goの場合、複数のgoroutineがOSの一つのスレッド上に多重化されます。

Goのネットワークサーバについては下記の資料が参考になります。 イベントループなしでのハイパフォーマンス – C10K問題へのGoの回答 | プログラミング | POSTD

JVMや軽量プロセス型の言語のように、言語処理系側で多くの制御をいれるような実装の場合、ロードアベレージやCPU利用率のようなOSのメトリックだけみてもボトルネックがわかりづらいです。

ミドルウェアクライアント

2つ目はMySQLやRedisなどのミドルウェアに接続するためのクライアントの実装に何が必要なのかについてです。

特にデータベースの接続管理まわりの話は以下のエントリにまとめています。

フェイルオーバ後の再接続

特に常時接続で問題になりやすいのが、ミドルウェア側がフェイルオーバした場合に、クライアントがフェイルオーバ先に再接続してくれるかどうかです。

再接続がうまくいかなければ、アプリケーションサーバを再起動するなどの対応が必要です。 これではいくらミドルウェア側で自動的に待機系に切り替わる仕組みをいれていたとしても意味がありません。

クライアントが再接続するためには、例えばRDBMSの場合、トランザクション開始時にpingして失敗したら再接続するような実装が必要です。 ただし、トランザクション中に再接続してしまうと、トランザクションがリセットされて、ACIDを満たせないことがあるからです。(再接続時にトランザクションがリセットされるかどうかはデータベースの実装によるところが大きいと思います。)

AWSのマネージドサービスであるRDS/ElasticCacheを利用する場合、DBのエンドポイントはDNSで参照することになります。 DNS参照の場合、再接続時に、DNSを引き直すような実装になっている必要があります。

このあたりはtkuchikiさんがわかりやすくまとめてくれています。

ブロッキングI/OとノンブロッキングI/O

ウェブアプリケーションサーバがイベント駆動型の場合、ミドルウェアクライアントのI/OがブロッキングI/OかノンブロッキングI/Oかはかなり重要です。

例えば、よくあるはまりどころに libmysqlclient はブロッキングI/Oしか対応していないため、libmysqlclientを用いたミドルウェアクライアントは仮にインタフェースが非同期であっても実際はブロックしてしまいます。

ScalaからJavaのライブラリを呼ぶ時などは注意が必要かもしれません。

ジョブキュー

ResqueやTheSchwartzのような汎用のジョブキューシステムが新しい言語では使えない可能性があることに注意が必要です。 実際、TheSchwartzやGearmanは基本的にPerlの実装です。ResqueやSidekiqは基本Rubyの実装ですが、他言語の実装もあります。しかし、挙動が一致している保証はないため、ある程度検証する必要はあると思います。

新言語を採用したときに、今までと同じジョブキューが使えない可能性があることは意外と盲点でした。 障害時にジョブキューの挙動を把握しておくことはかなり重要です。学習コストを抑えるために、できれば社内で言語ごとに異なる種類のジョブキューを運用するのは避けたいところです。

言語に依存しないジョブキューについて考えていた時期もありました。

実際には、特定言語に依存した汎用のジョブキューを使わずに、Redisを用いてアプリケーションに密結合する形で非同期処理をしていたりします。

DBスキーママイグレーション

DBスキーママイグレーションは、言語というより、ウェブアプリケーションフレームワークごとに管理の仕組みがあります。 Railsのmigrationが有名です。Play2ではevolutionという仕組みがあります。

Perlのプロダクトでは、スキーママイグレーションのための仕組みを用いずに、手動で丁寧にALTERするか、mysqldiffのような素朴なCLIツールを使っていました。

そこからスキーママイグレーションの仕組みに移ると、気軽にスキーマを変更しやすい反面、仕組みが複雑になりやすいというデメリットがあります。 データ量が増えているのに気づかず気軽にマイグレーションが発生した結果、ALTERによるテーブルロック時間が長くなり、無視できないダウンタイムが発生することもあるかもしれません。 (アプリケーションエンジニアが気をつけていてくれるので、自分はまだこの問題を実際に経験してはいませんが、今後を考えると不安にはなります。)

デプロイ

成果物の配置

インタプリタ型言語とコンパイラ型言語では、デプロイ先サーバへの成果物の配置方法が異なってきます。

PerlやRubyのようなインタプリタ型言語はビルドが不要なため、モジュールのインストールは必要ですが基本的にサーバ上でgit pullしてデプロイできます。 もしくは、管理サーバからデプロイ先にrsyncしてデプロイすることもできます。要はソースファイルを設置できればよいということになります。

ScalaやGoなどのコンパイラ型言語でも、ソースファイルをgit pullもしくはryncして各サーバでコンパイルすることも考えられなくはないです。しかし、本番サーバでCPU負荷のかかるコンパイルをやりたくはありません。コンパイルするための環境を用意するのも面倒です。

そこで、コンパイラ型の言語では、CIサーバかなにかで一旦ビルドしておき、ビルドした成果物をファイルサーバなりS3なりに置いて、デプロイ時に対象サーバに設置するというやり方でデプロイしています。

このとき、CIサーバと本番サーバとで、プロセッサのアーキテクチャ、OS、共有ライブラリなどを揃えておく必要があります。 ただし、JVM言語だとそのあたりの差分をある程度JVMが吸収してくれるため、そこまで気にしなくてよいかもしれません。さらに、Go言語はクロスコンパイルできてなおかつ、ポータビリティも高いので、JVM言語同様にそこまで気にしなくてもよいと思います。

ホットデプロイ(Graceful Restart)

言語ごとにホットデプロイ用のツール/ライブラリがある印象です。

PerlのPlackアプリケーションの場合、Server::Starterが有名です。 RubyのUnicornでServer::Starterに対応させたりもしているようです。「Server::Starterに対応するとはどういうことか」の補足 - sonots:blog Goの場合、Go言語でGraceful Restartをする - Shogo's Blog に紹介されている通り、いくつかの実装があります。ここに紹介されている以外にも、いくつか見た記憶があります。

JVM言語のホットデプロイの実装を見つけられなかったので、Scalaアプリケーションでは結局1台ずつローリングデプロイする手法をとっています。(追記:クラスローダによるホットデプロイの実装があるのは知っていますが、本番環境で使うものではないと聞いています。本番環境で使ってる事例があれば知りたいです。) 具体的には、SIGTERMシグナルを受け取ると、ロードバランサ用に生やしたヘルスチェックエンドポイントで503を返し、リクエストを振り分けられないようにします。その間、処理中のリクエストはそのまま処理しながら、N秒待ってからプロセスを停止します。 これら一連のフローを1or2台ずつ順番にやっています。 このような仕組みをPlay2のプラグインとして実装しています。

Server::Starter式に比べてデプロイに時間がかかるため、新旧のアプリケーションの共存時間が長くなってしまいます。 できればローリングデプロイをやめたいところです。

Server::StarterをJVM言語で使えるという話もあります Server::Starter から簡単に Java プロセスを起動できるようになった - tokuhirom blog。JVMのプロセスを2つたてたときのメモリ消費が気になるものの、そろそろこれを試すかという気持ちもあります。

もしBlue Green Deploymentができるなら、言語非依存な形でホットデプロイできることになります。

CI

インタプリタ型言語かコンパイラ型言語かによって、CIサーバのCPUリソースの消費特性が変わることがあります。

インタプリタ型の場合、CIではテストのみを実行するのが普通です。 しかし、コンパイラ型の言語だと、当然ですが、CIでコンパイルもします。 前述のデプロイの項目でも書いたとおり、成果物をCIサーバでビルドすることもあります。

特にScalaの場合、コンパイルが遅いことが有名です。 遅い上に自分の知る限りScalaの標準コンパイラはマルチコアスケールしないため、CIサーバにマイクロアーキテクチャが新しく、クロック周波数の高いCPUが求められます。(ということを実際のJenkins環境のCPUの使い方から観測していました。しかし、id:tarao さんによると、プロジェクトを複数のサブプロジェクトに分割していると並列ビルドされるということがあるようです。実際にサブプロジェクトに分割している環境でマルチコアスケールしていることを観測されたようです。)

一方で、インタプリタ型、コンパイラ型のどちらであっても、テスト実行にはCPUリソースを要します。 しかし、テストコードを工夫して、テスト実行を並列化できるため、テスト実行のためのCPUをコア数重視で選ぶこともできます。

モニタリング

ウェブアプリケーションサーバに必要なメトリックは、時間あたりに処理したリクエストの数や処理中のワーカー数などが一般的です。

当たり前ですが、言語処理系やウェブサーバごとに取得できるメトリックが異なります。 メトリックの取得方法も特に統一されたなにかがあるわけではありません。 メトリックがデフォルトで提供されていないことも普通です。 PerlだとPlack::Middleware::ServerStatus::Liteのような3rd partyのツールでメトリック取得用のエンドポイントを生やします。 Play2では、このようなプラグインが見当たらなかったので、stats取得用エンドポイントを生やしてもらいました。

ウェブサーバとしてのメトリック以外に、言語のランタイムに関するメトリックがほしいこともあります。 JVMの場合は、jstatもしくはjolokiaのようなツール/ライブラリで取得できます。 Goには標準的なものがなく、golang-stats-api-handlerのようなライブラリを使います。

あとがき

この文章で言いたいのは、開発言語の選択は単にプログラミング領域だけの問題ではないということです。

最初は開発言語の違いがどれくらいインフラに影響を及ぼすかはぼんやりとしかイメージできていませんでした。 実際にやってみて、結構あれこれと既存のものが使えないことに気づいていきました。

事前にわかっていればこの言語は選択しなかった、ということがないように、言語選択の議論に「インフラ」を絡める参考になればと思います。

「インフラ」と題打ってはいますが、実際には、ウェブアプリケーションエンジニアとウェブオペレーションエンジニアの境界の話です。 インフラの人がやってくれる、もしくはアプリの人がやってくれるというのではだめで、自分事として意識するのが大事だと思います。

※ 今回の話は 第3回関西ITインフラ系勉強会 - connpass で発表した内容を加筆修正したものです。