Skip to main content

HTTP接続を9時間維持する方法

feature keep http alive

2023年10月23日

0 分で読めます

あまりにも当たり前のものになっているため、HTTP仕様がいかに素晴らしいものか、つい忘れてしまいがちです。https://snyk.ioのようなウェブサイトを開くと、JavaScript、画像、動画などのアセットを取得するために、次々とHTTPリクエストが発生します。そして数秒もすれば、ページ全体が表示されます。実際、一般消費者向けウェブサイトは、完全にレンダリングされたページを最大でも数秒以内に表示することを目指しています。そうでなければ、わずかに表示が速いサイトにアクセスを奪われてしまう可能性があります(数秒の差も積み重なります)。

ただし、HTTP接続を通じて定期的に更新情報を送信する、長時間実行のプロセスが必要なケースもあります。そうしたケースの一つを見てみましょう。

SnykのCapture the Flagイベント運営方法

Snykでは毎年、Fetch the FlagというCapture the Flagイベントを開催しています(マスコットのPatchにちなんだ名前です)。今年は、CTF界のレジェンドであるJohn Hammondがイベントを主催してくれることになり、とても楽しみにしています。

その基盤には、オープンソースのCTFプラットフォームCTFdを利用しています。CTFdには、登録とログインのための独自システムがあります。しかし、デザインやトラッキングの都合上、登録用のランディングページには自社のものを使いたいと考えました。マーケティングチームから提示された要件は次のとおりです。

  1. 登録は登録用ランディングページからのみ受け付ける。

  2. CTFdサーバーにアカウントを自動作成する。

    1. 一意のエイリアスを生成する。

    2. 一意で複雑なパスワードを設定する。

    3. まだユーザーに通知しない。

  3. イベントが近づいたら、事前登録したすべてのユーザーに、認証情報の取得方法を一括メールで通知するプロセスを実行する。

    1. CTFdの登録モードを切り替え、新規ユーザーには登録時に認証情報をメールで送信する。

この記事では、私が作成したオープンソースプロジェクトctfd-account-hookが、登録済みの約4,000人の参加者にメールで通知するための、長時間にわたるセキュアなHTTPリクエストに対応するまでの進化をご紹介します。

CTFd APIを活用する

CTFdには、ユーザーのCRUD操作などの一般的なタスクや、メール通知用のエンドポイントに対応するAPIが組み込まれています。ホスト、ポート、認証情報を設定するだけで、メールサービスを簡単に利用できます。

ほとんどの最新APIと同様に、これらのエンドポイントにはレート制限があります。特にメール通知には厳しいAPIレート制限が設けられています。設定したメールサービスにも通常、独自のAPIレート制限があるためです。メールエンドポイントでは、標準のHTTPステータスコード429(「リクエストが多すぎます」)を返す前に、10通までメールを送信できます。1分経過すると、新たに10回のメールAPI呼び出しが可能になります。この情報は、ctfd-account-hookアプリケーションの構築方法を決めるうえで役立ちました。

CTFdアカウントフックアプリの開発

マーケティングチームが登録ページに使っているシステムでは、登録ページの送信時にAPIを呼び出せます。上記の要件を踏まえて、次のような仕組みにしたいと考えました。

  1. セキュアなエンドポイント

  2. アカウントフックへの入力は最小限にする(メールアドレスのみ)

  3. 登録時にメール通知を送信しないモードと、送信するモードを切り替えられるようにする

    1. 理想としては、モード切り替え時に登録用ランディングページの設定を一切変更しなくて済むこと。

Spring Boot、Spring Security、WebFluxを採用しました。これにより、セキュアなエンドポイントのサポート、CTFdへのAPI呼び出し、APIレート制限への対応、2つの動作モードをサポートするための設定変更が、とても簡単になりました。

アカウントの作成

ctfd-account-hookアプリへの入力はメールアドレスのみです。アプリは一意のエイリアスを生成し、APIを使ってCTFdのユーザーアカウントを作成します。

内部辞書から要素を選ぶエイリアス方式を採用しました。エイリアスは形容詞、色、犬種で構成されます。形容詞が900種類、色が52種類、犬種が80種類あるため、組み合わせは3,744,000通りです。

