Kubernetes 2025.01.09 13 min read

[하이브리드 Kubernetes 구축기 2] 2025년, RKE2 3 Control Plane과 etcd를 구축하다

OCI의 세 노드에 RKE2 Control Plane과 embedded etcd를 구성했다. membership, quorum, API readiness를 확인하고 DNS 등록 주소가 맡는 역할을 정리했다.

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


기록 시기: 2025년

2024년에 정리한 설계를 실제 클러스터로 옮긴 것은 2025년이었다. OCI의 core-a, core-b, core-c에 RKE2 server와 embedded etcd를 구성하고, 고정 등록 주소를 통해 노드가 특정 Control Plane 한 대에만 의존하지 않도록 만들었다. 구축 뒤에는 서비스 상태, etcd membership, API readiness를 반복해서 확인하며 구성을 안정화했다.

Control Plane을 세 대 띄운 뒤에는 etcd가 실제로 세 멤버로 합의하는지 확인해야 했다. 장애가 났을 때 API를 계속 사용할 수 있는지도 별도 시험이 필요했다.

2025년에 구축한 구성을 현재 상태에서 읽기 전용으로 다시 확인했다. OCI 노드의 DNS endpoint와 온프레미스 Worker의 bootstrap endpoint를 나눈 이유도 함께 정리했다. 아래 출력은 재검증 결과다. 남아 있지 않은 2025년 당시의 age나 출력 값을 만들어 넣지는 않았다.

이 글의 CLI 출력은 현재 구성을 바탕으로 공개 가능한 필드만 남겨 재구성했다. .example 도메인과 아래의 16진수 ID·suffix는 형식만 닮게 만든 공개용 임의 예시이며 실제 운영 값이 아니다. 실제 RKE2 token, 인증서 상세, 공인 endpoint와 Tailnet suffix는 싣지 않았다. 토큰을 예시로 적어야 할 때는 유효한 값 대신 EXAMPLE_ONLY_NOT_A_REAL_TOKEN을 사용한다.

2025년에 구축한 Control Plane 구조

Control Plane 노드는 모두 OCI에 있다.

노드 사설 IP 역할 아키텍처 / 자원
core-a 10.0.0.3 Control Plane + etcd ARM64 / 2 vCPU / 12 GB
core-b 10.0.0.2 Control Plane + etcd ARM64 / 2 vCPU / 12 GB
core-c 10.0.1.3 Control Plane + etcd ARM64 / 2 vCPU / 12 GB

core-acore-b10.0.0.0/24, core-c10.0.1.0/24에 있다. 두 사설망은 OCI 내부 경로로 연결되어 있고, etcd의 client/peer 통신도 Tailscale IP가 아닌 이 사설 IP를 사용한다.

먼저 노드 역할과 버전을 확인했다.

$ ssh user@core-a

$ alias k='sudo /var/lib/rancher/rke2/bin/kubectl --kubeconfig /etc/rancher/rke2/rke2.yaml'
$ k get nodes --no-headers | awk '
  BEGIN {printf "%-8s %-8s %-27s %s\n", "NAME", "STATUS", "ROLES", "VERSION"}
        {role=$3; gsub(/[<>]/, "", role); printf "%-8s %-8s %-27s %s\n", $1, $2, role, $5}'

NAME     STATUS   ROLES                       VERSION
core-a   Ready    control-plane,etcd,master   v1.33.5+rke2r1
core-b   Ready    control-plane,etcd,master   v1.33.5+rke2r1
core-c   Ready    control-plane,etcd,master   v1.33.5+rke2r1
edge-a   Ready    none                        v1.33.5+rke2r1
edge-b   Ready    none                        v1.33.5+rke2r1

$ sudo /usr/local/bin/rke2 --version | awk 'NR == 1 {print $1, $2, $3}'
rke2 version v1.33.5+rke2r1

이 구성의 RKE2 server 노드는 Kubernetes API server, scheduler, controller-manager와 embedded etcd를 실행한다. 세 core 노드의 서비스 상태도 함께 확인했다.

$ for node in core-a core-b core-c; do
    printf '%-8s ' "$node"
    ssh -t user@"$node" 'sudo systemctl is-active rke2-server'
  done

core-a   active
core-b   active
core-c   active

systemctl active이면 RKE2 server 프로세스가 실행 중인 것은 확인할 수 있다. etcd가 정상적으로 합의하는지는 별도로 조회했다.

3-member etcd에서 quorum은 무엇인가

