안녕하세요. 딜라이트룸 Software Engineer Paul입니다.
저는 딜라이트허브 스쿼드에서 인수한 서비스들을 개선하는 일을 하고 있습니다.
딜라이트허브가 궁금하다면? (딜라이트허브 소개)
이번 글에서는 딜라이트허브에서 성장 중인 학습 앱 Noji에서 겪은 문제와 해결 경험을 공유하려합니다. Noji는 학습 카드를 만들고 복습하는 서비스인데, 다른 곳에서 만든 학습 자료를 파일로 가져오는 기능이 있습니다. 이 가져오기가 일부 사용자에게만 실패한다는 문의가 들어왔습니다.
서버 로그에는 성공이 남아 있었고, 제 로컬에서 요청해봐도 문제가 없었습니다. 원인을 따라가다 보니 애플리케이션 코드가 아니라 DNS 응답까지 내려가게 됐습니다.
우선 본문에 나오는 용어부터 짧게 정리하겠습니다.
용어 | 뜻 |
|---|---|
DNS |
|
권한 DNS | 특정 도메인의 답을 직접 가진 서버입니다. 휴대폰은 보통 공유기나 통신사 리졸버를 거쳐 이 서버의 답을 받습니다. |
UDP와 TCP | DNS는 보통 가벼운 UDP로 묻고, 답이 너무 크면 TCP로 다시 묻습니다. |
EDNS | "더 큰 UDP 응답도 받을 수 있다"고 알리는 DNS 확장입니다. 이게 없으면 UDP 응답은 512바이트가 한도입니다. |
TC | 응답이 잘렸다는 표시입니다. 받은 쪽은 TCP로 다시 물어야 완전한 답을 얻습니다. |
1. 서버는 200, 앱은 DNS 실패
문의와 함께 받은 파일부터 검사했지만 압축 구조도, 내부 데이터도 정상이었습니다. 대신 앱의 오류 로그에 이런 문구가 있었습니다.
Unable to resolve host "our-services.s3.us-east-1.wasabisys.com":
No address associated with hostnameNoji는 파일 저장소로 Wasabi를 씁니다. 이 문구는 앱이 Wasabi 저장소 주소의 IP를 찾지 못했다는 뜻입니다. 그런데 서버 쪽에서는 업로드 주소를 발급하는 API가 멀쩡히 200을 돌려주고 있었습니다.
POST /api/…/upload_url → HTTP 200사실 이유는 업로드 구조에 있습니다. Noji는 큰 파일을 API 서버가 받아 저장소로 넘기지 않습니다. 서버는 서명된 업로드 주소만 발급하고, 파일은 앱이 저장소로 직접 보냅니다. 서버의 200은 "주소를 발급했다"까지의 성공이고, 실패는 그다음에 앱이 저장소 주소를 DNS로 찾는 단계에서 났습니다. 이 구간은 API 서버를 거치지 않으니 서버 로그에 남을 수가 없었습니다.
그래서 "업로드 API가 왜 실패했을까?"가 아니라 "왜 어떤 사용자는 저장소 주소의 IP를 못 찾을까?"로 질문이 바뀌게 되었습니다.
2. LTE로는 되는데 Wi-Fi에선 안 된다
터미널에서 같은 주소를 직접 조회해 봤습니다.
dig @1.1.1.1 our-services.s3.us-east-1.wasabisys.com A
결과는 정상이었습니다. 저장소에 연결할 IP 주소 목록이 그대로 나왔습니다.
그 사이 고객에게서 단서가 하나 더 왔습니다. 휴대폰을 껐다 켜도 안 되던 가져오기가 Wi-Fi를 끄고 모바일 데이터로 바꾸자 됐다는 내용이었습니다. 같은 휴대폰, 같은 앱, 같은 파일인데 네트워크만 바꾸니 결과가 달라진 겁니다. 휴대폰보다는 DNS 답이 오가는 네트워크 경로를 봐야 했습니다.
3. 512바이트를 7바이트 넘은 응답
이번에는 공용 리졸버 대신 Wasabi 도메인의 권한 DNS에 직접 쿼리했습니다. 응답에는 별칭(CNAME) 하나와 IP 주소가 28개가 들어 있었고, EDNS를 켠 응답 크기는 530바이트였습니다.
EDNS 없이 UDP로 받을 수 있는 DNS 응답은 512바이트까지입니다. 그래서 EDNS를 끄고 UDP로만 다시 물었습니다. +noedns는 EDNS를 끄는 옵션이고, +ignore는 응답이 잘려도 TCP로 다시 묻지 않고 받은 그대로 보여 주는 옵션입니다.
dig @ns0209.secondary.cloudflare.com \
our-services.s3.us-east-1.wasabisys.com A \
+noedns +ignoreflags: qr aa tc rd
ANSWER: 1
MSG SIZE rcvd: 71잘림 표시 tc가 붙은 71바이트짜리 응답이 왔습니다. 남은 답은 CNAME 하나뿐이고, 정작 필요한 IP 주소는 하나도 없었습니다. 같은 질문을 TCP로 보내자 IP 28개가 모두 담긴 519바이트짜리 답이 왔습니다. 한도보다 딱 7바이트 큰 응답이었습니다.
보통의 리졸버는 잘린 응답을 받으면 TCP로 다시 물어 완전한 답을 받습니다. 제 컴퓨터가 그랬습니다. 그러니 사용자에게 실패로 나타나려면 잘린 응답을 받은 뒤 TCP 재조회까지 실패하는 경로가 있어야 합니다.
휴대폰 자체가 원인일 가능성은 낮았습니다. 같은 휴대폰이 모바일 데이터에서는 성공했으니까요. 의심스러운 건 Wi-Fi 쪽의 DNS 경로였습니다. 가정용 공유기의 DNS 중계 기능이나 일부 리졸버는 EDNS를 빼고 전달하거나 TCP 53번 포트를 받지 않기도 합니다. 그런 경로를 지나면 휴대폰에는 IP 없는 잘린 응답만 남고, 결과는 "주소를 찾을 수 없음"이 됩니다.
오류를 남긴 사용자들의 기록도 이 설명과 어긋나지 않았습니다. 기기 제조사와 Android 버전, 접속 국가가 제각각이었고, 같은 사용자가 같은 공인 IP에서 성공과 실패를 모두 남긴 경우도 있었습니다. 권한 DNS 중 NS1 계열 서버는 IP를 24개만 돌려줘 455바이트로 한도 안에 들어오기도 했습니다. 특정 기기의 버그라기보다, 어떤 경로로 어떤 답을 받느냐에 따라 결과가 갈리는 문제였습니다. 다만 고객 네트워크의 실제 패킷은 없으니, 여기까지는 가설입니다.
4. 같은 저장소, 23바이트 짧은 주소
Wasabi 문서를 보니 우리가 쓰는 리전에는 더 짧은 공식 주소 s3.wasabisys.com이 있었습니다. 지금까지 쓰던 s3.us-east-1.wasabisys.com은 별칭이었습니다. (Wasabi 리전별 서비스 주소)
같은 조건으로 물어보니 응답은 496바이트였습니다. IP 28개가 그대로 들어 있는데도 512바이트 안에 들어왔고, 잘림 표시도 없었습니다.
내용물이 같은데 왜 23바이트가 차이 날까요? DNS 응답은 질문받은 이름을 그대로 다시 담아 보냅니다. 뒤따르는 CNAME과 IP 레코드는 이 이름을 2바이트짜리 포인터로 가리키기 때문에, 응답 크기 차이는 곧 질문한 이름의 길이 차이입니다. 긴 이름은 41바이트, 짧은 이름은 18바이트로 인코딩됩니다.
짧은 공식 주소를 사용하면서, 버킷 이름도 호스트명에서 URL 경로로 옮겼습니다. 두 변경을 합쳐 DNS가 조회하는 이름을 23바이트 줄일 수 있었습니다.
계산해 보면 긴 주소는 IP 27개까지, 짧은 주소는 29개까지 512바이트에 들어갑니다. 현재 IP 개수인 28개가 정확히 그 사이에 걸려 있었던 겁니다.
5. 실패를 재현해 보기
이 차이가 실제 실패로 이어지는지 확인하려고, 로컬에 UDP만 중계하는 작은 프로그램을 만들었습니다(UDP는 되고 TCP 재조회는 막힌 네트워크). dig의 질의를 Wasabi 권한 DNS로 넘기고 응답을 돌려주되, TCP 연결은 받지 않습니다.
실제로 실행한 명령은 아래 두 줄입니다.
+noedns로 EDNS를 끄고, 앞에서와 달리+ignore는 넣지 않았습니다. 그래서 응답이 잘리면 dig가 TCP로 다시 묻습니다.-p는 중계 프로그램이 연 임시 포트입니다.
dig @127.0.0.1 -p <중계 포트> our-services.s3.us-east-1.wasabisys.com A +noedns +time=2 +tries=1
dig @127.0.0.1 -p <중계 포트> s3.wasabisys.com A +noedns +time=2 +tries=1긴 주소는 잘린 응답을 받고 TCP로 넘어가려다 connection refused로 실패했습니다(종료 코드 9). 중계 프로그램과 권한 DNS는 그대로 두고 이름만 짧은 주소로 바꾸자, 496바이트 응답이 한 번에 도착해 성공했습니다(종료 코드 0).
고객의 공유기를 그대로 재현한 것은 아니지만, 어떤 조건에서 실패하는지와 그 조건을 피하는 방법은 분명해졌습니다.
6. 어떻게 해결했나
선택지는 여러 가지였습니다.
선택지 | 판단 |
|---|---|
실패하면 재시도 | 재시도로 성공할 수도 있지만, 문제가 되는 DNS 경로와 응답 조건이 유지되면 실패가 반복됩니다. 안정적인 해결책으로 삼기는 어렵습니다. |
실패하면 API 서버를 거쳐 업로드 | 확실한 우회로지만 서버 트래픽이 늘고, 중복 업로드를 막는 설계가 따로 필요합니다. |
모든 업로드를 API 서버로 중계 | 정상 사용자까지 비용과 병목을 떠안습니다. 직접 업로드의 장점을 버리는 셈입니다. |
Wasabi에 DNS 응답 개선 요청 | 근본적인 해결이지만 일정을 우리가 통제할 수 없습니다. |
짧은 공식 주소 + path-style URL | 앱 업데이트 없이 서버 설정 한 곳만 바꾸면 됩니다. |
저희는 비교적 명확한 마지막 방법을 골랐습니다. S3 호환 저장소는 버킷 이름을 호스트명에 넣는 방식(virtual-hosted-style)과 URL 경로에 넣는 방식(path-style)을 모두 지원합니다. 버킷을 경로로 옮기면 DNS가 묻는 이름이 짧아집니다.
기존 <https://our-services.s3.us-east-1.wasabisys.com>
변경 <https://s3.wasabisys.com/our-services>저장소도, 리전도, 파일 위치도 그대로입니다. 바뀌는 건 DNS가 묻는 이름뿐입니다. 앱은 서버가 발급한 URL을 그대로 쓰고 있었기 때문에, 서버에서 업로드 URL을 만드는 부분만 바꾸면 됐습니다. 이미지나 다운로드처럼 같은 저장소를 쓰는 다른 기능에는 영향이 없도록, 업로드 URL 발급에만 별도 클라이언트를 두었습니다.
def presigned_post_bucket
@presigned_post_bucket ||= begin
config = aws_s3_client.config
endpoint = config.endpoint.dup
endpoint.host = 's3.wasabisys.com' if endpoint.host == 's3.us-east-1.wasabisys.com'
client = Aws::S3::Client.new(
endpoint: endpoint.to_s,
credentials: config.credentials,
region: config.region,
force_path_style: true,
stub_responses: config.stub_responses
)
Aws::S3::Resource.new(client:).bucket(bucket.name)
end
end
다행히 배포 이후 같은 DNS 오류는 더 이상 나타나지 않았습니다.
마치며
딜라이트룸에서 원인이 애플리케이션 밖에 있더라도 끝까지 따라가 보는 걸 즐기는 분들과 함께하고 싶습니다. 문제를 깊게 파고드는 일이 즐겁다면, 딜라이트룸에 지원해 주세요.