LayerX エンジニアブログ

LayerX の エンジニアブログです。

ECS on EC2 でスケールする Playwright の実行基盤を構築した

こんにちは! バクラク事業部 Platform Engineering 部 SRE グループにインターンとして参加している xpadev です。

この記事ではインターン期間中に開発を行っていた Playwright 実行基盤を紹介します。

経緯

バクラク事業部では AI エージェントの開発が活発に行われています。AI エージェントは様々なタスクをこなす必要があり、ユーザーの代理としてブラウザを操作したいというユースケースがあるエージェントでは、それぞれが独自に Playwright のバイナリを保持する形になっていました。 しかし、各エージェントのコンテナイメージごとにブラウザのバイナリを持つことになるため、ビルドが長時間化し、コンテナイメージが肥大化するという弊害がありました。 そこで、エージェントとは切り離した共通の基盤として Playwright の実行環境を用意することで対応できないかと考えました。

構成

常にアイドル状態な Playwright Server のコンテナを複数確保し、ユーザーからコンテナの要求を受けて、サーバーのアドレスを返すようになっています。 基本的な処理はすべて Lambda 上で行っており、アプリケーションからのリクエストは ALB 経由で処理をするようにしています。

インフラレイヤーの構成図

具体的な処理フロー

本構成の具体的な処理フローは以下のとおりです。

  1. ユーザーからコンテナの要求があった場合、Lambda は DynamoDB 上に保管されているコンテナプールの状態を読み、利用可能なコンテナがあればそれを User に返します。

    Phase1: Dynamodbから読み出して返す

  2. もし、利用可能なコンテナが見つからない場合は ECS 上であらかじめ定義されている Task definition から Task を起動し、HealthCheck を行ってから User に返します。

    Phase2: アイドルコンテナが存在しない場合は新たに起動する

  3. 払い出しによって予備プールに変動があると、Lambda は SQS の FIFO(First In First Out) キューへメッセージを送信します。

    Phase3: プールの変動を FIFO キューへ通知

  4. EventBridge Scheduler ないし、Lambda から SQS へメッセージがあると、それをトリガーに Lambda が起動します。 この際、不要になったコンテナのチェックや、予備プールの不足分の補充などの処理を行い、即時利用可能なコンテナが一定数確保されるように処理を行います。

    Phase4: 定期実行またはプール変動時にコンテナの補充、クリーンアップを行う

技術選定の意図

本構成の技術的な選定意図は以下のとおりです。

API サービスのデプロイ先

バクラクのサービスでは大半が ECS を用いています。 しかし、今回の場合 API が呼び出されている時間よりも、呼び出されていない時間のほうが長くなりそうだと考え、コストの観点からリクエスト発生時にのみ課金される Lambda 上でデプロイすることにしました。

Playwright の起動先

ユーザーの操作を必要とするユースケースでは、入力待ちを含めて考えると1セッションが15分を超える可能性があります。 Playwright を Lambda 上で起動すると処理時間上限に達してしまい、強制終了されてしまう可能性があったため、Lambda を採用することは厳しいと考えていました。 また、起動時間のデメリットも ECS on EC2 のキャッシュを用いることである程度軽減できるという見込みがあったため、ECS を採用しました。

Sidecar 形式のアイドル状態監視

Playwright のサーバーには、コネクション状態を取得する API などがなく、それ単体では使われなくなったコンテナを検出することができません。 この場合、Playwright を使用するアプリケーションが正しくクリーンアップ処理を行うことが望ましいです。 しかし、アプリケーションは意図せず強制終了されたりクラッシュしたりする可能性があるため、自動的にクリーンアップをできる仕組みは外せません。 当初は既存のイメージを拡張し、メインコンテナ内に監視を行うプロセスを起動することを検討していましたが、この形で実装するとコンテナに複数のプロセスが同居するかたちになる、公式イメージの更新に追従する必要があることなど複数の問題点があるため、疎結合で監視ができる Sidecar 形式による状態監視を行う方法を採用しました。

ECS においてはネットワークモードに awsvpc を指定することでメインコンテナと Sidecar のネットワークを共通化することが可能です。 そこで、以下のように gopsutil を用いてコンテナに接続中のコネクションを監視し、Playwright Server の listen ポートへのコネクションが存在するかをみて判別するプログラムを作成し、利用しています。

Websocket によるコネクションを検知するデモ

func HasActiveConnections(serverPort uint32) bool {
    conns, err := net.Connections("tcp")
    if err != nil {
        return false
    }

    for _, conn := range conns {
        if conn.Laddr.Port == serverPort && conn.Status == "ESTABLISHED" {
            return true
        }
    }
    return false
}

コンテナの払い出し高速化

AIエージェントというユースケースにおいてはユーザーの入力に基づいてブラウザを操作し、結果を返す形になるため、ユーザーの待ち時間を可能な限り短縮できることが望ましいです。起動による待ち時間を減らすため、予めいくつかコンテナを起動しコンテナプールとして保持しておき、そちらを優先的に払い出すようにしています。 また、プール枯渇時のコンテナ起動を高速化するため、ECS は Fargate ではなく ECS_IMAGE_PULL_BEHAVIOR=once*1 を指定した EC2 上で起動しています。 これにより、スケールアウトによる起動直後のインスタンス以外であればイメージのキャッシュを使用できるため、プール枯渇時でもあらかじめ起動していた場合と同等の速度でコンテナを払い出すことが可能です。

SQS の FIFO キュー経由のプールメンテナンス

