- Published on
Nginx TLS 1.3 핸드셰이크 실패·ALPN 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 운영 중 갑자기 TLS 1.3 handshake failure 또는 ALPN 관련 오류로 HTTPS 접속이 불안정해지는 경우가 있습니다. 특히 HTTP/2를 켠 상태에서 일부 클라이언트만 실패하거나, CDN/로드밸런서 뒤에서만 재현되는 패턴이 흔합니다. 이 글에서는 Nginx에서 TLS 1.3 핸드셰이크 실패와 ALPN 협상 오류를 재현 가능한 방식으로 진단하고, 설정/버전/체인/프록시 구간별로 해결하는 방법을 정리합니다.
아래 내용은 단일 Nginx뿐 아니라 L4/L7 로드밸런서, CDN, Ingress 컨트롤러 등 앞단이 있는 구성에도 적용됩니다. (Kubernetes/EKS 환경이라면 네트워크 계층 문제도 함께 의심해야 하며, 연결이 간헐적으로 끊기는 증상은 커널 conntrack 포화와도 유사하게 보일 수 있습니다: EKS conntrack 테이블 포화로 연결 끊김 해결법)
증상 패턴과 로그 키워드
TLS 1.3/ALPN 문제는 대체로 아래 형태로 나타납니다.
1) Nginx error.log
SSL_do_handshake() failedtlsv1 alert protocol versiontlsv1 alert internal errorno shared cipherbad key shareSSL: error:...:tls_choose_sigalg:no suitable signature algorithm
ALPN 관련으로는 다음과 같은 단서가 보입니다.
client offered only unsupported versions(버전 협상)- HTTP/2 관련 경고(환경에 따라 표현이 다름)
2) 클라이언트 측(브라우저/SDK)
- Chrome:
ERR_SSL_PROTOCOL_ERROR,ERR_HTTP2_PROTOCOL_ERROR - curl:
ALPN, server did not agree to a protocol또는wrong version number - 일부 구형 Android/iOS에서만 실패
3) 프록시/로드밸런서 뒤에서만 실패
- 외부에서는 실패, 내부에서 Nginx 직접 붙으면 정상
- 특정 POP, 특정 리전에서만 실패
이 경우 “실제 TLS 협상이 어디에서 끝나는지”를 먼저 확정해야 합니다. 예를 들어 CDN이 TLS를 종료하고 오리진으로는 HTTP로 붙는데, 오리진 Nginx 설정만 만지면 해결되지 않습니다.
먼저 확인: TLS 종료 지점과 협상 경로
다음 질문에 답을 확정하세요.
- 클라이언트와 TLS 핸드셰이크를 하는 주체는 누구인가
- Nginx가 직접 종료하는지, ALB/NLB/CDN/Ingress가 종료하는지
- TLS가 두 번 있는지
- 클라이언트
HTTPS프록시HTTPS오리진 같은 이중 TLS
- 클라이언트
- HTTP/2는 어디 구간에서 적용되는지
- 클라이언트와 프록시 사이만
h2인지, 오리진까지h2인지
- 클라이언트와 프록시 사이만
이걸 확정한 뒤 아래 진단을 진행해야 시간을 아낍니다.
재현·진단 도구 4종 세트
1) OpenSSL로 TLS 1.3 강제 및 ALPN 확인
다음 명령으로 TLS 1.3을 강제하고 ALPN 협상 결과를 확인합니다.
openssl s_client -connect example.com:443 -servername example.com -tls1_3 -alpn h2 -brief
출력에서 확인할 포인트:
Protocol : TLSv1.3ALPN protocol: h2또는ALPN protocol: http/1.1- 인증서 체인, 서명 알고리즘, key share 관련 메시지
만약 ALPN protocol 줄이 없거나, no application protocol 류가 보이면 ALPN 협상이 실패했을 가능성이 큽니다.
2) curl로 HTTP/2 협상 확인
curl -Iv --http2 https://example.com/
- 성공 시
using HTTP/2또는HTTP/2 200같은 단서 - 실패 시
ALPN, server did not agree to a protocol등의 메시지
3) nginx -T로 최종 설정 덤프
Nginx는 include가 많아 실제 적용 설정을 놓치기 쉽습니다.
nginx -T 2>&1 | sed -n '1,200p'
특히 server 블록의 listen 옵션과 ssl_protocols, ssl_ciphers, ssl_conf_command, http2 여부를 확인합니다.
4) 패킷 캡처(필요 시)
TLS 1.3은 핸드셰이크 일부가 암호화되어 보기가 어렵지만, 최소한 실패 시점(초기 ClientHello 이후 끊김 등)은 확인됩니다.
sudo tcpdump -i any -nn 'tcp port 443' -w tls.pcap
원인 1: Nginx가 HTTP/2를 광고하지 않거나, 반대로 광고만 하고 처리 못함
ALPN은 클라이언트가 h2, http/1.1 같은 프로토콜 후보를 제시하고 서버가 하나를 선택하는 과정입니다. HTTP/2를 쓰려면 Nginx가 해당 리스너에서 HTTP/2를 활성화해야 합니다.
해결: listen에 HTTP/2 활성화
Nginx 버전에 따라 문법이 다릅니다.
- 비교적 널리 쓰이는 형태
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://upstream;
}
}
- 최신 문법을 쓰는 환경이라면(버전에 따라)
server {
listen 443 ssl;
http2 on;
server_name example.com;
}
중요한 점은 “HTTP/2를 쓰겠다”는 의도가 아니라, 실제 해당 리스너가 ALPN에서 h2를 선택할 수 있게 구성되었는지입니다.
흔한 함정
443 ssl과443 ssl http2가 서로 다른server블록에 섞여 있고, SNI 매칭이 꼬여 의도치 않은 블록이 선택됨- 리버스 프록시 앞단(예: CDN)이 오리진에
h2로 붙는데, 오리진이h2를 지원하지 않아 실패
이 경우 오리진은 http/1.1로만 받도록 앞단 설정을 바꾸거나, 오리진 Nginx에 HTTP/2를 켜야 합니다.
원인 2: TLS 1.3을 켰지만 OpenSSL/Nginx 조합이 불완전하거나 FIPS/정책에 의해 깨짐
TLS 1.3은 Nginx 설정만으로 끝나지 않고, 실제 암호화는 OpenSSL(또는 호환 라이브러리)이 담당합니다.
체크
- Nginx 빌드가 어떤 SSL 라이브러리를 쓰는지
nginx -V 2>&1 | grep -E 'OpenSSL|built with'
- OpenSSL 버전
openssl version -a
TLS 1.3은 OpenSSL 1.1.1 계열 이상에서 안정적으로 지원됩니다. 배포판에 따라 백포트 상태가 다르므로 “버전 문자열만 보고 확신”하기보다 실제 동작을 openssl s_client -tls1_3로 확인하세요.
해결 방향
- TLS 1.3을 임시로 끄고 영향 범위를 줄여 원인을 분리
ssl_protocols TLSv1.2;
- 또는 TLS 1.2와 1.3을 병행
ssl_protocols TLSv1.2 TLSv1.3;
만약 TLS 1.3을 켤 때만 실패한다면, 다음을 추가로 봅니다.
- 서명 알고리즘 호환(특히 ECDSA만 제공하는 경우)
- 키 교환 그룹(X25519 등) 호환
- 보안 정책 도구(예: FIPS 모드)로 인해 TLS 1.3 일부가 제한되는지
원인 3: 인증서 체인(fullchain) 문제로 핸드셰이크가 중간 실패
브라우저는 대체로 중간 인증서를 AIA로 보완하기도 하지만, 일부 클라이언트(특히 임베디드/서버 사이드)는 체인 누락에 더 엄격합니다. TLS 1.3으로 오면서 실패가 “더 잘 드러나는” 케이스도 있습니다.
해결: fullchain 사용 확인
Let’s Encrypt를 예로 들면 보통 아래처럼 설정합니다.
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
체인 검증은 다음으로 빠르게 확인할 수 있습니다.
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
출력된 인증서가 서버 인증서 1장만 달랑 나오면 체인 설정이 잘못됐을 가능성이 있습니다.
원인 4: ECDSA 전용 인증서로 인해 일부 클라이언트에서 실패
요즘은 ECDSA 인증서가 성능상 유리하지만, 특정 구형 클라이언트/라이브러리는 RSA를 더 잘 처리합니다. “특정 단말에서만 TLS 1.3 핸드셰이크 실패”라면 이 케이스를 의심하세요.
해결: RSA와 ECDSA를 함께 제공
Nginx는 RSA와 ECDSA 인증서를 함께 설정할 수 있습니다(조건은 환경에 따라 다르며, 동일 server 블록에서 다중 ssl_certificate를 지원하는 버전인지 확인 필요).
예시:
ssl_certificate /etc/nginx/certs/example.com.ecdsa.fullchain.pem;
ssl_certificate_key /etc/nginx/certs/example.com.ecdsa.key;
ssl_certificate /etc/nginx/certs/example.com.rsa.fullchain.pem;
ssl_certificate_key /etc/nginx/certs/example.com.rsa.key;
이 구성은 클라이언트가 지원하는 알고리즘에 맞춰 더 호환성 좋은 인증서를 선택하게 해줍니다.
원인 5: ssl_ciphers 와 TLS 1.3의 관계를 오해함
TLS 1.3의 cipher suite는 TLS 1.2와 다르게 동작합니다. Nginx에서 ssl_ciphers는 주로 TLS 1.2에 영향을 주고, TLS 1.3 cipher는 OpenSSL 기본 정책을 따르는 경우가 많습니다.
해결: TLS 1.2와 1.3 설정을 분리해서 생각
권장 접근:
- TLS 1.2 호환을 위해
ssl_ciphers를 너무 공격적으로 줄이지 말기 - TLS 1.3 문제는
ssl_conf_command또는 OpenSSL 정책/버전으로 접근
예시(보수적 TLS 1.2 cipher 설정):
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers HIGH:!aNULL:!MD5;
만약 “no shared cipher”가 뜬다면, TLS 1.2만 강제한 뒤(ssl_protocols TLSv1.2;) 협상 가능한 cipher가 남아있는지부터 확인하는 게 빠릅니다.
원인 6: SNI 불일치로 다른 서버 블록이 매칭되어 실패
멀티 도메인을 운영할 때 SNI가 빠지거나(일부 헬스체크/내부 클라이언트), 잘못된 server_name 우선순위로 인해 엉뚱한 인증서가 나가면 핸드셰이크가 실패합니다.
진단
SNI를 명시한 것과 안 한 것을 비교합니다.
openssl s_client -connect 1.2.3.4:443 -tls1_3 -brief
openssl s_client -connect 1.2.3.4:443 -servername example.com -tls1_3 -brief
두 결과가 다르면 SNI 라우팅 이슈입니다.
해결
- 기본
server블록에 안전한 인증서/설정을 넣고default_server를 명시
server {
listen 443 ssl http2 default_server;
server_name _;
ssl_certificate /etc/nginx/certs/default.fullchain.pem;
ssl_certificate_key /etc/nginx/certs/default.key;
return 444;
}
원인 7: 프록시 계층에서 HTTP/2 또는 TLS 설정이 어긋남
대표적으로 다음이 문제를 만듭니다.
- CDN은 클라이언트에
h2를 제공하지만 오리진에는http/1.1로만 붙어야 하는데, 오리진에h2를 강제 - 로드밸런서가 TLS를 종료하면서도 백엔드로 TLS를 재암호화하는데, 백엔드 인증서 검증/ALPN 설정이 미스매치
해결 체크리스트
- “클라이언트
ALPN h2성공”과 “오리진까지h2사용”은 별개임을 인지 - 프록시 구간별로
openssl s_client를 각각 수행(가능하면 내부 주소로) - Ingress를 쓴다면 컨트롤러(Nginx Ingress, Envoy 등)의 TLS/HTTP2 설정을 별도로 확인
Kubernetes/EKS에서만 문제가 재현된다면, 네트워크 계층의 병목/드랍이 TLS 오류처럼 보일 수 있습니다. 특히 DNS 지연이나 upstream 타임아웃이 겹치면 “핸드셰이크 실패”로 오해하기 쉽습니다. 관련해서는 EKS CoreDNS CrashLoopBackOff - upstream 타임아웃 해결도 함께 점검해보세요.
운영에서 바로 쓰는 권장 Nginx TLS 설정 예시
아래는 범용적으로 무난한 출발점입니다. 서비스 특성상 구형 클라이언트 지원 여부에 따라 TLS 1.2만 허용하거나, 더 강하게 조일 수 있습니다.
server {
listen 443 ssl http2;
server_name example.com;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
# TLS 1.2에 주로 영향
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://upstream;
}
}
포인트:
- HTTP/2를 쓸 거면
listen 443 ssl http2;로 ALPN 협상 가능 상태를 만들기 - 인증서는
fullchain사용 ssl_ciphers는 TLS 1.2 중심으로 과도하게 제한하지 않기
트러블슈팅 순서(시간 절약용)
- TLS 종료 지점 확정(CDN/LB/Ingress인지 Nginx인지)
openssl s_client로 TLS 1.3 강제 + ALPN 확인(-alpn h2)curl -Iv --http2로 HTTP/2 협상 확인nginx -T로 최종 설정에서listen ... http2와ssl_protocols확인- 인증서 체인(fullchain) 확인
- 특정 클라이언트만 실패하면 RSA/ECDSA, 구형 TLS 스택 호환성 점검
- 쿠버네티스/클라우드 환경이면 네트워크 드랍/타임아웃도 병행 점검
마무리
TLS 1.3 핸드셰이크 실패와 ALPN 오류는 “암호화 자체의 문제”처럼 보이지만, 실제로는 HTTP/2 협상 설정 불일치, 인증서 체인 누락, 버전/정책 호환성, 프록시 계층의 TLS 종료 지점 혼선에서 가장 많이 발생합니다.
핵심은 재현 명령(openssl s_client, curl --http2)로 “TLS 버전”과 “ALPN 선택 결과”를 눈으로 확인하고, 그 결과를 기준으로 Nginx 설정과 앞단 프록시 설정을 단계적으로 좁혀가는 것입니다.