Skip to content

쿠버네티스의 구성 요소끼리는 어떻게 신뢰를 하나요? (제로트러스트 구현 그리고 인증서 구조와 성능) #27

Description

@sysnet4admin

4회차 초반에 이런 질문이 나왔습니다.

큰건 아니고 내부적으로 MSA구조에 제로트러스트를 쓴다고 하셨는데, 그러면 내부적으로도 계속 인증을 하면 너무 느려질 것 같다고 생각했습니다.

수업에서 짧게 답하고 넘어갔으니 여기에 정리합니다. 마침 쿠버네티스 자신이 이 방식으로 만들어져 있어서, 실습용 클러스터를 열어 실제 인증서를 꺼내 보고 시간도 재 봤습니다.

이 글은 참고용입니다. 이 과정 커리큘럼에 없습니다. 읽지 않아도 수업 진행에는 지장이 없습니다.


제로트러스트가 무엇인가

예전 방식은 경계를 그어 놓고 그 안이면 믿는 것이었습니다. 회사 네트워크 안에 있으면 사내 서버에 그냥 접근할 수 있었습니다. 한 번 들어오면 그 뒤로는 묻지 않았습니다.

문제는 하나만 뚫리면 안쪽 전부가 열린다는 것입니다. 서버 하나가 침해당하면 그 서버는 이미 "안쪽"이라 나머지에 자유롭게 접근합니다. 그리고 요즘은 경계 자체가 흐려졌습니다. 서비스가 여러 클라우드에 흩어져 있고 직원은 밖에서 접속합니다.

제로트러스트는 이 전제를 버립니다. 위치로 믿지 않습니다. 안에서 왔든 밖에서 왔든 매번 누구인지 확인하고, 그 상대가 이 일을 할 권한이 있는지 확인합니다.

예전 방식 제로트러스트
무엇으로 믿나 어디에서 왔는가 누구인가
언제 확인하나 들어올 때 한 번 매 연결마다
하나가 뚫리면 안쪽 전부가 위험 그 하나가 가진 권한만큼만

질문하신 대로 이 방식은 확인이 늘어납니다. 그래서 느려지지 않느냐가 자연스러운 물음입니다.

쿠버네티스는 이미 이렇게 만들어져 있습니다

쿠버네티스 구성 요소끼리도 서로를 위치로 믿지 않습니다. 같은 클러스터 안이라고 통과시키지 않고 각자 신분증을 제시합니다. 그 신분증이 인증서입니다.

#20에서 모든 구성 요소가 API 서버에만 말을 건다고 적었습니다. 그 말을 걸 때마다 자기가 누구인지 증명합니다.

아래는 제 실습용 클러스터에서 실제로 꺼낸 것입니다. VirtualBox에 노드 3대를 올린 바닐라 쿠버네티스입니다. GKE에서는 컨트롤 플레인을 구글이 관리해서 이 부분이 안 보입니다.

인증서는 세 장입니다

먼저 클러스터를 만들 때 뿌리가 되는 인증 기관(CA)이 하나 생깁니다.

subject=CN=kubernetes
issuer=CN=kubernetes

발급자와 대상이 같습니다. 자기가 자기를 보증하는 뿌리라는 뜻입니다. 이 CA가 나머지를 전부 서명합니다.

flowchart TB
  CA["클러스터 CA<br/>CN=kubernetes<br/>클러스터를 만들 때 생깁니다"]
  CA -->|서명| SRV["API 서버 인증서<br/>CN=kube-apiserver<br/>용도: 서버 증명"]
  CA -->|서명| ADM["내 kubeconfig 인증서<br/>CN=kubernetes-admin<br/>용도: 클라이언트 증명"]
  CA -->|서명| KUB["각 노드의 kubelet 인증서<br/>CN=system:node:노드이름<br/>용도: 클라이언트 증명"]

  class CA root
  classDef root fill:#166534,stroke:#4ade80,color:#ffffff
Loading

API 서버 인증서입니다.

subject=CN=kube-apiserver
issuer=CN=kubernetes
X509v3 Extended Key Usage: TLS Web Server Authentication
X509v3 Subject Alternative Name:
    DNS:cp-k8s, DNS:kubernetes, DNS:kubernetes.default,
    DNS:kubernetes.default.svc, DNS:kubernetes.default.svc.cluster.local,
    IP Address:10.96.0.1, IP Address:192.168.2.60

