NomadのゲートウェイをNginxからEnvoyに置き換える

この記事はさくらインターネット Advent Calendar 2020 5日目の記事です。 https://qiita.com/advent-calendar/2020/sakura

現在社内にてNomadとConsulを利用してサービス開発を行っています。 Nomadはアプリケーションのスケジューリングを行い、ざっくりとKubernetes,Marathon等と同様の機能を提供しています。 Consulはサービスの死活監視・サービス検出・サービスメッシュ等を提供しています。

このような構成では、外部からの通信を適切にアプリケーションへ転送するゲートウェイが必要となります。 Nomad+Consulを利用している環境の多くは、consul-templateでNginxの設定を自動生成し、変更があった際にはreloadを行うという構成を採用しているかと思います。 consul-templateはConsulで登録されているサービスの変更をリアルタイムに受信し、サービス情報を任意のテンプレートに展開をするコンポーネントです。

しかしながらNginxとconsul-templateを組み合わせたLBはいくつかの問題を抱えており、現在のチームではEnvoyを利用した構成へと移行する事となりました。

LBとして使うNginx

今回Nginxはアプリケーションへのリバースプロキシを行うロードバランサーとして利用しています。 LBとして利用した際のNginxのメリットは、設定がシンプルで導入が非常に容易という点でしょう。

導入は多くの環境で apt install nginx とパッケージマネージャーを利用したインストールが行えます。 設定も以下の例のように、非常に簡潔で人が読み書きしやすいものとなっています。 例ではNginxへ http://www.example.com/api 以下への通信が発生した際に、 10.0.0.1:80 または 10.0.0.2:80 のアップストリームへとリバースプロキシを行うという設定を行っています。

http {
  upstream apps {
    server 10.0.0.1;
    server 10.0.0.2;
  }
  server {
    listen 80;
    server_name www.example.com;
    location /api {
      proxy_pass http://apps;
    }
  }
}

先に述べたconsul-templateは、この設定の server 等の部分を動的に書き換えることによって、アップストリームが動的に変動する環境での利用をサポートしています。

しかし、このような用途でNginxが抱える問題はいくつかあります。 代表的なものを以下に挙げました。

  • http_stub_status_module にて監視できる項目数がかなり少ない
  • Circuit Breakerの設定が行えない
  • エンドポイント毎のコネクション制限等を設定できない
  • 動的に設定が変化する環境には向いていない
  • UpstreamにはHTTP1系のみサポート

標準モジュールの http_stub_status_module では、 http://localhost/status などにアクセスすることでNginxプロセス全体のメトリクスを取得することが出来ます。 しかし項目数は限られており、ある程度の規模のリクエストを捌く際に問題になることがあるかと思います。 項目は connections accepts handled requests Reading Writing Waiting がサポートされています。 例えば一部のバックエンドに障害が発生した際、どのバックエンドに障害が発生しているのかをこれらのメトリクスから知ることは出来ません。

また、バックエンドのサービスが過負荷状態になった際に通信を遮断するCircuit Breakerのサポートも限定的です。 一部Late Limit等はサポートされていますが、エンドポイント毎に細かいコネクション数を制限することは難しいです。 なので、一部のバックエンドに障害が発生した際には性能への影響が全体に及ぶ可能性が高いと言えるでしょう。

動的に設定が変化する環境に対しての標準サポートは無く、設定ファイルを書き込んでSIGHUPを送信するというインタフェースを採用しています。 多くの環境では問題になることは無いのですが、内部プロセスの再起動が行われているので高頻度で書き換えられる際には問題になることが有るかと思います。

なお、こういった制限の一部は有償版のNginx Plusを利用することで緩和される箇所も有ります。

Envoyの特徴

EnvoyはL3/L4,L7のロードバランサーとして利用可能で、多くのモダンな仕組みをサポートしています。 HTTP,gRPCのような汎用プロトコルの他にRedis,MySQL等の一部のアプリケーションプロトコルもサポートしていて、固有のメトリクス情報等を収集することができます。

標準でAPI経由の設定書き換えがサポートされており監視できるメトリクスの種類が多いことからも、Cloud Nativeに必要となるIstio等のサービスメッシュやGloo等のAPIゲートウェイの内部にも利用されています。

  • L3/L4,L7 LB

    • TCP,UDP
    • HTTP,gRPC
    • Redis,MySQL
  • API経由での設定書き換え

    • yamlファイルのiNotify
    • RESTのポーリング, gRPCのストリーム
  • 監視出来る項目が多い

    • Prometheusで収集出来るエンドポイントを公開
  • 利用例

    • Service Mesh: Istio, Consul Connect
    • API Gateway: Gloo, Apigee