etcd는 Raft 합의를 사용하며, 변경을 확정하려면 전체 멤버의 과반수가 필요하다. 필요한 quorum은 다음 식으로 계산할 수 있다.

quorum = floor(N / 2) + 1

3개 멤버라면 quorum은 2다.

etcd 멤버 수 필요한 quorum 구조적으로 허용 가능한 동시 멤버 장애
1 1 0
2 2 0
3 2 1
5 3 2

그래서 두 대는 한 대보다 장애 허용성이 늘지 않는다. 한 대가 사라지면 남은 한 대만으로 과반수를 만들 수 없기 때문이다. 개인 클러스터 규모에서 세 대를 선택한 이유가 여기에 있다.

표의 장애 허용 수는 etcd가 합의를 유지할 수 있는 조건이다. 요청 실패 여부와 전환 시간은 이 표로 알 수 없다. API endpoint 선택, 클라이언트 재시도, 네트워크 수렴에 걸리는 시간도 측정해야 한다.

embedded etcd 배치와 membership 확인

먼저 Kubernetes에서 보이는 etcd static Pod를 확인했다. 출력이 버전별 보조 컨테이너 수에 영향을 받지 않도록 이름, phase, node만 선택했다.

$ k -n kube-system get pods -l component=etcd \
  -o custom-columns='NAME:.metadata.name,PHASE:.status.phase,NODE:.spec.nodeName'

NAME          PHASE     NODE
etcd-core-a   Running   core-a
etcd-core-b   Running   core-b
etcd-core-c   Running   core-c

호스트 파일시스템에는 etcdctl이 없지만, 실행 중인 etcd 컨테이너에는 포함되어 있었다. 다음 읽기 전용 명령으로 loopback endpoint에 접속해 membership을 확인했다.

$ k -n kube-system exec etcd-core-a -- etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert=/var/lib/rancher/rke2/server/tls/etcd/server-ca.crt \
  --cert=/var/lib/rancher/rke2/server/tls/etcd/server-client.crt \
  --key=/var/lib/rancher/rke2/server/tls/etcd/server-client.key \
  member list -w table

+------------------+---------+-----------------+-------------------------+---------------------------+------------+
|        ID        | STATUS  |      NAME       |       PEER ADDRS        |       CLIENT ADDRS        | IS LEARNER |
+------------------+---------+-----------------+-------------------------+---------------------------+------------+
| 9a1b2c3d4e5f6071 | started | core-a-a1c2e3f4 | https://10.0.0.3:2380   | https://10.0.0.3:2379     | false      |
| 8b2c3d4e5f607182 | started | core-b-b2d3f4a5 | https://10.0.0.2:2380   | https://10.0.0.2:2379     | false      |
| 7c3d4e5f60718293 | started | core-c-c3e4a5b6 | https://10.0.1.3:2380   | https://10.0.1.3:2379     | false      |
+------------------+---------+-----------------+-------------------------+---------------------------+------------+

표의 member ID와 노드 이름 뒤 8자리 suffix는 형식만 보여주기 위해 만든 임의의 16진수 예시다. 실제 값은 공개하지 않았다.

여기서 확인할 지점은 세 가지다.

  1. 세 멤버가 모두 started 상태다.
  2. learner가 모두 false다. 즉 새로 합류해 아직 voting member로 승격되지 않은 멤버가 없다.
  3. peer 2380과 client 2379 주소가 모두 OCI 사설 IP다. etcd 합의 경로에 온프레미스 회선이나 Tailscale 경로가 들어가지 않는다.

명령을 실행하기 전에 경로부터 확인했다. 현재 core-a/var/lib/rancher/rke2/data/current/bin/에는 etcdctl이 없었다. 그래서 인터넷에서 자주 보이는 호스트 경로를 그대로 사용하지 않고, 실제로 성공한 kubectl exec 방식을 운영 절차에 남겼다. RKE2 버전이나 Pod 구성이 바뀌면 컨테이너 이름과 인증서 경로도 다시 확인해야 한다.

마지막으로 현재 접속한 API server의 readiness endpoint를 확인했다.

$ k get --raw='/readyz'
ok

각 조회가 보여주는 범위가 달랐다. systemd에서는 RKE2 프로세스, static Pod 목록에서는 etcd 배치, membership에서는 voting member 구성을 확인했다. API readiness는 현재 접속한 API server가 요청을 받을 준비가 됐는지 보여준다.

RKE2의 고정 등록 주소를 DNS로 만든 이유

