EnvoyのExternal Processingのgoの実装サンプル紹介

これは何

EnvoyProxyのExternal Processingを利用した実装サンプルの紹介をします。 まだalpha版であり、実装サンプルが少ないため情報資源として残しています。

ext_procサーバーの実装サンプル

以下のサンプルを作成しました。実装の詳細はコードを追うとわかります。この記事では概説と実装時に躓くポイントを紹介します。

サンプルのアーキテクチャ

サンプルのアーキテクチャ

3つのサーバーで構成されています。

  • Envoy
  • WebServer(Node.jsのexpressサーバー)
  • External Processing Server(go runで実行)

これらを、Docker NetworkとHost Networkに配置することで動作確認のしやすさを確立しています。 External Processing Server(以後ext_proc)はホストマシンで実行すると、Docker Network内のEnvoyから見てhost.docker.internalでアクセスできます。 これを利用するとext_procはホストでgo runで実行するだけで済むため開発効率がすべてDocker Networkに閉じ込める場合と比較して上昇します。

サンプルの概要

01 simple-proxy

実装

最初のサンプルは、何もしないサンプルです。検証を進めていく上でこれがかなり重要な役割を果たします。 External Processing Serverの開発を進めていくとわかりますが、HTTPのRequest/ResponseをHTTPの仕様通りに実装しなければ、Envoy側がexitして停止します。

また、External Processing ServerのResponseは省略不可です。 これを指定しない場合、ブラウザでアクセスするとResponseが返ってくるまでコネクションを維持し続けます(ローディング状態になる)。 必ずレスポンスを指定する必要があります。

resp := &pb.ProcessingResponse{}
switch value := req.Request.(type) {
case *pb.ProcessingRequest_RequestHeaders:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_RequestHeaders{}, // 省略不可
  }
  break
case *pb.ProcessingRequest_RequestBody:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_RequestBody{},     // 省略不可
  }
  break
case *pb.ProcessingRequest_ResponseHeaders:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_ResponseHeaders{}, // 省略不可
  }
  break
case *pb.ProcessingRequest_ResponseBody:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_ResponseBody{},    // 省略不可
  }
  break
default:
  logrus.Debug(fmt.Sprintf("Unknown Request type %v\n", value))
}
if err := processServer.Send(resp); err != nil {
  logrus.Debug(fmt.Sprintf("send error %v", err))
}

02 rewrite-get-response-body

実装

ResponseBodyを書き換えるサンプルです。curlでenvoyproxyに対してGETリクエストを投げるとext_procサーバーで書き換えられた値が返ってきます。 ResponseHeaderを取得したタイミングで、ModeOverrideを利用し、ResponseBodyをext_procサーバーで処理するようにenvoy.yamlに記述されたもの方法から処理方法を上書きします。 これによりProcessingRequest_ResponseBody節の方に処理が流れ、ResponseBodyを加工できます。

ただし、この方法はcurl -IなどHEAD MethodでリクエストするとResponseBodyを期待していないためEnvoy側がexitする問題が発生します。 他にも、304 Not ModifiedもResponseBodyを期待していないため、単純んなブラウザリロードでEnvoy側が停止する問題があります。 したがって、ResponseBodyを書き換える条件をかなり厳密に定義する必要があります。

switch value := req.Request.(type) {
case *pb.ProcessingRequest_RequestHeaders:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_RequestHeaders{},
  }
  break
case *pb.ProcessingRequest_RequestBody:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_RequestBody{},
  }
  break
case *pb.ProcessingRequest_ResponseHeaders:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_ResponseHeaders{
      ResponseHeaders: &pb.HeadersResponse{
        Response: &pb.CommonResponse{
          HeaderMutation: &pb.HeaderMutation{
            RemoveHeaders: []string{
              "content-encoding",                             // ExternalProcessingが自動で付与するHeader。場合によってはブラウザで表示されない
            },
          },
        },
      },
    },
    ModeOverride: &v3alpha.ProcessingMode{                    // envoy.yamlで指定したprocessing_modeを書き換える
      ResponseBodyMode: v3alpha.ProcessingMode_BUFFERED,      // BUFFEREDにすることでProcessingRequest_ResponseBodyの節を通る様になる
    },
  }
  break