Extended Key Usage가 서버 증명입니다. 이 인증서로는 서버 노릇만 할 수 있고 클라이언트 행세는 못 합니다.

그리고 Subject Alternative Name을 보시기 바랍니다. 이 인증서가 어떤 이름으로 불릴 때 유효한지 적어 둔 목록입니다. 10.96.0.1이 들어 있는데, 이건 어제 다른 분이 물어보신 kubectl get svc의 그 kubernetes Service 주소입니다. 클러스터 안에서 그 주소로 API 서버를 부를 수 있게 미리 인증서에 넣어 둔 것입니다.

제 kubeconfig 안의 클라이언트 인증서입니다.

subject=O=kubeadm:cluster-admins, CN=kubernetes-admin
issuer=CN=kubernetes
X509v3 Extended Key Usage: TLS Web Client Authentication

여기가 재미있는 부분입니다. 쿠버네티스는 이 인증서에서 신원을 그대로 읽습니다. CN이 사용자 이름이 되고 O가 그룹이 됩니다. 별도의 사용자 목록이나 비밀번호 데이터베이스가 없습니다. 인증서 자체가 신분증입니다.

그래서 kubectl을 칠 때 아이디와 비밀번호를 입력한 적이 없는 것입니다. 1회차에 connect-cluster가 만든 kubeconfig 안에 이미 신분증이 들어 있습니다.

대조해 봤습니다. 이 CA가 정말로 두 인증서를 발급했는지 확인한 것입니다.

openssl verify -CAfile ca.crt apiserver.crt   # OK
openssl verify -CAfile ca.crt client.crt      # OK

컨트롤 플레인과 워커 노드는 이렇게 붙습니다

각 노드의 kubelet은 API 서버에 붙을 때마다 이 과정을 거칩니다.

sequenceDiagram
  participant K as kubelet (w1-k8s)
  participant A as API 서버 (cp-k8s)

  K->>A: 연결 요청
  A-->>K: 제 인증서입니다 (CN=kube-apiserver)
  Note over K: 가지고 있는 CA로 검증<br/>이 CA가 서명한 게 맞나<br/>주소가 SAN 목록에 있나
  K->>A: 제 인증서입니다 (CN=system:node:w1-k8s)
  Note over A: 같은 CA로 검증<br/>CN에서 사용자, O에서 그룹을 읽음
  Note over K,A: 여기까지가 연결 하나당 한 번입니다

  K->>A: 내 노드에 배정된 Pod를 알려 주세요 (watch)
  A-->>K: 변화가 생길 때마다 이 연결로 보냅니다
  K->>A: Pod 상태 보고
  K->>A: Pod 상태 보고
Loading

양쪽이 서로를 확인합니다. kubelet도 API 서버가 진짜인지 보고, API 서버도 kubelet이 누구인지 봅니다. 한쪽만 확인하는 것이 아니라 서로 확인합니다.

방향이 반대인 경우도 있습니다. kubectl logskubectl exec를 치면 API 서버가 kubelet에 접속합니다. 그때는 API 서버가 클라이언트가 되고 별도의 클라이언트 인증서를 씁니다.

노드가 처음 들어올 때는 어떻게 하나요. 아직 인증서가 없는데 인증서를 받아야 하는 상황입니다. 이때는 한 번만 쓰는 부트스트랩 토큰으로 API 서버에 붙어서 인증서를 신청합니다. 승인되면 정식 인증서를 받고, 그 뒤로는 토큰을 쓰지 않습니다.

실제 클러스터에 그 권한이 걸려 있습니다.

kubeadm:node-autoapprove-bootstrap                system:bootstrappers:kubeadm:default-node-token
kubeadm:node-autoapprove-certificate-rotation     system:nodes

두 번째 줄이 인증서 갱신 자동 승인입니다. 인증서에는 만료가 있어서 주기적으로 새로 받아야 하는데, 노드가 자기 것을 갱신하는 것은 자동으로 승인해 줍니다.

Pod는 어떻게 인증하나

Pod는 인증서가 아니라 토큰을 씁니다. Pod를 만들면 kubelet이 두 가지를 넣어 줍니다.

serviceAccountToken: {expirationSeconds: 3607, path: token}
configMap: kube-root-ca.crt

