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

박재훈·2024년 9월 16일

GO

목록 보기
21/24

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를 이용하려면 Rewrite나 Director 둘 중 하나의 함수의 구현이 필요하며, 둘 다 구현하면 안된다.

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

Test Environment

Rewrite와 Director를 설명하기 전에 먼저 실험 환경을 얘기해야 할 것 같다.
대충 리버스 프록시를 통해 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 하나를 생성해준다. 정말 간단하게 리버스 프록시 만들려면 써도 되긴 하겠지만 로깅도 못하고 아무 설정도 못해서 그냥 Rewrite나 Director 직접 구현 하는 게 낫다.

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/websocket과 gorilla가 있다.

뭘 쓸까 고민하다가 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,
}

Proxy와 HandshakeTimeout만 정의되어 있다.

이렇게 생성된 Dialer의 Dial 메소드를 실행시키면 아까 클라이언트의 요청에서 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
생각대로 되지 않을 때, 비로소 코딩은 재미있는 법.

0개의 댓글