Develop/JAVA

[디버깅 기록] Java DNS Cache TTL 무시되는 이슈 | [Debugging Log] Java DNS Cache TTL Ignored Issue

java main 에서 dns cache 를 끄는법을 찾아보면 아래와 같이 main() 안에서 이런 한 줄로 DNS cache 를 끄는 예제 코드가 실제로 많이 나온다.public static void main(String[] args) { Security.setProperty("sun.net.inetaddr.ttl", "0"); Security.setProperty("networkaddress.cache.ttl", "0");} 이렇게 되어있어서 당연히 main 에 설정한대로 DNS Cache 가 적용되지 않고있다고 (ttl = 0) 생각했는데만약 java process를 OTEL java agent 가 붙거나 jmx 설정과 함께 사용하고 있었다면, 위 main 에 설정한 값들이 무시된..

[디버깅 기록] Java DNS Cache TTL 무시되는 이슈 | [Debugging Log] Java DNS Cache TTL Ignored Issue

728x90

java main 에서 dns cache 를 끄는법을 찾아보면 아래와 같이 main() 안에서 이런 한 줄로 DNS cache 를 끄는 예제 코드가 실제로 많이 나온다.

public static void main(String[] args) {
      Security.setProperty("sun.net.inetaddr.ttl", "0");
      Security.setProperty("networkaddress.cache.ttl", "0");
}

 

이렇게 되어있어서 당연히 main 에 설정한대로 DNS Cache 가 적용되지 않고있다고 (ttl = 0) 생각했는데

만약 java process를 OTEL java agent 가 붙거나 jmx 설정과 함께 사용하고 있었다면, 위 main 에 설정한 값들이 무시된다.

export JAVA_TOOL_OPTIONS="-javaagent:/app/lib/opentelemetry-javaagent.jar"

java \
    -server -Xms4G -Xmx4G -XX:+UseG1GC \
    -Dcom.sun.management.jmxremote \
    -Dcom.sun.management.jmxremote.port=29010 \
    -Djava.rmi.server.hostname=$(hostname -f) \
    -jar /app/lib/my-service.jar

즉, ttl = 0 이 설정이 먹게하는 가장 간단한 방법은 JVM 시작 옵션에 넣는 방법이다.

java -Dsun.net.inetaddr.ttl=0 -jar /app/lib/my-service.jar

다만 sun.net.inetaddr.ttl=0 은 deprecated 예정인 설정이므로 networkaddress.cache.ttl=0 을 사용해야하는데, 이 설정은 보안속성 파일에 넣고, -D 런타임 인자로 보안속성 파일을 지정해야한다.

java -Dsun.net.inetaddr.ttl=0 -Djava.security.properties=/path/to/custom.security -jar /app/lib/my-service.jar

 

그래서 나는 이렇게 해결했다.

 

디버깅 과정

위 결과만 보면 참 간단하지만, 디버깅 과정은 험란했는데 다 요약하면 

reflection 으로 `sun.net.InetAddressCachePolicy.get()` 의 실제 값을 찍어봤다.
이 메서드는 JVM 내부의 진짜 cache TTL 정적 필드를 그대로 반환한다.

public static void main(String[] args) throws Exception {
    boolean shouldSet = args.length > 0 && "setProperty".equals(args[0]);

    if (shouldSet) {
        Security.setProperty("networkaddress.cache.ttl", "0");
        Security.setProperty("networkaddress.cache.negative.ttl", "0");
    }

    Class<?> c = Class.forName("sun.net.InetAddressCachePolicy");
    int positive = (int) c.getDeclaredMethod("get").invoke(null);
    int negative = (int) c.getDeclaredMethod("getNegative").invoke(null);
    System.out.println("Security networkaddress.cache.ttl        = "
            + Security.getProperty("networkaddress.cache.ttl"));
    System.out.println("InetAddressCachePolicy.get()  (positive) = " + positive);
    System.out.println("InetAddressCachePolicy.getNegative()     = " + negative);
}

 

따라서 위의 베이스 코드를 기반으로 setProperty를 main 으로 하고 OTEL agent runtime injection 여부에 따라 디버깅 코드를 실행시켜보면 아래와 같은 결과가 나오게 된다.

######## Case A: setProperty in main, no agent ########

Security networkaddress.cache.ttl        = 0
InetAddressCachePolicy.get()  (positive) = 0
InetAddressCachePolicy.getNegative()     = 0



######## Case B: setProperty in main + OTEL agent ########