コンテナプールの補充やクリーンアップの処理は EventBridge Scheduler や Lambda から直接呼び出すのではなく SQSの FIFO キューを経由しています。 これはメンテナンス処理が意図せず並列実行されてしまうのを防ぐ意図があり、排他制御の仕組みとして使用しています。 これにより意図せず過剰にコンテナを作成してしまう可能性を防ぎつつ、確実に実行することができます。

セキュリティ上の考慮

Playwright コンテナの権限の最小化

Playwright コンテナ上ではブラウザが読み込んだページに含まれるコードが実行されるため、攻撃者によって悪意のあるリクエストを発行される可能性があります。 AWS 上の ECS on EC2 コンテナではそのコンテナに関するメタデータを取得することが可能です。 *2 このメタデータから IAM Role の認証情報を得ることができ、そのコンテナと同等の権限でリソースの操作を実行することが可能になります。 しかし、これを完全に防ぐことは難しいため、認証情報が公開され、使用される前提で以下の対策を行うことにしました。

  • セキュリティグループによる通信制限で VPC 内での通信を制限し、不要なネットワークアクセスを遮断
  • Playwright コンテナに付与する IAM ロールは必要最小限の権限に限定

これにより、当初の設計で想定していた Playwright コンテナから Lambda に対してアイドル状態になったら自分を削除するリクエストを飛ばすという機能は、Sidecar としてサーバーを建て、Lambda 側から呼び出す形に変更となりました。

1 セッション 1 コンテナ化

意図せずブラウザのセッションやキャッシュが残ってしまい、攻撃に利用されることを防ぐため、コンテナは使い回さずに毎回作り直しています。 また、ノイジーネイバー問題を防ぐため、コンテナ内に複数のセッションを同居させるのではなくそれぞれのセッションにコンテナを割り当てています。

既存のサービスを採用しなかった理由

Amazon Bedrock AgentCore Browser

リリース当初に自前実装との比較検討を行いました。

バクラクのインフラは基本的に AWS 上に乗っているため、既存のインフラと共通して管理できる点はかなり魅力的でした。

しかし、現時点では起動する VM に日本語フォントがインストールされておらず、以下のように全角文字が文字化けしてしまう状況でした。

日本語(全角文字)がすべて文字化けしてしまっている
スクレイピングなどであれば DOM データの取得ができれば良いため、文字化けするというのは大した問題になりませんが、AI エージェントにおいてはユーザーへ画面を表示するユースケースが比較的多いため、このままでの採用はかなり厳しいと判断しました。 また、まだプレビュー段階で正式リリースされていないこと、日本リージョンがないこと、ブラウザの選択肢がないこと、拡張機能の導入ができないこと、整備されている SDK が Python にしか存在しないこと、課金体系が明確ではないこと *3 などの課題があり、採用を見送りました。

Browser Use Cloud

BrowserUse には ANONYMIZED_TELEMETRY というフラグがあり*4、明示的にオプトアウトしない限りフラグに紐づく Telemetry と Cloud Sync という機能が有効になります。 匿名テレメトリというフラグ名ではありますが、送信される内容は匿名化されていない生のプロンプトやブラウザのキャプチャデータであり、特に操作状況データについては一定時間閲覧可能な公開URLが発行され、URL さえ知っていれば誰でも閲覧可能になってしまいます。 この機能によって意図せず外部にデータが送信されてしまう可能性があることに加え、SOC(Service Organization Control) に非対応であったことから採用を見送りました。

Browserless

Browserless は一般提供されているプランにおいては最大でも 50 台までしか同時にブラウザを利用することができず*5、各 AI エージェントがブラウザを利用するようになるとボトルネックになることが想定されます。その他セッションタイムアウトや課金体系なども考慮して採用を見送りました。

まとめ

実際に Playwright を使用しているプロジェクトのひとつに対して、Dockerfile から Playwright の依存関係のインストールを削除することでビルド時間やイメージサイズについての変化を計測したところ、ビルド時間が 79.36s *6 から 39.46s *7 に、イメージサイズが 2.67GB から 1.13GB に減少しました。

実際のビルドにおいては layer ごとのキャッシュやネットワークの帯域など別の要素も絡んでくるため一概に言うことはできませんが、Chromium を同梱する必要がなくなるためイメージサイズは確実に小さくなります。

現時点では運用に導入するところまで到達できていませんが、投入できればそれなりの改善効果が得られると見込んでいます。

*1:prefer-cached を使用するとイメージのクリーンアップが一切行われなくなってしまうため、古いイメージなどが消えずに残ってしまう可能性があります。そのため、prefer-cached ではなく once を使用しています。 EC2 起動タイプと Amazon ECS の外部起動タイプのコンテナイメージのプル動作 - Amazon Elastic Container Service

*2:EC2 のタスクで使用できる Amazon ECS タスクメタデータ - Amazon Elastic Container Service

*3:従量課金制で、リソースの事前設定は不要です。CPU リソースについては、ツールがアクティブに処理しているときにのみ課金されます (LLM 応答を待っているだけの場合は I/O 待機期間には課金されません)。メモリリソースについては、エージェントが消費しているメモリに対してのみ課金されます。 Amazon Bedrock AgentCore (プレビュー) の料金 – AWS とあるものの、ブラウザにおいては広告や動画などページを開いているだけで継続的に I/O が発生するため、I/O 待機期間が具体的に何を指すのかが不明

*4:Telemetry - Browser Use

*5:Pricing - Browserless

*6:76.4, 90.1, 79.9, 74.1, 76.3 (min: 74.1, avg: 79.36, med: 76.4, max: 90.1)

*7:42.0, 39.4, 36.4, 38.3, 41.2 (min: 36.4, avg: 39.46, med: 39.4, max: 42.0)