Kubernetes 2025.02.04 13 min read

[하이브리드 Kubernetes 구축기 3] Tailscale은 정확히 어디에 쓰일까: 실제 경로 추적

관리 접속, RKE2 최초 등록, 온프레미스의 subnet routing을 나눠 Tailscale 경로를 확인했다. OCI 내부 통신과 라우터 장애 전환의 범위도 함께 정리했다.

하이브리드 Kubernetes 구축기 3/6 · ← 이전 글 · 다음 글 →


기록 시기: 2025년
이 글은 2025년에 진행한 RKE2 기반 5노드 하이브리드 Kubernetes의 구축·안정화 과정을 회고하며, 이후 확인한 현재 구성과 대조해 작성했다. .example 도메인, 203.0.113.0/24 엔드포인트, PID·전송량은 출력 형식만 보여주기 위한 공개용 예시값이며 실제 구성값이 아니다. 인증 토큰은 싣지 않았다. 본문에 남긴 10.0.0.0/8, 192.168.0.0/16, 100.64.0.0/10 범위 주소는 인터넷에서 직접 라우팅되지 않는 내부 토폴로지 주소다.

Tailscale을 설치한 뒤에도 실제 패킷이 어디로 가는지는 따로 확인해야 했다. 이 클러스터에서는 다음 세 구간에 Tailscale을 사용한다.

  • 관리자가 다섯 노드에 접속하는 관리망
  • 온프레미스 노드 edge-b의 최초 RKE2 부트스트랩 경로
  • edge-b와 OCI 사설망 사이를 잇는 서브넷 라우팅 경로

OCI 안에 있는 core-a, core-b, core-c, edge-a끼리는 OCI VCN과 LPG의 사설망을 사용한다. 장애가 나면 먼저 요청이 OCI 내부에 머무는지, 온프레미스 경계를 넘는지부터 확인했다.

먼저 주소와 역할을 분리해서 보자

한 노드에는 용도가 다른 주소가 여러 개 존재한다. 이 클러스터에서는 최소한 Kubernetes Internal-IP, Tailscale IP, Pod CIDR을 구분해야 한다.

노드 역할 Kubernetes Internal-IP Tailscale IP Pod CIDR
core-a Control Plane + etcd 10.0.0.3 100.123.123.1 10.42.0.0/24
core-b Control Plane + etcd 10.0.0.2 100.123.123.2 10.42.1.0/24
core-c Control Plane + etcd 10.0.1.3 100.123.123.3 10.42.2.0/24
edge-b On-Prem Worker 192.168.0.188 100.123.123.5 10.42.3.0/24
edge-a OCI Worker 10.0.1.2 100.123.123.4 10.42.4.0/24

다음 명령으로 Kubernetes가 실제로 사용하는 노드 주소를 확인했다.

$ kubectl get nodes -o custom-columns='NAME:.metadata.name,ROLE:.metadata.labels.node-role\.kubernetes\.io/control-plane,INTERNAL-IP:.status.addresses[?(@.type=="InternalIP")].address,POD-CIDR:.spec.podCIDR'
NAME     ROLE    INTERNAL-IP     POD-CIDR
core-a   true    10.0.0.3        10.42.0.0/24
core-b   true    10.0.0.2        10.42.1.0/24
core-c   true    10.0.1.3        10.42.2.0/24
edge-b   -       192.168.0.188   10.42.3.0/24
edge-a   -       10.0.1.2        10.42.4.0/24

Tailscale 주소인 100.123.123.x는 Kubernetes Internal-IP와 별개다. Kubernetes와 Cilium은 각 노드의 OCI 또는 홈 네트워크 사설 주소를 underlay로 사용한다. Tailscale은 온프레미스와 OCI 사설망 사이를 연결한다.

1. 관리 SSH는 Tailscale과 MagicDNS를 사용한다

관리 PC에서는 IP 대신 다음처럼 노드 이름으로 접속한다.

$ getent ahostsv4 core-a
100.123.123.1  STREAM core-a.lab-tailnet.example
100.123.123.1  DGRAM
100.123.123.1  RAW

$ ssh user@core-a
user@core-a:~$ hostname
core-a

core-a라는 짧은 이름을 Tailscale MagicDNS가 100.123.123.1로 해석한다. core-b, core-c, edge-a, edge-b도 같은 방식이다. 각 노드에서 Tailscale SSH가 켜져 있는지도 확인할 수 있다.

user@core-a:~$ sudo tailscale debug prefs | jq '{RunSSH, CorpDNS}'
{
  "RunSSH": true,
  "CorpDNS": true
}