https://www.envoyproxy.io/docs/envoy/latest/intro/whatisenvoy

サービスメッシュと一緒に語られることが多いですが、今回はConsulサービスのゲートウェイとして利用してみたいと考えているので一旦切り離して概要を確認していきたいと思います。

Envoy 設定例

Envoyの設定はYaml,JSON,ProtocolBuffer等のファイルが指定可能です。 実行バイナリにオプションで ./envoy -c config.yaml のように渡すことで設定ファイルが読み込まれます。 先に載せたnginxの設定例と同様の挙動を行うEnvoy設定ファイルがこちらです。

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 80 }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/api/" }
                route: { cluster: api_servers, prefix_rewrite: "/" }
          http_filters:
          - name: envoy.filters.http.router

  clusters:
  - name: api_servers
    connect_timeout: 0.25s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: 10.0.0.1, port_value: 80 }
        - endpoint:
            address:
              socket_address: { address: 10.0.0.2, port_value: 80 }

Nginxと比べると記述方法が複雑で、人が積極的に読み書きしたいフォーマットではなさそうです。 しかし、Listener, Route, Clusterの重要なリソースを中心に読み解いていけば読めるようになってくるんじゃないかと思います。

Listener, Route, Cluster

まずListenerですが、TCP/UDP・待受ポート・TLS設定・レート制限などを記述します。 この例では単に80番ポートでのListenを行っています。

listeners:
- name: listener_0
  address:
    socket_address: { address: 0.0.0.0, port_value: 80 }

次にRouteですが、nginxで言うところのlocationだとかの記述を行う箇所です。 HTTPのL7ルーティングの記述を行い、記述場所はListenerの中です。 ホスト名・パス文字列・ヘッダのマッチング/書き換えの他に、タイムアウト・リトライ・利用クラスタ(アップストリーム)の記述を行います。

route_config:
  name: local_route
  virtual_hosts:
  - name: local_service
    domains: ["*"]
    routes:
    - match: { prefix: "/api/" }
      route: { cluster: api_servers, prefix_rewrite: "/" }

次はUpstreamですが、利用するバックエンドサーバ・ウエイト・ディスカバリ方法・ヘルスチェックなどを指定します。 lb_policy フィールドはトラフィックの振り分け方法の指定し、 ROUND_ROBIN LEAST_REQUEST RING_HASH などが選択可能です。

clusters:
- name: api_servers
  connect_timeout: 0.25s
  type: STATIC
  lb_policy: ROUND_ROBIN     # https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/load_balancers
  health_checks:
  - timeout: 1s
    interval: 15s
    unhealthy_threshold: 3
    healthy_threshold: 3
    http_health_check: { path: /health }
  load_assignment:
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address: { address: 10.0.0.1, port_value: 80 }
      - endpoint:
          address:
            socket_address: { address: 10.0.0.2, port_value: 80 }

Listener, Route, Clusterを理解すると、 もはやnginxの設定と同じ様に見えてくるのではないでしょうか。

http {
  upstream apps {
    server 10.0.0.1;
    server 10.0.0.2;
  }
  server {
    listen 80;
    server_name www.example.com;
    location /api {
      proxy_pass http://apps;
    }
  }
}

xDSでの動的な設定

nginxではファイルを書き換えてreloadを行うことで、動的な設定変更を行っていました。 Envoyでは、主に3つの方法がサポートされています。

  • json,yamlファイルの変更をinotifyで検知
  • HTTPエンドポイントへ設定情報を一定時間ごとにポーリング
  • gRPCエンドポイントからストリームで設定情報を受け取る

HTTP,gRPCを利用した設定変更は以下のようなサービスに分類されており、全て合わせてxDSと呼んでいます。 余談ですが、HTTPのポーリングを利用している実装はあまり見ず、ほとんどgRPCで利用しているよう感じます。

  • Listener: Listener Discovery Service (LDS)
  • RouteConfiguration: Route Discovery Service (RDS)
  • Cluster: Cluster Discovery Service (CDS)
  • ClusterLoadAssignment: Endpoint Discovery Service (EDS)
  • ScopedRouteConfiguration: Scoped Route Discovery Service (SRDS)
  • VirtualHost: Virtual Host Discovery Service (VHDS)
  • Secret: Secret Discovery Service (SDS)
  • Runtime: Runtime Discovery Service (RTDS)

