Envoy WASM 필터로 프록시 기능 확장하기

쿠버네티스에서 널리 쓰이는 서비스 메시 구현체로 Istio가 있다. Istio는 Envoy 프록시를 사이드카로 이용해 네트워크를 제어한다.

Envoy 프록시는 클라우드 네이티브 애플리케이션을 위한 프록시 서버로, L4 및 L7 수준에서 다양한 트래픽 관리 기능을 제공하여 마이크로서비스 간의 통신을 관리한다.

Envoy는 네트워크 필터를 사용해 트래픽 처리 기능을 구현한다. Envoy 자체적으로도 다양한 필터가 있지만 커스텀 필터를 추가하여 Envoy의 기능을 새롭게 확장할 수 있다. 그 중에서도 HTTP 필터의 한 종류인 WASM 필터를 이용해 나만의 로직을 구현할 수 있다.

이 글이 작성된 2025년 6월 기준, WASM 필터는 실험적 기능이며 프로덕션 용으로 권장되지 않는다.

Envoy에서 WebAssembly의 역할은 여기를 참고.

실습

Envoy가 Kubernetes + Istio 생태계에서 주로 쓰이긴 하지만, 먼저 최소한의 작동만을 확인하기 위해 Docker 컨테이너로 실습을 진행한다.

플러그인 작성

Envoy 프록시를 위한 웹어셈블리(WebAssembly, WASM) Go SDK를 사용해 플러그인을 작성해보자.

Go SDK는 개발이 중단되었으며 더이상 지원되지 않는다. GC 언어라는 태생적 한계 때문으로 보인다. 이 글에서는 구현의 간단함 때문에 Go를 사용하지만 실제로 성능과 안정성이 필요한 환경에서는 C++나 Rust SDK 를 사용할 것을 권장한다.

소스 코드

// main.go

package main

import (
	"math/rand"

	"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm"
	"github.com/tetratelabs/proxy-wasm-go-sdk/proxywasm/types"
)

func main() {
	proxywasm.SetVMContext(&vmContext{})
}

type vmContext struct {
	types.DefaultVMContext
}

func (*vmContext) NewPluginContext(contextID uint32) types.PluginContext {
	return &pluginContext{
		config: config{},
	}
}

type pluginContext struct {
	types.DefaultPluginContext
	config config
}

type config struct {
	headerName string
	values     []string
}

func (p *pluginContext) OnPluginStart(pluginConfigurationSize int) types.OnPluginStartStatus {
	data, _ := proxywasm.GetPluginConfiguration()
	p.config = config{
		headerName: string(data),
		values:     []string{"hello", "world"},
	}

	return types.OnPluginStartStatusOK
}

func (p *pluginContext) NewHttpContext(contextID uint32) types.HttpContext {
	return &httpContext{
		cfg:       p.config,
		contextID: contextID,
	}
}

type httpContext struct {
	types.DefaultHttpContext
	cfg       config
	contextID uint32
}

func (c *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
	proxywasm.LogInfof("(context %d) >>> OnHttpRequestHeaders", c.contextID)

	if c.cfg.headerName == "" {
		return types.ActionContinue
	}

	i := rand.Intn(len(c.cfg.values))
	value := c.cfg.values[i]

	if err := proxywasm.ReplaceHttpRequestHeader(c.cfg.headerName, value); err != nil {
		proxywasm.LogErrorf("(context %d) Failed to replace request header: %v", c.contextID, err)
	} else {
		proxywasm.LogInfof("(context %d) Header added: '%s: %s'", c.contextID, c.cfg.headerName, value)
	}

	return types.ActionContinue
}