노드에는 ssh user@core-a처럼 일반 관리 계정으로 접속하고, 시스템 설정이나 다른 프로세스 정보를 읽을 때만 sudo를 사용한다. Tailscale SSH ACL 또는 grants에서도 사용자·장치·태그별 접근 범위를 좁히는 편이 안전하다. 인증 키, tailnet 전체 이름, SSH 정책 원문은 공개 저장소나 블로그에 올리지 않는다.

2. OCI 내부 네 노드는 Tailscale을 거치지 않는다

OCI 영역은 두 사설 대역으로 나뉜다.

OCI VCN A: 10.0.0.0/24
  ├─ core-a  10.0.0.3
  └─ core-b  10.0.0.2

             OCI LPG

OCI VCN B: 10.0.1.0/24
  ├─ core-c  10.0.1.3
  └─ edge-a  10.0.1.2

예를 들어 edge-acore-b의 RKE2 supervisor 포트에 접근할 때 선택되는 경로는 tailscale0가 아니다.

user@edge-a:~$ ip route get 10.0.0.2
10.0.0.2 via 10.0.1.1 dev enp0s6 src 10.0.1.2 uid 0

user@edge-a:~$ sudo ss -ntp | grep -E '10\.0\.(0\.2|0\.0\.3|1\.3):(9345|6443)'
ESTAB 0 0 10.0.1.2:46710 10.0.0.2:9345 users:(("rke2",pid=2184,fd=71))
ESTAB 0 0 10.0.1.2:43526 10.0.1.3:6443 users:(("rke2",pid=2184,fd=72))

Control Plane 사이의 etcd 2379/2380, Kubernetes API 6443, RKE2 supervisor 9345, 그리고 OCI 노드 사이의 Cilium VXLAN도 OCI 사설망을 사용한다. Tailscale은 이 구간의 주 데이터 경로가 아니다.

3. edge-b의 부트스트랩은 Tailscale IP에서 시작한다

OCI의 노드와 홈 네트워크의 edge-b는 RKE2 설정에 적힌 최초 서버 주소부터 다르다. 토큰이 함께 들어 있는 설정 파일 전체를 출력하지 않고 server 필드만 확인했다.

user@edge-a:~$ sudo sed -n 's/^server:/server:/p' /etc/rancher/rke2/config.yaml
server: https://rke2-oci.lab.example:9345

user@edge-b:~$ sudo sed -n 's/^server:/server:/p' /etc/rancher/rke2/config.yaml
server: https://rke2-hybrid.lab.example:9345

두 이름의 DNS 결과도 다르다.

user@edge-a:~$ OCI_BOOTSTRAP_FQDN='rke2-oci.lab.example'
user@edge-a:~$ getent ahostsv4 "$OCI_BOOTSTRAP_FQDN" | awk '{print $1}' | sort -u
10.0.0.2
10.0.0.3
10.0.1.3

user@edge-b:~$ HYBRID_BOOTSTRAP_FQDN='rke2-hybrid.lab.example'
user@edge-b:~$ getent ahostsv4 "$HYBRID_BOOTSTRAP_FQDN" | awk '{print $1}' | sort -u
100.123.123.1
100.123.123.2
100.123.123.3

edge-b는 처음 클러스터에 참가할 때 세 Control Plane의 Tailscale IP 중 하나로 9345/tcp에 연결한다. 인터넷에 OCI 사설 주소를 노출하거나 홈 라우터와 OCI 사이에 별도 site-to-site VPN을 먼저 구성하지 않아도 bootstrap이 가능한 이유다.

등록을 마친 뒤에도 같은 주소로 통신하는지는 실제 연결 목록에서 다시 확인했다.

4. 정상 운영에서는 10.0.x 목적지와 Tailscale 서브넷 라우트를 사용한다

RKE2 agent는 bootstrap 이후 클러스터에서 서버 목록을 전달받는다. 실제 연결을 보면 edge-b의 목적지는 Control Plane의 Tailscale IP가 아니라 OCI 사설 IP다.

user@edge-b:~$ sudo ss -ntp | grep -E ':(9345|6443)'
ESTAB 0 0 100.123.123.5:42138 10.0.0.2:9345 users:(("rke2",pid=2351,fd=81))
ESTAB 0 0 100.123.123.5:38974 10.0.0.3:9345 users:(("rke2",pid=2351,fd=82))
ESTAB 0 0 100.123.123.5:51620 10.0.1.3:6443 users:(("rke2",pid=2351,fd=83))