Worker나 추가 server가 core-a의 IP 하나만 바라보게 만들면, 최초 등록 주소가 단일 장애 지점이 된다. RKE2 공식 HA 설치 문서는 새 노드가 처음 접속할 수 있는 고정 등록 주소를 두도록 안내하며, 그 구현 방식으로 L4 load balancer, round-robin DNS, 가상 IP 등을 사용할 수 있다.

이 클러스터에서는 접근 경계에 따라 두 개의 DNS 이름을 사용한다. 아래 이름은 실제 내부 DNS 대신 예약된 .example 도메인으로 바꾼 공개용 예시다.

용도 공개용 예시 이름 해석되는 주소 주요 사용자
OCI 내부 등록 주소 oci-rke2.internal.example 10.0.0.2, 10.0.0.3, 10.0.1.3 core 노드, edge-a
온프레미스 bootstrap 주소 ts-rke2.internal.example 100.123.123.1, .2, .3 edge-b 최초 bootstrap

OCI 노드에서 첫 번째 DNS의 A record를 확인하면 세 Control Plane의 사설 IP가 나온다.

# 공개용 예시다. 실제 점검에서는 운영 중인 내부 DNS 이름으로 바꾼다.
$ CONTROL_PLANE_DNS='oci-rke2.internal.example'
$ getent ahostsv4 "$CONTROL_PLANE_DNS" | awk '{print $1}' | sort -u

10.0.0.2
10.0.0.3
10.0.1.3

RKE2 설정에서는 9345 supervisor endpoint를 가리킨다. 토큰을 실수로 화면에 출력하지 않도록 server 항목만 확인했다.

# core-b에서 실행
$ sudo sh -c "grep -hE '^server:' \
  /etc/rancher/rke2/config.yaml \
  /etc/rancher/rke2/config.yaml.d/*.yaml 2>/dev/null"

server: https://oci-rke2.internal.example:9345

# edge-a에서 실행
$ sudo sh -c "grep -hE '^server:' \
  /etc/rancher/rke2/config.yaml \
  /etc/rancher/rke2/config.yaml.d/*.yaml 2>/dev/null"

server: https://oci-rke2.internal.example:9345

# edge-b에서 실행
$ sudo sh -c "grep -hE '^server:' \
  /etc/rancher/rke2/config.yaml \
  /etc/rancher/rke2/config.yaml.d/*.yaml 2>/dev/null"

server: https://ts-rke2.internal.example:9345

OCI 노드와 온프레미스 노드는 아래처럼 접속 경로를 나눴다.

  • OCI의 노드는 OCI 사설 IP에 직접 접근할 수 있으므로 Tailscale을 경유할 필요가 없다.
  • edge-b는 최초 bootstrap 시 Control Plane의 Tailscale IP로 접근할 수 있는 별도 이름을 사용한다.
  • edge-b가 클러스터에 합류한 뒤 RKE2는 발견한 backend의 10.0.x 주소로 연결하며, 이 사설 대역 접근은 Tailscale subnet routing이 운반한다.

edge-b는 Tailscale IP로 처음 등록한 뒤, RKE2가 발견한 backend의 사설 IP로 연결한다. 이 두 단계의 경로를 따로 확인해야 했다.

DNS에 A record 세 개면 로드 밸런서인가

여러 A record는 최초 접속 후보를 여러 개 제공한다. 각 backend의 93456443 상태를 검사해 정상 노드로만 전달하는 L4 load balancer 기능은 없다.

RKE2 agent는 등록 주소를 seed로 사용하고, supervisor에서 server 목록을 받아 로컬 client-side load balancer를 구성한다. 등록 이후에는 발견한 여러 backend를 사용할 수 있다. 다만 처음 등록할 때의 DNS 응답, 캐시, 연결 재시도는 여전히 따로 확인해야 한다.

현재 DNS 등록 주소의 역할은 다음과 같다.

세 Control Plane의 사설 IP를 DNS로 반환하고, RKE2 supervisor의 seed endpoint로 사용한다. 최초 접속 후보를 분산하는 구성이다. DNS 자체가 active health check를 수행하지는 않는다.

API를 DNS 이름으로 직접 노출하거나 접근한다면 해당 이름이 인증서 SAN에 포함되어 있는지도 확인해야 한다. 공개 글에서 실제 SAN 전체를 출력하면 내부 이름이 노출될 수 있으므로, 로컬에서만 다음과 같이 점검하고 결과는 verify return:1이 아닌 0 (ok)인지 확인한다.

