2023年03月31日追記:この記事を基に、@sadnessOjisanさんより、コードレベルにより踏み込んだ、かつ、グリーンスレッドベースの新しいWebサーバアーキテクチャも含めて整理された記事Webサーバーアーキテクチャ進化論2023 | blog.ojisan.io が公開されました。
主に新卒のWebエンジニア向けに、古典的なWebサーバアーキテクチャを学ぶ道のりと代表的な実装モデルの概要を紹介します。
この辺りの話題がWeb界隈で流行っていたのは数年以上前というイメージですが、Webサービスは相変わらずWebサーバの上で動いているので、流行り廃り関係なく学ぶべき内容だと思っています。
また、HTTP/2がいよいよRFC化し、既にh2oやtrusterdなどのHTTP/2のサーバ実装があり、今後Webサーバアーキテクチャを再訪することが増えるような気がしています。
ところが、Webサーバアーキテクチャを学ぼうとすると、情報は古いものから新しいものまで山のようにあり、まさに海のものとも山のものともつかない、ググる度に翻弄されているという若者には厳しい状況があり、一旦自分の中でまとめてみようと思いました。
序論ということで、Webサーバアーキテクチャの基本を俯瞰できるということと、より発展的な話題へ進んでいくためのルーターのような役割を目指しています。
この春に入社した新卒のWebアプリケーションエンジニアと話をしていて、「preforkってなんですか」という話になった。アプリケーションサーバからRedisへ接続しているコネクション数のグラフを眺めていて、だいたいアプリケーションサーバのワーカープロセス数に等しいから妥当な数値だよね、とかそういう話をしていたときだったと思う。ちょっと前までAnyEvent::Redisを使っていたときに、アプリケーションサーバ・Redis間のコネクション数が異常な数値になっていて、AnyEvent::Redisをやめたら、通常値に戻ったという背景がある。
この話には2つの暗黙的な知識が前提にある。1つは対象のアプリケーションサーバがprefork型のアーキテクチャで動作しているということ、もう1つはワーカープロセス単位でRedisへのコネクションをキャッシュしているということだ。
なるほど、確かに個人で普通にアプリケーションのコードを書いていたら、WebサーバやDBへの接続管理の仕組みをあまり気にしないかもしれない。RailsのようなWebアプリケーションフレームワークを使いつつ、デプロイはHerokuにgit pushして終わりという時代なので、裏側がどのような仕組みで動いているかを気にする機会は少ない。特に新卒の彼はフロントエンドが得意なタイプなのでなおさらだ。研修で教えろよという気もするが、その手のことを教える研修はなかった。
いざ文献を教えようと思ったら、Webサーバの仕組みを学ぶための体系的なドキュメントが意外と見つからないことに気づいた。「Working With TCP Sockets」が分量的にも手軽で最適のように思うが、基本はソケットAPIの解説なので、Webサーバの実装面によりすぎている感もある。「サーバ・インフラを支える技術 4.2章 Apacheのチューニング」がよいかもしれない。UNIXのプロセスの知識もある程度必要だ。
そもそも自分もどうやって学習していたのか要を得ない。体系的に学んだとはとても言えず、ひたすらググって、naoyaさんやkazuhoさんの昔の記事を読んだり、Starletなどのサーバ実装を読んでみるというようなボトムアップ式で学んでいると思う。
一応、学生のときに基本的なTCPサーバ・クライアントを作っていて、socket、bind、connect、listen、select、accept、read、writeなどのソケットAPIのシステムコールを一通り叩いたことはあったが、それらが具体的に何を意味するのか、なぜ子プロセスでacceptしているのか親プロセスでacceptはしないのか、ノンブロッキングI/Oが何かなどをあまり理解できていてなかった
Webサーバアーキテクチャの歴史的経緯の複雑さも理解の難しさに拍車をかけている。Webサーバ、特にWebアプリケーションサーバのアーキテクチャについて、CGIから始まりmod_xxxやFastCGIなどがあり、現在のUnicornやStarletまで至る経緯を順番に学習していくのは骨が折れる。UnicornやStarletを使うということは、アプリケーションサーバとしてApacheを使わずに、アプリケーションロジックを書く言語と同じ言語のWebサーバを使うということだ。PerlならPerlで書かれたWebサーバを使うという具合に。
さらに、forkによるマルチプロセスモデル以外にマルチスレッドモデル、イベント駆動など、より負荷の少ない実装モデルやモデルの組み合わせが多数あることも理解を難しくしている。
少なくとも今からWebサーバアーキテクチャを勉強するのに、CGIやmod_xxxを学ばなければならないということはないと思う。ここでいうWebサーバアーキテクチャと呼んでいるのは、実際はWeb(HTTP)というよりはより汎用のTCPサーバの実装モデルの1つだからだ。実装を読んで勉強するなら、Perlの場合、StarletからPlack、アプリケーションフレームワークの順に処理を追ってみるとよさそう。Webサーバ以外にpreforkなTCPサーバをあまり知らないが、pgpool はpreforkだったと思う。
最終的に、古典名著とされている「UNIXネットワークプログラミング第2版 Vol.1 ネットワークAPI:ソケットとXTI」を読んでモヤモヤしていたものがすっきりした。UNIXで使用できるI/Oモデルや古典的なTCPサーバの設計について非常によく整理されている。原著が発行されたのが1990年台と古いが、読んでみると今でも色あせないことがわかると思う。Linux固有の事情などを除いて、ネットワークAPIまわりで困ったら、これを読めばよいという気持ちでいる。「UNIXネットワークプログラミング」を購入したのは5年前だったが、今読んでもまだまだ学びが多い。
Webサーバを理解しようとすると、UNIX、特にプロセスとネットワークAPIについての基本的な知識が必要だと思う。
最初から学習するなら、プロセスについては「Working With Unix Processes」と、ネットワークAPIについては前述の「Working With TCP Sockets」をおすすめしたい。前者の「Working With Unix Processes」は「なるほどUNIXプロセス ― Rubyで学ぶUnixの基礎」というタイトルで翻訳されているので、とっつきやすい。残念がら後者については翻訳されていないので、気合を入れて原文を読むことになる。
[asin:B0078VSRUE:detail][asin:B00BPYT6PK:detail]
プロセスについては、OS上で実行されるプログラムの基本的な実行単位であることと親プロセスと子プロセスなどのOSの基本的なプロセス管理をまず知っておけば良いと思う。ps
コマンドの見方がわかればだいたいよさそう。
ネットワークAPI周りについては、ソケットとは何か、listenやacceptなどの各システムコールの動作、プロセスおよびポート番号の関係などがわかればよさそう。lsof
、netstat
コマンドなどを叩いてみて、ファイルディスクリプタやコネクションの様子を眺めるとよい。簡単なTCP echoサーバを書ければなおよい。
これだけではあんまりなので、ポート番号とソケットについて理解しているところを書いてみる。
TCPやUDPで通信するときに、IPアドレスだけでは互いのエンドポイントを識別できない。IPアドレスで一意に決まるのはホストだけで、TCP/UDPで通信しようとすると単一ホスト上で動いている複数のプロセス(sshdやhttpdなど)のうちのどれかということを識別する必要がある。TCPとUDPはどちらも16ビット整数のポート番号を用いて異なるプロセスを識別している。
ちなみにIPアドレスはパケットのIPヘッダに書かれており、ポート番号はパケットのTCP/UDPヘッダに書かれている。
ソケットAPIの使い方について書かれていたり、実装としてのソケットについて書かれていても、ソケットがデータモデルとして何を表現しているのかということが書かれている文献は意外と少ないように思う。UNIXネットワークプログラミングには次のようにはっきりとソケットが何であるかが書かれていて、漠然していたものがすっきりした。ソケットとは、通信におけるエンドポイントを表現したデータモデルのことだ。
TCPコネクションのソケットペア(socket pair)は、コネクションの両方のエンドポイントを定義する、ローカルIPアドレス、ローカルTCPポート、リモートIPアドレス、およびリモートTCPポートの4つ組である。あるソケットペアは、インターネットの中の特定のコネクションを一意に識別する。
各エンドポイントを識別する2つの値、すなわちIPアドレスとポート番号は、多くの場合ソケット (socket) と呼ばれる。
UNIXネットワークプログラミング 第2版 Vol.1 p43
ソケットはUDPなどのコネクションレスなネットワークプロトコルにも使用されるが、上記引用分の続きにUDPにも拡張できると書かれている。
ソケットペアの概念は、コネクションを持たないUDPにも拡張することができる。ソケット関数(bind、connect、getpeernameなど)の説明をする場合、どの関数がソケットペアのどの要素を操作するのかに注目する。例えば、bindはTCPとUDPのどちらのソケットに対しても、アプリケーションがローカルIPとローカルポートを指定することを可能にしている。
UNIXネットワークプログラミング 第2版 Vol.1 p43
基本的には、HTTPでリクエストを受けてレスポンスを返すだけである。この一連の流れをリクエスト処理と呼ぶことにする。このリクエスト処理の中で、リクエストを受信し、HTTPヘッダをパースし、アプリケーション側で適切なハンドラを呼び出し、必要な場合は外部のサービスをたたいたり、DBアクセスやキューにデータをいれたりした上で、テンプレートエンジンなどによりHTMLをレンダリングもしくは任意のフォーマットでデータを構築して、クライアントにレスポンスとしてレスポンスヘッダをつけて返す。
preforkモデルなどのWebサーバアーキテクチャの主な関心は、リクエスト処理をどのようにして効率良く並行実行するかということにあると思う。
もし同時に1つしかリクエストを受け付けなくてよいなら、何も考えずにこれらの処理をループ内でシリアルに実行すればよい。しかし、実際は複数のクライアントからのリクエストを並行して処理しなければならない。
並行処理するためには、OSが提供するプロセスやスレッド、言語のランタイム上の軽量スレッド、イベント駆動モデルにおけるハンドラなどのなんらかの実行コンテキストにリクエスト処理を委譲する必要がある。並行処理の手法の数だけWebサーバアーキテクチャのモデルがあり、一番大雑把な分類がマルチプロセス、マルチスレッド、イベント駆動、もしくはこれらのハイブリッドしたモデルである。
以下では、各モデルの概要について大雑把に説明する。分類の仕方は、「UNIXネットワークプログラミング第2版 Vol.1」や「Working With TCP Sockets」を参考にしているが、例えばUNIXネットワークプログラミングではイベント駆動モデルは紹介されていないといった理由から同じ分類そのままではない。
最も単純なモデル。Perlの場合はHTTP::Server::PSGIがよい実装例。並行処理はぜずに、逐次に処理するだけである。アプリケーションの開発用途には並行処理はいらないので、手元のローカル環境だと、シリアルモデルのサーバで動かしたりする。
Perlの場合、ソケットライフサイクルのうち、例えばsocket、bind、listenまでをIO::Socket::INET
モジュールがやってしまうので、あまりソケットAPIを使っているという実感はないかもしれない。
下図に、シリアルモデルのソケットライフサイクルを示す。
以下のどのモデルにおいても、シリアルモデルのライフサイクルが基本となる。
先にも述べたように、シリアルモデルでは並行にリクエストを処理できない。そこで、リクエストを受信するたびに、forkにより子プロセスを生成し、子プロセスにリクエスト処理を任せる(1 connection per process)。
forkはメモリ上のプロセスのアドレス空間を丸ごと別のアドレス空間にコピーするため、一般に遅いと言われている。
しかし、実際はOSのメモリ管理の最適化手法の1つであるCopy On Write(Cow)という仕組みで、瞬間的なメモリコピーの負荷を抑えている。CoWはfork時には子プロセスの仮想アドレス空間に親プロセスのアドレス空間をマッピングし、親と子でアドレス空間を共有する。子プロセスは、メモリの参照時には親プロセスの物理アドレス空間を参照する。一方で、メモリの書き込み時には書き込まれたページを親子で共有することはできないので、書き込み前に該当メモリページを子プロセスにコピーしてから書き込む。以降、該当ページのメモリ共有はしない。(fork & execの場合は、子プロセスで親とは全く異なるプログラムを実行するため、親子間で共有できるページがなく、CoWが効かないはず)
CoWでメモリコピー負荷が抑えられるとはいえ、リクエストの度に余分な処理が発生するのは間違いないので、なるべくforkさせない手法としてpreforkモデルがある。preforkは言葉の通り、事前にforkすることを指す。サーバ起動時に事前に一定数の子プロセス*1をforkしておき、それらを使いまわすことで、リクエストごとにforkしなくてすむ。
prefork型のデメリットは、同時接続数分だけのプロセスをメモリ上に確保しておく必要があるため、メモリ消費量が多くなることだ。CoWで共有できると言っても、リクエストを処理していく度に親子間のメモリページの差異は大きくなっていく。
これをある程度解決するために、N回リクエストを処理した子プロセスを親プロセスが殺して再forkするような実装もある。これにより、定期的にプロセスが死んでメモリが開放されて、CoWによるメモリ共有率が比較的高いプロセスしか残らないようになり、メモリ使用量を削減できる。アプリケーションのメモリリークをそれほど気にしなくてもよいのがメリットと言える。MaxReqsPerChild
というような名前のパラメータで、Nを指定できるWebサーバが多い。
さらに、子プロセスの数以上のクライアント数を同時に捌くことができないという別のデメリットもある。これは後述するマルチスレッドモデルにも当てはまる。同時接続クライアント数が子プロセスの数を超えると、3-wayハンドシェイク済みの接続はカーネル内のキューにたまっていくが、acceptする人がいないため、接続は処理されないままとなる。すなわち、一般に詰まると言われるような現象が起きやすい。
PerlならStarlet、RubyならUnicornなどがこのモデルに該当する。
スレッドと呼ばれるものに、OSが提供するネイティブスレッドとプログラミング言語のVM上に実装されたグリーンスレッド(Erlangの「プロセス」、Go言語のgoroutineなど)がある。後者は軽量プロセスもしくは軽量スレッドと呼ばれたりする。ここでは、スレッドは主に前者を指すことにする。スレッドを明示的に生成せずに、単に1つのプロセスが動いている場合でも、1プロセス=1スレッドと考えて、1つのスレッドで動作すると表現することもある。
マルチスレッドモデルも、基本はマルチプロセスモデルと同じく、リクエストごとにスレッドを生成するモデル(1 connection per thread)と、事前にスレッドを生成しておくモデル(スレッドプール)がある。
スレッドは生成元のプロセスとアドレス空間を共有するため、プロセスのforkのように丸ごとアドレス空間からコピーすることはなく、スレッド生成のコストは一般にプロセス生成より小さいと言われている。(マルチスレッドのコンテキスト切り替えに伴うコスト - naoyaのはてなダイアリー )
メモリ消費量についても、スレッドのほうが一般に小さくすむと言われているが、これについては生成コストと同様の議論になる。
実際にマルチスレッドプログラミングしようとすると、複数のスレッドがメモリアドレス空間を共有しているため、リソース競合をプログラマが意識して避けなければならない。その点、マルチプロセスモデルの方がメモリアドレス空間が隔離されるため、コードが複雑になりにくいというメリットがある。MySQLに対してPostgresのほうがコードが綺麗と言われる所以は、前者がマルチスレッドモデルなのに対して、後者がマルチプロセスモデルであるということもあるかもしれない。
Node.jsなどに代表されるモデル。クライアントからの接続管理もリクエスト処理も、イベントループにより1つのスレッドで実行する。
シリアルモデルの説明のところで、accept、read、writeなどがI/Oが完了するまで処理をブロックすると書いた。したがって、1スレッドでは同時に複数のブロック処理を扱えないため、前述の2つのモデルでは、プロセスやスレッドのような異なる実行コンテキストに処理を委譲していた。
処理をブロックするようなI/OモデルをブロッキングI/Oと呼ぶ。UNIXには他にもI/Oモデルがあり、「UNIXネットワーキングプログラミング第2版 Vol.1 第6章」によると以下の5つがある。
- ブロッキングI/O
- 非ブロッキングI/O
- I/Oの多重化(selectとpoll)
- シグナル駆動I/O
- 非同期I/O(Posix.1のaio_関数群)
ブロッキングI/Oモデルでは通常1つのソケットのI/Oで処理をブロックしてしまうため、1つのプロセス/スレッドで複数のソケットを扱うことは難しい。そこで、イベント駆動モデルでは、上記のうちI/Oの多重化により、どのソケットからI/Oがあるかを知ることで、ブロッキングI/Oを用いていても複数のネットワークI/Oを捌けるようにしている。イベント駆動モデルについては、mizzyさんのスライドイベント駆動プログラミングとI/O多重化 がわかりやすい。
I/Oの多重化は、select/pollなどのシステムコールを用いて、複数のソケットのI/Oイベントを監視する。selectにより、データが受信可能なソケットでのみ、acceptやreadなどのブロッキングI/Oを基本的にブロックせずに呼び出せる。このI/Oイベント監視は、通常ループとして実装するためイベントループと呼ばれる。ループの先頭でselectを呼び出し、イベントがあるまでブロックし、イベントがあれば処理を再開する。
イベント駆動モデルのメリットは、preforkやスレッドプールと違って、同時に接続できるクライアント数に上限がないことだ。厳密にはハードウェアリソースやオープンしているディスクリプタ数の制限、listenバックログの制限などはもちろんあるが、モデルそのものには接続数の限界はない。
一方で、イベント駆動モデルのデメリットは1スレッドで動作するため、マルチコアスケールしないことだ。これは後述のハイブリッドモデルで解決される。
さらに、もうひとつのデメリットとして、リクエスト処理中にブロッキングするコードを書くとスレッドごとブロックしてしまうという問題がある。例えば、mizzyさんのスライドに書かれているように、libmysqlclient
はブロッキングI/O前提のコードなので、イベント駆動モデルでは使えない。基本的に、リクエスト処理中のデータベースとの接続にはノンブロッキングI/Oを使うなどの処理をブロックしない工夫が必要だ。いずれにしても、言語またはフレームワークレベルにおけるプログラミングモデルの非同期処理サポートがないとかなり複雑なコードになってしまうことが予想できる。
Node.jsや、PerlならTwiggy、RubyならEventMachine、PythonならTwisted、Webサーバ以外にはRedisなどがこのモデルに該当する。
ハイブリッドモデルは上記3つを組み合わせたモデルのこと。組み合わせ方により、様々なモデルが考えられる。ここでは、「マルチプロセス/スレッド -> イベント駆動」と「イベント駆動 -> マルチプロセス/スレッド」を紹介する。
純粋なイベント駆動モデルの場合、1スレッドでしか動作しないためマルチコアスケールしないという問題がある。そこで、prefork/スレッドプールとイベント駆動モデルを組み合わせることにより、マルチコアスケールさせるモデルがある。
具体的にはpreforkまたはスレッドプールと同様に起動時に一定数のワーカープロセス/スレッドを生成しておき、各ワーカーではイベント駆動モデルにより接続を受け付けリクエスト処理する。
Nginx、Play2などがこれに近い。前者はprefork+イベント駆動、後者はスレッドプール+イベント駆動で動作する。PerlならTwiggy::Preforkがprefork+イベント駆動モデルに相当する。
イベント駆動モデル同様ブロッキングI/Oなどの処理をブロックするようなコードを書くとそのプロセス/スレッドの処理は他のI/Oを捌けなくなるので注意が必要だ。
メインのスレッドがイベントループで接続管理をしつつ、なんらかの方法でメインスレッドがacceptで得たクライアントのソケットを後続のプロセス/スレッドに渡して、リクエスト処理を委譲するモデルがある。
preforkの場合、親プロセスでacceptしたソケット(ディスクリプタ)をUNIXドメインソケットにより子プロセスに渡す方式がある。これをディスクリプタパッシングと呼ぶ。
kazeburoさんのMonocerosがこれに近い。
EventMachineのように、基本はシングルスレッドのイベントループで接続とリクエスト処理を捌く一方で、長時間動いているもしくはブロッキング処理を遅延させるためにスレッドプールを提供するような実装もある。イベント駆動モデルにおいて、ブロッキング処理をどうしても書かないといけないときに、スレッドプールにそれを任せてしまえるということだと思う。
最大の参考文献は書籍「UNIXネットワークプログラミング」だが、Web上の資料についてもリストアップする。
これをみると、Webサーバアーキテクチャの話が2007年から2009年ぐらいまでの日本のWeb業界で盛んな話題だったことがなんとなくわかる。2010年以降はPerl界隈ではPlackが普及し、Plack上で動作するWebサーバがいくつか開発され、話題になったこともわかる。
上記の参考文献を読んでいくと、今回紹介したものをベースにより発展的な関連トピックとして、次のようなものがあるということがわかってくる。この辺がわかってくると、Webサーバアーキテクチャまわりの知識がついてきたと言えるかもしれない。
冒頭に書いているようにちょうど最近社内で話題になった内容ということもあり、以前から整理したいとも思っていたので、自分なりにまとめてみました。まだまだわかっているつもりでわかっていないところも多いと思いますが、もし自分が今から学んでいくならこういうエントリがあると助けになるかもしれないという視点で、Webサーバアーキテクチャの学び方のようなものを一応書いたつもりです。
古い話題とはいえ、今でも同じアーキテクチャのWebサーバを使っているので、トピックの重要度はそんなに変わってはいないと思っています。Web上の文献に頼るとどうしてもボトムアップ式の学習になり、体系的な知識が得られないと悩むこともあります。原典や古典と言われるような長く読まれ続ける書籍に案外答えが書いてあるということを個人的に特に顕著に体験した分野でもあります。
原典や古典を読む体験と聞くと、tomomiiさんの記事をよく頭に思い浮かべます。
「陳腐化しない確かな技術が必要だ」再び本に向かう
Webサーバについては、5年後、10年後もおそらく同じような仕組みの延長線上で実装されていると思うので、3年後に使っているかどうかもわからない流行りのツールでただ消耗するよりは息の長い技術や知識を身につけたいと思っている昨今です。もちろん流行りのツールも好きなんですが。
*1:preforkにおいて、子プロセスのことをワーカープロセスと呼ぶこともある。
引用をストックしました
引用するにはまずログインしてください
引用をストックできませんでした。再度お試しください
限定公開記事のため引用できません。