スクエニ ITエンジニア ブログ

突然の災害に備える Envoy Lua filter

envoyproxy/artwork is licensed under the Apache License 2.0

備えあれば憂いなし

web サービスを運用していると、不測の事態というのは起きるものです。
プログラムのバグであったり、ハードウェアの障害や性能不足であったり、時には理由がわからないが何かしら動かない、でも早急になんとかしないといけない… なんていうことが。もちろん、事前の準備は万全にしつつ、そういったことが起きないことが最善ではありますが、起きたときのために備えておくのもたいせつなことです。

今回は “HTTP で送られた request 内容を見て、条件によって処理する backend をわけたい” という仮想シナリオをたて、これをなるべくかんたん・じゅうなんに解決できる手段を考えてみます。

Envoy?

近年様々な用途で使われている高機能 proxy、Envoy。きっと Envoy ならこんな要求なんてかんたんにこたえてくれるだろう。という期待を持って軽い気持ちではじめましたが、結果を先に言うと非常に苦労しました。もし本番環境で問題が発生してから準備をはじめていたら… とぞっとします。今回は心に余裕を持って。では、やってみましょう。

以下、先日 install した Rancher Desktop の環境 を使い、Docker Compose で Envoy、Nginx container を起動して相互通信しています。

Envoy の設定を書く

さっそく、Envoy の設定をつくります。これさえできれば終わったようなものですが、これがたいへんでした。
今回は “じゅうなんに” ということで、Lua filter を使います。Lua で request、response の処理を組めばかなりいろんなことができるようですが、これを実現する例や document がなかなか見つからず、非常に苦労しました。

以下に動作する envoy.yaml を置いておきますので、試行錯誤や様々な要件に合わせての検証にご利用ください。
HTTP request から JSON POST body を受け取り、そこに特定の文字列 (例では “1234”) が含まれていたら特定の backend に route させるという例になっています。

admin:
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 9902 }
    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: http_routes
          codec_type: AUTO
          http_filters:
          - name: envoy.lua
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
              inline_code: |
                function envoy_on_request(request_handle)
                  local body = ""
                  local content_type = request_handle:headers():get("content-type")
                  if content_type:find("application/json") ~= nil then
                    local body_obj = request_handle:body()
                    local body_bytes = body_obj:getBytes(0, body_obj:length())
                    body = tostring(body_bytes)
                  end
                  if string.find(body, "1234") then
                    local meta = request_handle:streamInfo():dynamicMetadata()
                    meta:set("envoy.filters.http.lua", "group", "x2")
                    request_handle:headers():add("xxxx", "9999")
                  end
                end                
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          route_config:
            name: route_1
            virtual_hosts:
            - name: svc_x
              domains: ["*"]
              routes:
              # sepcial route
              - match:
                  prefix: "/"
                  dynamic_metadata:
                  - filter: "envoy.filters.http.lua"
                    path:
                    - key: "group"
                    value:
                      string_match:
                        exact: "x2"
                route: { cluster: svc_secret }
              # default route
              - match: { prefix: "/" }
                direct_response:
                  body:
                    inline_string: '(default response)'
                  status: 200
  clusters:
  - name: svc_secret
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: svc_secret
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: "nginx-2"
                port_value: 80

ちょっとだけ説明

dynamic metadata というものを利用して routing しています。Lua script 中で条件によってこれを set すると、route_config でそれを検出して特定の route を選択します。かんたんに言うとそれだけです。なのですが、それがこれだけ複雑な設定になるというのは、ちょっとツライ感じがしますね…

Lua script 中で無駄に HTTP header を追加しています。header 自体は本来必要がないものですが、わたしが試した範囲ではこれが無いと意図した動作をしませんでした。request に対して header の変更などがないと Lua filter 動作後に Envoy の route 再評価がおこなわれず、default の route に行ってしまうようです。これはもうすこしうまい方法があるのかもしれませんが、今回は発見できませんでした。もともとは request に変更を入れないようにと dynamic metadata を使ったのですが、これなら最初から header 条件で routing してもよいかもしれません。

Docker Compose で起動する

上記の Envoy 設定を使った Envoy と、backend として Nginx を起動します。最小限の docker-compose.yaml は以下のようなものになるでしょう。

services:
  envoy-2:
    image: envoyproxy/envoy:v1.24-latest
    volumes:
      - type: bind
        source: "./envoy.yaml"
        target: "/etc/envoy/envoy.yaml"
    ports:
      - "127.0.0.1:19902:9902"
  nginx-2:
    image: nginx

docker compose up などして起動します。

試す

curl などを使い、今回の条件文字列 “1234” のありなしで request の行き先、response が変わることを確認します。上記の設定では、“1234” を POST body に含まない request の場合は固定文字列 ‘(default response)’ が返されます。

$ curl -v http://localhost:19902/ \
  -H 'Content-Type: application/json; charset=utf-8' \
  --data-binary @- << EOF
  {
    "field1": "1234",
    "field2": {
      "foo": "bar"
    }
  }
EOF

Envoy の設定に、http_filters とおなじレベルで以下の設定を入れると Envoy が access_log を吐くようになり、試行錯誤の役にたちます。format の変更もかなりじゅうなんにできるようですが、よい document を発見できませんでした。

          access_log:
          - name: envoy.access_loggers.file
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
              path: "/dev/stdout"

performance 計測してみる

