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版であるため状況を注視しつつ、今後に大いに期待したい機能です。