Go로 리버스 프록시를 만들 일이 있어서, 만드는 과정에서 언어에서 제공해주는 리버스 프록시에 대해 알게 되어 이에 대한 기록.
리버스 프록시라고 하는 건 클라이언트로부터 온 요청을 서버로 연결시켜주는 역할을 하는 프록시이다. 다양한 이유로 리버스 프록시를 쓸 수 있다. 로드밸런싱을 위해서 쓸 수도 있고(이 경우에는 로드밸런서라는 이름으로 불리고), 서버 보안을 위한 필터로서 쓰일 수도 있겠다.
내 경우에는 테스트용 서버에 여러 인스턴스를 띄워놓은 뒤 외부 요청을 각 인스턴스에 연결시켜주기 위해 필요했었다. 어찌보면 일종의 포트포워딩..
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
둘 중 하나의 함수의 구현이 필요하며, 둘 다 구현하면 안된다.
나름 신기하게도 메소드가 아닌 멤버함수 형태로 함수가 있어서 함수 구현을 통해 템플릿 메소드 패턴처럼 쓸 수 있다.
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
함수는 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
를 구현하는 게 더 적합하긴 하다.
Rewrite
에서는 클라이언트로부터의 요청, 도착지에게의 요청이 나뉘어져 있었지만 Director
에서는 그렇게 따로 구분되어 있지 않는다. 그냥 하나의 Request
객체만 있다.
각설하고 바로 코드를 보자면,
rp := &httputil.ReverseProxy{
Director: func(r *http.Request) {
r.URL = u
},
}
이렇게 더욱 간단하게 구현할 수 있다.
일종의 예제 느낌으로 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
직접 구현 하는 게 낫다.
$ 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
를 리버스 프록시에 날려서 원하는 응답을 받을 수 있다.
위에서 설명한 httputil.ReverseProxy
는 HTTP 요청에 한해서만 유효하다. 즉, 웹소켓 리버스 프록시를 짜고 싶다면 쓸 수 없다는 말이다.
그래서 웹소켓 요청에 대한 리버스 프록시는 따로 만들어보았다.
golang.org/x/net/websocket
vs gorilla
Go에서 웹소켓 구현하기 위해 몇가지 라이브러리가 있는데, 대표적으로 소제목에 적힌 golang.org/x/net/websocket
과 gorilla
가 있다.
뭘 쓸까 고민하다가 Gemini에게 물어보니 전자는 유지보수가 끊겨서 고릴라 쓰는 게 낫다고 한다. 그래서 고릴라로 하기로 했다. 애초에 고랭에서 웹소켓=고릴라는 거의 공식이기도 하고..
그래서 고릴라로 결정.
RFC6455에 따르면 웹소켓 메시지 타입은 다음과 같다.
JSON도 텍스트기 때문에 1이다. 고릴라에서는 websocket.TextMessage
로 제공된다.
고릴라 웹소켓의 간단한 구조는 다음과 같다.
// 웹소켓 업그레이드
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)
}
}
연결 후 무한루프를 돌면서 사이클마다 유저의 메시지를 기다리고, 이후에 유저에게 응답을 전송하는 식이다.
한쪽에서 연결을 끊거나 특정 메시지가 오면 끊는 코드를 짜는 식으로 연결을 종료할 수 있다.
이건 내가 서버인 입장에서의 코드이고, 내가 다른 서버에게 요청을 보내고자 할 때는 또 다르다.
고릴라를 이용해 요청을 보내기 위해서는 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
을 통해 바이트 슬라이스로 읽을 수 있다.
그래서 이 둘을 조합해서 웹소켓 리버스 프록시를 구현할 수 있다.
// 웹소켓 업그레이드 및 클라이언트와의 연결용 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
}
}
유저 메시지 수신 → 그걸 도착지에 전송 → 도착지로부터 메시지 수신 → 그걸 유저에게 전송
전체 코드는 깃허브에 올려놓았다.
테스트는 아까의 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 같은 것들도 대응을 하긴 해야 되는데..!