ユーザーの作成には、CTFd APIエンドポイント/api/v1/usersを使用します。このエンドポイントには、任意のクエリ文字列パラメーターnotifyがあります。ユーザーを作成すると同時に、認証情報をメールで通知するには、次のようなPOSTリクエストを送信します。

POST /api/v1/users?notify=true

クエリ文字列パラメーターnotifyを指定しない場合、ユーザーにはメール通知が送信されません。

Spring Bootを使う最初の大きな利点は、環境変数の処理機能でした。CtfdApiServiceImplクラスには、notifyOverrideというブール型フィールドがあります。次の構文で環境変数を指定すれば、値が自動的に設定されます。

@Value("#{ @environment['ctfd.api.notify-override'] ?: false }")
private Boolean notifyOverride;

デフォルトではnotifyOverrideはfalseに設定されます。ただし、環境変数ctfd.api.notify-overrideをtrueに設定すると、新しく作成されたすべてのCTFdアカウントにメール通知が送信されます。これはコードの後続部分で処理されます。

…
String notify = (notifyOverride || req.getNotify()) ? "?notify=true" : "";
String uri = API_URI + "/users" + notify;

アプリはHerokuにデプロイされています。アカウント作成時にメールを送信するモードへ切り替える際も、環境設定を変更するだけで簡単に対応できました。

heroku config:set ctfd.api.notify-override=true

一括メールの送信

メール通知の有無を切り替えてアカウントを作成できるようになり、次の大きな課題は一括メール通知の実装でした。Fetch the Flagイベントの数週間前から参加登録を受け付ける計画でした。CTFdアカウント(自動生成したエイリアスを含む)は割り当てますが、認証情報はまだ通知しません。

イベントの約1週間前にモードを切り替え、新規登録者には登録時にすぐメール通知を送信します。その後、事前に登録済みのすべてのユーザーに通知を送る長時間実行のプロセスを開始します。

この長時間実行プロセスには、既存ユーザーの一覧を取得するページネーション対応のCTFd APIエンドポイントと、APIレート制限に対処する適切なバックオフ/リトライ方式が必要でした。ここで力を発揮するのが、Spring Bootの非同期処理サポートとWebFlux HTTPクライアントです。WebFluxでCTFdのメールエンドポイントにAPIリクエストを送る例を見てみましょう。

this.webClient.post().uri(uri)
    .bodyValue(emailText)
    .retrieve()
    …
    .bodyToMono(CtfdUserResponse.class)
    .retryWhen(retryBackoffSpec)
    .block();

この1行、.retryWhen(retryBackoffSpec)によって、APIレート制限に達したときに、適切な方法でリクエストが再試行されます。retryBackoffSpecの定義は次のとおりです。

this.retryBackoffSpec = Retry.backoff(maxAttempts, Duration.ofSeconds(backoffSeconds))
    .doBeforeRetry(retrySignal -> log.debug(
        "Waiting {} seconds. Retry #{} of {} after exception: {}",
        backoffSeconds, (retrySignal.totalRetriesInARow()+1), maxAttempts,
        retrySignal.failure().getLocalizedMessage()
    ))
    .onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> retrySignal.failure());

最初の行では、環境変数maxAttemptsとbackoffSecondsを使い、HTTPリクエストでエラーが発生した場合の動作を制御します。便利なのは、この定義があらゆる種類のエラーに対応することです。最もよくあるのは、リクエスト過多を示す429エラーでしょう。しかし、サービス障害が起きて5xxエラーが返された場合も、リクエストは再試行されます。わずかなコードで、非常に堅牢なWebリクエストを実現できます。これがWebFluxの力です。

バックオフ/リトライ方式が整ったところで、長時間実行のメール通知プロセスを設定します。メール通知を10通送るごとに1分待機すること、登録者が約4,000人いることから、全員への通知に6.5時間以上かかると見込んでいました。実際には、ページネーション、パスワード更新、メール通知のAPI呼び出しにかかる時間も加わり、プロセス全体の完了には9時間以上かかりました。

