|
| 1 | +--- |
| 2 | +title: JVM의 DNS 캐싱과 연결 풀 - (IP 변경으로 인한 프로덕션 서비스 장애) |
| 3 | +author: rojae |
| 4 | +date: 2025-07-05 16:20:15 +0900 |
| 5 | +published: true |
| 6 | +categories: [backend] |
| 7 | +tags: [java, spring, dns, connection-pool, service-outage] |
| 8 | +mermaid: true |
| 9 | +image: |
| 10 | + path: /assets/img/posts/2025-07-05-jvm-dns-caching-meets-connection-pools/jvm-dns-caching-meets-connection-pools.jpg |
| 11 | +--- |
| 12 | +> 운영 중인 서비스에서 IP 변경이 어떻게 장애로 이어질 수 있었는지, 이를 해결하며 얻은 인사이트를 정리해 둡니다. |
| 13 | +> + 영문 버전은 [Medium](https://medium.com/p/0b1cadd08ba2/edit)에서 확인하실 수 있습니다. |
| 14 | +{: .prompt-info } |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## **“문제는 예고 없이 찾아온다”** |
| 19 | + |
| 20 | +얼마 전 실 서비스 중에 문제를 겪었다. |
| 21 | +운영 중인 세션 서비스의 라우팅 IP를 변경했지만, DNS 이름은 그대로 유지했었다. |
| 22 | + |
| 23 | +나를 포함한 팀원들은 별다른 문제없이 새 IP로 잘 붙을 거라고 예상했는데, 그게 아니었다. |
| 24 | + |
| 25 | +동료가 세션 서비스의 IP를 바꾼 뒤에도 DNS 이름이 같았던 탓에, 체크아웃 서비스 쪽에서 504 게이트웨이 타임아웃이 발생했고, 다른 팀에서도 장애를 호소했다. |
| 26 | +결국 원인을 살펴보니, 체크아웃 서비스가 새 IP를 받아오지 못하고 예전 IP로 계속 요청을 보내고 있었던 거다. |
| 27 | + |
| 28 | +바로 체크아웃 서비스의 파드를 롤아웃해서 다시 띄우자 정상적으로 새 IP로 연결되었고, 곧바로 서비스도 복구됐다. |
| 29 | + |
| 30 | +--- |
| 31 | + |
| 32 | +## **"팀원들과 논의"** |
| 33 | +<figure style="text-align: center;"> |
| 34 | + <img |
| 35 | + src="/assets/img/posts/2025-07-05-jvm-dns-caching-meets-connection-pools/discussion.jpg" |
| 36 | + alt="서로 눈치보는 논의" |
| 37 | + width="500" |
| 38 | + style="border-radius: 8px;"> |
| 39 | + <figcaption style="margin-top: 0.5rem; font-size: 0.95rem; color: #666;"> |
| 40 | + we are team |
| 41 | + </figcaption> |
| 42 | +</figure> |
| 43 | + |
| 44 | +*나: "혹시, 세션 서비스의 라우트 IP를 왜 변경했는지 설명해줄 수 있을까요?"* |
| 45 | + |
| 46 | +*팀원 1: "라우트 IP를 바꿔도 문제없이 잘 동작할 거라고 생각했어요. DNS는 동일하니까요"* |
| 47 | + |
| 48 | +*팀원 2: "HTTP 옵션, 예를 들어 keep-alive 같은 부분도 신경 써야 하지 않을까요?"* |
| 49 | + |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +## **"지금 다시 생각해보면서, 정리해보니.."** |
| 54 | + |
| 55 | +```mermaid |
| 56 | +sequenceDiagram |
| 57 | + participant Checkout as Checkout Service (Pod) |
| 58 | + participant DNS as session.address.com (DNS) |
| 59 | + participant SessionA as Session Service (IP A) |
| 60 | + participant SessionB as Session Service (IP B) |
| 61 | +
|
| 62 | + Checkout->>DNS: Resolve session.address.com |
| 63 | + DNS-->>Checkout: Returns IP A |
| 64 | + Checkout->>SessionA: Sends requests |
| 65 | + Note over Checkout,SessionA: Communication OK (200) |
| 66 | +
|
| 67 | + Note over SessionA: Teammate changes to IP B |
| 68 | + SessionA-->>DNS: Updates to IP B |
| 69 | +
|
| 70 | + Checkout->>SessionA: Sends requests |
| 71 | + Note over Checkout,SessionA: Communication failed (504) |
| 72 | +
|
| 73 | + Checkout->>SessionB: Sends requests |
| 74 | + Note over Checkout,SessionB: Communication restored after rolling out the Checkout Service |
| 75 | +``` |
| 76 | + |
| 77 | +음… 😕 |
| 78 | + |
| 79 | +정리하자면, “팀원이 세션 서비스의 라우트 IP를 변경했지만 DNS 이름은 그대로 뒀다”는 거다. |
| 80 | + |
| 81 | +사실 나도.. 체크아웃 서비스가 DNS를 통해 새 IP를 자동으로 찾아갈 거라고 예상했는데, 왜 계속 타임아웃이 났을까? |
| 82 | + |
| 83 | +DNS가 새 주소를 받아야 정상일 텐데. |
| 84 | +아마 캐시 문제나 TTL(수명)이 너무 길어서 이전 IP를 계속 쓰고 있었던 것 같기도 하다. |
| 85 | +DNS 레코드는 멀쩡해 보여도, 그 때문에 체크아웃 서비스가 세션 서비스에 도달하지 못했을 가능성이 있을 것 같다. |
| 86 | + |
| 87 | +--- |
| 88 | + |
| 89 | +## **"Java에서 DNS가 어떻게 해석되는가?"** |
| 90 | +<figure style="text-align: center;"> |
| 91 | + <img |
| 92 | + src="/assets/img/posts/2025-07-05-jvm-dns-caching-meets-connection-pools/InetAddress.png" |
| 93 | + alt="InetAddress.java" |
| 94 | + width="650" |
| 95 | + style="border-radius: 8px;"> |
| 96 | + <figcaption style="margin-top: 0.5rem; font-size: 0.95rem; color: #666;"> |
| 97 | + InetAddress.java |
| 98 | + </figcaption> |
| 99 | +</figure> |
| 100 | + |
| 101 | +이 부분은 `디버깅`을 통해서 확인이 가능했다. |
| 102 | +Java Core에 포함된 `InetAddress.java`가 DNS(호스트)를 IP 주소로 해석해주는 역할을 한다. |
| 103 | + |
| 104 | +**그러나 아래 2가지 현상 때문에 의아했다.** |
| 105 | + |
| 106 | +1. *DNS 해석이 매번 정상적으로 동작하지 않았다.* |
| 107 | +2. *통신 오류가 발생한 이후에도, 리졸버가 새 IP를 안정적으로 받아오지 못하고 계속 예전 IP로만 연결을 시도했다.* |
| 108 | + |
| 109 | +원인은 같아 보이지만, 사실상 **완전히 다른 문제**라고 볼 수 있다. |
| 110 | + |
| 111 | +--- |
| 112 | + |
| 113 | +## **"networkaddress.cache.* in JVM"** |
| 114 | +- 1. *DNS 해석이 매번 정상적으로 동작하지 않았다.* |
| 115 | +→ 답은 `/java/security` 파일(JVM Option)에 있었다. |
| 116 | + |
| 117 | +대부분의 `Java API 통신 라이브러리`는 **Java Core의 DNS 해석 메커니즘**을 그대로 사용한다. |
| 118 | +이때, `DNS에서 반환된 IP 주소`를 **메모리에 캐싱**해서 애플리케이션의 성능을 높인다. |
| 119 | + |
| 120 | +주요 옵션으로는 다음과 같다. |
| 121 | +- networkaddress.cache.ttl : 긍정적(정상) 캐시의 TTL |
| 122 | +- networkaddress.cache.negative.ttl : 부정적(실패) 캐시의 TTL |
| 123 | + |
| 124 | +--- |
| 125 | + |
| 126 | +## **"HTTP Connection Pool Considerations"** |
| 127 | +- 2. *InetAddress.java 안에 있는 리졸버가 새 IP를 안정적으로 가져오지 못했다. 통신 실패가 발생한 이후에도 계속 같은 IP로만 연결을 시도했다.* |
| 128 | +→ 답은 Http Connection Pool의 설정이 잘못되어 있었기 때문이다. |
| 129 | + |
| 130 | +요청서버(`checkout-server`)는 `spring-cloud-feign`을 사용하고 있었고 아래와 같은 방식이였다. |
| 131 | + |
| 132 | +```java |
| 133 | +@Configuration |
| 134 | +public class FeignHttpClientConfig { |
| 135 | + |
| 136 | + @Bean |
| 137 | + public Client feignClient() { |
| 138 | + return new ApacheHttpClient(HttpClientBuilder.create().build()); |
| 139 | + } |
| 140 | + |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +하지만 이 방식은 안전하지 않다. |
| 145 | +일부 서버가 200 OK를 반환하지 않더라도, 동일한 IP에만 계속 연결을 시도하고 DNS를 다시 조회하지 않는다. |
| 146 | + |
| 147 | +그래서 504 오류가 발생했을 때 연결을 선제적으로 끊는 등의 폴백(fallback) 메커니즘이 분명히 필요하다는 점이 명확해졌다. |
| 148 | + |
| 149 | +**아래는 원인 분석을 끝낸 뒤 작성한 최종 다이어그램이다.** |
| 150 | + |
| 151 | +```mermaid |
| 152 | +sequenceDiagram |
| 153 | + participant Checkout as Checkout Service (Pod) |
| 154 | + participant DNS as session.address.com (DNS) |
| 155 | + participant SessionA as Session Service (IP A) |
| 156 | + participant SessionB as Session Service (IP B) |
| 157 | +
|
| 158 | + Checkout->>DNS: Resolve session.address.com |
| 159 | + DNS-->>Checkout: Returns IP A |
| 160 | + Checkout->>SessionA: Sends requests |
| 161 | + Note over Checkout,SessionA: Communication OK |
| 162 | +
|
| 163 | + Note over SessionA: teammate changes to IP B |
| 164 | + SessionA-->>DNS: Updates to IP B |
| 165 | +
|
| 166 | + Checkout->>SessionA: Continues using cached IP A |
| 167 | + SessionA-->>Checkout: Connection fails |
| 168 | + Note over Checkout: JVM still caches IP A |
| 169 | +
|
| 170 | + Checkout->>DNS: Eventually queries again because of connection pool configuration |
| 171 | + DNS-->>Checkout: Returns IP B |
| 172 | + Checkout->>SessionB: Sends requests |
| 173 | + Note over Checkout,SessionB: Recovered after pod restart |
| 174 | +``` |
| 175 | + |
| 176 | +결국 이 문제는 JVM의 IP 캐싱과 HTTP 커넥션 풀 때문에 발생했다. |
| 177 | + |
| 178 | +## **"라이브 환경에서 IP 주소를 변경할 때"** |
| 179 | +아래 **두 가지 요소**를 반드시 기억해야 한다. |
| 180 | +- JVM TTL 옵션 (java/security 파일의 networkaddress.cache.*) |
| 181 | +- HTTP 커넥션 풀 팩토리 (애플리케이션 설정) |
| 182 | + |
| 183 | +**결론적으로,** JVM의 DNS 캐싱은 흔히 간과되지만 서비스 가용성에 치명적인 영향을 줄 수 있다. |
| 184 | +커넥션 풀과 결합되면, 오래된 DNS 레코드가 훨씬 더 오래 유지될 수 있으며, 그 결과 IP가 변경될 경우 예기치 못한 장애로 이어질 수 있다. |
| 185 | + |
| 186 | + |
| 187 | +--- |
| 188 | + |
| 189 | +## **"느낀 점"** |
| 190 | + |
| 191 | +JVM의 DNS 캐싱은 의외로 사람들이 많이 간과한다. |
| 192 | + |
| 193 | +운영 환경에서는 DNS가 바뀌거나, 로드밸런서가 교체되거나, 라우팅이 바뀌는 일이 흔하다. |
| 194 | +근데 JVM이 너무 똑똑하게(?) 예전 IP를 계속 기억하고 있으면, 그게 그대로 장애로 터질 수 있다. |
| 195 | + |
| 196 | +커넥션 풀도 마찬가지다. |
| 197 | + |
| 198 | +“재사용”은 성능 면에서는 좋지만, 장애가 발생하면 그 재사용이 오히려 문제를 키울 수 있다. |
| 199 | + |
| 200 | +- `JVM`의 `networkaddress.cache.ttl`에 대해서 어느정도 인식하고 있자. |
| 201 | +- `Connection pool`은 장애 상황을 고려해서 `fallback` 로직을 반드시 설계하자. |
| 202 | + |
| 203 | +*IP가 변경되면 “즉시” 반영될 거라고 쉽게 믿지 말하야 한다.* |
| 204 | + |
| 205 | +**JVM DNS 캐싱 + Connection Pool** 이 두 가지가 얽히면 |
| 206 | + |
| 207 | +**장애가 터질 수 있다는 걸** 이번에 제대로 배웠다. |
| 208 | + |
| 209 | +--- |
| 210 | + |
| 211 | +## **"샘플 코드"** |
| 212 | +이 코드는 테스트 편의를 위해 작성된 샘플입니다. |
| 213 | +> [feign-dns-lookup-test](https://github.com/rojae/feign-dns-lookup-test) |
| 214 | +
|
| 215 | +--- |
| 216 | + |
| 217 | + |
| 218 | +## **"참고한 내용들"** |
| 219 | + |
| 220 | +### Standard JVM `networkaddress.cache.*` Properties with Java version |
| 221 | + |
| 222 | +| Property | Description | Default | Java Version Introduced | Status | Java Proposal Reference | |
| 223 | +| ----------------------------------- | --------------------------------------------------------------------------------- | ------- | ----------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 224 | +| `networkaddress.cache.ttl` | Positive cache TTL (seconds) for successful name resolutions. `-1` means forever. | `-1` | JDK 1.4+ | Standard | introduced in JDK 1.4, no JEP (java.security standard) | |
| 225 | +| `networkaddress.cache.negative.ttl` | Negative cache TTL (seconds) for failed name resolutions. `-1` means forever. | `10` | JDK 1.4+ | Standard | introduced in JDK 1.4, no JEP (java.security standard) | |
| 226 | +| `networkaddress.cache.stale.ttl` | TTL (seconds) for allowing stale (expired) cache reuse if fresh lookup fails. | `0` | JDK 17+ | Standard | [JDK-8306653 (Reuse stale data to improve DNS resolver resiliency)](https://bugs.openjdk.org/browse/JDK-8306653) AND [Oracle-Docs Java-24](https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/net/doc-files/net-properties.html#address-cache-heading) | |
| 227 | +| `sun.net.inetaddr.ttl` | Legacy alias for positive TTL (same as `networkaddress.cache.ttl`). | n/a | pre-JDK 1.4 | Deprecated | no JEP | |
| 228 | +| `sun.net.inetaddr.negative.ttl` | Legacy alias for negative TTL (same as `networkaddress.cache.negative.ttl`). | n/a | pre-JDK 1.4 | Deprecated | no JEP | |
| 229 | + |
| 230 | +### Shutdown OS Cache |
| 231 | + |
| 232 | +| OS | Command | Description | |
| 233 | +| ----------- | ------------------------------------------------------------------- | ------------------------------------------ | |
| 234 | +| **macOS** | `sudo killall -HUP mDNSResponder` | Flush macOS DNS cache immediately | |
| 235 | +| **Linux** | `sudo systemd-resolve --flush-caches`<br>OR<br>`sudo nscd -i hosts` | Flush Linux systemd-resolved or nscd cache | |
| 236 | +| **Windows** | `ipconfig /flushdns` | Flush Windows DNS client cache | |
| 237 | + |
| 238 | +### Reference Links |
| 239 | + |
| 240 | +#### Oracle Docs |
| 241 | + |
| 242 | +- [Oracle-Docs Java-24](https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/net/doc-files/net-properties.html#address-cache-heading) |
| 243 | +- [Oracle-Docs Java-17](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/net/doc-files/net-properties.html) |
| 244 | +- [Oracle-Docs Java-11](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/net/doc-files/net-properties.html) |
| 245 | +- [Oracle-Docs Java-8](https://docs.oracle.com/javase/8/docs/technotes/guides/net/properties.html) |
| 246 | + |
| 247 | +#### Open JDK |
| 248 | + |
| 249 | +- [JDK-8306653 (Reuse stale data to improve DNS resolver resiliency)](https://bugs.openjdk.org/browse/JDK-8306653) |
| 250 | +- [JDK-8219993 (OpenJDK bug)](https://bugs.openjdk.org/browse/JDK-8219993) |
| 251 | +- [JDK-8323089 (OpenJDK bug)](https://bugs.openjdk.org/browse/JDK-8323089) |
| 252 | + |
| 253 | +#### RFC |
| 254 | + |
| 255 | +- [RFC-8767 (Serving Stale Data to Improve DNS Resiliency)](https://www.rfc-editor.org/rfc/rfc8767) |
0 commit comments