gRPCサーバでxDSサービス実装は、ライブラリが用意されており簡単です。 xDSの各サービスごとに対応する設定ファイルのpb定義がライブラリに入ってるので、設定情報を組み立ててEnvoyに返すという流れです。 https://github.com/envoyproxy/go-control-plane

xDSを実際に利用したデモ

https://github.com/kamijin-fanta/envoy-xds-sample

実際にxDSサーバを利用したでも環境を用意しました。 docker-compose up で起動が行えるかと思います。 以下のポートで各管理画面が閲覧可能です。

内部の構成は以下のようになっています。

  • envoy proxy

    • 主役・dummy_clusterへ通信をリバースプロキシ
  • dummy_cluster

    • ランダムなポートでHTTPサーバが建つGoアプリケーション・1秒毎に追加/削除する
    • 2%の確率で高レイテンシ・2%の確率で500番エラー
  • xds_service

    • dummy_clusterで建てられたサーバアドレスをenvoyに伝えるxDSサーバ
  • prometheus, grafana

    • envoyのメトリクスを収集
  • hey

    • HTTPリクエストジェネレータ

Envoyの設定はこのようになっており、上で例示した設定ファイルの 10.0.0.1:80 10.0.0.2:80 のアップストリームを動的に書き換えるという構成です。 アップストリームを書き換えるので利用するEnvoyの設定サービスはEDS(Endpoint Discovery Service)となり、リソースは ClusterLoadAssignment となります。

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 8123 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          stat_prefix: ingress_http
          route_config:
            name: route
            virtual_hosts:
            - name: app_service
              domains: ["app.local"]
              routes:
              - match: { prefix: "/" }
                route: { cluster: app_cluster, timeout: 0.7s }
          http_filters:
          - name: envoy.router
  clusters:
  - name: app_cluster
    type: EDS
    eds_cluster_config:
      eds_config:
        api_config_source:
          api_type: GRPC
          grpc_services:
            envoy_grpc: { cluster_name: xds_cluster }
  - name: xds_cluster
    load_assignment:
      cluster_name: xds_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: 127.0.0.1, port_value: 20000 }

dummy_cluster は、1秒毎にランダムなポートでHTTPサーバが建つGoで作ったアプリケーションです。 起動中のサーバが10台以上になったら一番古いサーバが終了します。 また、変動するメトリクスを収集したいので一定確率でエラーが出るロジックも追加しています。 2%の確率で高レイテンシ・2%の確率で500番エラーが発生します。

$ go run ./cmd/dummy-cluster/. 
http listen on [::]:5000
add: 127.0.0.1:37631    remove: 127.0.0.1:45589
add: 127.0.0.1:40271    remove: 127.0.0.1:40367

また、以下のように GET /servers エンドポイントでサーバのリストが取得できます。

$ curl localhost:5000/server
127.0.0.1:36577
127.0.0.1:45459
127.0.0.1:34989

xds_service は、gRPCでxDSエンドポイントを公開しenvoyにエンドポイントリストを伝えます。 dummy_clusterGET /servers からサーバリストを100ms毎にポーリングします。 サーバリストに変化が有ればxDSを通じてenvoyに通知します。

メトリクス監視は、 http://localhost:8111/stats/prometheus でメトリクスが公開されるので、それをPrometheusでスクレイピングする設定を行います。 以下のようにメトリクスが出力され、メトリクス数は700を超えていました。 出力されるメトリクスの意味はリンク先で解説されています。 アップストリームのステータスコード・レスポンスタイム等、非常に細かい粒度でメトリクスが出力されていることが分かります。

# TYPE envoy_cluster_external_upstream_rq counter
envoy_cluster_external_upstream_rq{envoy_response_code="200",envoy_cluster_name="app_cluster"} 6839
envoy_cluster_external_upstream_rq{envoy_response_code="500",envoy_cluster_name="app_cluster"} 290
envoy_cluster_external_upstream_rq{envoy_response_code="504",envoy_cluster_name="app_cluster"} 92

# TYPE envoy_cluster_external_upstream_rq_completed counter
envoy_cluster_external_upstream_rq_completed{envoy_cluster_name="app_cluster"} 7221