case *pb.ProcessingRequest_ResponseBody:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_ResponseBody{
      ResponseBody: &pb.BodyResponse{
        Response: &pb.CommonResponse{
          BodyMutation: &pb.BodyMutation{
            Mutation: &pb.BodyMutation_Body{
              Body: []byte("Rewrite value !"),                // 書き換える
            },
          },
        },
      },
    },
    ModeOverride: &v3alpha.ProcessingMode{},
  }
  break
default:
  logrus.Debug(fmt.Sprintf("Unknown Request type %v\n", value))
}

03 not-found

実装

たとえばnginxからEnvoyに乗り換える際に404ページをどうするのか、という問題を解決する一つの解を紹介しています。 ほとんどのユースケースとして、ブラウザからアクセスした際にユーザーに404ページが表示されれば良い、であるためResponseBodyを返す対象は RequestのHTTP MethodがGETかつ、UpstreamのResponse Status Codeが404である場合と限定されます。 Request/ResponseのBody、Headerの書き換え処理はgoroutineによって処理のスコープが別れているため、contextを利用して値を共有する必要があります。

それ以外の場合は、01のサンプルで示したように「何もしない」処理を実施します。 より条件を厳しくしたり、:path単位で返却するResponseBodyを分けるなど、実装次第でさまざまな要求を実現できます。

switch value := req.Request.(type) {
case *pb.ProcessingRequest_RequestHeaders:
  httpMethod, _ := share.GetHeaderValue(value.RequestHeaders.Headers.Headers, ":method")
  ctx = context.WithValue(ctx, HTTP_METHOD, httpMethod)
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_RequestHeaders{},
  }
  break
case *pb.ProcessingRequest_RequestBody:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_RequestBody{},
  }
  break
case *pb.ProcessingRequest_ResponseHeaders:
  status, _ := share.GetHeaderValue(value.ResponseHeaders.Headers.Headers, ":status")
  httpMethod := (ctx).Value(HTTP_METHOD).(string)
  if httpMethod == "GET" && status == "404" {               // Request MethodがGETかつ、Response Headerの:statusが404であるときにResponse Bodyを上書きする
    resp = &pb.ProcessingResponse{
      Response: &pb.ProcessingResponse_ResponseHeaders{
        ResponseHeaders: &pb.HeadersResponse{
          Response: &pb.CommonResponse{
            HeaderMutation: &pb.HeaderMutation{
              RemoveHeaders: []string{
                "content-encoding",
              },
            },
          },
        },
      },
      ModeOverride: &v3alpha.ProcessingMode{
        ResponseBodyMode: v3alpha.ProcessingMode_BUFFERED,
      },
    }
  } else {
    resp = &pb.ProcessingResponse{                            // 条件を満たさない場合は何もしない
      Response: &pb.ProcessingResponse_ResponseHeaders{},
    }
  }
  break
case *pb.ProcessingRequest_ResponseBody:
  httpMethod := (ctx).Value(HTTP_METHOD).(string)
  if httpMethod == "GET" {                                    // 404かつGETのときしか来ないので、それに甘えてやや条件がゆるい(実装をサボってる)
    resp = &pb.ProcessingResponse{
      Response: &pb.ProcessingResponse_ResponseBody{
        ResponseBody: &pb.BodyResponse{
          Response: &pb.CommonResponse{
            BodyMutation: &pb.BodyMutation{
              Mutation: &pb.BodyMutation_Body{
                Body: []byte(HTML_404),                       // 404のHTMLを返す
              },
            },
          },
        },
      },
      ModeOverride: &v3alpha.ProcessingMode{},
    }
  } else {
    resp = &pb.ProcessingResponse{
      Response: &pb.ProcessingResponse_ResponseBody{},
    }
  }

  break
default:
  logrus.Debug(fmt.Sprintf("Unknown Request type %v\n", value))
}

04 status-code-mapping-response

実装

Upstreamのステータスコードに対してResponseBodyを書き換えるようにするサンプル。 まだまだ詰が甘い部分がたくさんありますが、404以外のざっくりとした発展形として紹介しています。

