proxy-wasm-go-sdk에 Envoy라고 써있는 WASM Filter Get Wild로 하면...

이 글은 CyberAgent Developers Advent Calendar 2020(비공식)의 글이다.

개시하다


2020년 5월 발표된 아이스티오 1.5에 대한 주요 업데이트Istio 1.5 Release에서는 웹Assiembly(이하 WASM)를 사용하여 Istio의 확장성 모델을 Envoy와 통합하여 새로운 확장성 모델로 발표했다.
이 업데이트를 통해 version1.5 이후의 Istio에는 WASM을 지원하는 Envoy가 첨부되어 있으며 사용자는 이 확장성 모델을 알파 기능으로 사용할 수 있습니다. (기본값은 무효입니다.)
그래서 이번에는 엔보이의 WASM 확장에 착안해 엔보이가 WASM을 지원한 배경을 간략하게 설명한 뒤 실제 WASM Filter를 개발할 때의 궤적을 적고 싶다.

Envoy가 WASM을 지원하는 배경


Service Mesh의 Data Plane 기능을 확장하면 다음을 수행할 수 있습니다.
  • 독특한 액세스 제어 시스템과 통합
  • 독특한 도량을 사용하는 Observability
  • 향상
  • 독립 프로토콜 지원
  • 이에 과거 아이스티오와 엔보이 두 프로젝트 모두 다른 방법을 준비했다.
    Istio에서는 Mixer 플러그인의 맞춤형 정책과 도량을 이용하여 Mixer 플러그인을 재구성하는 방법이 있고, Envoy에서는 C++를 통해 Envoy Filter를 독립적으로 개발하여 Envoy 자체를 재구성하는 방법이 있다.
    그러나 이런 방법에는 과제가 존재한다.
    믹서 플러그인을 사용하는 방법에서는 매번 엔보이가 믹서에 문의해 지연 문제나 자원 효율이 떨어지는 등 성능에 좋지 않은 영향을 미친다.
    한편, C++로 Envoy Filter를 독자적으로 개발하는 방법에는 C++로 Filter를 개발해야 하는 언어에 대한 제약과 기능 추가와 오류 수정 등을 게시할 때 새로운 바이너리 정보를 배포해야 해 플러그인을 동적으로 읽을 수 없다는 문제 등이 있었다.
    따라서 이러한 과제를 해결하면서 엔보이 확장이 가능한 구현은 엔보이에서 WASM이 지원하는 새로운 기능이다.
    참고로 이 일대의 배경에 대한 말여기 기사.에 더 상세한 설명이 있으니 여기를 참조하면 잘 이해할 수 있습니다.
    또 아이스티오 커뮤니티에서 나온 것블로그 홈페이지도 참고할 수 있다.

    WASM 시작


    WASM에 익숙하지 않은 사람에게 WASM을 소개합니다.
    먼저 WASM은 웹 브라우저에서 로컬 코드와 동일한 속도로 실행되는 바이너리 형식(바이트 코드 사양)입니다.
    바이너리 형식이기 때문에 C/C++, Go 언어, Rust, Type Script 등 다양한 언어에서 생성할 수 있고, 개발자는 원하는 언어를 선택해 개발할 수 있다.
    또한 WASM은 하드웨어, 언어, 플랫폼의 독립성을 추구한다. WASM이 기술한 프로그램은 브라우저에 편입되어 독립된 VM으로 작업할 수 있고 다른 환경에 통합될 수 있어 휴대성이 좋다.
    이러한 특성 때문에 WASM은 브라우저 이외의 다양한 환경에서도 적극적으로 사용되기 시작했다.
    그러나 전제는 여러 언어에서 컴파일할 수 있지만 자바스크립트에 설치되면 WASM이 등장했을 때 컴파일러가 통용할 수 있는 Host 환경은 브라우저 외에는 없었다.
    따라서 WASM의 VM과 WASM을 실행하는 호스트 환경 사이의 인터페이스를 정의하는 Application Binary Interface(이하 ABI) WebAssiembly System Interface(이하 WASI)로 새롭게 정의됐다.
    WASI의 정의로 인해 WASI가 실행되는 동안 WASM을 실행할 수 있습니다.
    WASI를 실제로 구현한 실행 시간의 예에는 다음과 같은 예가 있습니다.
  • WAVM
  • Wasmtime
  • Wasmer
  • Lucet
  • WASI와 같은 장소에 WebAssiembly for Proxies(Proxy-WASM)라는 ABI도 있다.
    Proxy-WASM은 WASM과 프록시 서버 사이에 사용되는 ABI로 Proxy에서 WASM 모듈을 이동하여 확장하는 규격이다.
    Envoy는 Proxy-WASM의 참고로 존재하며, Envoy의 WASM 확장에서는 Envoy에서 V8 엔진(이동 WASM의 VM)을 실행함으로써 WASM을 처리한다.

    WASM Filter가 개발한 생태계


    이번 WASM Filter 개발에 사용된 도구 모음을 소개합니다.

  • WebAssembly Hub
    Solo.io, Google, Istio 커뮤니티에서 공동 개발한 WebAssiembly용 원격 레지스트리입니다.
    WebAssembly Hub은 WASM Filter를 OCI 이미지로 관리/배포하는 플랫폼입니다.

  • wasme
    WebAssiembly Hub을 사용할 때 사용되는 전용 CLI입니다.
    wasme와 조합하여 WebAssiembly Hub을 사용하면 Docker와 같이 WASM Filter의build/pulsh/pull/deploy 등 조작을 간단하게 할 수 있다.
  • 또한 wasme 설치부터 WASM Filter까지의 설계 절차는 여기 기사.를 참고했다.
    이 방면의 말은 너무 길어서 이번에는 말하지 않겠다🙇‍♀️

    WASM Filter 설치


    이번에는 TinyGo가 기초적으로 쓴 proxy-wasm-go-sdk를 사용해 Go 언어를 개발했다.
    wasme로 프로젝트를 만들 때 TinyGo를 지정하면 proxy-wasm-go-sdk를 사용하여 WASM Filter의 초기 형태를 생성합니다.
    또한 기본적으로 Hello라는 response header가 추가됩니다.
    main.고오의 주요 방법만 다음과 같다.
    // OnHttpRequestHeaders リクエストヘッダーが来たときに呼ばれるメソッド
    func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
    	hs, err := proxywasm.GetHttpRequestHeaders()
    	if err != nil {
    		proxywasm.LogCriticalf("failed to get request headers: %v", err)
    	}
    
    	for _, h := range hs {
    		proxywasm.LogInfof("request header: %s: %s", h[0], h[1])
    	}
    	return types.ActionContinue
    }
    
    // OnHttpResponseHeaders レスポンスヘッダーが返却されるときに呼ばれるメソッド
    func (ctx *httpContext) OnHttpResponseHeaders(numHeaders int, endOfStream bool) types.Action {
    	if err := proxywasm.SetHttpResponseHeader("hello", "world"); err != nil {
    		proxywasm.LogCriticalf("failed to set response header: %v", err)
    	}
    	return types.ActionContinue
    }
    
    이 방법들을 오버라이드로 실행한다.

    Try1: Firebase Authentication 검증 요청을 결합한 JWT


    Filter를 개발하여 Firebase Authentication과 협력하여 요청을 검증하는 JWT를 개발하는 것이 최초 목적이었다.
    그래서main.goo의 OnHttp Request Headers 내용은 다음과 같습니다.
    func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
    	ictx := context.Background()
    	app, err := firebase.NewApp(ictx, nil, option.WithCredentialsJSON([]byte(``)))
    	if err != nil {
    		return types.ActionPause
    	}
    	auth, err := app.Auth(ictx)
    	if err != nil {
    		return types.ActionPause
    	}
    	hs, err := proxywasm.GetHttpRequestHeaders()
    	if err != nil {
    		proxywasm.LogCriticalf("failed to get request headers: %v", err)
    		return types.ActionPause
    	}
    	for _, h := range hs {
    		proxywasm.LogInfof("request header: %s: %s", h[0], h[1])
    		if h[0] == "Authorization" {
    			// JWTを認証
    			token, err := auth.VerifyIDToken(ictx, strings.TrimPrefix(h[1], "Bearer "))
    			if err != nil {
    				return types.ActionPause
    			}
    			counter.Increment(1)
    			proxywasm.LogInfof("granted to access %s", token)
    			return types.ActionContinue
    		}
    	}
    	proxywasm.LogInfo("unauthorized request detected")
    
    	return types.ActionPause
    }
    
    구축 후 다음과 같은 오류가 발생했습니다.🤔
    $ wasme build tinygo -t webassemblyhub.io/mayusy/sample:dev ./
    Building with tinygo...vendor/go.opencensus.io/trace/trace_go11.go:21:2: cannot find package "." in:
            /src/workspace/vendor/runtime/trace
    package github.com/mayusy/envoy-wasm-firebase-sample
            imports firebase.google.com/go
            imports cloud.google.com/go/firestore
            imports cloud.google.com/go/firestore/apiv1
            imports google.golang.org/api/transport/grpc
            imports go.opencensus.io/plugin/ocgrpc
            imports go.opencensus.io/trace
    Error: failed producing filter file: exit status 1
    
    Firebase SDK에서 사용하는 opencensus-go 라이브러리인 것 같지만 TinyGo는runtime/trace 지원이 없어 구축할 수 없습니다.
    거기서는 그다지 좋지 않지만,goomod vendor 로컬 vendoring trace로go11.go에서runtime/trace 의존 코드를 삭제할 때 다음과 같은 오류가 발생했습니다🤔
    $ wasme build tinygo -t webassemblyhub.io/mayusy/getwild:dev ./
    Building with tinygo...# net
    ../../usr/local/go/src/net/cgo_linux.go:10:10: fatal: 'netdb.h' file not found
    ../../usr/local/go/src/net/cgo_resnew.go:14:10: fatal: 'netdb.h' file not found
    ../../usr/local/go/src/net/cgo_unix.go:14:10: fatal: 'netdb.h' file not found
    /usr/local/go/src/net/cgo_unix.go:20:22: unexpected token ILLEGAL
    Error: failed producing filter file: exit status 1
    
    이번에는 네트워크 주변에서 발생한 오류일 수 있습니다.
    Firebase SDK를 사용했다면 그 안에 어떤 네트워크 라이브러리가 사용됐는지 알기 어려웠을 텐데, 이번에는 네트워크 패키지를 간단하게 사용해 구축할 수 있는지 검증해 보겠습니다.

    Try2: 디버그된 네트워크/http를 개별적으로 구축할 수 있는지 확인


    net/http를 사용하여 적절한 URL에 GET 요청을 보내고 body에 응답하는 코드를 표시합니다.
    func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
    	proxywasm.LogInfo("accessed to OnHttpRequestHeaders")
    	// 適当なURLにGETリクエスト
    	res, err := http.Get("https://httpbin.org/uuid")
    	if err != nil {
    		proxywasm.LogCriticalf("failed to request to httpbin.org : %v", err)
    		return types.ActionContinue
    	}
    	// レスポンスをRead
    	b, err := ioutil.ReadAll(res.Body)
    	if err != nil {
    		proxywasm.LogCriticalf("failed to read http response body : %v", err)
    		return types.ActionContinue
    	}
    	// GETリクエストで受け取ったレスポンスをResponse BodyにSet
    	proxywasm.SendHttpResponse(200, [][2]string{}, string(b))
    	proxywasm.LogInfo("finished to execute OnHttpRequestHeaders function")
    	return types.ActionContinue
    }
    
    그러나 구축 결과는 Try2에서 발생한 오류와 같습니다.
    살짝 조사해 보니 TinyGo가 넷/http를 지원하지 않는 것으로 나타났다.( 참고 자료 )
    타이니고 환경에서는 외부에 대한 HTTP 요청을 일률적으로 보낼 수 없다는 것이다.
    여기서 나는 가까스로 Go 언어로 개발하는 것을 포기하고 다른 언어로 개발하는 것을 연구하기로 결정했다.

    TinyGo를 포기하고 다른 언어의 타당성을 탐색하다


    우선 Proxy-WASM의SDK가 존재하는 언어에서Rust에 주의하여 Try2와 같은 일을 실현할 수 있는지 모색해 보자.
    또 한동안여기 기사.의 정보에 따르면 Rust의 WASI는 네트워크 기능을 지원하지 않고 WASI는 파일 접근 이외의 자원에 접근하는 기능이 없다.
    1년 이상의 보도를 거치면서 현재도 같은 상황인지는 알 수 없지만, Rust의 마스터 지점WASI의 네트워크 라이브러리 코드만 보면 외부로 HTTP 요청을 보내기는 어려울 것 같다.

    뇌사Get Wild


    디버깅에 지친 나는 정신을 차리고 보니 뇌사했고, Response body에 GetWild 코드를 쓰기 시작했다.
    코드는 다음과 같습니다.
    func (ctx *httpContext) OnHttpRequestHeaders(numHeaders int, endOfStream bool) types.Action {
    	proxywasm.LogInfo("accessed to OnHttpRequestHeaders")
    	hs, err := proxywasm.GetHttpRequestHeaders()
    	if err != nil {
    		proxywasm.LogCriticalf("failed to get request headers: %v", err)
    		return types.ActionContinue
    	}
    	for _, h := range hs {
    		proxywasm.LogInfof("request header received key:%s\tval:%s", h[0], h[1])
    		if strings.Contains(h[0], "method") && strings.EqualFold(h[1], "get") {
    			proxywasm.LogInfof("get method detected key:%s\tval:%s", h[0], h[1])
    			proxywasm.SendHttpResponse(418, [][2]string{{"powered-by", "proxy-wasm-go-sdk!!"}}, getWild)
    			return types.ActionContinue
    		}
    	}
    	proxywasm.LogInfo("finished to execute OnHttpRequestHeaders function")
    	return types.ActionContinue
    }
    
    const getWild = `
    // GetWildのアスキーアート
    `
    
    드디어 마지막 디버깅의 때가 왔다.
    나는 험상궂은 깃털로 총을 쏜 것처럼 기세를 좋게 Enter 키를 눌러 컬을 두드렸다.
    ................

    WASI GET WILD!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
    ...현장은 이게 다야.

    끝말


    결국 결과가 없는 것이지만 다양한 디버깅으로 동분서주하고 있어 앞으로 WASM Filter를 개발하는 사람들에게 유용한 메시지를 전달할 수 있다면 좋겠다.
    이미 붙여넣은 내용에 대한 정보는 넘지 않았지만, 코드의 전체 내용여기 있다이 놓여 있었다.
    길어졌지만 끝까지 읽어주셔서 감사합니다.

    참고 문헌

  • https://qiita.com/ryysud/items/bf4139edc8165f991045
  • https://qiita.com/ryysud/items/f4faa4e92cb925ebbfce
  • https://mathetake.github.io/assets/pdfs/proxy-wasm-and-its-landscape.pdf
  • https://speakerdeck.com/mathetake/proxy-wasm-wasmwoli-yong-sitapluginji-gou-falsekai-fa
  • 좋은 웹페이지 즐겨찾기