次のステップは、非同期処理の実装でした。長時間実行のプロセスを開始しながら、コントローラーはすぐに応答させたいと考えました。また、HTTPリクエストの接続を開いたままにして、処理状況を定期的に送信したいと思いました。そこで登場するのがServer Sent Events(SSE)です。SSEは、情報を送り続けられる開いたパイプラインのようなものです。購読者は情報を受け取ります。

Spring BootにはSSEのサポートが組み込まれており、HTTPリクエストは自動的にSSEに登録されます。長時間実行のメール通知プロセスを開始するコントローラーのコードは次のとおりです。

@PostMapping("/api/v1/update-and-email/{affiliation}")
public SseEmitter updateAndEmailUsers(@PathVariable String affiliation) {
    SseEmitter emitter = new SseEmitter(1000*60*60*24L);
    ctfdApiService.updateAndEmail(emitter, affiliation);
    return emitter;
}

メソッドの最初の行で、タイムアウトを24時間に設定したSseEmitterオブジェクトを作成します。次に、非同期メソッドctfdApiService.updateAndEmailを呼び出し、新しく作成したエミッターを渡します。最後に、コントローラーメソッドからエミッターを返します。この3行のコントローラーメソッドで、非同期のSSEハンドラーが動作します。updateAndEmailメソッドは定期的にエミッターへイベントを送り、開いたままのHTTPリクエストを通じて自動的に送信されます。

サービスコードを見る前に、非同期呼び出しに対応するようSpring Bootアプリケーションを設定しましょう。メインのSpring Bootアプリケーションでは、`EnableAsync`アノテーションを使って非同期処理を有効にします。

@SpringBootApplication
@EnableAsync
public class CtfdAccountHookApplication {

    public static void main(String[] args) {
        SpringApplication.run(CtfdAccountHookApplication.class, args);
    }
}

次に、サービスメソッドに@Asyncアノテーションを付けると、自動的に非同期処理にできます。CtfdApiServiceImplクラスにおけるupdateAndEmailメソッドの定義は次のとおりです。

    @Async
    @Override
    public void updateAndEmail(SseEmitter emitter, String affiliation) {
        Integer page = 1;
        int processed = 0;

        do {
            try {
                CtfdUserPaginatedResponse ctfdUserResponse =
                    getUsersByAffiliation(affiliation, page);
                for (CtfdUser ctfdUser : ctfdUserResponse.getData()) {
                    SseEmitter.SseEventBuilder  event = SseEmitter.event()
                        .data("Processing - " + ctfdUser.getId() + " - " + LocalTime.now().toString())
                        .id(String.valueOf(ctfdUser.getId()))
                        .name(ctfdUser.getId() + " - " + ctfdUser.getName());
                    emitter.send(event);
…
                    ctfdUser = updatePassword(ctfdUser);
                    emailUser(ctfdUser);
                }
                page = ctfdUserResponse.getMeta().getPagination().getNext();
                processed += ctfdUserResponse.getData().length;
…
            } catch (Exception e) {
                log.error("Failure while update/email operation: {}", e.getMessage());
                emitter.completeWithError(e);
                return;
            }
        } while (page != null);
…
        emitter.complete();
}

Spring Bootは、このメソッドを独自のスレッドで実行します。各ページ(CTFd API呼び出し)について、そのページに含まれる登録ユーザーの一覧を順に処理します。さらに各ユーザーについて、パスワードを更新(CTFd API呼び出し)し、メール通知を送信します(CTFd API呼び出し)。処理の間、SSEエミッターを使ってパイプラインにメッセージを送信します。リクエストとその出力は、HTTPieクライアントを使うと、次のようになります。

http POST \
https://<ctfd account hook url>/api/v1/update-and-email/fetch2023 \
 x-api-key:"<api token>"

HTTP/1.1 200
data:Processing - 1 - 17:19:00.595414016
id:1
event:1 - raw-blue-armant

data:Finished Processing - 1 - 17:19:03.958247179
id:1
event:1 - raw-blue-armant

サーバーログを見ると、次のような内容が記録されていました。

Processing user id: 1, name: raw-blue-armant
Password updated for user id: 1
Email sent for user id: 1
…
Processing user id: 10, name: conscious-harlequin-cursinu
Password updated for user id: 10
Waiting 10 seconds. Retry #1 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Waiting 10 seconds. Retry #2 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Waiting 10 seconds. Retry #3 of 10 after exception: 429 Too Many Requests from POST https://snyk.ctf.games/api/v1/users/10/email
Email sent for user id: 10

