🌐

Webサーバーの仕組みをまとめる

に公開

Webアプリを開発していても、Webサーバーについての詳細な知識が必要になることは少ないと思います。依然としてWebアプリはWebサーバーの上で動いてはいますが、クラウドのマネージドサービスで動かすことも多くなった現代でそういった知識が必要になるケースは、経験の少ない僕には想像できません。Webアプリのボトルネックはもう少し高いレイヤーにあることが多く、パフォーマンス改善のためならそちらを学ぶほうが効率が良いと考えています。

そんなことより、Webアプリを開発しているとWebサーバーの仕組みは気になりますよね。僕は気になります。特に、Webサーバーがどのように複数のリクエストを捌いているのかは全くわかりませんでした。また、WebサーバーがWebアプリとやり取りする方法や、WebサービスにおいてWebサーバーの他に必要になるコンポーネントもあまり想像できません。

この投稿は、Webサーバーの仕組みについて調べ、自分の理解のためにまとめたものです。

Webサーバーとは

Webサーバーとは、リクエストを受けて何らかのデータをレスポンスとして返すソフトウェアやハードウェアのことです。データとしてはHTMLページ、画像、CSSスタイルシート、JavaScriptファイル、JSONなどがあり、要求に応じてこれらのデータを返します。

現代のWebサーバーは複数のクライアントからのリクエストを並行して処理する必要があります。一つのリクエストが処理されるまで他のリクエストをブロックするというように、リクエストを一つずつ処理するだけならイメージはつきやすいと思います。しかし実際には並行処理が行われるため、複雑になります。

Webサーバーのアーキテクチャは並行処理の手法の数だけ存在し、詳細を理解するためにはシステムプログラミングの領域に踏み込む必要があります。アーキテクチャとしては、マルチプロセス・マルチスレッド・イベント駆動・ハイブリッド・グリーンスレッドなどがあります。これらのアーキテクチャの詳細を理解するには、I/Oがどのように処理されるかといったシステムプログラミングの知識が必要になってきます。

ネットワークプログラミングの概要

Webサーバーの仕組みを知るために重要なシステムプログラミングの領域はネットワーク通信です。Webサーバーはクライアントからネットワークを通してリクエストを受け付けたあと、クライアントからデータを受け取ったり、クライアントにデータを渡したりします。

基礎知識

まずは、ネットワークプログラミングで前提となる基礎的な知識について解説します。

システムコール

ここではシステムコールを利用するプログラミングをシステムプログラミングと呼びます。

システムコールは、アプリケーションがOSのカーネルの機能を呼び出すための方法です。現代のCPUではセキュリティのためにユーザーモードとカーネルモードを区別しています。カーネルモードは無制限の操作が可能ですが、ユーザーモードには制限があります。OSは、カーネルモードで動作する処理をシステムコールとして提供し、アプリケーションはシステムコールを通じてカーネルモードでの操作を実行できるようになります。

代表的なシステムコールとしては、openreadwritecloseなどがあり、ファイルの操作を行います。後述するネットワーク通信で使用されるソケットに関連するものとしては、socketbindlistenacceptconnectなどがあります。他にネットワーク通信で重要になってくるものだと、selectepoll(kqueue)のようなI/Oイベントの通知のためのシステムコールがあります。

ソケット

ネットワーク通信では、ソケットと呼ばれるインターフェースが使用されます。

ソケットは通信におけるエンドポイントを表現したデータモデルのことで、

  • ローカルIPアドレス
  • ローカルTCPポート
  • リモートIPアドレス
  • リモートTCPポート

が含まれます。例えばクライアントとWebサーバーが通信しているとき、Webサーバー側のソケットには、Webサーバー自身の情報がローカルIPアドレス・TCPポートに含まれており、通信先のクライアントの情報がリモートIPアドレス・TCPポートに含まれています。

ネットワーク通信ではソケットを使用してクライアントからデータを受け取ったり、クライアントにデータを渡すことができます。具体的には、ソケットを読み取ることでデータを受け取り、ソケットに対して書き込むことでデータを渡すことができます。