htmlMap := map[string]string{                 // サーバー起動時にキャッシュする
  "403": share.ReadFile("public/403.html"),
  "404": share.ReadFile("public/404.html"),
  "500": share.ReadFile("public/500.html"),
  "503": share.ReadFile("public/503.html"),
}

// 中略

switch value := req.Request.(type) {
case *pb.ProcessingRequest_RequestHeaders:
  httpMethod, _ := share.GetHeaderValue(value.RequestHeaders.Headers.Headers, ":method")
  requestPath, _ := share.GetHeaderValue(value.RequestHeaders.Headers.Headers, ":path")
  ctx = context.WithValue(ctx, HTTP_METHOD, httpMethod)   // Request時のHTTP MethodをResponse時の処理に対して共有する
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_RequestHeaders{},
  }
  break
case *pb.ProcessingRequest_RequestBody:
  resp = &pb.ProcessingResponse{
    Response: &pb.ProcessingResponse_RequestBody{},
  }
  break
case *pb.ProcessingRequest_ResponseHeaders:
  responseStatus, _ := share.GetHeaderValue(value.ResponseHeaders.Headers.Headers, ":status")
  httpMethod := (ctx).Value(HTTP_METHOD).(string)    // Request時のHTTP Methodを取得する
  html := ""
  for status, resHtml := range htmlMap {             // 該当するstatus codeが探索する
    if responseStatus == status {
      html = resHtml
    }
  }
  ctx = context.WithValue(ctx, RES_CONTENT, html)    // ResponseHeaderとResponseBodyで情報を共有する
  if httpMethod == "GET" && html != "" {
    resp = &pb.ProcessingResponse{
      Response: &pb.ProcessingResponse_ResponseHeaders{
        ResponseHeaders: &pb.HeadersResponse{
          Response: &pb.CommonResponse{
            HeaderMutation: &pb.HeaderMutation{
              RemoveHeaders: []string{
                "content-encoding",
              },
              SetHeaders: []*core.HeaderValueOption{
                {
                  Header: &core.HeaderValue{
                    Key:   "content-type",
                    Value: "text/html",
                  },
                },
                {
                  Header: &core.HeaderValue{
                    Key:   "content-length",
                    Value: strconv.Itoa(len(html)),
                  },
                },
              },
            },
          },
        },
      },
      ModeOverride: &v3alpha.ProcessingMode{
        ResponseBodyMode: v3alpha.ProcessingMode_BUFFERED,
      },
    }
  } else {
    resp = &pb.ProcessingResponse{
      Response: &pb.ProcessingResponse_ResponseHeaders{},
    }
  }
  break
case *pb.ProcessingRequest_ResponseBody:
  httpMethod := (ctx).Value(HTTP_METHOD).(string)
  html := (ctx).Value(RES_CONTENT).(string)
  if httpMethod == "GET" && html != "" {
    resp = &pb.ProcessingResponse{
      Response: &pb.ProcessingResponse_ResponseBody{
        ResponseBody: &pb.BodyResponse{
          Response: &pb.CommonResponse{
            BodyMutation: &pb.BodyMutation{
              Mutation: &pb.BodyMutation_Body{
                Body: []byte(html),
              },
            },
          },
        },
      },
      ModeOverride: &v3alpha.ProcessingMode{},
    }
  } else {
    resp = &pb.ProcessingResponse{
      Response: &pb.ProcessingResponse_ResponseBody{},
    }
  }

  break
default:
  logrus.Debug(fmt.Sprintf("Unknown Request type %v\n", value))
}

まとめ

External ProcessingのServer実装はHTTPのRequest/Responseの仕様に対して正確かつ忠実に実装する必要があります。 さもなくばExtProcのサーバーではなく、Envoy側が停止するためラフにHEADリクエストを飛ばして停止する可能性が十分にありうる諸刃の剣です。 ユースケースを厳密にし、実装することでこの問題は回避できますがまだまだ安心して使える状況ではないと考えられます。 alpha版であるため状況を注視しつつ、今後に大いに期待したい機能です。