ここでは、WebFlux HTTPクライアントのバックオフ/リトライ機構が動作しているのがわかります。

思わぬ障害

ローカル環境ですべてをテストした後、全体のテストを実施しました。9時間以上かかりましたが問題なく完了し、SSEの出力ログもすべて取得できました。いよいよHerokuにデプロイし、本番環境で実行する段階です。

本番環境ではローカルマシンと異なる動作をする可能性があると考え、Heroku上でダミーアカウントを約100件使ってテストしました。すると予想に反してエラーが発生し、処理開始から約1分でリクエストが切断されてしまいました。どうやらHerokuが、アイドル状態が長すぎるとしてHTTPリクエストを終了していたようです。

Herokuにはエッジプロキシがあり、デプロイしたアプリケーションにHTTPSアドレスでパブリックインターネットからアクセスできるようにします。これらはすべて自動設定され、デプロイしたアプリケーションはデフォルトでSSLにより保護されます。Heroku上で稼働するすべてのアプリケーションに良質なサービスを提供するため、アイドル状態の接続は積極的に切断されます。今回の問題は、バックオフ/リトライのロジックが動作すると、最大1分間SSEエミッターから通知が送信されないことでした。Herokuは約10秒でアイドル状態の接続を切断します。これを解決するには、定期的に「ハートビート」SSEメッセージを送信する、別の非同期メソッドが必要でした。

更新後のサービスコントローラーメソッドは次のとおりです。

@PostMapping("/api/v1/update-and-email/{affiliation}")
public SseEmitter updateAndEmailUsers(@PathVariable String affiliation) {
    // TODO - should probs be another env var setting
    SseEmitter emitter = new SseEmitter(1000*60*60*24L);
    ctfdApiService.emitterHeartBeat(emitter);
    ctfdApiService.updateAndEmail(emitter, affiliation);
    return emitter;
}

emitterHeartBeatとupdateAndEmailはどちらも非同期メソッドなので、すべて期待どおりに動作します。emitterHeartBeatメソッドは次のとおりです。

@Async
@Override
public void emitterHeartBeat(SseEmitter emitter) {
    try {
        do {
            emitter.send("beat");
            Thread.sleep(5000);
        } while (true);
    } catch (Exception e) {
        log.debug("exception during emitter: {}", e.getMessage());
    }
}

これにより、5秒ごとにbeatメッセージがSSEエミッターを通じて、開いたままのHTTPリクエストに送信されます。アイドル状態にならないため、Herokuによって接続が切断されることもありません。この変更をデプロイした後、9時間以上かかる通知プロセスを実行しましたが、見事に完了しました。

さあ、Fetch the Flagへ


ctfd-account-hookプロジェクトを誇りに思っています。ぜひコントリビューションをお寄せください!Hacktoberfestにも参加しているので、プルリクエストが採用されるとバッジを獲得できます。 

この経験から、時間のかかるこの処理は、すべてバックグラウンドで実行するのが最適だとわかりました。そうすれば、進捗を確認するエンドポイントを作成できます。Server Sent Eventプロトコルは魅力的ですが、現在の方法には依然として不安定さがあります。開いているHTTPリクエストが中断されると、処理全体が失敗する可能性があります。GitHubのこのissueで説明されているように、非同期サービスのプロセスはほぼ現状のまま維持できます。時間のかかる処理を開始したら、コントローラーはすぐにjob idを返します。ポーリング用のエンドポイントは、処理が完了したことを示す情報を含め、ジョブのステータスを返します。この方法では、進捗を追跡するためにデータベースとテーブルのレコードを使う複雑さが加わる一方で、開いたままのHTTP接続に伴う不安定さを解消できます。

2023年10月27日に開催されるイベント「Fetch the Flag」へのご参加をお待ちしています。登録すると、CTFdプラットフォームにログインするための認証情報がメールで届きます。30個のチャレンジに、24時間かけて挑戦できます。新しいチームを結成することも、既存のチームに参加することもできます。また、Discordサーバーのチャットに参加することもできます。