Troubleshooting 2026.09.13 8 min read

RKE2 관리 연결이 끊겼을 때: Tailscale 경로와 백업 전송을 나눠 확인했다

Ghost를 HA 구조로 옮기던 중 관리 API 연결과 대용량 전송이 간헐적으로 끊겼다. 로그로 확인한 원인 범위, 전송 검증을 바꾼 이유, 아직 확정하지 못한 부분을 남겼다.

Ghost를 HA 구조로 옮기는 과정에서 기존 노드의 Kubernetes 관리 연결이 간헐적으로 끊겼다. 짧은 명령은 되는데 콘텐츠를 묶어 가져오는 작업에서 EOF가 발생했다. 연결을 다시 확인하면 정상으로 돌아와 있었고, 일부 전송은 종료 코드가 0이어도 결과 파일이 온전하지 않았다.

처음에는 Tailscale 라우팅 문제를 의심했다. 다만 의심한 경로와 실제로 확인한 장애는 구분해서 기록하기로 했다. 아래에서는 실제 서버 이름과 주소를 공개하지 않고, 기존 서비스 노드를 ‘외부 노드’, 클라우드의 관리 노드를 ‘클라우드 노드’로 적었다.

localhost로 접속해도 통신은 로컬에서 끝나지 않았다

외부 노드에서 사용하던 API 주소는 127.0.0.1:6443이었다. 이 포트를 듣는 프로세스는 API 서버가 아니라 RKE2 에이전트였다. 에이전트의 로컬 로드밸런서가 클라우드의 API 서버 세 곳으로 연결을 넘기고 있었다.

실제 백엔드 주소는 클라우드 VPC의 사설 주소였고, 외부 노드에서는 Tailscale subnet 경로를 거쳤다. 따라서 로컬 포트에 연결됐다는 사실만으로 클라우드 API 서버까지의 연결이 정상이라고 볼 수 없었다. 확인해야 할 구간은 다음과 같았다.

외부 노드의 관리 명령
  → RKE2 로컬 로드밸런서
  → Tailscale subnet 경로
  → 클라우드 API 서버

장애 시간대 로그에서는 같은 TCP 연결에 대한 Tailscale 연결 열기 timeout이 두 번 기록됐다. 이어서 RKE2가 해당 API 백엔드를 ACTIVE에서 FAILED로 바꾸고, 기존 연결 31개를 닫은 뒤 다른 백엔드를 활성화했다. 실패한 백엔드는 곧바로 복구 상태를 거쳐 다시 선호 대상으로 돌아왔다.

로그에서 Tailscale subnet 경로의 연결 실패와 로컬 로드밸런서의 연결 종료를 확인했다. 이 구간을 통과하는 관리 스트림도 함께 끊길 수 있었다.

짧은 정상 응답으로 대용량 전송까지 통과시킬 수는 없었다

장애 이후에는 로컬 주소, VPC 주소 세 개, 관리용 Tailscale 주소 세 개를 나눠 점검했다. 각 주소에 5회씩 보낸 /readyz 요청은 총 35회 모두 성공했다. TLS 검증도 통과했고, 명령 실행 시간을 포함한 응답은 약 0.106~0.143초였다.

이 결과는 점검 시점에 짧은 API 요청이 정상이었다는 뜻이다. 앞서 발생한 대용량 exec 스트림의 잘림까지 설명하지는 못한다. 첫 tar EOF와 뒤에 확인한 timeout의 발생 시점도 완전히 같지 않았다. 종료 코드 0으로 끝난 전송의 클라이언트나 프로토콜 동작까지 원인을 확정하지 않았다.

같은 시간대에 Tailscale의 peer endpoint가 바뀐 기록도 있었다. 하지만 endpoint 변경이 timeout을 직접 일으켰다는 증거는 확보하지 못했다. 이 단계에서 자동 생성되는 RKE2 로드밸런서 파일을 수동 고정하거나 전체 라우팅을 바꾸지는 않았다.

주소를 바꿔 같은 요청을 반복했다

아래는 실제 점검 명령에서 주소를 문서용 값으로 바꾼 예시다. 192.0.2.10은 실제 서버 주소가 아니다. 관리용 kubeconfig는 기존 것을 사용했고, TLS 검증을 끄는 옵션은 넣지 않았다.

API_PRIVATE_IP='192.0.2.10'  # 문서용 예시: 실제 점검 주소로 치환
ss -lntp '( sport = :6443 )'
ip route get "$API_PRIVATE_IP"

API_URL="https://${API_PRIVATE_IP}:6443"
for attempt in 1 2 3 4 5; do
  kubectl --server="$API_URL" --request-timeout=5s \
    get --raw=/readyz
done

포트의 소유 프로세스, 목적지까지 선택되는 인터페이스와 경로, 실제 API 응답을 순서대로 봤다. 반복문은 일곱 주소에 각각 적용했다. 단순 TCP 접속 여부에 그치지 않고 인증과 TLS 검증이 포함된 API 요청이 5초 안에 성공하는지를 기준으로 삼았다.

장애 로그는 같은 시간 창으로 맞춰 조회했다. 아래 시각은 형식을 보여 주기 위한 예시이며, 실제로는 전송 실패 직전부터 백엔드 복구 직후까지의 짧은 구간을 사용했다.