# 공개용 예시다. 실제 점검에서는 운영 중인 API DNS 이름을 주입한다.
$ API_DNS='kube-api.internal.example'
$ sudo openssl s_client \
  -connect "${API_DNS}:6443" \
  -servername "$API_DNS" \
  -CAfile /var/lib/rancher/rke2/server/tls/server-ca.crt \
  -verify_hostname "$API_DNS" </dev/null 2>&1 \
  | grep 'Verify return code'

Verify return code: 0 (ok)

위 출력은 사용 중인 API DNS가 실제 인증서 SAN에 포함된 경우의 확인 예시다. bootstrap용 9345 이름과 Kubernetes API 6443에 사용하는 이름이 다를 수 있으므로, 운영 환경의 실제 접속 방식을 기준으로 검증해야 한다.

지금 확인한 것과 아직 확인하지 않은 것

이번 점검으로 확인한 현재 상태는 다음과 같다.

  • RKE2 버전은 v1.33.5+rke2r1이다.
  • core-a, core-b, core-c가 모두 Control Plane + etcd 역할로 Ready다.
  • 세 노드의 rke2-server가 실행 중이다.
  • embedded etcd의 세 멤버가 모두 started이며 learner가 없다.
  • etcd client/peer 주소는 OCI 사설 IP다.
  • 현재 Kubernetes API readiness 응답은 ok다.
  • OCI용 DNS 등록 주소는 세 Control Plane의 사설 IP를 반환한다.
  • 온프레미스 edge-b는 별도의 Tailscale bootstrap 주소를 사용한다.

아래 항목은 이번 상태 조회로 확인하지 못했다.

  • core 한 대를 실제로 중단했을 때 etcd leader가 전환되는 데 걸리는 시간
  • leader 전환 중 API 요청의 실패율과 지연시간
  • DNS cache 상태별 최초 bootstrap 성공 시간
  • 동시에 두 멤버를 잃었을 때의 복구 절차와 RTO
  • snapshot에서 복원했을 때의 데이터 정합성과 실제 복구 시간

현재 확인한 결과는 3-member etcd quorum을 구성했고 모든 멤버가 정상이라는 데까지다. 노드 중단과 복구에 걸리는 시간은 장애 시험 후에 기록할 수 있다.

운영 점검용 최소 명령 모음

운영 중 Control Plane 상태를 빠르게 확인할 때는 아래 순서로 조회했다.

# 1. 노드 Ready와 역할
k get nodes

# 2. API server readiness
k get --raw='/readyz'

# 3. etcd static Pod 배치
k -n kube-system get pods -l component=etcd -o wide

# 4. 각 RKE2 server 서비스
for node in core-a core-b core-c; do
  ssh -t user@"$node" 'hostname; sudo systemctl is-active rke2-server'
done

# 5. etcd membership
ETCD_TLS=/var/lib/rancher/rke2/server/tls/etcd
k -n kube-system exec etcd-core-a -- etcdctl \
  --endpoints=https://127.0.0.1:2379 \
  --cacert="$ETCD_TLS/server-ca.crt" \
  --cert="$ETCD_TLS/server-client.crt" \
  --key="$ETCD_TLS/server-client.key" \
  member list -w table

이 명령들은 상태를 읽기만 한다. 일부러 systemctl restart, etcd member remove, 방화벽 변경처럼 클러스터 상태를 바꾸는 명령은 포함하지 않았다.

마무리

현재 클러스터의 Control Plane은 OCI 내부의 세 RKE2 server와 3-member embedded etcd로 구성되어 있다. etcd 통신은 10.0.0.2, 10.0.0.3, 10.0.1.3의 사설망을 사용하고, 세 멤버가 모두 voting member로 정상 동작 중이다. 이 구조는 한 멤버 장애를 견딜 수 있는 quorum 조건을 갖추지만, 무중단이라는 결과까지 자동으로 보장하지는 않는다.

DNS 등록 주소는 새 노드가 특정 core 한 대의 IP에 고정되지 않도록 만들었다. OCI에서는 사설 endpoint를, 온프레미스에서는 Tailscale bootstrap endpoint를 사용한다.

다음 편에서는 온프레미스의 실제 경로를 추적한다. edge-b에서 10.0.0.0/24는 왜 core-b를, 10.0.1.0/24는 왜 core-c를 경유하는지, RKE2의 bootstrap 이후 실제 연결은 어디로 향하는지 ip rule, table 52, ss, tailscale status 출력으로 추적해 볼 예정이다.

참고 자료


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


SEARCH JOURNAL

기록 검색

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

검색을 준비하고 있어요.

검색어를 입력해 주세요.

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