Go의 리버스 프록시 (HTTP, WebSocket)

박재훈·2024년 9월 16일
0

GO

목록 보기
21/23
post-custom-banner

Go로 리버스 프록시를 만들 일이 있어서, 만드는 과정에서 언어에서 제공해주는 리버스 프록시에 대해 알게 되어 이에 대한 기록.

Reverse Proxy

리버스 프록시라고 하는 건 클라이언트로부터 온 요청을 서버로 연결시켜주는 역할을 하는 프록시이다. 다양한 이유로 리버스 프록시를 쓸 수 있다. 로드밸런싱을 위해서 쓸 수도 있고(이 경우에는 로드밸런서라는 이름으로 불리고), 서버 보안을 위한 필터로서 쓰일 수도 있겠다.

내 경우에는 테스트용 서버에 여러 인스턴스를 띄워놓은 뒤 외부 요청을 각 인스턴스에 연결시켜주기 위해 필요했었다. 어찌보면 일종의 포트포워딩..

Golang Reverse Proxy

Go의 httputil 패키지에 ReverseProxy라는 구조체가 있다.

type ReverseProxy struct {
	Rewrite        func(*ProxyRequest)
	Director       func(*http.Request)
	Transport      http.RoundTripper
	FlushInterval  time.Duration
	ErrorLog       *log.Logger
	BufferPool     BufferPool
	ModifyResponse func(*http.Response) error
	ErrorHandler   func(http.ResponseWriter, *http.Request, error)
}

내부적으로 http.Handler 인터페이스를 구현하고 있어서 http.Handle처럼 해당 인터페이스를 통해 라우팅하는 함수에 사용할 수 있다.

ReverseProxy를 이용하려면 RewriteDirector 둘 중 하나의 함수의 구현이 필요하며, 둘 다 구현하면 안된다.

나름 신기하게도 메소드가 아닌 멤버함수 형태로 함수가 있어서 함수 구현을 통해 템플릿 메소드 패턴처럼 쓸 수 있다.

Test Environment

RewriteDirector를 설명하기 전에 먼저 실험 환경을 얘기해야 할 것 같다.
대충 리버스 프록시를 통해 geth로 띄운 노드에 접근하는 시나리오를 상정하겠다.

geth는 다음 명령어로 dev 모드로 띄웠다.

geth \
    --dev \
    --dev.period 5 \
    --http \
    --http.port 8545 \
    --http.addr 0.0.0.0 \
    --http.corsdomain '*' \
    --http.api eth,net,web3,personal,debug \
    --ws \
    --ws.origins '*' \
    --ws.addr 0.0.0.0 \
    --ws.port 8546 \
    --ws.api eth,net,web3,personal,debug

간단하게 말하자면.. 블록타임 5초인 네트워크를 8545 포트에는 HTTP로, 8546 포트에는 웹소켓으로 연결할 수 있도록 띄운 것이다.

/node로 접근하는 요청을 이 노드에 연결시키는 웹서버를 짜려고 한다.

Rewrite

Rewrite 함수는 httputil.ProxyRequest 타입의 파라미터를 가지고 있다. 이 구조체를 뜯어보면 내부는 다음과 같다.

type ProxyRequest struct {
	// In is the request received by the proxy.
	// The Rewrite function must not modify In.
	In *http.Request

	// Out is the request which will be sent by the proxy.
	// The Rewrite function may modify or replace this request.
	// Hop-by-hop headers are removed from this request
	// before Rewrite is called.
	Out *http.Request
}

간단하게 말하자면, 클라이언트로부터 들어온 요청은 In이고 프록시 서버로부터 실제 도착지로 들어갈 요청은 Out인 것이다.
그래서 함수명인 Rewrite에 맞게 클라이언트의 요청을 바로 도착지로 보내지 않고 요청의 세부사항을 좀 더 수정할 수 있다.

Rewrite가 호출되기 전에 Out에서 Forwarded, X-Forwarded, X-Forwarded-Host, and X-Forwarded-Proto 헤더가 재설정된다. 정확히는, SetXForwarded 메소드에 의해 재정의된다.

SetXForwarded sets the X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto headers of the outbound request.

  • The X-Forwarded-For header is set to the client IP address.
  • The X-Forwarded-Host header is set to the host name requested by the client.
  • The X-Forwarded-Proto header is set to "http" or "https", depending on whether the inbound request was made on a TLS-enabled connection.

코드를 보는 게 조금 더 이해가 빠른데 코드를 보자면,

func (r *ProxyRequest) SetXForwarded() {
	clientIP, _, err := net.SplitHostPort(r.In.RemoteAddr)
	if err == nil {
		prior := r.Out.Header["X-Forwarded-For"]
		if len(prior) > 0 {
			clientIP = strings.Join(prior, ", ") + ", " + clientIP
		}
		r.Out.Header.Set("X-Forwarded-For", clientIP)
	} else {
		r.Out.Header.Del("X-Forwarded-For")
	}
	r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
	if r.In.TLS == nil {
		r.Out.Header.Set("X-Forwarded-Proto", "http")
	} else {
		r.Out.Header.Set("X-Forwarded-Proto", "https")
	}
}

