- Published on
Go net/http2 unexpected EOF·RST_STREAM 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 외부 API를 호출하는 Go 서비스에서 간헐적으로 net/http2: unexpected EOF 또는 stream error: stream ID ...; RST_STREAM 같은 오류가 튀어나오면, 대부분은 “애플리케이션 버그”라기보다 중간 경로(프록시/로드밸런서), 타임아웃 불일치, 커넥션 재사용 정책의 조합에서 발생합니다. 특히 HTTP/2는 하나의 TCP 커넥션 위에 여러 스트림을 멀티플렉싱하기 때문에, 커넥션 레벨에서 무언가가 끊기면 여러 요청이 동시에 영향을 받으며 로그가 더 혼란스러워집니다.
이 글에서는 Go net/http 클라이언트 기준으로 unexpected EOF·RST_STREAM을 재현 가능한 형태로 진단하고, 실전에서 효과가 컸던 해결 패턴을 정리합니다.
관련해서 인프라 레이어에서 502/리셋이 섞여 보인다면 함께 읽을 만합니다.
증상: 에러 메시지가 의미하는 것
net/http2: unexpected EOF
- HTTP/2 프레이밍을 읽는 도중, 상대가 커넥션을 조용히 닫아버려 더 이상 바이트를 읽을 수 없을 때 자주 발생합니다.
- 원인은 다양합니다.
- L7/L4 로드밸런서가 유휴 커넥션을 정리
- 프록시가 최대 커넥션 수/스트림 수 제한을 넘겨 강제 종료
- 서버가 크래시/재시작
- TLS 중간장비가 세션을 끊음
RST_STREAM
- HTTP/2에서 스트림 단위로 “이 스트림은 더 이상 처리하지 않겠다”는 신호입니다.
- 대표 상황
- 서버가 요청 바디를 다 읽기 전에 취소(예:
CANCEL) - 서버/프록시가 타임아웃으로 스트림 종료
ENHANCE_YOUR_CALM(과도한 요청),REFUSED_STREAM(리밋),INTERNAL_ERROR등 다양한 코드로 나타날 수 있음
- 서버가 요청 바디를 다 읽기 전에 취소(예:
핵심은 둘 다 “클라이언트가 뭘 잘못했다”로 단정하기 어렵고, 네트워크 경로 전체를 함께 봐야 한다는 점입니다.
가장 흔한 원인 7가지
1) 로드밸런서/프록시의 유휴 커넥션 타임아웃과 Go 커넥션 재사용
Go http.Transport는 기본적으로 커넥션을 풀에 보관하고 재사용합니다. 문제는 중간 장비(ALB, Nginx, Envoy 등)가 유휴 커넥션을 먼저 끊어버렸는데, 클라이언트는 그 커넥션이 살아있다고 생각하고 다음 요청을 태우면서 unexpected EOF가 터지는 패턴입니다.
해결 포인트
IdleConnTimeout을 중간 장비보다 짧게 설정- 또는 일정 기간마다 커넥션을 새로 만들도록 유도
package main
import (
"net"
"net/http"
"time"
)
func newClient() *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: 30 * time.Second, // 예: ALB/Nginx 유휴 타임아웃보다 짧게
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
return &http.Client{
Transport: tr,
Timeout: 20 * time.Second, // 전체 요청 상한
}
}
func main() {
_ = newClient()
}
운영 환경에서 ALB idle timeout, Nginx keepalive_timeout, Envoy idle_timeout 같은 값과 정렬시키는 것이 중요합니다.
2) 서버/프록시가 HTTP/2를 “완전 지원”하지 않는 경우
겉으로는 HTTP/2가 붙는 것처럼 보여도, 중간 프록시가 업스트림과 다운스트림에서 프로토콜을 다르게 처리하거나(예: 클라이언트는 HTTP/2, 백엔드는 HTTP/1.1), 특정 상황에서 스트림을 리셋하는 제품/설정이 있습니다.
진단
curl로 HTTP/2 강제 및 상세 로그 확인
아래처럼 부등호가 들어갈 수 있는 표현은 MDX에서 문제될 수 있으니, 커맨드 자체만 사용합니다.
curl -v --http2 https://api.example.com/health
curl -v --http2-prior-knowledge http://service.example.com/health
- 프록시 계층이 있다면 “클라이언트-프록시”와 “프록시-업스트림” 각각에서 HTTP/2 여부를 확인
해결
- 문제 구간에서 HTTP/2를 끄고 HTTP/1.1로 고정(임시 우회로 유용)
- 또는 프록시/ALB의 HTTP/2 설정을 재검토
Go 클라이언트에서 HTTP/2를 피하고 싶다면, 가장 단순한 방법은 ForceAttemptHTTP2를 false로 두고(또는 기본값 사용), 서버가 HTTP/2를 광고해도 선택되지 않게 환경을 조정하는 것입니다. 다만 TLS ALPN 협상 등으로 완전히 차단하려면 구성에 따라 추가 조치가 필요합니다.
3) 타임아웃 불일치: 클라이언트는 기다리는데 중간 장비가 먼저 끊음
HTTP/2 스트림은 “요청 하나”가 길어질 때 중간 장비의 read_timeout/stream_idle_timeout에 걸려 RST_STREAM이 날아오기 쉽습니다.
체크리스트
- Go
http.Client.Timeout(전체 상한) Transport.ResponseHeaderTimeout(헤더 대기 상한)- 서버 핸들러의 타임아웃(예: 서버
ReadTimeout,WriteTimeout) - Ingress/프록시의 업스트림 타임아웃
해결 패턴
- 클라이언트 타임아웃은 가장 바깥(사용자 경험) 기준, 프록시/서버 타임아웃은 그보다 조금 더 짧거나 길게 “의도적으로” 계층화
- 긴 작업은 동기 호출 대신 비동기 작업 큐/폴링 패턴 고려
4) 요청 바디 재시도 불가로 인한 EOF/RST 혼합
네트워크가 흔들릴 때 재시도를 넣으면 좋아 보이지만, POST/PUT처럼 바디가 있는 요청은 바디가 한 번 읽히면 재전송이 어려워 실패가 꼬일 수 있습니다. HTTP/2에서 스트림이 리셋되면, 클라이언트는 “보냈다/안 보냈다” 경계가 애매해집니다.
해결
- Go 1.13+에서는
http.Request.GetBody를 설정하면 재시도를 안전하게 구성할 수 있습니다.
package main
import (
"bytes"
"context"
"io"
"net/http"
"time"
)
func newJSONRequest(ctx context.Context, url string, body []byte) (*http.Request, error) {
r, err := http.NewRequestWithContext(ctx, http.MethodPost, url, io.NopCloser(bytes.NewReader(body)))
if err != nil {
return nil, err
}
r.Header.Set("Content-Type", "application/json")
// 재시도 시 바디를 다시 만들 수 있게 제공
r.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(body)), nil
}
return r, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, _ = newJSONRequest(ctx, "https://api.example.com/v1", []byte(`{"ping":"pong"}`))
}
그리고 재시도는 무조건이 아니라, 재시도 가능한 에러만 선별하는 것이 핵심입니다.
5) 과도한 동시성으로 인한 스트림/커넥션 제한 초과
HTTP/2는 커넥션 하나에 많은 요청을 얹을 수 있지만, 서버는 MAX_CONCURRENT_STREAMS 같은 제한을 둡니다. 제한을 넘기면 REFUSED_STREAM 또는 내부적으로 RST_STREAM이 발생할 수 있습니다.
해결
- 클라이언트 동시성 제한(세마포어)
Transport.MaxConnsPerHost로 호스트별 커넥션 상한을 설정해 “커넥션 하나에 몰빵”을 완화
tr := &http.Transport{
ForceAttemptHTTP2: true,
MaxConnsPerHost: 50,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: 30 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
_ = client
동시성은 애플리케이션 레벨에서도 제한하는 편이 안정적입니다.
6) 서버가 응답을 쓰는 도중 클라이언트 컨텍스트가 취소됨
클라이언트가 타임아웃으로 취소하면 서버는 context canceled를 겪고, HTTP/2에서는 스트림이 CANCEL로 리셋되는 형태가 흔합니다. 이때 서버 로그만 보면 “왜 갑자기 끊겼지”가 됩니다.
해결
- 클라이언트 타임아웃을 실제 처리 시간에 맞게 조정
- 서버는
Request.Context()취소를 정상 흐름으로 취급하고, 불필요한 에러 로그를 줄임
7) 노드 시간 드리프트, TLS/인증 계열 이슈가 간헐적 끊김으로 보이는 경우
unexpected EOF로만 보이지만 실제로는 TLS 재협상/인증서 검증/토큰 검증 실패가 원인인 경우도 있습니다. 특히 컨테이너/노드 시간 드리프트는 TLS와 STS 호출 실패를 연쇄적으로 만들 수 있습니다.
진단을 빠르게 만드는 로깅/관측 팁
1) 요청 단위로 반드시 남길 것
method,url host,path,statusrequest_id(헤더로 전파)duration_ms- 에러 발생 시
net.Error여부,timeout여부
2) Go에서 에러 분류
func isTimeoutErr(err error) bool {
if err == nil {
return false
}
if ne, ok := err.(interface{ Timeout() bool }); ok {
return ne.Timeout()
}
return false
}
그리고 메시지에 RST_STREAM 또는 unexpected EOF가 포함되는지로 1차 분류를 하되, 최종 원인은 인프라 로그와 함께 교차 확인해야 합니다.
3) 패킷/프록시 로그로 “누가 먼저 끊었는지” 확인
- 클라이언트 측:
tcpdump또는 eBPF 기반 네트워크 트레이싱 - 프록시 측: Envoy/Nginx access log에 upstream reset 사유
- 로드밸런서: ALB/NLB 타겟 리셋/502 지표
특히 ALB에서 target reset이 보이면 애플리케이션보다 먼저 인프라 설정을 의심하는 것이 비용이 적습니다.
실전 해결 레시피: 안전한 HTTP 클라이언트 템플릿
아래는 “운영에서 덜 터지는” 쪽으로 타임아웃과 커넥션 정책을 명시한 예시입니다.
package httpx
import (
"crypto/tls"
"net"
"net/http"
"time"
)
type ClientConfig struct {
RequestTimeout time.Duration
DialTimeout time.Duration
TLSHandshakeTimeout time.Duration
ResponseHeaderTimeout time.Duration
IdleConnTimeout time.Duration
MaxIdleConns int
MaxIdleConnsPerHost int
MaxConnsPerHost int
}
func NewClient(cfg ClientConfig) *http.Client {
if cfg.RequestTimeout == 0 {
cfg.RequestTimeout = 20 * time.Second
}
if cfg.DialTimeout == 0 {
cfg.DialTimeout = 5 * time.Second
}
if cfg.TLSHandshakeTimeout == 0 {
cfg.TLSHandshakeTimeout = 5 * time.Second
}
if cfg.ResponseHeaderTimeout == 0 {
cfg.ResponseHeaderTimeout = 10 * time.Second
}
if cfg.IdleConnTimeout == 0 {
cfg.IdleConnTimeout = 30 * time.Second
}
if cfg.MaxIdleConns == 0 {
cfg.MaxIdleConns = 200
}
if cfg.MaxIdleConnsPerHost == 0 {
cfg.MaxIdleConnsPerHost = 50
}
if cfg.MaxConnsPerHost == 0 {
cfg.MaxConnsPerHost = 100
}
dialer := &net.Dialer{Timeout: cfg.DialTimeout, KeepAlive: 30 * time.Second}
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
MaxConnsPerHost: cfg.MaxConnsPerHost,
IdleConnTimeout: cfg.IdleConnTimeout,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
ResponseHeaderTimeout: cfg.ResponseHeaderTimeout,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
return &http.Client{Transport: tr, Timeout: cfg.RequestTimeout}
}
이 템플릿을 기반으로, 환경별(ALB, 사내 프록시, 외부 API)로 IdleConnTimeout과 각종 타임아웃을 조정하면 unexpected EOF의 빈도가 눈에 띄게 줄어드는 경우가 많습니다.
재시도는 “무조건”이 아니라 “조건부”로
unexpected EOF나 RST_STREAM은 일시적일 수 있지만, 무한 재시도는 더 큰 장애를 만듭니다. 다음을 권장합니다.
- 멱등 요청(
GET, 일부PUT)만 기본 재시도 POST는Idempotency-Key같은 서버 지원이 있을 때만 적극 재시도- 지수 백오프와 지터 적용
- 최대 재시도 횟수 제한
간단한 백오프 예시입니다.
func backoff(attempt int) time.Duration {
base := 100 * time.Millisecond
max := 2 * time.Second
d := base * time.Duration(1<<attempt)
if d > max {
d = max
}
return d
}
최종 점검표
- 중간 장비의 유휴 커넥션 타임아웃과 Go
IdleConnTimeout을 정렬했는가 - 프록시/ALB가 HTTP/2를 안정적으로 처리하는가(필요 시 특정 구간에서 HTTP/1.1로 우회)
- 클라이언트/프록시/서버 타임아웃이 계층적으로 설계되었는가
- 동시성/스트림 제한을 넘기지 않는가
- 재시도는 멱등성과 바디 재생성(
GetBody)을 고려했는가 - 노드 시간, TLS, 인증 실패가 네트워크 오류로 위장되지 않는가
net/http2: unexpected EOF와 RST_STREAM은 원인이 하나로 고정되지 않습니다. 하지만 위 체크리스트대로 “커넥션 재사용과 타임아웃 정렬”부터 잡으면, 대부분의 간헐 오류는 빠르게 안정화됩니다.