이 글은 회사에서 있었던 네트워크 통신 이슈에 대해 정리 및 회고해보는 글입니다.
회사 네트워크 정책 및 서비스와 관련없는 오류와 관련된 내용만을 작성해 조금 짧은 회고가 될 것 같네요.
4월 점검 이후 고객의 서비스 문의 사항을 통해 오류 내용을 최초로 인지하였습니다.
오류가 발생한 주요 변경사항은 SPF 레코드 인증 강화 태스크였으며 아래와 같은 내용을 서비스 인증 내용에 반영하였습니다.
m3aawg_managing-spf_records-2017-08.pdf - 3.2.1 Syntax Issues 참고
오류로 인해 발생한 이슈는 SPF 레코드를 인증한 모든 고객사에 SPF 미인증 메일 발송이었고, 해당 메일이 발송된 이후 몇몇 고객사에서 해당 내용 관련 문의가 유입되었습니다.
사실 앞서 공유드린 SPF 레코드 인증 강화 로직은 1월 반영건이었습니다. 당시에는 발견되지 않았던 오류가 왜 4월에 되어서야 확인이 되었는지 의문이 들었습니다.
해당 내용을 자세히 파악해보니 두 가지 변경사항이 존재했습니다.
다만, 변경된 로직은 동일하게 동작하였고 단 한 줄의 차이가 존재했습니다. 그것이 2. DNS LookUp Resolver 개선
건이었습니다.
서비스에서는 GoogleDNSResolver를 통해 도메인에 정의된 레코드를 Lookup하도록 설계되었습니다.
GoogleDNSResolver에 정상적으로 레코드가 정상적으로 반영되어 있을경우, 서비스에서는 해당 내용을 인증 처리하고 있습니다. 그럼 내부 로직을 간단하게 볼까요? (해당 내용은 서비스 내부 로직 중 오류 상황에 영향을 미친 로직만을 재구성한 코드입니다.)
//Before
records = Optional.ofNullable(new Lookup(domain, Type.TXT).run()).orElse(new Record[0]);
//After
Lookup lookup = new Lookup(domain, Type.TXT);
lookup.setResolver(googleDnsResolver);
records = Optional.ofNullable(lookup.run()).orElse(new Record[0]);
확인 결과 기존에는 자체 정의된 googleDnsResolver를 사용하고 있지 않았습니다. 그렇다면 googleDnsResolver를 설정하지 않은 LookUp 클래스는 어떻게 정의되는지 확인해 보겠습니다.
public DNSResolverHelper() throws UnknownHostException {
try {
this.googleDnsResolver = new SimpleResolver(GOOGLE_DNS_IP_ADDRESS);
this.googleDnsResolver.setTimeout(DNS_LOOKUP_TIMEOUT_SECOND);
} catch (UnknownHostException e) {
log.error("Make DNS Resolver is failed", e);
throw e;
}
}
public Lookup(Name name, int type, int dclass) {
Type.check(type);
DClass.check(dclass);
if (!Type.isRR(type) && type != 255) {
throw new IllegalArgumentException("Cannot query for meta-types other than ANY");
} else {
this.name = name;
this.type = type;
this.dclass = dclass;
synchronized(Lookup.class) {
this.resolver = getDefaultResolver();
this.searchPath = getDefaultSearchPath();
this.cache = getDefaultCache(dclass);
}
this.credibility = 3;
this.verbose = Options.check("verbose");
this.result = -1;
}
}
public static synchronized void refreshDefault() {
try {
defaultResolver = new ExtendedResolver();
} catch (UnknownHostException var1) {
throw new RuntimeException("Failed to initialize resolver");
}
defaultSearchPath = ResolverConfig.getCurrentConfig().searchPath();
defaultCaches = new HashMap();
defaultNdots = ResolverConfig.getCurrentConfig().ndots();
}
public ExtendedResolver() throws UnknownHostException {
this.init();
String[] servers = ResolverConfig.getCurrentConfig().servers();
if (servers != null) {
for(int i = 0; i < servers.length; ++i) {
Resolver r = new SimpleResolver(servers[i]);
r.setTimeout(5);
this.resolvers.add(r);
}
} else {
this.resolvers.add(new SimpleResolver());
}
}
public ResolverConfig() {
if (!this.findProperty()) {
if (!this.findSunJVM()) {
if (this.servers == null || this.searchlist == null) {
String OS = System.getProperty("os.name");
String vendor = System.getProperty("java.vendor");
if (OS.indexOf("Windows") != -1) {
if (OS.indexOf("95") == -1 && OS.indexOf("98") == -1 && OS.indexOf("ME") == -1) {
this.findNT();
} else {
this.find95();
}
} else if (OS.indexOf("NetWare") != -1) {
this.findNetware();
} else if (vendor.indexOf("Android") != -1) {
this.findAndroid();
} else {
this.findUnix();
}
}
}
}
}
private void findUnix() {
this.findResolvConf("/etc/resolv.conf");
}
/etc/resolv.conf
파일을 보고 nameserver를 확인하고 있는 것을 확인할 수 있습니다./etc/resolv.conf
파일에는 googleDNS 서버 IP가 아닌 사내 nameserver IP가 정의되어 있었습니다.LookUp 클래스 초기화간 Resolver 세팅 유무에 따른 차이점은 다음과 같았습니다.
/etc/resolv.conf
파일에 정의된 nameserver로 DNS Lookup진행어차피 둘 다 nameserver인 것은 똑같은데 왜 한 쪽은 오류가 발생했고 한쪽은 오류가 발생하지 않았을까요?
이는 회사 내부 정책과 RFC 표준을 통해 알 수 있었습니다.
현재 서비스에서는 DNS Lookup간 org.xbill.DNS 패키지를 사용하고 있습니다.
org.xbill.DNS 패키지에서 LookUp간 네트워크 통신을 하는 로직을 세부적으로 확인할 경우 다음과 같은 로직을 확인하실 수 있습니다.
public Message send(Message query) throws IOException {
if (Options.check("verbose")) {
System.err.println("Sending to " + this.address.getAddress().getHostAddress() + ":" + this.address.getPort());
}
if (query.getHeader().getOpcode() == 0) {
Record question = query.getQuestion();
if (question != null && question.getType() == 252) {
return this.sendAXFR(query);
}
}
query = (Message)query.clone();
this.applyEDNS(query);
if (this.tsig != null) {
this.tsig.apply(query, (TSIGRecord)null);
}
byte[] out = query.toWire(65535);
int udpSize = this.maxUDPSize(query);
boolean tcp = false;
long endTime = System.currentTimeMillis() + this.timeoutValue;
while(true) {
while(true) {
if (this.useTCP || out.length > udpSize) {
tcp = true;
}
byte[] in;
if (tcp) {
in = TCPClient.sendrecv(this.localAddress, this.address, out, endTime);
} else {
in = UDPClient.sendrecv(this.localAddress, this.address, out, udpSize, endTime);
}
if (in.length < 12) {
throw new WireParseException("invalid DNS header - too short");
}
int id = ((in[0] & 255) << 8) + (in[1] & 255);
int qid = query.getHeader().getID();
if (id == qid) {
Message response = this.parseMessage(in);
this.verifyTSIG(query, response, in, this.tsig);
if (tcp || this.ignoreTruncation || !response.getHeader().getFlag(6)) {
return response;
}
tcp = true;
} else {
String error = "invalid message id: expected " + qid + "; got id " + id;
if (tcp) {
throw new WireParseException(error);
}
if (Options.check("verbose")) {
System.err.println(error);
}
}
}
}
}
private int maxUDPSize(Message query) {
OPTRecord opt = query.getOPT();
return opt == null ? 512 : opt.getPayloadSize();
}
여기서 주요 로직은 다음과 같음
int udpSize = this.maxUDPSize(query); // udp size를 넘을 경우 TCP로 자동 변환
if (this.useTCP || out.length > udpSize) {
tcp = true;
}
byte[] in;
if (tcp) {
in = TCPClient.sendrecv(this.localAddress, this.address, out, endTime);
} else {
in = UDPClient.sendrecv(this.localAddress, this.address, out, udpSize, endTime);
}
private int maxUDPSize(Message query) {
OPTRecord opt = query.getOPT();
return opt == null ? 512 : opt.getPayloadSize();
}
RFC 6891에 의거하여 udp size를 초과할 경우 tcp 통신으로 변경하는 로직이 내부적으로 동작하고 있습니다.
현재 기본 옵션을 사용하고 있어 기본적인 LookUp간 네트워크 통신은 UDP를 사용하나 512 bytes를 초과할 경우 tcp 통신으로 변환됩니다.
사내 정책은 기본적으로 UDP를 허용하고 있으나 외부 IP로의 TCP 통신은 차단하고 있었습니다. 차단 해제를 위해서는 별도의 허용 정책 요청이 필요한 상황입니다.
이 중 2번이 가장 쉽게 적용할 수 있는 방안이어서 정책을 허용으로 변경하는 것으로 대응 마무리 하였습니다.