ファイルディスクリプタ

ソケットはファイルディスクリプタと呼ばれる整数によって参照されます。

ファイルディスクリプタはUnixにおいてプロセス内のファイルを参照するときに使用される整数のことで、Unixでは様々なリソースがこれによって参照されます。ファイルやソケットのほか、接続されているキーボードや仮想デバイスであるターミナルなどもあります。ファイルディスクリプタはプロセス内で一意な整数であり、プロセス間では一意な整数にはなっていません。

このファイルディスクリプタはOS内部に存在するオープンファイル記述と呼ばれるデータで管理されています。オープンファイル記述の中でプロセスIDとファイルディスクリプタによって、どの情報が参照されるのかが決まります。

Unixのこのような思想は「Everything is a file」と呼ばれ、統一したインターフェースであらゆるリソースを扱えるようになります。例えばファイルの読み書きとソケットの読み書きを同じシステムコールで行うことができます。

Web開発でもよく見る標準入力、標準出力、標準エラー出力にもファイルディスクリプタが割り当てられており、それぞれ0、1、2になっています。画面への出力は、標準出力のファイルディスクリプタである1に対してファイル書き込みのシステムコールを実行することで実現できます。

リクエスト処理のフロー概要

ソケットを使用してWebサーバーとしてリクエストを処理するには、ソケットに関するいくつかのシステムコールを呼ぶ必要があります。リクエストを処理する流れは以下のようになります。

  1. ソケットの作成 (socket)
  2. ソケットにアドレス割り当て (bind)
  3. リクエストの待機 (listen)
  4. リクエストの受け付け (accept)
  5. リクエストを処理...
  6. ソケットのクローズ (close)

Rubyのsocketライブラリを使って書くと、以下のようになります。

require 'socket'

server = Socket.new(:INET, :STREAM) # ソケットの作成
addr = Socket.pack_sockaddr_in(4481, '0.0.0.0')
server.bind(addr) # アドレスの割り当て
server.listen(5) # リクエストの待機

connection, _ = server.accept # リクエストの受け付け
# リクエストを処理...
connection.close # ソケットのクローズ

Socket.acceptはリクエストがあるまで後続の処理をブロックし、リクエストが到着するとクライアントと接続されているソケットを作成します。acceptはクライアントとWebサーバーでTCPハンドシェイクによってコネクションを確立した時点でブロックを解除します。

このacceptで作成されたソケットを読み書きしてクライアントとデータをやり取りします。acceptによって作成されるソケットには、WebサーバーのIPアドレスとTCPポートがローカル情報として、クライアントのIPアドレスとTCPポートがリモート情報として含まれているため、クライアントとの通信に使用できます。

また、Rubyのsocketライブラリではリクエスト処理のループを以下のように実装できます。

require 'socket'

Socket.tcp_server_loop(4481) do | connection |
  # リクエストを処理...
  connection.close
end

ソケットのI/Oモデル

ここでは、ソケットに関するI/Oモデルについて解説していきます。

ブロッキングI/O

ソケットを読み書きする方法として、ブロッキングI/Oがあります。

ブロッキングI/Oとは、I/O操作の前に待ちが発生して後続の処理がブロックされる可能性のあるI/Oです。例えばソケットがノンブロッキングモードではない場合のreadwriteが該当します。ブロッキングI/Oの場合、readはデータの読み込み準備が完了するまで処理をブロックし、writeは書き込み準備が完了するまで処理をブロックします。readはEOFを受け取るか最小バイト数を受け取るまで処理をブロックすることもあります。

ブロッキングI/Oを使用したコードは以下のようになります。

require 'socket'

Socket.tcp_server_loop(4481) do | connection |
  request = connection.read
  connection.write request

  connection.close
end

ノンブロッキングI/O

ソケットを読み書きするもう一つの方法は、ノンブロッキングI/Oです。