# TYPE envoy_cluster_external_upstream_rq_xx counter
envoy_cluster_external_upstream_rq_xx{envoy_response_code_class="2",envoy_cluster_name="app_cluster"} 6839
envoy_cluster_external_upstream_rq_xx{envoy_response_code_class="5",envoy_cluster_name="app_cluster"} 382

Statistics — envoy 1.17.0-dev-7554d6 documentation

Consul+Nomad環境でEnvoyをゲートウェイとして利用

Nomadのサービスは全てIP:PORTの組み合わせが自動的にConsulに登録され、自動的にヘルスチェックが行われます。 ConsulのHTTP APIを利用することで、サービス内で正常にアクセス出来ると判断されたIP:PORTのリストを得ることが出来ます。 また、APIの Blocking Queries を利用すれば変更をリアルタイムに監視することも出来ます。

サービスの一覧は /agent/services 、各サービスのアドレス情報などは /agent/services/:service_id のエンドポイントを利用します。 10個のサービスのアドレスを監視する場合、11個のエンドポイントに対し監視を行うことになります。 Consulの開発元からGo言語のAPIクライアントライブラリが提供されているので、多くの場合はこちらを利用することになると思います。

最初の目標として、Consulサービスのアドレスが変動した際にEnvoyの設定変更を適切に行いたいと思います。 Consulに登録されているサービス情報をEDS(Endpoint Discovery Service)にてEnvoyに伝えると、アップストリームが動的に設定可能になります。

コミュニティライブラリとして gojek/consul-envoy-xds が存在しますが、古いEnvoyのAPIタイプであるV2を利用している・機能が豊富でコードが少し複雑になっているので今回は自作することにしました。 成果物はこちらで公開しています。 Consul APIの /agent/services/agent/services/:service_id をWatch(内部的にはBlocking Queries)を利用してサービスの情報を監視し、変更が有ればEDSを通じてEnvoyへと配信します。

https://github.com/kamijin-fanta/consul-envoy-xds-server

Envoyの設定例は以下のようになります。 EDSの設定にて service_name: hoge-api-service と有りますが、Consulのサービス名をそのまま書きます。 EDSのクラスタは 127.0.0.1:15000 と指定しており、上のconsul-envoy-xds-serverを起動しています。

node:
  id: hostname
  cluster: cluster.local

admin:
  access_log_path: /dev/null
  profile_path: /dev/null
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 8080
static_resources:
  listeners:
  - name: listener_http
    address:
      socket_address: { address: 0.0.0.0, port_value: 80 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          stat_prefix: ingress_http
          route_config:
            name: route
            virtual_hosts:
            - name: consul
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route: { cluster: consul_hoge_api_service }
          http_filters:
          - name: envoy.router
  clusters:
  - name: consul_envoy_xds_server
    connect_timeout: 0.25s
    lb_policy: ROUND_ROBIN
    http2_protocol_options: {}
    load_assignment:
      cluster_name: consul_envoy_xds_server
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: 127.0.0.1, port_value: 15000 }
  - name: consul_hoge_api_service
    type: EDS
    connect_timeout: 0.25s
    eds_cluster_config:
      service_name: hoge-api-service
      eds_config:
        resource_api_version: v3
        api_config_source:
          api_type: GRPC
          transport_api_version: v3
          grpc_services:
            envoy_grpc: { cluster_name: consul_envoy_xds_server }

Let's Encrypt証明書をEnvoyに設定する

Envoyは標準ではLet's Encryptの証明書取得プロセスACMEをサポートしていません。 Issueにもなっているのですが、通常はEnvoyを直接触ることが無いので問題になることが無いため解決されていないと想像しています。

https://github.com/envoyproxy/envoy/issues/96

そこで、証明書を自動取得しSDS(Secret Discovery Service)でEnvoyに自動設定するコンポーネントを作りました。 今回はワイルドカード証明書を取得する必要が有ったため、 Lego を利用したDNS01認証を行っています。

https://github.com/kamijin-fanta/envoy-acme

1時間毎に証明書の期限を確認し、指定した日付以下になればDNS01認証での証明書取得を開始します。 HA構成をサポートするためにConsul KVSへと証明書を保存し、同時に証明書の更新を開始しないようロック処理を行っています。 SDSサーバが1台のみで良い場合は、Consul KVSではなくローカルファイルシステムに証明書を保存することも可能です。

