인증서 및 키 불일치 추적기
새로 구축한 SSE 프록시가 배포 검증을 모두 통과하고도 단 한 건의 TLS 연결도 받지 못했던 장애를 추적한 기록이다. 결론은 단순했지만(인증서와 키가 서로 다른 쌍), 거기까지 가는 길에 L4, 방화벽, 구형 클라이언트, nginx 로그 레벨이 차례로 우리를 속였다. 같은 함정에 빠질 사람들을 위해 추적 과정을 그대로 남긴다.
배경: SSE 전용 nginx 프록시 구축
실시간 시세를 SSE(Server-Sent Events)로 내려주는 백엔드 앞에 전용 nginx 프록시를 새로 두기로 했다. 기존 API 프록시(이하 api-proxy)와 같은 서버에 올리되, SSE 특성(장시간 연결, 버퍼링 금지, 수만 동접)에 맞춘 별도 컨테이너로 완전히 분리했다.
클라이언트 ──https:443──▶ L4 VIP (sse.example.com) ──tcp:9444──▶ web-01 / web-02
└─ sse-proxy 컨테이너 (nginx, TLS 종료)
└─ http ──▶ SSE 백엔드
핵심 설계만 요약하면:
nginxinc/nginx-unprivileged:mainline(Debian, OpenSSL 3.x) 기반 이미지- TLS는 nginx가 종료. L4는 SSL 오프로드 없이 TCP만 중계
- 인증서는 api-proxy가 쓰는 와일드카드 인증서를 같은 디렉터리에서
:ro로 공유 (/srv/api-proxy/etc/nginx/certs/example.com.chained.crt,example.com.key) - 헬스체크는 별도 평문 포트(
:8081/healthz) - 배포 스크립트는 이미지 전송 → 체크섬 검증 → 실제 인증서를 마운트한
nginx -t→ 컨테이너 교체 → healthy 대기까지 자동화
배포 스크립트는 두 서버 모두에서 초록불을 띄웠다. nginx -t 통과, 컨테이너 healthy. 여기까지는 완벽해 보였다.
증상
$ curl -v https://sse.example.com/sse/prices
* Connected to sse.example.com (203.0.113.73) port 443
* (304) (OUT), TLS handshake, Client hello (1):
* LibreSSL/3.3.6: error:1404B410:SSL routines:ST_CONNECT:sslv3 alert handshake failure
curl: (35) ...
ClientHello를 보내자마자 alert handshake failure(alert 40). 그리고 두 서버의 nginx access.log, error.log에는 아무것도 찍히지 않았다.
추적 과정
1단계: "로그가 비었으니 트래픽이 안 오는 것" — L4를 의심하다
로그가 완전히 비어 있으니 첫 가설은 자연스럽게 "L4 풀 멤버 등록이 안 됐다"였다. 실제로 배포 스크립트 마지막 안내도 "L4 등록은 네트워크팀 수동 작업"이었다.
VIP에 SNI를 바꿔가며 직접 찔러봤다:
$ openssl s_client -connect 203.0.113.73:443 -servername sse.example.com
...alert handshake failure ... SSL alert number 40
no peer certificate available
SSL handshake has read 7 bytes and written 1552 bytes
SNI를 뭘로 바꿔도, SNI를 빼도 똑같이 7바이트짜리 alert 레코드 하나만 돌아오고 인증서는 제시되지 않았다. 비교군으로 찔러본 api-proxy의 VIP는 정상적으로 와일드카드 인증서를 제시하며 TLS 1.3 핸드셰이크를 끝냈다.
"TCP 패스스루라면 뒤의 nginx가 인증서를 제시했을 텐데, 인증서 없이 즉시 거절한다 → L4가 SSL 프로파일을 물고 있거나 풀 멤버가 없어서 장비가 자체 거절하는 것"이라고 판단하고 네트워크팀에 확인을 요청했다.
이 판단은 틀렸다. 뒤에서 밝혀지지만 "인증서 없이 즉시 alert 40"은 nginx도 충분히 낼 수 있는 응답이었다.
2단계: 네트워크팀 "패킷은 9444까지 정상적으로 들어갑니다"
네트워크팀 답변은 명확했다. L4는 패스스루이고 패킷은 서버 9444까지 간다고 했다.
그 사이 L4를 우회해 서버에 직접 붙는 테스트도 했는데, 이것도 실패했다:
curl: (35) error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure
그런데 이 에러 문자열을 잘 보면 SSL23_GET_SERVER_HELLO — OpenSSL 1.0.2 이하에서만 나오는 형식이다. 테스트를 돌린 점프 서버의 OpenSSL이 너무 오래돼서, TLS 1.2 + ECDHE 전용인 nginx 설정에는 nginx가 정상이어도 거절당할 클라이언트였다. 즉 이 테스트는 아무것도 증명하지 못했다.
교훈 ①: 핸드셰이크 실패를 진단할 때는 클라이언트의 연식부터 확인하자. 에러 문자열의 형식(
SSL23_*,error:14077410vserror:0A000410)만으로도 OpenSSL 세대를 구분할 수 있다.
3단계: 컨테이너 안에서 자기 자신에게 — 의심의 여지를 없애다
네트워크, 방화벽, docker-proxy, 클라이언트 연식을 전부 배제하려면 컨테이너 안에서 127.0.0.1로 붙어보면 된다. 이미지에 헬스체크용 curl(OpenSSL 3.x)이 들어 있었다.
$ docker exec sse-proxy curl -kv https://127.0.0.1:9444/
curl: (35) TLS connect error: error:0A000410:SSL routines::ssl/tls alert handshake failure
여기서도 alert 40. nginx 자신이 최신 클라이언트의 핸드셰이크를 거절하고 있다는 게 확정됐다. 네트워크팀이 옳았다.
4단계: 인증서를 뜯어보다 — 그런데 멀쩡하다?
Debian + OpenSSL 3.x는 기본 보안 레벨(SECLEVEL=2)에서 SHA-1 서명이나 짧은 키를 핸드셰이크에 쓰는 걸 거부하므로, 마운트된 인증서를 의심했다.
$ docker exec sse-proxy openssl x509 -in /etc/nginx/certs/example.com.chained.crt -noout -text \
| grep -E 'Signature Algorithm|Public-Key|NIST CURVE'
Signature Algorithm: ecdsa-with-SHA256
Public-Key: (256 bit)
NIST CURVE: P-256
ECDSA P-256, SHA-256, 체인 전체가 ECDSA/SHA-2, 유효기간도 현재 유효. 완전히 정상적인 인증서였다.
그런데 한 가지가 걸렸다. api-proxy의 VIP가 실제로 서빙하는 인증서는 RSA 2048 (다른 CA 발급) 이었다. 같은 파일 경로를 참조하는데 서빙 내용이 다르다니. 이 시점에 "디스크의 파일이 api-proxy 기동 이후에 바뀌었다"는 가설이 생겼지만, 아직 핸드셰이크 실패의 *메커니즘*은 설명되지 않았다.
5단계: nginx의 입으로 직접 듣다 — error_log info
여기서 결정적인 도구를 꺼냈다. nginx는 TLS 핸드셰이크 실패 사유를 info 레벨로 기록한다. 우리 설정은 error_log ... notice였기 때문에 그동안 error.log가 비어 있었던 것이다. 로그가 없었던 게 아니라 안 찍고 있었던 것이었다.
운영 컨테이너는 건드리지 않고, 같은 이미지·같은 마운트로 디버그 컨테이너를 하나 더 띄워 로그 레벨만 올렸다:
docker run -d --name sse-proxy-debug \
-v /srv/api-proxy/etc/nginx/certs:/etc/nginx/certs:ro \
-v /srv/sse-proxy/etc/nginx/tickets:/etc/nginx/tickets:ro \
sse-proxy:1.0.0 nginx -g 'error_log stderr info; daemon off;'
docker exec sse-proxy-debug curl -kv https://127.0.0.1:9444/
docker logs sse-proxy-debug 2>&1 | tail -3
[info] 22#22: *1 SSL_do_handshake() failed
(SSL: error:0A000076:SSL routines::no suitable signature algorithm)
while SSL handshaking, client: 127.0.0.1, server: 0.0.0.0:9444
no suitable signature algorithm. 서버가 핸드셰이크 서명(CertificateVerify)을 만들 알고리즘을 찾지 못했다는 뜻이다. 인증서는 멀쩡한 ECDSA P-256인데 서명을 못 만든다면, 남는 용의자는 하나 — 개인키.
교훈 ②: 핸드셰이크 문제는
error_log를info로 올리면 nginx가 사유를 그대로 말해준다. 운영 설정을 건드리기 부담스러우면 디버그 컨테이너를 따로 띄우면 된다. 포트를 publish하지 않고 내부에서만 테스트하면 운영에 영향이 없다.
6단계: 범인 — 키는 RSA였다
$ docker exec sse-proxy openssl pkey -in /etc/nginx/certs/example.com.key -noout -text | head -1
Private-Key: (2048 bit, 2 primes) ← RSA!
$ docker exec sse-proxy sh -c "openssl x509 -in /etc/nginx/certs/example.com.chained.crt -noout -pubkey | sha256sum; \
openssl pkey -in /etc/nginx/certs/example.com.key -pubout | sha256sum"
32e973c1... ← 인증서의 공개키
96c68176... ← 키 파일의 공개키 ← 불일치
인증서는 ECDSA P-256, 키는 RSA 2048. 서로 다른 쌍이었다.
마지막 퍼즐 조각: api-proxy VIP가 서빙 중인 RSA 인증서의 공개키 해시를 구해보니 96c68176... — 디스크의 키와 정확히 일치했다.
원인: 인증서만 교체되고 키는 그대로
사건의 전말은 이렇다.
- 원래 certs 디렉터리에는 RSA 인증서 + RSA 키 쌍이 있었고, api-proxy는 이걸 로드해 서빙 중이었다.
- 이후 인증서 갱신 작업에서
example.com.chained.crt만 새 ECDSA 인증서로 교체되고,example.com.key는 교체되지 않았다. (인증서 관리는 다른 팀 소관이라 우리는 이 변경을 알 수 없었다.) - api-proxy는 reload된 적이 없어 메모리에 있는 옛 RSA 쌍으로 계속 정상 서빙 중이었다. 디스크가 깨진 상태였다는 신호가 전혀 없었다.
- 새로 배포한 sse-proxy는 디스크의 ECDSA 인증서 + RSA 키를 로드했고, 모든 핸드셰이크가 실패했다.
왜 nginx -t가 못 잡았나
이게 이 장애에서 가장 배울 점이다. "인증서와 키가 안 맞으면 nginx -t가 잡아준다"고 흔히들 알고 있다. 실제로 같은 알고리즘이면 잡는다(key values mismatch).
하지만 OpenSSL은 인증서를 키 알고리즘별 슬롯(RSA / ECDSA / …)에 따로 보관한다. ECDSA 인증서는 EC 슬롯에, RSA 키는 RSA 슬롯에 들어가고, 서로 다른 슬롯이라 쌍 일치 비교 자체가 일어나지 않는다. 결과:
- EC 슬롯: 인증서만 있고 키 없음
- RSA 슬롯: 키만 있고 인증서 없음
nginx -t통과, 기동 성공, 헬스체크(평문 포트) healthy- 핸드셰이크마다 "완성된 쌍"이 없어
no suitable signature algorithm
배포 파이프라인의 모든 검증이 초록불인 채로 서비스는 0%였던 이유다.
왜 "인증서 없이 즉시 alert 40"이었나
1단계에서 우리를 L4 쪽으로 오도한 관찰이다. 서명 알고리즘 선택은 ServerHello를 보내기 전에 일어나므로, 실패하면 nginx는 인증서를 제시할 기회도 없이 alert만 보낸다. "인증서를 안 주니 nginx가 아니다"는 추론은 틀린 전제였다.
해결
인증서는 공개 정보이므로, api-proxy가 서빙 중인 RSA 인증서 체인을 라이브 TLS 세션에서 그대로 추출해 디스크의 RSA 키와 다시 짝을 맞췄다. (새 ECDSA 인증서의 키는 우리 손에 없었고, 만료일도 RSA 쪽이 더 길었다.)
# 1. 라이브 세션에서 전체 체인 추출 (leaf → 중간 CA → 루트)
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null \
| sed -n '/BEGIN CERT/,/END CERT/p' > rsa.chained.crt
# 2. 디스크의 키와 쌍이 맞는지 검증 — 두 해시가 같아야 한다
openssl x509 -in rsa.chained.crt -noout -pubkey | sha256sum
openssl pkey -in example.com.key -pubout | sha256sum
# 3. 교체 (잘못 스테이징된 ECDSA 인증서는 백업으로 보존) 후 재기동
cp example.com.chained.crt example.com.chained.crt.ecdsa-mismatched
cp rsa.chained.crt example.com.chained.crt
docker restart sse-proxy
# 4. 확인
docker exec sse-proxy curl -kv https://127.0.0.1:9444/ # 404 = TLS 정상
curl -N https://sse.example.com/sse/prices # L4 경유 실동작
두 서버 모두 적용 후 외부에서 SSE 스트림 수신을 확인했다.
부수 효과로, api-proxy의 시한폭탄도 함께 해체됐다. 디스크가 깨진 상태에서 api-proxy가 reload나 재기동을 한 번이라도 했다면, 기존 서비스가 통째로 같은 장애를 겪었을 것이다. 인증서 관리팀에는 미완료된 갱신 건(키 없이 인증서만 배포됨)과 만료 일정을 정리해 전달했다.