ノンブロッキングI/Oとは、I/O操作の前に待ちが発生しそうなら即座に関数から返ってくるI/Oです。例えばソケットがノンブロッキングモードである場合のreadwriteが該当します。ノンブロッキングI/Oの場合、I/O操作の準備ができていないと関数からすぐに返ってきます。

ノンブロッキングI/Oを使用したコードの例は以下のようになります。

require 'socket'

Socket.tcp_server_loop(4481) do | connection |
  loop do
    begin
      # 内部でreadを呼ぶ
      puts connection.read_nonblock(4096)
    rescue Errno::EAGAIN
      retry
    rescue EOFError
      break
    end
  end

  connection.close
end

readが準備中の場合にはリトライしていますが、あまり意味がないように見えます。準備中の場合にはErrno::EAGAINが発行されるので、準備が完了するまでreadを呼び続けています。readがブロックしなくなったとはいえ、結局はループが発生しており、ブロッキングI/Oと変わらないように見えす。

I/O多重化

ノンブロッキングI/Oの真価は、I/O多重化と一緒に使うことで発揮されます。詳細は後述するWebサーバーアーキテクチャのイベント駆動で解説します。

I/O多重化とは、selectシステムコールで複数のファイルディスクリプタを1プロセス1スレッドで管理することを指します。selectはI/Oイベントの通知を監視するためのシステムコールですが、効率があまり良くないので現代では効率の良いepollkqueueが使用されます。インターフェースは異なっていますが、似たようなことを実現するシステムコールなので、ここではselectで説明します。

selectは複数のファイルディスクリプタを監視し、I/O操作の準備が完了しているファイルディスクリプタを返すシステムコールです。例えばWebサーバーが複数のクライアントと接続している場合、それらのソケットをselectに渡すと、I/O操作の準備ができたソケットが返ってきます。準備ができているソケットがなかったりタイムアウトになるまでselectは後続の処理をブロックします。

selectを使用するIO.selectを使ったI/O多重化のコードは以下のようになります。

connections = [<TCPSocket>, <TCPSocket>, <TCPSocket>]

loop do
  ready = IO.select(connections)

  readable_connections = ready[0]
  readable_connections.each do | conn | 
    data = conn.readpartial(4096)
    process(data)
  end
end

ここでは3つのソケットをIO.selectの第一引数に渡し、読み込み準備が完了したソケットのデータがIO.selectが返す配列の最初の要素に入っています。readpartialは使用可能なデータをすぐに返すRubyの関数ですが、読み込み準備ができていない場合にはブロックします。selectから返って来ているのでI/Oの準備は完了しているためこれを使用しています。

IO.selectは第一引数に読み込みたいIOオブジェクトの配列、第二引数に書き込みたいIOオブジェクトの配列を渡すことができます。内部でselectシステムコールが呼ばれ、読み込みと書き込みを監視するファイルディスクリプタの配列が渡されます。

非同期I/O

ソケットを読み書きする方法として、非同期I/Oもあります。

非同期I/Oは、I/O操作を発行するとすぐに関数から返り、バックグラウンドでI/O操作が実行されます。Linuxで現在普及しているのはio_uringというもので、Linux独自のシステムコールとして非同期I/Oを実現しています。

ここで言っている非同期I/Oは、あくまでシステムコールレベルでの非同期I/Oです。プログラミング言語やライブラリは、こういったシステムコールを直接使用せずにアプリケーションレベルの非同期I/Oを実現できます。アプリケーションレベルの非同期I/Oは、システムコールではなく言語やライブラリが提供する関数でこれを実現する方法といえます。

例えばNode.jsで使われている非同期I/Oのためのライブラリであるlibuvはシステムコールレベルの非同期I/Oを使用していません。ネットワークI/Oは非同期I/OではなくノンブロッキングI/O + I/O多重化を使用していますし、I/O多重化が使えないファイルI/Oは非同期I/Oではなくマルチスレッド + ブロッキングI/Oを使っています。

Webサーバーアーキテクチャ