kube-root-ca.crt는 위에서 본 그 CA입니다. Pod가 API 서버 인증서를 검증하는 데 씁니다. 클러스터의 모든 네임스페이스에 자동으로 뿌려져 있습니다.

토큰은 만료가 3607초, 약 1시간입니다. 영원히 쓰는 것이 아니라 kubelet이 만료 전에 알아서 새로 받아서 갈아 끼웁니다. 이것도 제로트러스트의 성질입니다. 한 번 받은 것을 무기한 믿지 않습니다.


그래서 느려지지 않나

질문에 대한 답입니다. 실제로 재 봤습니다.

같은 클러스터에 요청을 보내면서 시간을 나눠 측정한 것입니다.

무엇 시간
TCP 연결 약 0.0013초
TLS 핸드셰이크까지 (인증서 교환과 검증 포함) 약 0.007초
응답까지 전체 약 0.009초

그리고 같은 연결로 요청을 이어서 보내면 이렇게 됩니다.

요청 핸드셰이크 시간 전체
첫 번째 0.0076초 0.0095초
두 번째 0초 0.00076초
세 번째 0초 0.00093초
이후 0초 0.0006초에서 0.0009초

핵심이 여기 있습니다. 인증은 요청마다 하는 것이 아니라 연결마다 합니다. 첫 요청에서 7밀리초쯤 쓰고, 그다음부터는 같은 연결을 재사용하니 인증 비용이 0입니다. 전체 시간도 10분의 1로 줄어듭니다.

제 바닐라 클러스터에서 잰 값이라 GKE에서는 네트워크가 달라 숫자가 다릅니다. 다만 구조는 같습니다.

왜 이 구조가 버티나

네 가지 이유가 있습니다.

미리 받아 둔 것을 씁니다. 요청할 때마다 인증 기관에 물어보러 가지 않습니다. 인증서는 이미 파일로 가지고 있고, 상대 인증서가 우리 CA의 서명인지만 계산으로 확인합니다. 네트워크를 한 번도 더 타지 않습니다.

연결을 오래 씁니다. kubelet은 API 서버에 watch 연결을 걸어 두고 몇 시간이고 유지합니다(#19에서 본 그 watch입니다). 그 연결에서 오가는 수천 건의 요청이 핸드셰이크 한 번을 나눠 씁니다.

만료가 있어서 확인을 미룰 수 있습니다. 요청마다 "이 사람 아직 유효한가"를 되묻지 않습니다. 인증서와 토큰에 만료 시각이 적혀 있어서 그때까지는 믿고, 만료가 가까워지면 갱신합니다. 토큰이 1시간짜리인 이유가 이것입니다. 짧으면 안전하고 길면 갱신 부담이 적은데 그 사이 어딘가를 고른 값입니다.

확인 자체가 싸집니다. TLS 핸드셰이크는 오래전부터 최적화되어 왔고 요즘 CPU는 관련 연산을 하드웨어로 처리합니다. 그리고 위에서 봤듯이 그 비용은 연결당 한 번입니다.

정리하면 제로트러스트가 "매 요청마다 인증 서버에 물어본다"는 뜻이 아닙니다. **"위치를 믿지 않고 신원을 확인한다"**는 뜻이고, 그 확인을 싸게 하는 방법이 이미 갖춰져 있습니다.

직접 확인해 보시기 바랍니다

여러분의 GKE 클러스터에서도 일부는 볼 수 있습니다.

# 내 kubeconfig에 무엇이 들어 있나
kubectl config view

# API 서버 주소
kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}'

# 클러스터 안에서 API 서버를 가리키는 Service
kubectl get svc kubernetes
kubectl get endpoints kubernetes

# Pod에 들어가는 CA
kubectl get configmap kube-root-ca.crt -o yaml

GKE는 인증서 대신 gcloud 토큰을 쓰기 때문에 kubectl config view 결과가 위에서 본 것과 다릅니다. 인증 방식이 여러 개 있고 그중 하나를 고르는 것입니다. 어느 쪽이든 "위치가 아니라 신원으로 통과시킨다"는 점은 같습니다.

더 읽을 것

인증 방식이 왜 여러 개인지, 각각 어떻게 동작하는지는 이 시리즈가 잘 정리해 두었습니다. 오늘 본 인증서 방식은 1편입니다.

2편이 Pod가 쓰는 토큰 방식입니다. 오늘 본 serviceAccountToken이 그것입니다.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions