| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- jsp
- oracle
- 백준 알고리즘
- c#
- 티스토리
- 리눅스
- 오라클
- gradle
- 자바 프로젝트
- MongoDB
- 유사코드
- 오라클 디비
- spring
- smart cast
- resilience4j
- auto configure
- 문법 정리
- JVM
- 알고리즘
- 파이썬 소스
- 프로젝트
- K6
- hyperledger
- 파이썬
- 자바
- 초대장
- 학점
- SQL
- 운영체제
- dynamic query
- Today
- Total
모종닷컴
Docker 이미지 변경 후 TLS Handshake가 깨졌다 본문
Docker 이미지를 변경했더니 TLS Handshake가 실패했다
운영 환경에서 Docker Base Image를 변경한 뒤 예상하지 못한 장애를 경험했습니다.
애플리케이션 코드도 변경하지 않았고, 외부 서비스도 변경되지 않았습니다.
정말 Docker 이미지만 변경했을 뿐인데 특정 외부 서비스와의 HTTPS 통신이 모두 실패하기 시작했습니다.
처음에는 단순히 JDK 업데이트로 인해 TLS 정책이 변경된 것이라고 생각했고 가볍게 대처하였습니다.
하지만 문제를 추적하는 과정을 통해 정확한 원인을 짚고, 올바른 대처를 할 수 있었습니다.
이번 글에서는 장애를 어떻게 분석했고 어떤 가설을 세우며 원인을 좁혀갔는지를 정리해보려고 합니다.
장애 발생
운영 환경에서는 Spring Boot의 WebClient를 사용하여 외부 기관과 HTTPS 통신을 수행하고 있습니다.
Docker Base Image를 변경한 후 기존과 동일한 애플리케이션을 배포했는데, 특정 기관과의 통신에서 아래와 같은 예외가 발생하기 시작했습니다.
javax.net.ssl.SSLHandshakeException
Received fatal alert: handshake_failure
흥미로운 점은 다음과 같았습니다.
- 애플리케이션 코드는 변경되지 않았다.
- 요청 데이터도 동일했다.
- 다른 외부 기관과는 정상적으로 통신된다.
- 특정 기관과만 실패한다.
- 로컬에서는 정상적으로 동작한다.
즉,
운영 환경에서만 특정 외부 서비스와 TLS Handshake가 실패하는 상황이었습니다.
TLS Handshake가 왜 실패하는 걸까?
SSLHandshakeException이라는 예외만으로는 원인을 알 수 없습니다.
TLS Handshake 실패는 다양한 이유로 발생할 수 있기 때문입니다.
예를 들어
- 인증서 문제
- TLS Version 불일치
- Cipher Suite 협상 실패
- Trust Store 문제
- SNI 문제
- 서버 정책 변경
등 여러 가능성이 존재합니다.
처음부터 TLS Debug 로그를 켜는 것보다 먼저 외부 서버가 어떤 TLS 환경을 제공하는지 확인하기로 했습니다.
OpenSSL로 TLS 연결 확인
가장 먼저 OpenSSL을 이용하여 외부 서버와 직접 TLS 연결을 시도했습니다.
openssl s_client -connect xxx.xxx.xxx:443
결과는 정상적으로 연결되었습니다.
Verification: OK
Protocol : TLSv1.2
Cipher : AES128-GCM-SHA256
즉,
- 인증서는 정상
- TLS 연결도 정상
- TLS 1.2로 통신 가능
이라는 사실을 확인했습니다.
적어도 서버 자체가 죽어있거나 인증서가 만료된 것은 아니었습니다.
서버는 어떤 Cipher Suite를 지원할까?
다음으로 확인한 것은 서버가 지원하는 Cipher Suite였습니다.
이를 위해 sslscan을 사용했습니다.
sslscan xxx.xxx.xxx
결과를 보면 TLS 1.2에서 다음과 같은 Cipher Suite를 지원하고 있었습니다.
Preferred TLSv1.2
AES128-GCM-SHA256
Accepted
AES256-GCM-SHA384
AES128-SHA
AES256-SHA
DHE-RSA-AES128-GCM-SHA256
DHE-RSA-AES256-GCM-SHA384
...
여기서 중요한 사실은
서버는 TLS 1.2를 지원하며, 여러 종류의 Cipher Suite를 지원한다.
는 것이었습니다.
이 시점에서는 아직 어느 Cipher가 실제로 선택되는지, 어떤 Cipher 때문에 Handshake가 실패하는지는 알 수 없었습니다.
Docker 이미지가 바뀌면서 무엇이 달라졌을까?
애플리케이션 코드는 변경되지 않았습니다.
Spring Boot도 동일했습니다.
Reactor Netty 버전도 동일했습니다.
결국 가장 큰 차이는 Docker Base Image에 포함된 JDK 버전이었습니다.
기존 이미지는
JDK 17.0.16
새로운 이미지는
JDK 17.0.19
를 사용하고 있었습니다.
그래서 자연스럽게
JDK의 TLS 정책이 변경된 것은 아닐까?
라는 가설을 세우게 되었습니다.
TLS Debug 로그 확인
TLS Debug 로그를 활성화했습니다.
-Djavax.net.debug=ssl,handshake
실제로 로그를 출력해보면 수천 줄의 로그가 생성됩니다.
그런데 로그 초반부터 반복해서 나타나는 내용이 있었습니다.
Ignore disabled cipher suite:
TLS_RSA_WITH_AES_128_GCM_SHA256
Ignore disabled cipher suite:
TLS_RSA_WITH_AES_256_GCM_SHA384
...
JDK가 일부 Cipher Suite를 협상 후보에서 제외하고 있다는 의미였습니다.
JDK 정책 확인
이번에는 JDK의 java.security 파일을 확인해보았습니다.
JDK 17.0.19에는 다음과 같은 설정이 존재했습니다.
jdk.tls.disabledAlgorithms=
...
TLS_RSA_*
...
즉,
TLS_RSA_* Cipher Suite가 기본적으로 비활성화되도록 변경된 것입니다.
지금까지의 결과를 종합하면
- OpenSSL에서는 정상 연결된다.
- JDK는 TLS_RSA_*를 비활성화한다.
- TLS Handshake는 실패한다.
이 세 가지 사실을 확인할 수 있었습니다.
당시 내렸던 결론
이 시점에서 저는
JDK가 TLS_RSA를 기본적으로 비활성화하면서 TLS Handshake가 실패한 것
이라고 판단했습니다.
그래서 운영 환경에서는 java.security 정책을 우회하여 TLS_RSA_*를 다시 사용할 수 있도록 설정했고, 실제로 장애도 해결되었습니다.
운영 장애를 빠르게 복구해야 하는 상황에서는 충분히 합리적인 판단이었습니다.
그런데 정말 원인은 TLS_RSA였을까?
장애는 해결되었지만 한 가지 의문이 계속 남아있었습니다.
상대 서버는 여러 Cipher Suite를 지원하고 있는데,
정말 TLS_RSA_* 하나만 사용할 수 있었던 걸까?
혹시 JDK의 보안 정책이 아니라 다른 원인이 있었던 것은 아닐까?
그래서 운영 장애가 해결된 이후, 문제를 처음부터 다시 재현하며 하나씩 검증해보기 시작했습니다.
다음 글에서는 순수 JSSE와 Netty를 각각 이용하여 동일한 환경에서 TLS Handshake를 재현해보면서, 왜 JSSE는 성공했는데 Netty(WebClient)는 실패했는지, 그리고 실제 원인이 무엇이었는지를 자세히 살펴보겠습니다.
'Programming > JAVA' 카테고리의 다른 글
| 컨테이너 환경에 따른 GC 전략 (0) | 2025.10.30 |
|---|---|
| Compressed Class 영역 (0) | 2025.10.19 |
| [JVM] 메모리 누수 현상 재현하고 이를 모니터링해보자. (1) | 2023.05.21 |
| CompletableFuture 예외 핸들링 3가지 방법 (0) | 2022.09.24 |
| final, finally, finalize (0) | 2018.11.20 |