ここでは、Webサーバーが複数のリクエストを処理している手法で分類されたWebサーバーアーキテクチャについて解説します。これらのアーキテクチャは一つのリクエストを処理する方法がベースになっています。

アーキテクチャの名前は必ずしも一般的なものではありません。

マルチプロセス

マルチプロセスアーキテクチャは、複数のプロセスでリクエストの並行処理を実現するアーキテクチャです。1つのプロセスが1つのリクエストを処理し、そのプロセスを複数生成します。

このアーキテクチャは、リクエストを受け付けてからプロセスを生成する方法と、事前に生成したプロセスを使う方法が存在します。ここでは前者をオンデマンド型(造語です)、後者をprefork型と呼びます。

オンデマンド型はリクエストを受け付けてからプロセスを生成するアーキテクチャです。処理の流れは以下のようなものが考えられます。

  1. サーバーのメインプロセスがリクエストを受け付ける (accept)
  2. サーバーのメインプロセスが子プロセス(ワーカープロセス)をforkする
  3. 子プロセスでリクエストを処理し、メインプロセスは1に戻る

メインプロセスはリクエストを受け付けるためのソケットを持っており、acceptしたあとにforkします。forkでは親プロセスのファイルディスクリプタを子プロセスにコピーするため、acceptで作成されたソケットを子プロセスで操作できます。

オンデマンド型にはシンプルな実装で複数リクエストを並行処理できるというメリットはありますが、リクエスト毎のforkは無駄なので事前に生成するアーキテクチャが使われることが多いと思います。また、大量のリクエストでプロセスの数が膨大な量になってしまい、システムがダウンするという問題が発生する可能性があります。

prefork型は事前に子プロセスを生成しておき、子プロセスでリクエストを受け付けるアーキテクチャです。処理の流れは以下のようなものが考えられます。

  1. サーバーのメインプロセスがリクエストを待機するソケットを作成する
  2. サーバーのメインプロセスが指定の数だけ子プロセスをforkする
  3. 子プロセスが1で作成したソケットでリクエストを受け付け、処理する

メインプロセスで事前に待機用のソケットを作成して、複数の子プロセスがそのソケットを使ってリクエストを受け付けます。forkするとソケットが親子と兄弟間で共有され、リクエストがあった場合にはどれか一つのプロセスだけが応答します。

prefork型にはリクエストのたびにforkのコストを払う必要がないというメリットはありますが、複数のプロセスを生成するため多くのメモリを消費するというデメリットはあります。また、上の例だと事前に生成したプロセス以上のリクエストがあると性能が落ちてしまいます。

prefork型が使われているWebサーバーソフトウェアとしては、PHPでよく使われるApache MPM prefork があります。

マルチスレッド

マルチスレッドアーキテクチャは、複数のスレッドでリクエストの並行処理を実現するアーキテクチャです。1つのスレッドが1つのリクエストを処理し、そのスレッドを複数生成します。

このアーキテクチャは、リクエストを受け付けてからスレッドを生成する方法と、事前に生成したスレッドを使う方法が存在します。ここでは前者をオンデマンド型(造語です)、後者をスレッドプール型と呼びます。

このアーキテクチャはマルチプロセスと似ていますが、プロセスとスレッドにはいくつか違いがあります。プロセスはスレッドよりも分離性が高く、プロセス間ではメモリを共有しませんが、スレッド間ではメモリを共有します。一方で、プロセスはメモリを共有せずコピーするので生成のコストが高く、スレッドは共有するので生成のコストが低いです。

オンデマンド型はリクエストを受け付けてからスレッドを生成するアーキテクチャです。処理の流れは以下のようなものが考えられます。

  1. サーバーのメインスレッドがリクエストを受け付ける (accept)
  2. サーバーのメインスレッドがスレッドを生成する
  3. スレッドでリクエストを処理し、メインスレッドは1に戻る

スレッドはプロセスを共有するので、acceptで生成したソケットをスレッドの中で使用することができます。プロセスと違いメモリをコピーするわけではないので、acceptで生成したソケットをグローバルな領域に保存しておくと、別のリクエストを処理しているスレッドから触れてしまうことに注意が必要です。