Security networkaddress.cache.ttl        = 0
InetAddressCachePolicy.get()  (positive) = 30   ← ???
InetAddressCachePolicy.getNegative()     = 10

Case B 에서 SetProperty() 로 분명 TTL을 0으로 줬는데 적용되지 않았다. Security.getProperty("networkaddress.cache.ttl")  0을 반환하는데, JVM 내부의 실제 cache policy 는 30으로 박혀있다. 즉, main 에서 setProperty() 호출을 했으나 실제 DNS resolve 시점에는 이 값이 전혀 안쓰이게 된다.

 

왜그럴까? 이유

JDK 17 소스 의 sun.net.InetAddressCachePolicy 일부 발췌

public static final int DEFAULT_POSITIVE = 30;
private static volatile int cachePolicy = FOREVER;


static {
    Integer tmp = AccessController.doPrivileged(new PrivilegedAction<Integer>() {
        public Integer run() {
            try {
                String tmpString = Security.getProperty("networkaddress.cache.ttl");
                if (tmpString != null) return Integer.valueOf(tmpString);
            } catch (NumberFormatException ignored) {}
            
            try {
                String tmpString = System.getProperty("sun.net.inetaddr.ttl");
                if (tmpString != null) return Integer.decode(tmpString);
            } catch (NumberFormatException ignored) {}
            return null;
        }
    });

    if (tmp != null) {
        cachePolicy = tmp < 0 ? FOREVER : tmp;
    } else if (System.getSecurityManager() == null) {
        cachePolicy = DEFAULT_POSITIVE;   // 둘 다 null 이면 30 박힘
    }
}

public static int get() {
    return cachePolicy;

}

 