実際に投入するEnvoyの設定は以下のようになります。 SDSサーバは標準で 127.0.0.1:20000 で立ち上がるので、これをクラスターとして記述します。 そして filter_chains 中に transport_socket を設定することで、実際に証明書と秘密鍵を設定しています。

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 443 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          stat_prefix: ingress_http
          route_config:
            name: route
            virtual_hosts:
            - name: app_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                direct_response:
                  status: 200
                  body:
                    inline_string: hello envoy
          http_filters:
          - name: envoy.router
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
          common_tls_context:
            tls_certificate_sds_secret_configs:
            - name: "setting-name"
              sds_config:
                resource_api_version: v3
                api_config_source:
                  api_type: GRPC
                  transport_api_version: v3
                  grpc_services:
                    envoy_grpc:
                      cluster_name: envoy_acme_sds_cluster
  clusters:
  - name: envoy_acme_sds_cluster
    connect_timeout: 0.25s
    http2_protocol_options: {}
    load_assignment:
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: {address: 127.0.0.1, port_value: 20000 }

まとめ

  • Nomad+Consulの環境に適したゲートウェイは通常NginxとConsul Templateを利用して構築する
  • 技術的な課題(可観測性)などがある場合Envoyに置き換えることが出来る
  • ConsulからEnvoyにアップストリームを伝えるために kamijin-fanta/consul-envoy-xds-server を作成した
  • EnvoyにLet's Encrypt証明書を設定するために kamijin-fanta/envoy-acme を作成した
  • Envoyを直接利用するためにはコードの記述などが必要となる場合が多いので、k8s等の環境においては間接的に利用するのが良いと感じる
続きを読む

Puppeteer/Playwrightでの画面録画・コードリーディング

この記事はkb Advent Calendar 2020 1日目の記事です。 https://adventar.org/calendars/5280

Chrome等のブラウザを自動操作するためのライブラリとして、Googleが開発するPuppeteerと、Firefox等もサポートに加えたMicrosoftのPlaywrightなどが有る。現時点で機能差は多くないが、Playwrightにはブラウザ画面をwebmとして録画出来る機能が有る。しかしPuppeteerには実装されていない。何故Puppeteerでは実装されないのかを中心に、今回調査を行った。

Playwrightでの録画

Microsoftのライブラリ実装のPlaywrightでは、録画向けのAPIが提供されている。v1.4.0のリリースで公開され、次のv1.5.0でAPIが若干変わりstableとして公開された。

これを利用するのは非常に簡単だ。Contextを作成する際に recordVideo フィールドを指定すれば録画が始まり、コンテキストが終了した際に映像が指定したディレクトリに保存される。

import * as playwright from "playwright";

(async () => {
  const browser = await playwright.chromium.launch({
    headless: false,
  });
  const context = await browser.newContext({
    recordVideo: {
      dir: ".",
      size: { height: 900, width: 1600 },
    },
    viewport: {
      height: 900,
      width: 1600,
    },
  });
  const page = await context.newPage();

  await page.goto("https://google.com/");
  await page.type('input[aria-label="検索"]', "playwright microsoft", {
    delay: 50,
  });

  await page.waitForTimeout(1000);
  await context.close();
  await browser.close();
})();

以下はこの機能で録画した映像。

フレームレートも十分で、テスト中で行った操作やコケた箇所を判断するには十分な映像だと考えられる。

Playwrightでの実装

利用するのが非常に容易な一方で、実装は複雑なものになっている。

録画を行う際の下準備は主に src/server/chromium/crPage.tsFrameSession#_startScreencast() で実装されている。ページで明示的に内部API CRPage#startScreencast() を呼び出すか、ブラウザコンテキストのオプションにて recordVideo フィールドが指定されていれば、この処理が呼び出しされる。

https://github.com/microsoft/playwright/blob/v1.6.2/src/server/chromium/crPage.ts#L764-L787

CRPage#startScreencast() 周辺の実装を読むと以下のことを行っている。

  1. VideoRecorder.launch を起動し、ファイルへの記録を開始する(後述)
  2. Chrome CDPクライアントにて Page.startScreencast メソッドを呼び出す。jpegで送信するようオプション指定。
  3. CDPの Page.screencastFrame イベントをハンドリングし、到着したフレームをBase64でエンコード
  4. 1にて起動したインスタンスへ videoRecorder.writeFrame(encodedFrameBuffer, captureTimestamp) として送信