マルチスレッドのオンデマンド型は、マルチプロセスのオンデマンド型と同じメリット・デメリットがあります。プロセスと違うのはスレッドのほうが分離性と生成のコストが低いことです。

スレッドプール型は事前にスレッドを生成しておき、スレッドでリクエストを受け付けるアーキテクチャです。処理の流れは以下のようなものが考えられます。

  1. サーバーのメインスレッドがリクエストを待機するソケットを作成する
  2. サーバーのメインスレッドが指定の数だけスレッドを生成する
  3. スレッドが1で作成したソケットでリクエストを受け付け、処理する

メインプロセスで事前に待機用のソケットを作成して、複数のスレッドがそのソケットを使ってリクエストを受け付けます。スレッドはプロセスを共有しているので、ソケットを共有することができ、リクエストがあった場合にはどれか一つのスレッドだけが応答します。

こちらもマルチプロセスのprefork型と同じメリット・デメリットがあります。また、プロセスとスレッドの違いによるメリット・デメリットはオンデマンド型と同じです。

イベント駆動

イベント駆動アーキテクチャは、シングルスレッドで動作するイベントループでリクエストを並行処理するアーキテクチャです。1つのスレッドが複数のリクエストを処理します。一般的にはノンブロッキングI/OI/O多重化が使用されます。

イベント駆動の処理の流れは以下のようなものが考えられます。以降はselectを想定しますが、より効率的なepoll(kqueue)でも同じことが言えます。

  1. サーバーは接続を待機するソケットを作成し監視する (select)
  2. 接続があったら、監視するソケットのリストにそのソケットを追加する
  3. サーバーは1と2の接続を監視する
  4. 監視中の接続が読み込み可能という通知を受けると、一定サイズだけ読み取り、関連するコールバックを呼び出す - 最後まで読み取ったら監視リストから削除し、それ以外の場合は引き続き監視する
  5. 監視中の接続が書き込み可能という通知を受けると、一定サイズだけ書き込む
    • 書き込みが終了したら監視リストから削除し、それ以外の場合は引き続き監視する

このアーキテクチャは、イベントループと呼ばれるループの中で「読み込み可能」「書き込み可能」のようなI/Oイベントの通知をselectで監視して処理します。読み込みや書き込みなどのI/OにはノンブロッキングI/Oが使われ、一度にすべてのデータが読み書きされるのではなく、一定のサイズで行われます。

このアーキテクチャでは、I/O多重化によって以下のソケットを1つのスレッドで管理します。

  • クライアントからの接続を待機する1つのソケット
  • 読み込みを監視したい複数のソケット
  • 書き込みを監視したい複数のソケット

接続を待機するソケットはサーバーの起動時に生成して監視し、接続を受け付けて生成されたソケットも合わせて監視します。起動時に生成したソケットでacceptを行い、acceptで接続が確立されるたびに、作成されたソケットを監視リストに追加していきます。

4の読み込みにはクライアントからのリクエストデータの読み込みが含まれ、「関連するコールバック」にリクエストの処理が含まれています。このコールバックの中ですべてのリクエストデータが揃ったときに何らかの処理を実行し、レスポンスに書き込みます。データを最後(EOF)まで読み込めなかった場合には、次の通知で読み込みを再開します。

5の書き込みにはクライアントへのレスポンスデータの書き込みが含まれています。データを最後まで書き込めなかった場合には、次の通知で書き込みを再開します。

イベント駆動のメリットは、プロセスやスレッドの生成コストや生成の上限の影響を受けないことです。マルチプロセス/マルチスレッドのオンデマンド型ではプロセス/スレッドの生成コストや大量の生成でパフォーマンスが落ちてしまう問題がありましたが、このアーキテクチャでは1プロセス1スレッドなので影響を受けません。preforkやスレッドプールの場合には事前に生成する数を決めなければいけないという問題がありましたが、こちらも問題にはなりません。上記のような問題はC10K問題と呼ばれることもあり、イベント駆動はそれを解決すると言えます。