static {} 블록은 clinit(class initializer)에서 클래스가 처음 로드될 때 딱 한 번만 실행된다.
그때
Security.getProperty("networkaddress.cache.ttl")과 System.getProperty("sun.net.inetaddr.ttl" 둘 다 읽어보고 cachePolicy 정적 필드에 값을 넣어버린다. 그리고 이후 InetAddressCachePolicy에서 get() 을 할 때는 cachePolicy를 리턴해 버린다.

이런 상황에서 main 에서 Security.setProperty(...) 를 호출해봤자, 그건 Security 모듈의 properties맵을 업데이트하는 거지 cachePolicy 정적 필드를 갱신하는 게 아니다. clinit(정적 초기화 메서드) 이 이미 끝났으면 그냥 안 먹는다.

위에서 테스트한 CaseB가 발생해서 디버깅을 하게된 점은 clinit 이 main() 보다 먼저 실행될 수 있다는 점을 간과했다. OTEL과 같은 javaagent 같은 게 -javaagent: 로 붙어있으면 premain 단계에서 동작하고, 이 premain 단계에서 hostname 을 한 번이라도 resolve 하면 이 시점에 InetAddressCachePolicy 가 트리거된다. 그 시점엔 main() 의 Security.setProperty가 아직 실행 안 됐으니 Security.getProperty 가 null 을 반환하고, fallback 인 System.getProperty("sun.net.inetaddr.ttl") 도 null이었기에 default 30 이 박혀버린다.

여기서 말하는 premain 단계라는 것은 java agent 가 main() 보다 먼저 실행되는 진입점을 의미하더라. APM 등등 각종 라이브러리 jar 안에는 보통 클래스에 아래와 같은 premain() 메서드가 있다고하더라

public static void premain(String args, Instrumentation inst) {
      // 여기 코드가 너의 main() 보다 먼저 실행됨
}

실제로 otel-agent.jar 를 까보라고 해보니 premain 코드호출체인 속에 InetAddress.getLocalHost()를 호출하면서 InetAddressCachePolicy를 호출하게 되는 부분이 있었다.

  premain(agentArgs, inst)
    └─ startAgent(inst, true)
         └─ AgentInitializer.initialize(...)
              └─ ... (SDK/resource 초기화)
                   └─ HostResource.buildResource()
                        └─ InetAddress.getLocalHost()   ← <clinit> 트리거

 

따라서 -Dsun.net.inetaddr.ttl=0 이라도 JVM 시작시점에 넣어주거나, -Djava.security.properties=/path/to/custom.security 로 networkaddress.cache.ttl=0을 지정한 보안설정파일을 넣어주지 않는다면 default 30 이 되버리는 것이다. 이때문에 CaseB 에서 Security.getProperty() 로 읽어왔을땐 main 에서 세팅한 0이 읽혔으나, InetAddressCachePolicy를 통해서 읽었을때는 아니게 된 것이다.

사실 OTEL 만의 문제는 아니고, premain 단계에서 InetAddress 를 한 번이라도 건드리는 모든 컴포넌트가 trigger 가 될 수 있다. 예를 들면 -Dcom.sun.management.jmxremote (JMX 관리 에이전트) 나 -agentlib:jdwp= (디버거) 같은 옵션도 hostname resolve 를 하니까 같은 효과가 난다. 운영 환경에서는 보통 JMX 는 켜져 있을 거고, 디버그 빌드면 JDWP 도 켜져 있다. 결국 main() 보다 먼저 InetAddressCachePolicy 가 init 되는 경우는 생각보다 흔하다.

 

결론

premain() 단계에서 host resolving을 하는 컴포넌트가 트리거 되는 경우, Dns Cache TTL 설정을 main() 함수에서 한다면 DnsCache의 static 변수 캐싱으로 인해 내가 세팅한 값이 안먹을 수 있다.

옵션 한 줄 빠뜨려서 운영 사고로 직결되는 게 쉽지 않았고. 당연히 main()에 ttl 설정을 해두었으니 동작할거라고 생각한 부분이 패인이었다. 동료분과 함께 디버그 하지 않았다면 알기 어려웠다. 또한 DNS 변경이 일어나는 운영상 경험(domain 변경 작업)이 있을때 발견가능한 버그였어서 작업 진행시 오류도 많았다.

쉽지않다 코딩

If you look up how to disable the DNS cache in a Java main, you'll find plenty of example code that turns off the DNS cache with a single line inside main(), like this:

public static void main(String[] args) {
      Security.setProperty("sun.net.inetaddr.ttl", "0");
      Security.setProperty("networkaddress.cache.ttl", "0");
}

 

Since that's how it was set up, I naturally assumed the DNS cache was disabled exactly as configured in main (ttl = 0).

But if your Java process was running with an OTEL Java agent attached or together with a JMX configuration, the values you set in main above are ignored.

export JAVA_TOOL_OPTIONS="-javaagent:/app/lib/opentelemetry-javaagent.jar"

java \
    -server -Xms4G -Xmx4G -XX:+UseG1GC \
    -Dcom.sun.management.jmxremote \
    -Dcom.sun.management.jmxremote.port=29010 \
    -Djava.rmi.server.hostname=$(hostname -f) \
    -jar /app/lib/my-service.jar

In other words, the simplest way to make ttl = 0 actually take effect is to pass it as a JVM startup option.

java -Dsun.net.inetaddr.ttl=0 -jar /app/lib/my-service.jar

That said, sun.net.inetaddr.ttl=0 is slated to be deprecated, so you should use networkaddress.cache.ttl=0 instead. This one has to go into a security properties file, and you specify that security properties file via a -D runtime argument.

java -Dsun.net.inetaddr.ttl=0 -Djava.security.properties=/path/to/custom.security -jar /app/lib/my-service.jar

 

So that's how I solved it.

 

The Debugging Process

Looking at just the result above, it seems pretty simple, but the debugging process was rough. To sum it all up: 

I used reflection to print out the actual value of `sun.net.InetAddressCachePolicy.get()`.
This method returns the real cache TTL static field inside the JVM as-is.

public static void main(String[] args) throws Exception {
    boolean shouldSet = args.length > 0 && "setProperty".equals(args[0]);

    if (shouldSet) {
        Security.setProperty("networkaddress.cache.ttl", "0");
        Security.setProperty("networkaddress.cache.negative.ttl", "0");
    }

    Class<?> c = Class.forName("sun.net.InetAddressCachePolicy");
    int positive = (int) c.getDeclaredMethod("get").invoke(null);
    int negative = (int) c.getDeclaredMethod("getNegative").invoke(null);
    System.out.println("Security networkaddress.cache.ttl        = "
            + Security.getProperty("networkaddress.cache.ttl"));
    System.out.println("InetAddressCachePolicy.get()  (positive) = " + positive);
    System.out.println("InetAddressCachePolicy.getNegative()     = " + negative);
}

 

So, based on the base code above, if I call setProperty in main and run the debugging code depending on whether the OTEL agent runtime injection is present, I get results like the following.

######## Case A: setProperty in main, no agent ########

Security networkaddress.cache.ttl        = 0
InetAddressCachePolicy.get()  (positive) = 0
InetAddressCachePolicy.getNegative()     = 0



######## Case B: setProperty in main + OTEL agent ########

Security networkaddress.cache.ttl        = 0
InetAddressCachePolicy.get()  (positive) = 30   ← ???
InetAddressCachePolicy.getNegative()     = 10

In Case B, I clearly set the TTL to 0 with setProperty(), but it didn't take effect. Security.getProperty("networkaddress.cache.ttl") returns 0, but the actual cache policy inside the JVM is stuck at 30. In other words, even though setProperty() was called in main, this value isn't used at all at the point of the actual DNS resolve.

 

Why Does This Happen? The Reason

An excerpt from sun.net.InetAddressCachePolicy in the JDK 17 source

public static final int DEFAULT_POSITIVE = 30;
private static volatile int cachePolicy = FOREVER;


static {
    Integer tmp = AccessController.doPrivileged(new PrivilegedAction<Integer>() {
        public Integer run() {
            try {
                String tmpString = Security.getProperty("networkaddress.cache.ttl");
                if (tmpString != null) return Integer.valueOf(tmpString);
            } catch (NumberFormatException ignored) {}
            
            try {
                String tmpString = System.getProperty("sun.net.inetaddr.ttl");
                if (tmpString != null) return Integer.decode(tmpString);
            } catch (NumberFormatException ignored) {}
            return null;
        }
    });

    if (tmp != null) {
        cachePolicy = tmp < 0 ? FOREVER : tmp;
    } else if (System.getSecurityManager() == null) {
        cachePolicy = DEFAULT_POSITIVE;   // if both are null, it gets stuck at 30
    }
}

public static int get() {
    return cachePolicy;

}

 

The static {} block runs in clinit (the class initializer) exactly once, when the class is first loaded.
At that moment it reads both
Security.getProperty("networkaddress.cache.ttl") and System.getProperty("sun.net.inetaddr.ttl", and assigns a value to the cachePolicy static field. After that, whenever get() is called on InetAddressCachePolicy, it just returns cachePolicy.

In this situation, calling Security.setProperty(...) in main does nothing useful — that updates the properties map of the Security module, not the cachePolicy static field. If clinit (the static initializer method) has already finished, it simply won't take effect.

What led me to debug this — because Case B above happened — was that I overlooked the fact that clinit can run before main(). When a javaagent like OTEL is attached via -javaagent:, it operates in the premain phase, and if it resolves the hostname even once during that premain phase, that's when InetAddressCachePolicy gets triggered. At that point main()'s Security.setPropertyhasn't run yet, so Security.getProperty returns null, and since the fallback System.getProperty("sun.net.inetaddr.ttl") was also null, the default 30 gets stuck in there.

The premain phase I'm talking about here refers to the entry point where the java agent runs before main(). Apparently, inside various library jars like APMs, a class usually has a premain() method like the one below.

public static void premain(String args, Instrumentation inst) {
      // the code here runs before your main()
}

When I actually had someone crack open otel-agent.jar, there was indeed a part in the premain call chain that calls InetAddress.getLocalHost(), which in turn ends up calling InetAddressCachePolicy.

  premain(agentArgs, inst)
    └─ startAgent(inst, true)
         └─ AgentInitializer.initialize(...)
              └─ ... (SDK/resource 초기화)
                   └─ HostResource.buildResource()
                        └─ InetAddress.getLocalHost()   ← <clinit> 트리거

 

So unless you pass -Dsun.net.inetaddr.ttl=0 at JVM startup, or supply a security properties file with networkaddress.cache.ttl=0 set via -Djava.security.properties=/path/to/custom.security, it ends up defaulting to 30. This is why, in Case B, when reading via Security.getProperty() it returned the 0 that was set in main, but when reading through InetAddressCachePolicy it didn't.

This actually isn't an OTEL-only problem — any component that touches InetAddress even once during the premain phase can be a trigger. For example, options like -Dcom.sun.management.jmxremote (the JMX management agent) or -agentlib:jdwp= (the debugger) also resolve the hostname, so they produce the same effect. In a production environment, JMX is usually on, and for a debug build JDWP is on too. In the end, InetAddressCachePolicy getting initialized before main() is more common than you'd think.

 

Conclusion

If a component that does host resolving during the premain() phase gets triggered, and you set the DNS Cache TTL in the main() function, the value you set may not take effect due to the static variable caching in DnsCache.

It wasn't easy that missing a single option could lead directly to a production incident. The thing that did me in was assuming it would obviously work just because I'd set the ttl in main(). If I hadn't debugged it together with a colleague, it would have been hard to figure out. Also, it was a bug you could only discover when there was an operational DNS change (a domain migration task), so there were plenty of errors while doing that work.

Coding ain't easy.

The end

댓글

Comments