10.0.x는 홈 네트워크에서 일반 라우팅으로 도달할 수 없는 주소다. 하지만 Tailscale이 주입한 정책 라우팅을 확인하면 목적지가 tailscale0로 향한다.

user@edge-b:~$ ip route get 10.0.0.3
10.0.0.3 dev tailscale0 table 52 src 100.123.123.5 uid 0

user@edge-b:~$ ip route get 10.0.1.3
10.0.1.3 dev tailscale0 table 52 src 100.123.123.5 uid 0

user@edge-b:~$ ip route show table 52 | grep '^10\.0\.'
10.0.0.0/16 dev tailscale0
10.0.0.0/24 dev tailscale0
10.0.1.0/24 dev tailscale0

이때 각 경로를 실제로 광고하는 subnet router는 다음과 같다.

user@edge-b:~$ tailscale status --json \
  | jq -r '.Peer[] | select((.PrimaryRoutes // []) | length > 0) | [.HostName, (.PrimaryRoutes | join(","))] | @tsv'
core-b  10.0.0.0/24
core-c  10.0.1.0/24
edge-a  10.0.0.0/16

조회한 결과를 경로로 정리하면 다음과 같다.

edge-b에서 10.0.0.0/24로 가는 패킷
  └─ tailscale0 → core-b → OCI 10.0.0.0/24

edge-b에서 10.0.1.0/24로 가는 패킷
  └─ tailscale0 → core-c → OCI 10.0.1.0/24

예를 들어 edge-b → core-a(10.0.0.3) 연결은 Tailscale peer 관점에서 core-a로 곧장 보내는 것이 아니다. 10.0.0.0/24의 subnet router인 core-b로 암호화해 보내고, core-b가 OCI 사설망으로 전달한다. 목적지가 core-a의 Tailscale IP인 100.123.123.1일 때만 tailnet의 core-a peer 자체가 목적지가 된다.

5. 반대 방향은 edge-b가 192.168.0.0/24를 광고한다

경로는 양방향으로 필요하다. edge-b는 홈 네트워크의 192.168.0.0/24를 tailnet에 광고한다.

user@edge-b:~$ sudo tailscale debug prefs | jq '.AdvertiseRoutes'
[
  "192.168.0.0/24"
]

OCI 노드에서는 edge-b의 Kubernetes Internal-IP192.168.0.188로 가는 경로가 tailscale0에 주입된다.

user@core-a:~$ ip route get 192.168.0.188
192.168.0.188 dev tailscale0 table 52 src 100.123.123.1 uid 0

이 경로는 4편에서 다룰 Cilium VXLAN에도 중요하다. OCI Pod에서 edge-b의 Pod로 보내는 패킷은 Cilium이 192.168.0.188을 터널 종단점으로 선택하고, Linux 라우팅이 그 VXLAN 패킷을 다시 Tailscale에 태운다.

6. edge-a의 /16 광고는 장애 조치 경로가 아니다

현재 edge-a10.0.0.0/16을 광고한다. core-bcore-c가 광고하는 /24와 겹치는 구성이다.

라우팅은 longest-prefix match를 사용하므로 평상시에는 더 구체적인 /24가 선택된다.

10.0.0.3  → 10.0.0.0/24(core-b)가 /16(edge-a)보다 우선
10.0.1.3  → 10.0.1.0/24(core-c)가 /16(edge-a)보다 우선

다만 core-b 또는 core-c 장애가 났을 때 edge-a의 /16으로 자동 전환되는 구성은 아니다. Tailscale은 동일한 prefix를 여러 subnet router가 광고하고 해당 경로들이 승인·사용 가능한 경우 그 라우터들 사이의 failover를 지원한다. 길이가 다른 겹치는 prefix는 failover 쌍으로 취급하지 않는다. 따라서 /24 라우터 장애 시 /16이 자동 대체 경로가 되지 않아 해당 /24가 blackhole될 수 있다. 같은 prefix를 중복 광고하더라도 active-active 부하 분산이나 무중단 전환까지 보장하는 것은 아니다.

이 구성에서 subnet route HA를 원한다면 다음처럼 동일한 prefix를 두 대 이상이 광고하도록 설계해야 한다.

10.0.0.0/24 → core-b + 별도 HA router
10.0.1.0/24 → core-c + 별도 HA router

edge-a/16과 exit-node 광고가 필요한지도 다음 점검에서 확인할 항목으로 남겼다. 필요 없다면 승인된 경로를 정리하고 운영 문서에도 반영해야 한다.

7. direct와 DERP는 “라우터 선택”과 다른 문제다