イベント駆動のデメリットは、リクエストの処理中にブロッキングするコードを書くとスレッドごとブロックされてしまうことです。例えば上記の処理の流れの4にあるコールバックの中で無限ループをするようなコードを書くと、リクエストを受け付けられなくなります。これはマルチプロセス/マルチスレッドのアーキテクチャでは発生しにくい問題です。

コンピュータにおける処理は、性能が制限される要因によってCPUバウンドな処理、I/Oバウンドな処理に分類することができます。CPUによって性能が制限される処理がCPUバウンドな処理で、データの暗号化や画像処理などの処理が該当します。I/Oによって性能が制限される処理がI/Oバウンドな処理で、データベースの操作や外部APIの呼び出しなどの処理が該当します。

イベント駆動は、I/Oバウンドな処理が多いケースで性能を発揮することができますが、CPUバウンドな処理が多いケースでは性能が発揮できないことがあると言えます。ただ、CPUバウンドな処理を外部のAPIにオフロードして、I/Oバウンドな処理にすることはできるかもしれません。

また、イベント駆動はCPUの性能を限界まで引き出すことが難しいというデメリットもあります。このアーキテクチャは1スレッドで動作するため、CPUのコア数が増えても1スレッドを処理しているコア以外は無駄になってしまいます。

イベント駆動はNode.js (libuv) で使われています。

ハイブリッド

上記の各アーキテクチャの要素を部分的に組み合わせたアーキテクチャをハイブリットアーキテクチャと呼びます。マルチプロセスでマルチスレッド、マルチプロセスでイベント駆動、マルチスレッドでイベント駆動のアーキテクチャなどが考えられます。

ここではWebサーバーソフトウェアがどのようなアーキテクチャなのかを見ていきます。

Nginxは、マルチプロセスでイベント駆動なアーキテクチャと呼ぶことができると思います。これはpreforkで事前にプロセスを生成しておき、各プロセスでイベント駆動を使用してリクエストを並行処理します。マルチプロセスアーキテクチャとは違って1プロセス1リクエストではないのですが、リクエストの処理のために複数のプロセスを生成します。

NgixnはCPUの性能を引き出すことが難しいというイベント駆動のデメリットを解消しています。マルチプロセスの中でイベント駆動が使用されるため、一つのサーバーで複数のプロセスが使用され、マルチコアの恩恵を受けることができます。デフォルトではプロセスは1つですが、CPUコア数と同じ数のプロセスを生成することが推奨されています。

Apache MPM eventは、マルチプロセスでマルチスレッド + イベント駆動なアーキテクチャと呼ぶことができると思います。これは複数のプロセスの中に一つのリスナースレッドと複数のワーカースレッドがあり、実際のリクエスト処理はワーカースレッドが行うため、マルチスレッドアーキテクチャに近いです。しかし、リクエストを受け付けているリスナースレッドにイベント駆動が使われ、複数のリクエスト受け付けなどを並行で行えます。リクエストの受け付け以外にも、keep aliveな接続でレスポンスを返してリクエストを待機しているときなどには、ワーカースレッドがリスナースレッドにソケットを渡し、イベント駆動のリスナースレッドでリクエストを待機できます。これは、クライアントとのネットワークI/Oでだけイベント駆動が使用できると言えるのではないかと思います。

Apache MPM eventはNginxとは違い、イベント駆動アーキテクチャを部分的にしか使用していませんが、性能に大きな差はないと思います。計測していないので詳細はわからないのですが、2012年の時点で同等かそれ以上のパフォーマンスを出せることが示されています

グリーンスレッド

グリーンスレッドアーキテクチャは、ユーザーランドで管理される軽量なスレッドであるグリーンスレッドでリクエストを並行処理するアーキテクチャです。1つのグリーンスレッドで1つのリクエストを処理し、グリーンスレッドを複数作成します。