func (c *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action {
	proxywasm.LogInfof("(context %d) >>> OnHttpResponseHeaders", c.contextID)

	_ = proxywasm.AddHttpResponseHeader("x-proxy-wasm", "my-wasm-filter")

	return types.ActionContinue
}

func (c *httpContext) OnHttpStreamDone() {
	proxywasm.LogInfof("(context %d) >>> OnHttpStreamDone", c.contextID)
}

설명

이 플러그인은 특정 헤더의 값을 바꿔버리는 역할을 수행한다.

플러그인이 로드될 때, OnPluginStart 함수가 실행되며 플러그인의 구성 설정을 읽어들인다. 여기서는 단순히 텍스트로 읽어들여 그 내용을 headerName 필드에 저장한다.

HTTP 요청이 갈 때마다, OnHttpRequestHeaders 함수가 실행되어 위에서 설정한 headerName 필드와 일치하는 헤더를 찾아 그 헤더의 내용을 hello 혹은 world로 무작위로 대체한다.

또한, HTTP 응답이 올 때마다, OnHttpResponseHeaders 함수가 실행되어 응답 헤더에 x-proxy-wasm: my-wasm-filter를 추가한다.

Go SDK 리포지토리에서 더 다양한 예제를 찾아볼 수 있다.

빌드

모듈을 선언하고 빌드한다. 일반적인 Go 컴파일러가 아닌 TinyGo를 사용한다.

go mod init envoy-proxy-demo
go mod tidy
docker run --rm --mount type=bind,src=$(pwd),target=/build \
  --workdir /build tinygo/tinygo:0.31.2 \
  tinygo build -o my-filter.wasm -no-debug -scheduler=none -target=wasi main.go

WASM 빌드를 위해 -scheduler=none 옵션을 주었기 때문에 고루틴, 채널과 같은 기능을 사용할 수 없다.

이제 my-filter.wasm 파일이 빌드되었으면, 이걸 Envoy의 필터로 적용하는 일만 남았다.

플러그인 적용

cURL을 사용할 수 있는 Docker 컨테이너를 준비한다.

docker run --name client -it --rm nicolaka/netshoot:latest bash

컨테이너 내부에서 https://www.whatsmyua.info/api/v1/ua 로 GET 요청을 주면 요청을 보낸 User-Agent가 무엇인지 응답이 온다.

$ curl -kv https://www.whatsmyua.info/api/v1/ua
...(중간 생략)...
> GET /api/v1/ua HTTP/1.1
> Host: www.whatsmyua.info
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Server: nginx
< Date: Sat, 08 Jun 2025 14:01:27 GMT
< Content-Type: application/json; charset=utf-8
< Content-Length: 751
< Connection: keep-alive
< Vary: X-HTTP-Method-Override, Accept-Encoding
< Access-Control-Allow-Origin: *
< Link: <https://ipv4.games/claim?name=sandinmyjoints>; rel="next"
< X-Content-Type-Options: nosniff
< ETag: W/"2ef-onUitwZzYMp4vJ+cX9nkfg"
<
* Connection #0 to host www.whatsmyua.info left intact
[{"meta":{"name":"useragent","repo":"https://github.com/3rd-Eden/useragent","version":"2.2.1"},"ua":{"rawUa":"curl/8.7.1","family":"curl","major":"8","minor":"7","patch":"1","device":{"family":"Other","major":"0","minor":"0","patch":"0"}},"os":{"string":{"family":"Other","major":"0","minor":"0","patch":"0"},"family":"Other","major":"0","minor":"0","patch":"0"}},{"meta":{"name":"ua-parser-js","repo":"https://github.com/faisalman/ua-parser-js","version":"0.7.31"}},{"meta":{"name":"platform.js","repo":"https://github.com/bestiejs/platform.js/","version":"1.3.6"},"ua":{"name":null,"version":null,"layout":null},"os":{"os":{"architecture":null,"family":null,"version":null}},"device":{"product":null,"manufacturer":null,"description":"curl/8.7.1"}}]

cURL의 로그에서 내가 보낸 User-Agent: curl/8.7.1 헤더를 확인할 수 있고, 응답 내용에서도 마찬가지로 나의 유저 에이전트가 curl임을 확인할 수 있다.

이제 이 컨테이너에 Envoy 프록시를 붙여보자.

Envoy 설정 파일 envoy-demo.yaml을 작성한다.

# envoy-demo.yaml

static_resources:

  listeners:
  - name: listener_0
    address:
      socket_address:
        # 프록시가 바인드할 주소와 포트
        address: 0.0.0.0
        port_value: 10000
    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
          access_log:
          - name: envoy.access_loggers.stdout
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
          http_filters:
          - name: my-wasm-filter
            typed_config:
              '@type': type.googleapis.com/udpa.type.v1.TypedStruct
              type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
              value:
                config:
                  configuration:
                    # WASM 필터 플러그인의 구성 설정
                    '@type': type.googleapis.com/google.protobuf.StringValue
                    value: "User-Agent"
                  vm_config:
                    runtime: envoy.wasm.runtime.v8
                    code:
                      local:
                        # WASM 바이너리 경로
                        filename: /my-filter.wasm
                    vm_id: my-vm
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  host_rewrite_literal: www.whatsmyua.info
                  cluster: service_whatsmyua

  clusters:
  - name: service_whatsmyua
    type: LOGICAL_DNS
    dns_lookup_family: V4_ONLY
    load_assignment:
      cluster_name: service_whatsmyua
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: www.whatsmyua.info
                port_value: 443
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: www.whatsmyua.info

Envoy 프록시가 10000번 포트를 리슨한다. 이 프록시로 보내는 모든 HTTP 요청은 도메인에 상관없이 무조건 www.whatsmyua.info:443으로 전달된다.

새로운 터미널을 열고, 이 설정 파일을 적용한 Envoy 플러그인을 실행한다.

docker run --rm --network container:client \
  --mount type=bind,src=$(pwd)/envoy-demo.yaml,target=/envoy-demo.yaml \
  --mount type=bind,src=$(pwd)/my-filter.wasm,target=/my-filter.wasm \
  envoyproxy/envoy:dev --config-path /envoy-demo.yaml

--network 옵션을 통해 client 컨테이너와 네트워크를 공유하도록 했다. 설정 파일 envoy-demo.yaml과 WASM 플러그인 my-filter.wasm도 마운트했다.

이제 아까 전의 client 컨테이너의 셸에서 다시 한번 cURL로 요청을 쏴보자. 이번엔 www.whatsmyua.info로 직접 요청하는 것이 아니라 프록시 주소인 127.0.0.1:10000에 요청한다.

$ curl -kv 127.0.0.1:10000/api/v1/ua

*   Trying 127.0.0.1:10000...
* Connected to 127.0.0.1 (127.0.0.1) port 10000
> GET /api/v1/ua HTTP/1.1
> Host: 127.0.0.1:10000
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< server: envoy
< date: Sat, 08 Jun 2025 14:00:31 GMT
< content-type: application/json; charset=utf-8
< content-length: 742
< vary: X-HTTP-Method-Override, Accept-Encoding
< access-control-allow-origin: *
< link: <https://ipv4.games/claim?name=sandinmyjoints>; rel="next"
< x-content-type-options: nosniff
< etag: W/"2e6-0rmDfbCmSl4/6pH4pbgUkA"
< x-envoy-upstream-service-time: 430
< x-proxy-wasm: my-wasm-filter
<
* Connection #0 to host 127.0.0.1 left intact
[{"meta":{"name":"useragent","repo":"https://github.com/3rd-Eden/useragent","version":"2.2.1"},"ua":{"rawUa":"hello","family":"Other","major":"0","minor":"0","patch":"0","device":{"family":"Other","major":"0","minor":"0","patch":"0"}},"os":{"string":{"family":"Other","major":"0","minor":"0","patch":"0"},"family":"Other","major":"0","minor":"0","patch":"0"}},{"meta":{"name":"ua-parser-js","repo":"https://github.com/faisalman/ua-parser-js","version":"0.7.31"}},{"meta":{"name":"platform.js","repo":"https://github.com/bestiejs/platform.js/","version":"1.3.6"},"ua":{"name":null,"version":null,"layout":null},"os":{"os":{"architecture":null,"family":null,"version":null}},"device":{"product":null,"manufacturer":null,"description":"hello"}}]

cURL의 로그에서 내가 보낸 User-Agent 헤더의 값은 여전히 curl/8.7.1 이지만, API의 응답 내용에서는 나의 유저 에이전트가 hello (또는 world)라고 나타난다. 또한, 응답 헤더에도 x-proxy-wasm: my-wasm-filter가 추가되었다.

즉, 요청 헤더와 응답 헤더가 도중에 프록시에 의해 변경되었음을 알 수 있다.

응용

상태 관리

WASM VM에는 자체적인 Key-Value 상태 저장소가 내장되어 있다. 그래서 상태가 있는(Stateful) 플러그인을 개발하여 좀 더 복잡한 로직을 개발하는 것도 가능하다.

예시 코드:
(설명의 편의상 에러 처리는 모두 생략.)

func (c *httpContext) OnHttpRequestHeaders(_ int, _ bool) types.Action {
	// Select valid value from values pool
	c.target = c.cfg.values[0]
	for _, i := range rand.Perm(len(c.cfg.values)) {
		value = c.cfg.values[i]

		data, _, _ := proxywasm.GetSharedData("eviction:" + value)
		evictedUntil, _ := time.Parse(time.RFC3339, string(data))
		if time.Now().Before(evictedUntil) {
			continue
		} else {
			c.target = value
			break
		}
	}

	_ = proxywasm.ReplaceHttpRequestHeader(c.cfg.headerName, value)

	return types.ActionContinue
}

func (c *httpContext) OnHttpResponseHeaders(_ int, _ bool) types.Action {
	headers, _ := proxywasm.GetHttpResponseHeaders()

	var status string
	for _, h := range headers {
		if h[0] == ":status" {
			status = h[1]
			break
		}
	}

	// On 4xx response, evict the value from the pool.
	if strings.HasPrefix(status, "4") {
		evictedUntil := time.Now().Add(time.Minute).Format(time.RFC3339)
		_ = proxywasm.SetSharedData("eviction:"+c.target, []byte(evictedUntil), 0)
	}

	return types.ActionContinue
}

무작위로 특정 헤더의 값을 변경하는 것까지는 아까 위에서 만든 것과 동일하지만, 그렇게 선택한 헤더로 요청을 보냈을 때 4xx 응답 코드가 반환된다면 해당 헤더 값을 1분간 무작위 선택 풀에서 제외시키도록 하는 로직이 추가되었다.

상태를 읽고 저장하기 위해 GetSharedData()SetSharedData() 메서드를 사용했다.

위 코드를 좀 더 응용한다면, 애플리케이션에서 보내는 요청을 여러 호스트로 분산시키고 그 중 특정 호스트에서 응답이 실패했을 때 그 호스트로는 일시적으로 요청을 보내지 않는 식으로, 클라이언트 단에서 부하 분산 시스템을 구현할 수도 있다.

Kubernetes + Istio 환경 실습

Istio는 사이드카 프록시로 Envoy를 사용하므로 방금 작성한 플러그인을 Istio 메시에 바로 적용할 수 있다.

실습 클러스터: WSL Ubuntu 22.04에서 kind로 생성한 싱글 노드 Kubernetes 1.33

네임스페이스: default

Istio 설치

curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.26.2 sh -
./istio-1.26.2/bin/istioctl install

사이드카 인젝션도 활성화한다.

kubectl label namespace default istio-injection=enabled

ConfigMap 생성

이전 단계 실습에서 빌드한 my-filter.wasm 바이너리로부터 ConfigMap을 생성한다.

kubectl create configmap my-wasm-filter --from-file my-filter.wasm

Pod 매니페스트 작성

# my-client-pod.yaml
---
apiVersion: v1
kind: Pod
metadata:
  # 어노테이션으로 사이드카 마운트 구성
  annotations:
    sidecar.istio.io/userVolume: >-
      [{"name":"my-wasm-filter","configMap":{"name":"my-wasm-filter"}}]
    sidecar.istio.io/userVolumeMount: >-
      [{"name":"my-wasm-filter","mountPath":"/var/local/wasm"}]
  labels:
    app: my-client
  name: my-client
spec:
  containers:
  - command:
    - sleep
    - infinity
    image: nicolaka/netshoot:v0.13
    name: my-client

Envoy 사이드카에서 WASM 바이너리를 읽을 수 있도록 my-wasm-filter ConfigMap의 내용물을 사이드카 컨테이너에 마운트해야 한다. sidecar.istio.io/userVolume, sidecar.istio.io/userVolumeMount 어노테이션을 이용하면 자동으로 사이드카에 볼륨 마운트가 구성된다.

참고: https://istio.io/latest/docs/reference/config/annotations/#SidecarUserVolume

EnvoyFilter 생성

Istio 환경에서는 Envoy 사이드카 프록시의 설정을 수정하기 위해 EnvoyFilter 리소스를 활용한다.

참고: https://istio.io/latest/docs/reference/config/networking/envoy-filter/

# envoyfilter.yaml
---
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: my-wasm-filter
spec:
  workloadSelector:
    # EnvoyFilter를 `my-client` 애플리케이션에 선택적으로 적용.
    labels:
      app: my-client
  configPatches:
  - applyTo: HTTP_FILTER
    match:
      listener:
        filterChain:
          filter:
            name: envoy.filters.network.http_connection_manager
            subFilter:
              name: envoy.filters.http.router
    patch:
      operation: INSERT_BEFORE
      value:
        name: my-wasm-filter
        typed_config:
          '@type': type.googleapis.com/udpa.type.v1.TypedStruct
          type_url: type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
          value:
            config:
              configuration:
                '@type': type.googleapis.com/google.protobuf.StringValue
                value: "User-Agent"
              vm_config:
                runtime: envoy.wasm.runtime.v8
                code:
                  local:
                    # 사이드카 내부에 ConfigMap의 파일이 마운트된 경로와 일치해야 한다.
                    filename: /var/local/wasm/my-filter.wasm
                vm_id: my-vm

위 내용으로 EnvoyFilter 리소스를 생성한다.

kubectl apply -f envoyfilter.yaml

DestinationRule / ServiceEntry 생성

EnvoyFilter는 HTTPS로 암호화된 패킷을 못 읽기 때문에, 애플리케이션 내부에서는 HTTP로 요청을 보내고 사이드카에서 HTTPS로 암호화하도록 구성해야 한다.

# istio-resources.yaml
---
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
  name: whatsmyua-svc
spec:
  exportTo:
  - "."
  hosts:
  - www.whatsmyua.info
  location: MESH_EXTERNAL
  ports:
  - name: http
    number: 80
    protocol: HTTP
    targetPort: 443
  resolution: DNS
---
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: my-destination-rule
spec:
  exportTo:
  - "."
  host: "*"
  trafficPolicy:
    portLevelSettings:
    - port:
        number: 80
      tls:
        mode: SIMPLE
kubectl apply -f istio-resources.yaml

애플리케이션 배포 및 테스트

좀 전에 작성한 Pod 매니페스트를 적용한다.

kubectl apply -f my-client-pod.yaml

이제 필터가 적용되었는지 확인해보자.

kubectl exec -it my-client -- \
  curl http://www.whatsmyua.info/api/v1/ua

(http임에 유의하자. https로 보내면 Envoy 프록시 필터 적용이 안 된다.)

Pod 생성 시 초기화 컨테이너 proxy-init 덕분에 애플리케이션의 패킷을 사이드카 프록시가 알아서 가로채고 있다. 따라서 이전의 Docker 실습에서 했던 것처럼 프록시에 직접 요청을 보낼 필요가 없다.

출력 (일부 생략):

[
  {
    "meta": {
      "name": "useragent",
      "repo": "https://github.com/3rd-Eden/useragent",
      "version": "2.2.1"
    },
    "ua": {
      "rawUa": "hello",
      "family": "Other",
      ...
    },
    "os": ...
  },
  ...
]

프록시 필터에 의해 헤더가 변경되었다.


참고 자료

https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/wasm_filter
https://events.istio.io/istiocon-2021/sessions/extending-envoy-with-wasm-from-start-to-finish/