C++でHTTPサーバを作った話

エンジニア養成機関 42Tokyo の課題で、WebサーバをC++で作る課題に取り組みました。

この記事では課題で学んだこと、取り組みについて振り返ります。

課題の概要

NGINXのようなWebサーバをC++98で作る課題です。

「NGINXのような」というのは以下のような意味です。

  • HTTP/1.1に準拠していること。
    • といっても完全準拠ではありません。
    • 対応するメソッドは GET, POST, DELETE だけです。
  • I/O多重化によって複数のリクエストをさばくこと。
    • I/O多重化については、以下の記事が分かりやすいです。 christina04.hatenablog.com
    • I/O多重化に使う関数は select, poll, epoll, kqueue から選択できます。
  • Configファイルによってサーバの設定を読み込むこと。
    ※Configファイルの記法はNGINXに完全に準拠する必要はありません。
    • バーチャルホスト
    • リダイレクト
    • 許可するメソッド
    • リクエストボディのサイズの最大値
    • indexとして使用するファイル(例:URLの末尾が / で終わる時に index.html を使用する)
    • autoindex(indexが無い場合のディレクトリ内一覧表示)
    • デフォルトのエラーページ
      など

以下はNGINXと異なります。

  • 直接のCGI実行
  • POSTでのファイルアップロード

成果物

github.com

以下スクリーンショットです。

f:id:nafuka11:20220413144509p:plain:h240
ファイルをレスポンスとして返す

f:id:nafuka11:20220413144552p:plain:w400
ディレクトリ内のファイル一覧をレスポンスとして返す(autoindex)

f:id:nafuka11:20220208011758p:plain:w400
ステータスコードに応じたエラーレスポンスの返却

f:id:nafuka11:20220208011927p:plain:w400
ステータスコードに応じてファイルをレスポンスとして返す(NGINXのerror_page)

かかった時間

  • 期間:約98日(2021/11/09〜2022/02/14)
  • 時間:約197時間

開発の流れ

echoサーバを作ったあと、

  • HTTPリクエストのパース
  • I/O多重化対応
  • Configファイル読み込み

など、少しずつ機能追加をしていきました。

実装

大まかなフロー

f:id:nafuka11:20220413002015p:plain

Configファイルを読み込み、開くポート毎にSocket(ServerSocket)を作成し、bind()とlisten()して接続を受け付けるようにします。

ServerSocketのディスクリプタをイベント(kevent)に登録し、クライアントからの接続を検知できるようにします。

その後イベントループに入ります。イベントが発生したら、発生したイベントに応じてSocketのメンバ関数を実行します。

  • イベントは void * のユーザデータを持てるので、そこにオブジェクトのポインタをセットしています。
  • ServerSocketは、クライアントから接続があったら、accept()して新たなSocket(ClientSocket)を作成します。
  • ClientSocketは状態を持ちます。最初はHTTPリクエストを受信する状態です。 この状態に応じてイベントを登録・登録解除し、HTTPリクエストの受信、ファイルの読み込み、HTTPレスポンスの送信をしていきます。

I/O多重化の関数選定

課題の概要 で説明したように、I/O多重化の関数は select, poll, epoll, kqueue から選ぶことができます。

自分たちのチームはkqueueを採用しました。

理由はselect, pollに比べ、パフォーマンスが優れているためです。

  • 各関数のパフォーマンスについては、 libevent のBenchmarkが参考になります。
    • select, poll, epoll, kqueueのベンチマークが載っています。
    • 画像サイズが小さいですが、URLの benchmarksbenchmark に変更すると、サイズの大きい画像にアクセスできます。

epollも候補でしたが、epollの場合はVMでレビューをする必要がありました。
VMはレビュー時に安定せず、レビューでの負荷テストに耐えられない可能性があったため、kqueueを選択しました。

パース方法

HTTPリクエストのパースは調べたところ、3つ方法があるようでした。

  1. 文字ごとに状態を持たせる
  2. 行ごとに状態を持たせる
    • リクエスト行、ヘッダ、ボディと、行ごとに状態を持たせる方法です。
  3. "\r\n\r\n" を見つけたら一気にパースする
    • ヘッダとボディの間の空行まで読み込んだら、リクエスト行とヘッダを一気にパースする方法です。

自分たちのチームは、行ごとに状態を持たせてパースする方法を採用しました。

NGINXと挙動を合わせたかったのですが、文字ごとに状態を持たせるのは大変そうだったので、行ごととしました。

テスト

HTTPリクエスト・レスポンスのテスト

f:id:nafuka11:20220413002945p:plain

Python + pytest + HTTPConnection を使って作成しました。

もう少しテストケースを追加しやすい作りにすればよかったな……と後になって思いました。

Configファイルのテスト

f:id:nafuka11:20220413003245p:plain

ShellScriptで作成しました。不正なConfigファイルを読み込ませ、その出力と想定されるエラーメッセージとのdiffを取って結果を表示します。

Configファイルのテストは自分がベースを作り、チームメイトにメンテしていただきました。

チーム

この課題は2-3人で取り組むことができます。

課題に取り組み始めて1ヶ月は2人で、その後は1人入っていただき3人で取り組みました。

役割分担は以下のようになっていました。

コミュニケーション

チームのコミュニケーションは試行錯誤をしていました。

  • 1日2時間もくもく作業し、終了時に成果報告
  • 週1で進捗報告
  • 毎日5-10分で進捗報告 + timesで適宜つぶやく

最終的に一番下の方法でコミュニケーションを取っていました。

その他

チーム3人だけだと分からないことがあったりしたので、他の学生が別の課題で行っていた「P2P勉強会」を開催しました。

P2P勉強会は、週2-3回決まった時間に、課題に取り組んでいる学生が集まって進捗や困っていることを報告する会です。

他の方に聞くことで疑問点が解消されたり、モチベーションがアップしたりしたので、開催してよかったです。

大変だったこと

3つ大変だったことがありました。

  1. 機能追加による設計変更

    echoサーバから機能を少しずつ追加したので、考慮できていないことがあり、後で設計を変更する必要がありました。

    • I/O多重化:クラスを入れ子にした実装だったのですが、メインループでイベントを受け取って関数を実行する都合上、入れ子にしない作りに変更しました。
    • CGI:標準出力をHTTPレスポンスとしてパースする必要があり、パース方法をどう使い回すかで悩みました。HTTPパーサのクラスを継承してCGIパーサを実装しました。
    • バーチャルホスト対応:ポート1つに対しサーバ設定が複数になるので、Configの持たせ方を変更する必要がありました。具体的には std::vector<Config> から std::map<int, std::vector<Config> > に変更しました。
  2. URIのルーティング

    考慮できてないパターンが見つかり修正を複数回行いました。フローチャートを作った方がいいかもしれません。

  3. siegeでのリクエスト詰まり

    HTTPサーバは、siegeを使った負荷テストで一定のAvailabilityを保つ必要があります。

    私達が作成したHTTPサーバは、siegeを使うと初めは動くのですが、しばらくするとリクエストが詰まり、時間を置くと解消されて、また詰まって……という動作をしました。時にはタイムアウトもしていました。

    他のチームのselectを使ったプログラムでは、同事象は発生しないようでした。

    selectのように動作が遅くなるよう、試しにイベントの発生毎に1msスリープすると、リクエストの詰まりは解消されました。

    根本的な原因が分からず、kqueueの良さを打ち消す対処しかできなかったのが悔やまれます。

参考書籍・URL

HTTP全般

Webサーバの実装

HTTPパーサ

I/O多重化