一般的に現代のグリーンスレッドはM:Nモデルと呼ばれるものになっています。これはM個のグリーンスレッドをN個のネイティブスレッドで実行するというモデルです。1つのネイティブスレッドで複数のグリーンスレッドを実行するよりも、CPUのマルチコアを活かすことができます。

グリーンスレッドはスレッドよりも軽量で、生成や切り替えのコストが低いです。そのため、プロセスやスレッドと比べて少ないリソースで大量に生成することができ、C10K問題の解決策として使うことができます。

このアーキテクチャが使われているものとしては、RustのtokioやGoのgoroutineなどがあります。

WebサーバーとWebアプリのやり取り

Webサーバーのアーキテクチャについて紹介しましたが、WebサービスではWebサーバーとWebアプリが連携する必要があります。この連携方法は複数あり、様々な技術が使われてきました。CGIやFastCGI、組み込みモジュール、言語ごとのインターフェースなどがあります。また、標準ライブラリだけで高性能なWebサーバーを実装できる言語もあり、そういった言語ではWebサーバーとWebアプリが一体化しているケースもあります。

ここでは、WebサーバーとWebアプリの連携方法を簡単に見ていきます。

CGI(Common Gateway Interface)は、標準入出力を使用してWebアプリと通信する方法です。リクエストに応じてCGIプログラム(Webアプリ)を起動し、標準入力や環境変数でプログラムにデータを渡して、標準出力に出力されたデータをクライアントに送信します。CGIプログラムのファイルにはスクリプトを読み込むインタプリタを指定するShebang(シェバン)が含まれることが多く、これを使ってファイルを実行します。PHPであればphp script.phpをWebサーバーが実行するようなイメージです。CGIは過去に使われていた仕組みであり、リクエストごとにプロセスが生成されるためオーバーヘッドが大きいです。

FastCGIは、プロセスを事前にプールして再利用するCGIです。リクエストを受け取ると待機しているプロセスでリクエストを処理し、ソケットを使用してWebサーバーとデータをやり取りします。FastCGIの実装としてはPHPのFPMがあり、Nginxではこのphp-fpmを使用することが多いです。このとき、FastCGIクライアントであるNginxがリクエストを受け付けて、FastCGIサーバーであるphp-fpmがリクエストを取得して処理するという流れになります。PHPだと後述する組み込みモジュールがよく使われていましたが、これを非推奨にしているOSも出てきて、最近だとFastCGIが使われることも多くなってきました。

組み込みモジュールは、言語をApache HTTP Serverに結合するためのモジュールです。リクエストを受け取るとWebサーバーであるApacheのプロセス内で直接Webアプリを実行してリクエストを処理します。このモジュールは言語ごとに実装されており、mod_phpやmod_python、mod_rubyなどがあります。ただ、PHP以外の言語ではApacheではなく、後述するWebアプリと同じ言語で実装されたWebサーバーを使うため、mod_php以外はあまり使われていないと思います。

言語ごとのインターフェースは、WebサーバーとWebアプリを連携するための言語ごとのインターフェースのことです。PythonのWSGIやASGI、RubyのRackなどがあり、対応したWebサーバーとWebアプリを連携させることができます。これらのインターフェースは、複数のWebサーバーと複数のWebアプリフレームワークで互換性を高めるために作られました。大量のWebフレームワークが乱立していた時代に、複数のWebサーバーに対応させるのが開発者の負担になっていたという背景があったみたいです。

また、最適化のためにWebアプリと同じ言語で作られたWebサーバーが使われることがあります。特にPythonやRubyが有名で、Pythonだと上述するWSGIを実装しているGunicornやASGIを実装しているUvicorn、RubyだとRackを使用しているPumaなどがあります。

WebサーバーとWebアプリが一体化しているケースもあります。これは、標準ライブラリだけで高性能なWebサーバーを実装できる言語でよくみられます。例えばGoやNode.jsは標準ライブラリだけで高性能なWebサーバーを実装できるので、専用のWebサーバーソフトウェアが必要になることが少ないです。Rustは標準ライブラリにはありませんが、非同期ランタイムライブラリを使うことで高性能なWebサーバーを実装することはできます。