Chromeは Page.startScreencast メソッドが呼び出されると、フレームごとにjpegでのキャプチャを行い Page.screencastFrame イベントをクライアントに送信する。

Chromeの Page.startScreencast メソッドは現時点でExperimentalなAPIとしてドキュメントに記述されている。 https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-startScreencast


startScreencast 内で作成された実際に録画を行っているインスタンスは src/server/chromium/videoRecorder.ts で記述されている。処理の流れとしては以下の通り。

  1. _launch メソッドにてffmpegを起動する。主な引数を -f image2pipe -c:v mjpeg -i - -c:v vp8 hogehoge.webp とし、標準入力にてjpegを待ち受ける。
  2. フレームを受信する毎に writeFrame メソッドが呼び出される
  3. writeFrame 内では目標FPSに達さない場合、到着時間を元にフレームを複数回ffpmegに標準入力を通じて送信する。Chromeが実際に出力するフレーム10fps程度で変動する一方で、ffmpegでの記録は25fpsで行うため。

https://github.com/microsoft/playwright/blob/v1.6.2/src/server/chromium/videoRecorder.ts#L88-L105


Playwrightは実装がClient,Serverで分かれており、Serverが実際にChrome CDP等を操作してブラウザの操作を行う。通常の利用ではClient,Serverは同一のプロセスで処理され2つの実装はコード上の物だけとなるが、ユーザが明示的に指定することでWebSocketで通信する2つのプロセスとして実行することが出来る。

Client,Serverが異なるプロセスで実行されている場合、動画はユーザが指定したオプション recordVideo.dir ディレクトリには記録されず /tmp に記録され、コンテキストを終了した際にServerからClientに動画ファイルがWebSocketストリームを通じて転送される。

https://github.com/microsoft/playwright/blob/v1.6.2/src/browserServerImpl.ts#L179-L192

Puppeteerでの録画

Playwrightでの実装をまとめると、ChromeのAPI Page.startScreencast を利用することでフレームの画像を受信し、手元のffmpegにてwebp動画を作成している。これならPuppeteerでも実装できそうだが、現在のところ実装されていない。そのIssueはこれ。 https://github.com/puppeteer/puppeteer/issues/478

過去にPuppeteerでも Page.startScreencast を利用した実装が検討された。 https://github.com/puppeteer/puppeteer/pull/881 2017年のこのPRにて現在のPlaywrightと同様の機能が実装されたが、一度jpegにエンコードする無駄で複雑なプロセスはパフォーマンス面・動画品質の面で良くないと判断された。そして、Chronium側にビデオストリームを録画する機能が実装されるのを待とうという選択がなされた。 https://bugs.chromium.org/p/chromium/issues/detail?id=781117

現状Puppeteerで録画を行うための代替えの方法としては以下が挙げられている。この中ではPuppeteerでのe2eテスト時に動画を撮影するという用途ではpuppetcamが最も秀でているように思える。

puppetcamの実装

https://github.com/muralikg/puppetcam

puppetcamでは、Chromeで実行する拡張機能の background.js content_script.js が実装の本体と言える。前者で録画を行い、後者でPuppeteerユーザとの対話を行っている。

  • Chromeの拡張機能内 background.js にて chrome.desktopCapture を行うことでコンテンツの録画を開始する
  • content_script.js 内で window.postMessage を用いたメッセージの橋渡しを行い、Puppeteerと拡張機能間の通信を行う
  • 記録が終了されればobjectURLが発行され、ダウンロードが開始する
  • xvfbを利用することでHeadlessの際にも正常に画面が描画される

利用しているAPIがChromeの一般的な拡張機能で利用されているものを利用しており、今後の変更される可能性が低いよう感じる。また、他の実装と異なり画像へのエンコードを行わないのでリソースの利用効率も非常に良い実装である。しかし、ライブラリとして隠蔽されているとは言えず、利用者には一定の負担が有るだろう。

まとめ

  • Microsoftが開発するPlaywrightでの録画はブラウザでの自動テストを記録するために非常に手軽に利用できる
  • Googleが開発するPuppeteerでは録画機能が実装されていない
  • Puppeteerで録画を行うのであれば、コミュニティライブラリや自前の実装が必要となり一定の苦痛が有るだろう

Puppeteerは独自のセレクタの導入・Playwrightは簡易的な録画機能を提供するなど行っており、両者の実装は徐々に広がっており短期的に両者の互換性は失われていくかもしれないと感じた。

続きを読む