さて、やりたかった request 切り分けができることはわかりましたが、たくさんの request が来たときにさばききれないようなことになると問題です。latency が劣化したり、詰まったりしないかを確認しておきましょう。

最初は古き良き ab を使おうとしたのですが、Envoy が HTTP/1.1 を要求するようで、HTTP/1.0 しか対応していない ab では HTTP 426 response となってしまい使えませんでした。

ということで、Vegeta を使ってみます。手抜きですが、固定の POST body を用意してひたすら繰り返し request するだけのものを試してみました。
POST body はすこし量があったほうが差が出やすいだろうということで、例文といえばで有名な Lorem ipsum … を入れて合計 500bytes 程度に膨らませた適当な JSON を使用しています。(以下 test.json)
※ 大きな data を filter で全文処理するには、Envoy の buffer size 設定を変更する必要があるようです。ご注意ください。(未確認)

Nginx 直 (GET)

まずは比較対象として、Vegeta -> Nginx 直です。

$ echo "GET http://nginx-2:80/" \
  | vegeta attack -duration=10s -rate=1000
Requests      [total, rate, throughput]         10000, 999.96, 468.46
Duration      [total, attack, wait]             21.026s, 10s, 11.026s
Latencies     [min, mean, 50, 90, 95, 99, max]  12.141ms, 3.17s, 2.528s, 8.732s, 10.092s, 15.254s, 15.995s
Bytes In      [total, mean]                     88650, 8.87
Bytes Out     [total, mean]                     0, 0.00
Success       [ratio]                           98.50%
Status Codes  [code:count]                      0:150  200:9850  
Error Set:
Get "http://nginx-2:80/": read tcp 172.19.0.6:37159->172.19.0.4:80: read: connection reset by peer
  ...

docker stats で見ていると CPU 使用率は Vegeta が 60% 前後、Nginx が 20% 程度でした。いくぶんエラーが出ているのが確認できますが、nginx.conf の worker_connections は初期値の 1024 から変更しておらず、納得感はあります。これを増やすとどうなるか… というのは興味深くはありますが、全体的にもすでに限界が近く見えていることからこのまま行きます。

Envoy 経由

次、本題の Vegeta -> Envoy -> Nginx (100% routing) です。

$ echo "POST http://envoy-2:9902/
Content-Type: application/json; charset=utf-8
@./test.json" \
  | vegeta attack -duration=10s -rate=1000
Requests      [total, rate, throughput]         9093, 909.30, 373.31
Duration      [total, attack, wait]             17.398s, 10s, 7.398s
Latencies     [min, mean, 50, 90, 95, 99, max]  23.004ms, 5.06s, 4.903s, 8.257s, 8.636s, 9.186s, 10.776s
Bytes In      [total, mean]                     268893, 29.57
Bytes Out     [total, mean]                     4810197, 529.00
Success       [ratio]                           71.43%
Status Codes  [code:count]                      200:6495  503:2598  
Error Set:
503 Service Unavailable

CPU 使用率は Vegeta, Envoy でいずれも 40% 前後、Nginx で 20% 前後でした。エラー率が上がっており、またその種類も変わっています。Envoy が backend に接続できず 503 ということなので、Nginx 直でのテスト結果と原因はおなじかもしれません。Envoy に CPU 時間をかなり持っていかれているところを見るに、実際に運用する際には backend に合わせて Envoy 用にもしっかり CPU パワーを確保する必要がありそうです。

また、今回 long run は試していません。過去には memory leak の報告があったりした? ようなので、長期間の利用にはお気をつけください。

ちなみに、envoy.yaml 例にあるもうひとつの backend direct_response に reqeust をまわす (Nginx に行かない) ようにすると、-rate=2000 にあげても高速にエラーなしでさばくことができていました。なにかの条件に当てはまる request を超高速に捨てたいというときには効果的に利用できそうです。

他の方法

調べると、Nginx でも Lua での動的 proxy 処理が書けるようですが、そちらは試せていません。すでに Nginx で service されている場合は直接そこに分岐を入れてしまってもよいのかもしれません。
使うのは このへん と思われます。
ぱっと見ほぼおなじことができそうなので、Nginx と Envoy で性能や使い勝手を比較してみるのも面白そうですね…

ちょっと探した感じでは、他には今回のやりたかったことをかんたんに実現する方法が見つけられませんでした。
やはりあまり需要が無いのでしょうか…

まとめ

すでに web server があり、間に Envoy を入れたい! といった場合には、今回の例が割とすんなりと活用いただけるのではないかと思います。

それにしても今回は、Envoy 設定を書くための調査に非常に時間がかかりました。公式 document や community の例など、似たようなことをしているものをかなり探したのですが情報量が少なく、試行錯誤しながらの確認に時間を使いました。さらに設定 yaml の構造がそもそもかなり複雑で、reference を見てもおなじような単語が多かったりで読み解くのが困難でした。

やってみた印象として、Envoy でよく使われる機能は built-in filter としてすでに備わっているので、だいたいよく必要とされるようなユースケースはそれでカバーされており、今回のようにカスタムで書くようなこと自体があまり無いのではないかと感じました。しかしそんな中でも、どうしても使わなくてはいけなくなった時にここに置いた例が少しでもお役に立ちましたら幸いです。

参考

Lua filter を書くときの注意点や癖がまとまっていて参考になりました。

この記事を書いた人

記事一覧
SQUARE ENIXでは一緒に働く仲間を募集しています!
興味をお持ちいただけたら、ぜひ採用情報ページもご覧下さい!