リバースプロキシとロードバランサ

Webサービスの規模が大きくなってくると、クライアントとWebサーバーの間にリバースプロキシやロードバランサが必要になるかもしれません。Webサーバーが直接リクエストを受け付けていると、大量のリクエストを捌ききれない可能性があります。

リバースプロキシは、クライアントとWebサーバーの間に入って、さまざまな前後処理を行うサービスです。クライアントからリクエストを受け取ってWebサーバーに転送するサービスというのが狭義の定義だと思いますが、リバースプロキシといったときには、さまざまな前後処理を想定していることが多いです。リバースプロキシはWebサーバーの前段に配置して制御可能なポイントを増やし、Webサービス全体のリソースを最適化するために使用されるます。

リバースプロキシが行う処理の例としては、URLのリライトや静的ファイルの配信が挙げられます。URLのリライトによって、システムを変更することなく外から見えるURLを柔軟に変更できます。また、静的ファイルをリバースプロキシから配信することで、Webサーバーのリソースを動的コンテンツ処理に集中させることも可能です。

リバースプロキシは、静的ファイル配信の用途での必要性は低下したものの、システムの制御ポイントを増やすというメリットはあります。最近は静的ファイル配信にはCDNが広く利用されるようになり、この用途でのリバースプロキシの必要性は低下しています。しかし、Webサーバーの前段にリバースプロキシを配置することで、システムの制御ポイントを増やすことができます。これにより、問題が発生した際の対応が容易になり、Webアプリ自体を変更せずに問題を解決できる可能性が高まります。

ロードバランサは、複数のWebサーバーにリクエストを振り分けて負荷を分散させるサービスです。リバースプロキシがロードバランサとして機能する場合もあります。負荷が増加してリクエストを捌くのが難しくなった場合でも、ロードバランサを使用することで裏にあるWebサーバーを増やし、システムをスケールさせることが可能になります。それぞれのWebサーバーにSSL証明書を発行する必要をなくすために、ロードバランサでSSLを終端させる事もできます。

仮想化技術の発展によってWebサーバーを水平に並べるケースが増えたため、ロードバランサはより重要なコンポーネントになっていると感じています。水平スケーリングが増えた理由としては、仮想化技術によってWebサーバーの台数を柔軟に調整できるようになったことが挙げられます。また、1台の物理サーバー上で複数のWebサーバーを動かせるようになり、コストが低くなったというのもあると思います。

水平に並べるケースが増えた結果、Webサーバー1台の性能が以前よりも重要ではなくなっています。そのため、Webサーバーアーキテクチャでも少し触れたC10K問題などは、当時よりも影響が少なくなっていると考えられます。Webサーバー1台でリクエストが捌けないのであればWebサーバーを増やせば良く、それを昔よりも容易に行うことができます。

さいごに

Webサーバーの仕組みや関連するコンポーネントについてまとめました。

現代のWebアプリはクラウドを利用することも多く、Webアプリ開発において1台1台のWebサーバーがどのように動いているのかはあまり重要ではなくなっているように思います。Webサーバーの水平スケールが容易になったため、ボトルネックはその前段にあるリバースプロキシやロードバランサに移動していることも多いです。

一方で、Webサーバーの仕組みを理解することは、プログラムの並行処理を理解することにつながると感じています。Webサーバーの仕組みの理解は、複数のリクエストをいかに捌くかという並行処理の方法を理解することだと思います。それを詳細に理解するためにはシステムレベルでのネットワークプログラミングの知識が必要になり、その知識はWebサーバーだけではなく、あらゆるものに適用できるものだと考えています。

並行処理の理解は、Webサーバーに限らず様々なアプリケーションのパフォーマンスを改善するために必要になってきます。

この投稿が、Webサーバーの仕組みの理解、ひいてはアプリケーションのパフォーマンス改善のきっかけになることを願っています。

参考資料

Discussion