코드를 보면 X-Forwarded-For 헤더는 클라이언트의 IP로, X-Forwarded-Host는 클라이언트 요청의 호스트로, 그리고 X-Forwarded-Proto는 요청의 TLS 적용 여부에 따라 http 또는 https로 설정한다.

SetXForwarded 함수는 Rewrite가 호출되기 이전에 실행되기 때문에 Rewrite 함수에서 헤더들을 다시 설정할 수도 있긴 하다.

그래서 이걸 가지고 리버스 프록싱 하는 코드를 짜보자면,

// 노드 URL 인스턴스 생성
u, err := url.Parse("http://localhost:8545")
if err != nil {
	log.Fatal(err)
}

// ReverseProxy 인스턴스 생성
rp := &httputil.ReverseProxy{
	// Rewrite 함수 정의
	Rewrite: func(pr *httputil.ProxyRequest) {
    	// Out 요청의 URL을 노드 URL로 설정
		pr.Out.URL = u
	},
}

// /node path에 해당 리버스 프록시 설정
http.Handle("/node", rp)
// 3000번 포트에서 리스닝
if err := http.ListenAndServe(":3000", nil); err != nil {
	log.Fatal(err)
}

간단하게만 짠다면 이게 끝이다. Rewrite 함수 내에서 요청의 세부사항을 더 설정하거나 로깅을 추가하거나 할 수 있는 것이다.

Director

Director는 심지어 더 간단하다. 사실 복잡한 설정이 필요하지 않다면 Director를 구현하는 게 더 적합하긴 하다.

Rewrite에서는 클라이언트로부터의 요청, 도착지에게의 요청이 나뉘어져 있었지만 Director에서는 그렇게 따로 구분되어 있지 않는다. 그냥 하나의 Request 객체만 있다.

각설하고 바로 코드를 보자면,

rp := &httputil.ReverseProxy{
	Director: func(r *http.Request) {
		r.URL = u
	},
}

이렇게 더욱 간단하게 구현할 수 있다.

NewSingleHostReverseProxy

일종의 예제 느낌으로 NewSingleHostReverseProxy라는 것이 있다. 얘는 Director를 구현하고 있다.

u, err := url.Parse("http://127.0.0.1:8545")
if err != nil {
	log.Fatal(err)
}

rp := httputil.NewSingleHostReverseProxy(u)

URL 인스턴스를 받아서 ReverseProxy 하나를 생성해준다. 정말 간단하게 리버스 프록시 만들려면 써도 되긴 하겠지만 로깅도 못하고 아무 설정도 못해서 그냥 RewriteDirector 직접 구현 하는 게 낫다.

Test

$ curl -X POST -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' http://localhost:3000/node
< {"jsonrpc":"2.0","id":1,"result":"0x882"}

블록넘버 가져오는 메소드 eth_blockNumber를 리버스 프록시에 날려서 원하는 응답을 받을 수 있다.

WebSocket Reverse Proxy

위에서 설명한 httputil.ReverseProxy는 HTTP 요청에 한해서만 유효하다. 즉, 웹소켓 리버스 프록시를 짜고 싶다면 쓸 수 없다는 말이다.

그래서 웹소켓 요청에 대한 리버스 프록시는 따로 만들어보았다.

golang.org/x/net/websocket vs gorilla

Go에서 웹소켓 구현하기 위해 몇가지 라이브러리가 있는데, 대표적으로 소제목에 적힌 golang.org/x/net/websocketgorilla가 있다.

뭘 쓸까 고민하다가 Gemini에게 물어보니 전자는 유지보수가 끊겨서 고릴라 쓰는 게 낫다고 한다. 그래서 고릴라로 하기로 했다. 애초에 고랭에서 웹소켓=고릴라는 거의 공식이기도 하고..

그래서 고릴라로 결정.

WebSocket Message Type

RFC6455에 따르면 웹소켓 메시지 타입은 다음과 같다.

  • 텍스트 메시지: 1
  • 바이너리 메시지: 2
  • 컨트롤 메시지:
    • Ping: 9
    • Pong: 10
    • Close: 8

JSON도 텍스트기 때문에 1이다. 고릴라에서는 websocket.TextMessage로 제공된다.

Gorilla WebSocket Flow (Handle Request)

고릴라 웹소켓의 간단한 구조는 다음과 같다.

// 웹소켓 업그레이드
conn, err := ws.upgrader.Upgrade(w, r, nil)
if err != nil {
	log.Fatal(err)
}

// 웹소켓 통신이 이루어지는 for문
for {
	// 유저로부터의 메시지
	messageType, data, err := conn.ReadMessage()
	if err != nil {
		log.Fatal(err)
	}

	// 유저에게 메시지 전송
	err = conn.WriteMessage(messageType, data)
	if err != nil {
		log.Fatal(err)
	}
}

연결 후 무한루프를 돌면서 사이클마다 유저의 메시지를 기다리고, 이후에 유저에게 응답을 전송하는 식이다.
한쪽에서 연결을 끊거나 특정 메시지가 오면 끊는 코드를 짜는 식으로 연결을 종료할 수 있다.