서브넷 라우터를 고르는 과정과 그 라우터까지 패킷을 보내는 방식도 따로 확인했다.

  • 서브넷 라우터 선택: 10.0.0.0/24를 누가 광고하는가? 현재는 core-b다.
  • Tailscale 전송 방식: edge-bcore-b 사이 WireGuard 패킷이 direct UDP인가, DERP relay인가?

현재 edge-b에서 확인한 네 OCI peer 연결은 모두 direct였다. 공인 엔드포인트는 공개하지 않도록 마스킹했다.

user@edge-b:~$ tailscale ping core-b
pong from core-b (100.123.123.2) via 203.0.113.12:41641 in 12ms

user@edge-b:~$ tailscale status | grep -E 'core-a|core-b|core-c|edge-a'
100.123.123.1  core-a  linux  active; direct 203.0.113.11:41641, tx 12.4MiB rx 18.7MiB
100.123.123.2  core-b  linux  active; direct 203.0.113.12:41641, tx 10.8MiB rx 16.2MiB
100.123.123.3  core-c  linux  active; direct 203.0.113.13:41641, tx 11.1MiB rx 17.5MiB
100.123.123.4  edge-a  linux  active; direct 203.0.113.14:41641, tx 9.6MiB rx 14.3MiB

tailscale ping의 첫 응답이 잠시 DERP(seo)로 보인 뒤 direct로 바뀌는 것은 경로 탐색 과정에서 정상적으로 나타날 수 있다. NAT 환경 때문에 direct UDP 연결이 끝내 성립하지 않으면 DERP 또는 peer relay가 계속 사용된다. 모두 WireGuard로 종단 간 암호화되지만, relay 경로는 지연시간과 처리량에 영향을 줄 수 있으므로 subnet router 간 경로는 주기적으로 확인하는 편이 좋다.

# 현재 peer 전송 경로 확인
tailscale status
tailscale ping core-b
tailscale ping core-c

# UDP 가능 여부와 relay 환경 점검
tailscale netcheck

Hubble에서 Pod 흐름이 FORWARDED로 보여도 가장 바깥쪽 Tailscale 전송이 direct인지 DERP인지는 알 수 없다. Kubernetes/Cilium 관측과 tailscale ping을 함께 봐야 하는 이유다.

최종 경로를 한 장으로 정리하면

[관리 접속]
관리 PC ── Tailscale SSH + MagicDNS ──> core-a/b/c, edge-a/b

[OCI 내부 Kubernetes 통신]
core-a/b ── OCI VCN + LPG ── core-c/edge-a
          (Tailscale을 주 경로로 쓰지 않음)

[edge-b 최초 bootstrap]
edge-b ── rke2-hybrid.lab.example ──> 100.123.123.1/2/3:9345

[edge-b 정상 운영]
edge-b ── Tailscale ──> core-b(subnet router) ──> 10.0.0.0/24
       └─ Tailscale ──> core-c(subnet router) ──> 10.0.1.0/24

[OCI에서 edge-b 방향]
OCI nodes ── Tailscale ──> edge-b(subnet router) ──> 192.168.0.0/24

확인한 경로는 아래와 같다.

OCI의 네 노드는 사설망으로 통신한다. 온프레미스 Worker인 edge-b는 Tailscale subnet routing으로 OCI 사설망에 접근한다. 관리 접속에도 Tailscale을 사용한다.

공개 글 작성 시 주의할 정보

  • RKE2 join token, Tailscale auth key, kubeconfig client key는 절대 출력하지 않는다.
  • tailnet 전체 MagicDNS suffix와 실제 공인 UDP endpoint는 마스킹한다.
  • tailscale status --json에는 사용자 계정이나 장치 정보가 포함될 수 있으므로 필요한 필드만 jq로 고른다.
  • config.yaml 전체를 cat하지 말고 공개 가능한 필드만 선택한다.
  • 노드 접속은 일반 관리 계정을 사용하고, 필요한 명령에만 sudo 권한을 부여한다.

참고 문서

다음 편에서는 이 경로 위에서 Cilium VXLAN이 Pod 패킷을 운반하는 과정을 확인한다. OCI Pod와 edge-b Pod 사이에서 VXLAN과 Tailscale WireGuard가 겹치는 이중 캡슐화, 그리고 MTU를 1280/1230으로 조정한 이유를 CLI로 확인한다.


하이브리드 Kubernetes 구축기 3/6 · ← 이전 글 · 다음 글 →


SEARCH JOURNAL

기록 검색

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

검색을 준비하고 있어요.

검색어를 입력해 주세요.

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