FROM='2026-09-09 15:05:30 UTC'
TO='2026-09-09 15:07:20 UTC'
journalctl -u tailscaled --since "$FROM" --until "$TO" --no-pager
journalctl -u rke2-agent --since "$FROM" --until "$TO" --no-pager

timeout, 백엔드 상태 변경, 연결 종료를 시각순으로 대조했다. 상대 API 서버도 같은 구간으로 봤지만 대응되는 오류를 찾지 못했다. 이것만으로 서버 측 모든 문제를 배제하지는 않았다. 원본 로그에는 내부 주소가 들어가므로 공개 글에는 사건의 순서와 개수만 남겼다.

복구 데이터는 파일로 만든 뒤 양쪽에서 검증했다

마이그레이션을 계속하려면 연결 경로와 데이터 전송 방식을 바꿔야 했다. 관리 명령마다 TLS 검증을 통과한 API 주소를 지정해 로컬 로드밸런서의 백엔드 선택에 대한 의존을 줄였다. 최종 전환에서는 선택한 클라우드 API의 사설 주소로 직접 접속했다. 이 경우에도 외부 노드에서 사설망까지 가는 Tailscale subnet 경로는 남는다.

DB 덤프와 콘텐츠는 노드 내부에서 파일로 완성한 뒤 전송했다. 컨테이너 접근도 필요한 구간에서는 노드의 로컬 런타임을 사용했다. 수신한 파일은 송신 측 파일과 바이트 수 및 SHA256을 비교했고, gzip 무결성과 tar 읽기 여부를 별도로 확인했다. 이후부터는 전송 명령의 종료 코드만 보고 다음 단계로 넘어가지 않았다.

이 방식으로 최종 전환에 사용할 데이터를 확보했고, 원본과 복사본의 검증을 마친 뒤 새 운영 환경에 반영했다. 전송이 끝난 뒤에도 파일 검사를 통과해야 복원에 사용했다.

전송 완료 기준을 바꾼 명령

아래는 콘텐츠 아카이브의 검증 흐름이다. 경로와 호스트명은 모두 예시다. 최종 전환용 파일을 만들 때는 Ghost와 다른 작성자가 멈춘 것을 먼저 확인했다. DB 덤프도 같은 쓰기 중단 구간에서 만들었다.

# 송신 노드에서: 디렉터리를 먼저 확인하고 아카이브를 완성한다.
CONTENT_ROOT='/srv/content-example'
ARCHIVE='/srv/checkpoint-example/content.tar.gz'
tar -C "$CONTENT_ROOT" -czf "$ARCHIVE" .
gzip -t "$ARCHIVE"
tar -tzf "$ARCHIVE" > /dev/null
stat --format='%s' "$ARCHIVE"
sha256sum "$ARCHIVE"

# 파일 전송 후 수신 노드에서 같은 검사를 한다.
RECEIVED='/srv/restore-example/content.tar.gz'
gzip -t "$RECEIVED"
tar -tzf "$RECEIVED" > /dev/null
stat --format='%s' "$RECEIVED"
sha256sum "$RECEIVED"

양쪽 바이트 수와 SHA256이 모두 일치하고 압축 및 아카이브 검사가 성공해야 다음 단계로 넘어갔다. 길이가 다르거나 해시가 다르면 전송 실패로 처리했다. 길이만 같아도 내용이 같다고 판단하지 않았다. 실제 파일 전송에는 SSH 연결을 사용했고, 큰 데이터를 Kubernetes exec의 표준 출력으로 계속 흘려보내는 경로는 최종 복구 데이터 전달에 사용하지 않았다.

운영 경로는 옮겼고, 간헐 장애의 원인은 남겨 두었다

Ghost와 DB, 콘텐츠 볼륨을 클라우드 노드로 옮기면서 Cloudflare Tunnel 연결기 두 개도 클라우드에 배치했다. 연결기마다 QUIC 연결 네 개가 등록됐고 readiness를 통과했다. 전환 중 공개 사이트에 보낸 12회 요청도 모두 200으로 응답했다. 현재 사이트의 일반적인 요청 처리는 외부 노드의 이 관리 경로에 의존하지 않는다.

별도로 관찰했던 DNS timeout은 같은 문제로 묶지 않았다. 후속 점검에서는 UDP 질의 30회와 실제 Node.js c-ares 질의 12회가 모두 성공했지만, 간헐 장애를 재현하지 못한 결과일 뿐이다. DNS 장애가 해결됐다고 적을 근거는 없었다.

이번 작업에서 끝낸 것은 운영 경로 이관과 복구 데이터의 전송 검증이다. Tailscale subnet 경로의 순간 연결 실패는 확인했지만, 모든 EOF와 DNS timeout을 설명하는 영구적인 원인과 수정까지 확인한 상태는 아니다. 후속 조사는 이 기록에서 이어가면 된다.

SEARCH JOURNAL

기록 검색

글 제목, 요약, 태그에서 찾습니다.

검색을 준비하고 있어요.

검색어를 입력해 주세요.

    Ctrl/⌘ K 검색 Esc 닫기 결과 이동