이건 내가 서버인 입장에서의 코드이고, 내가 다른 서버에게 요청을 보내고자 할 때는 또 다르다.

Gorilla WebSocket Flow (Send Request)

고릴라를 이용해 요청을 보내기 위해서는 websocket.Dialer 구조체를 이용해야 한다.

type Dialer struct {
	NetDial                         func(network, addr string) (net.Conn, error)
	NetDialContext                  func(ctx context.Context, network, addr string) (net.Conn, error)
	NetDialTLSContext               func(ctx context.Context, network, addr string) (net.Conn, error)
	Proxy func(*http.Request)       (*url.URL, error)
	HandshakeTimeout                time.Duration
	ReadBufferSize, WriteBufferSize int
	WriteBufferPool                 BufferPool
	Subprotocols                    []string
	EnableCompression               bool
	Jar                             http.CookieJar
}

여러 옵션을 정의한 Dialer 인스턴스를 생성할 수 있다. 직접 옵션들을 설정할 수도 있고, DefaultDialer를 이용할 수도 있다. DefaultDialer에 정의된 옵션은 다음과 같이

var DefaultDialer = &Dialer{
	Proxy:            http.ProxyFromEnvironment,
	HandshakeTimeout: 45 * time.Second,
}

ProxyHandshakeTimeout만 정의되어 있다.

이렇게 생성된 DialerDial 메소드를 실행시키면 아까 클라이언트의 요청에서 Upgrade를 했을 때 얻어졌던 것과 같은 *websocket.Conn 인스턴스를 얻을 수 있다.
다만 아까와는 다르게 보내는 게 먼저고 받는 게 그 다음이다.

// dialer 선언 및 dial을 통해 conn 생성
dialer := &websocket.Dialer{}
conn, _, err := dialer.Dial("ws://localhost:8546", nil)
if err != nil {
	panic(err)
}

// 서버에 메시지 전송
err = conn.WriteMessage(websocket.TextMessage, msg)
if err != nil {
	log.Fatal(err)
}

// 서버로부터의 메시지 수신
_, reader, err := conn.NextReader()
if err != nil {
	log.Fatal(err)
}

// 수신된 메시지 읽음
data, err := io.ReadAll(reader)
if err != nil {
	log.Fatal(err)
}

// 출력
fmt.Println(string(data))

NextReader 메소드를 통해 상대가 보낸 메시지를 읽을 수 있으며, 이 타입은 io.Reader이기 때문에 io.ReadAll을 통해 바이트 슬라이스로 읽을 수 있다.

그래서 이 둘을 조합해서 웹소켓 리버스 프록시를 구현할 수 있다.

WebSocket Reverse Proxy

// 웹소켓 업그레이드 및 클라이언트와의 연결용 conn 생성
conn, err := ws.upgrader.Upgrade(w, r, nil)
if err != nil {
	w.Write([]byte(err.Error()))
	return
}

// 도착지와의 연결을 위한 dial 생성
dial, _, err := websocket.DefaultDialer.Dial(ws.dst, nil)
if err != nil {
	w.Write([]byte(err.Error()))
	return
}
defer dial.Close()

for {
	// 유저로부터 메시지 수신
	messageType, data, err := conn.ReadMessage()
	if err != nil {
		conn.WriteMessage(messageType, []byte(err.Error()))
		return
	}

	// 유저에게서 수신한 메시지를 도착지에 전송
	err = dial.WriteMessage(messageType, data)
	if err != nil {
		conn.WriteMessage(messageType, []byte(err.Error()))
		return
	}

	// 도착지로부터 메시지 수신
	_, dstReader, err := dial.NextReader()
	if err != nil {
		conn.WriteMessage(messageType, []byte(err.Error()))
		return
	}

	// 도착지 메시지 읽음
	dstData, err := io.ReadAll(dstReader)
	if err != nil {
		conn.WriteMessage(messageType, []byte(err.Error()))
		return
	}

	// 읽어진 도착지의 메시지를 유저에게 전송
	err = conn.WriteMessage(messageType, dstData)
	if err != nil {
		conn.WriteMessage(messageType, []byte(err.Error()))
		return
	}
}

유저 메시지 수신 → 그걸 도착지에 전송 → 도착지로부터 메시지 수신 → 그걸 유저에게 전송

전체 코드는 깃허브에 올려놓았다.

Test

테스트는 아까의 HTTP와 동일하게 geth 네트워크에다가 했다.

$ wscat -c ws://localhost:3001/ws
Connected (press CTRL+C to quit)
> {"jsonrpc": "2.0","method": "eth_blockNumber","params": [],"id": 1}
< {"jsonrpc":"2.0","id":1,"result":"0x11c8"}

잘 된 다 !

결론

QUIC이나 webtransport 같은 것들도 대응을 하긴 해야 되는데..!

GitHub

profile
생각대로 되지 않을 때, 비로소 코딩은 재미있는 법.
post-custom-banner

0개의 댓글