리소스와 특수 파드
- 9.1 리소스 요구 사항과 제한
- 9.2 DaemonSet
- 9.3 정적 파드
스케줄러가 파드를 배치할 때 리소스를 어떻게 따지는지, 그리고 일반적인 스케줄링과 다르게 동작하는 특수한 파드 배포 방식 두 가지 — 모든 노드에 하나씩 올리는 DaemonSet과, API 서버 없이 kubelet이 직접 관리하는 정적 파드 — 를 다뤄.
파드를 스케줄링할 때 스케줄러는 그냥 아무 노드에 올리는 게 아니라, 파드가 요구하는 리소스와 노드에 남아 있는 리소스를 비교해서 적합한 노드를 골라. 어떤 노드에도 충분한 리소스가 없으면 파드는 Pending 상태에 머물고, kubectl describe pod로 이벤트를 보면 "Insufficient cpu" 같은 메시지가 떠.
파드를 만들 때 컨테이너가 필요로 하는 CPU와 메모리를 **resources.requests**로 지정할 수 있어. 이건 "최소한 이만큼은 보장해줘"라는 뜻이야. 스케줄러는 이 숫자를 보고 리소스가 충분한 노드를 찾아서 배치하고, 파드가 노드에 올라가면 그만큼의 리소스는 보장받게 돼.
spec:
containers:
- name: my-app
image: my-app
resources:
requests:
memory: "4Gi"
cpu: 2
CPU 단위를 좀 설명하면, CPU 1은 vCPU 1개에 해당해. AWS에서는 vCPU 1개, GCP나 Azure에서는 코어 1개, 일반 시스템에서는 하이퍼스레드 1개야. 0.1까지 지정할 수 있고, 이걸 **100m(밀리)**으로 표현할 수도 있어. 최소 1m까지 낮출 수 있지만 그보다 더 낮추는 건 안 돼. 노드에 충분한 리소스가 있다면 5개든 10개든 더 높은 CPU를 요청할 수도 있어.
메모리는 Mi(메비바이트), Gi(기비바이트) 접미사를 써. 여기서 주의할 게, G(기가바이트)와 Gi(기비바이트)는 다른 거야. 1G는 1,000MB이고 1Gi는 1,024MiB야. 킬로바이트와 키비바이트, 메가바이트와 메비바이트도 마찬가지로 구분돼.
이제 **리소스 제한(limits)**에 대해 얘기해보자. 기본적으로 컨테이너는 노드에서 사용할 수 있는 리소스에 제한이 없어. 하나의 컨테이너가 노드의 모든 리소스를 먹어버려서 다른 컨테이너나 네이티브 프로세스를 질식시킬 수 있다는 거지. 그래서 제한을 걸 수 있어.
resources:
requests:
memory: "1Gi"
cpu: 1
limits:
memory: "2Gi"
cpu: 2
requests와 limits는 파드가 아니라 파드 내 각 컨테이너별로 설정하는 거야. 여러 컨테이너가 있으면 각각 다른 값을 줄 수 있어.
CPU와 메모리가 제한을 넘으려고 하면 동작이 달라. CPU는 시스템이 스로틀링해서 제한 이상은 절대 못 쓰게 막아. 컨테이너가 죽지는 않아. 근데 메모리는 스로틀링이 안 돼. 컨테이너가 제한보다 더 많은 메모리를 순간적으로 쓸 수 있는데, 지속적으로 초과하면 파드가 OOM(Out of Memory) Kill로 종료돼.
이제 다양한 requests/limits 조합별로 어떤 일이 벌어지는지 보자. CPU 기준으로 설명할게.
requests도 limits도 안 설정하면, 하나의 파드가 노드의 모든 CPU 리소스를 소비해서 다른 파드가 굶을 수 있어. 최악이야.
requests 없이 limits만 설정하면, 쿠버네티스가 자동으로 requests를 limits와 같은 값으로 맞춰. 예를 들어 limits가 3이면 requests도 3이 돼. 각 파드가 3 vCPU를 보장받지만 그 이상은 못 써.
requests와 limits를 둘 다 설정하면, 각 파드가 requests만큼(예: 1 vCPU) 보장받고 limits(예: 3 vCPU)까지 올라갈 수 있지만 그 이상은 안 돼. 합리적으로 보이지만, 만약 파드 1은 CPU가 더 필요하고 파드 2는 안 쓰고 있는 상황이라면 파드 1을 불필요하게 제한하는 셈이야. 파드 2가 안 쓰는 CPU 사이클을 파드 1이 활용할 수 있으면 좋은데 그걸 못 하는 거지.
가장 이상적인 건 requests만 설정하고 limits는 안 거는 거야. 각 파드가 requests만큼(예: 1 vCPU)은 보장받으면서, 여유 리소스가 있으면 마음껏 쓸 수 있거든. 어느 시점에서든 파드 2가 CPU가 필요하면 보장받은 만큼은 확보할 수 있어. 다만 이 경우 모든 파드에 반드시 requests를 설정해야 해. requests가 없는 파드가 있으면 다른 파드에 대한 제한이 없을 때 리소스를 다 빼앗길 수 있거든.
물론 제한을 걸어야 하는 경우도 있어. 예를 들어 이 강의 과정의 실습 환경 자체가 클러스터에서 컨테이너로 호스팅되는 건데, 공개적으로 접근 가능하고 사용자가 원하는 워크로드를 돌릴 수 있으니까, 비트코인 채굴 같은 걸 막으려고 제한을 걸어둔 거야. 이런 경우에는 limits가 필요해.
메모리에서는 주의할 점이 하나 더 있어. CPU와 달리 메모리는 한번 할당하면 스로틀링이 안 되거든. requests만 있고 limits가 없는 상태에서 한 파드가 메모리를 많이 써버리면, 다른 파드가 메모리가 필요할 때 회수가 안 돼. 유일한 방법은 그 파드를 죽여서 메모리를 확보하는 거야.
기본적으로 쿠버네티스에는 CPU나 메모리 requests/limits가 설정되어 있지 않아. 모든 파드에 기본값을 강제하고 싶으면 LimitRange를 사용해. LimitRange는 네임스페이스 수준에서 적용되는 오브젝트로, 컨테이너의 기본 requests, 기본 limits, 최대/최소 값을 정의할 수 있어.
apiVersion: v1
kind: LimitRange
metadata:
name: cpu-resource-constraint
spec:
limits:
- default:
cpu: 500m
defaultRequest:
cpu: 500m
max:
cpu: "1"
min:
cpu: 100m
type: Container
메모리도 마찬가지로 CPU 대신 메모리를 넣고 기본값, 최대, 최소를 지정하면 돼. LimitRange를 만들거나 변경해도 기존 파드에는 영향 없어. 그 이후에 새로 생성되는 파드에만 적용돼.
마지막으로 네임스페이스 전체에서 사용할 수 있는 총 리소스를 제한하고 싶으면 ResourceQuota를 써. ResourceQuota는 네임스페이스 수준 오브젝트로, 해당 네임스페이스의 모든 파드가 합쳐서 쓸 수 있는 requests와 limits의 총량을 정의해.
apiVersion: v1
kind: ResourceQuota
metadata:
name: my-resource-quota
spec:
hard:
requests.cpu: "4"
requests.memory: 4Gi
limits.cpu: "10"
limits.memory: 10Gi
리소스를 어떻게 관리하는지 알았으니, 이제 일반적인 스케줄링과 다른 방식으로 파드를 배포하는 두 가지 패턴을 보자.
**데몬셋(DaemonSet)**은 클러스터의 모든 노드에 파드를 딱 하나씩 자동으로 배포하는 거야. 레플리카셋이랑 비슷한데, 레플리카셋은 지정한 수만큼 파드를 만드는 거고, 데몬셋은 노드 수만큼 자동으로 만들어. 클러스터에 새 노드가 추가되면 거기에 파드 복제본이 자동으로 올라가고, 노드가 제거되면 파드도 자동으로 사라져.
그러면 데몬셋을 어디에 쓰느냐. 가장 대표적인 건 모니터링 에이전트나 로그 수집기야. 클러스터의 모든 노드에서 모니터링하거나 로그를 수집하려면 각 노드마다 에이전트가 하나씩 있어야 하잖아. 데몬셋으로 배포하면 노드가 추가되든 삭제되든 신경 쓸 필요 없어, 데몬셋이 알아서 관리해줘. kube-proxy도 데몬셋의 좋은 사용 사례야. kube-proxy는 클러스터의 모든 노드에 필요한 워커 노드 구성 요소거든. 실제로 kube-proxy 구성 요소가 클러스터에 데몬셋으로 배포될 수 있어. 네트워킹 솔루션인 Calico 같은 것도 클러스터의 각 노드에 에이전트를 배포해야 하는데, 이것도 데몬셋으로 하면 딱이야.
데몬셋 정의 파일은 레플리카셋과 거의 동일해. API 버전은 apps/v1이고, kind만 DaemonSet으로 바꾸면 돼. 템플릿 섹션 아래에 파드 스펙이 들어가고, 셀렉터로 파드를 데몬셋에 연결하는 구조도 같아.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: monitoring-daemon
spec:
selector:
matchLabels:
app: monitoring-agent
template:
metadata:
labels:
app: monitoring-agent
spec:
containers:
- name: monitoring-agent
image: monitoring-agent
셀렉터의 라벨이 파드 템플릿의 라벨과 일치하는지 확인해야 해. 준비가 되면 kubectl create -f 명령으로 생성하고, kubectl get daemonsets로 조회하고, kubectl describe daemonset으로 상세 정보를 볼 수 있어.
데몬셋이 각 노드에 파드를 어떻게 스케줄하느냐 궁금할 수 있는데, 쿠버네티스 v1.12까지는 각 파드의 nodeName 속성을 직접 설정해서 스케줄러를 우회하는 방식이었어. v1.12 이후부터는 기본 스케줄러와 노드 어피니티 규칙을 사용해서 노드에 파드를 스케줄링해.
데몬셋이 모든 노드에 파드를 배포한다면, 아예 API 서버 없이 kubelet이 직접 파드를 관리하는 방법도 있어. 바로 **정적 파드(Static Pod)**야.
정적 파드는 API 서버 없이 kubelet이 직접 관리하는 파드야. 쿠버네티스 컨트롤 플레인 구성 요소들이 바로 이 방식으로 배포되기 때문에, kubeadm으로 설치한 클러스터를 이해하려면 이걸 꼭 알아야 해.
보통 kubelet은 kube API 서버한테 "어떤 파드를 올려라"라는 지시를 받아서 일하잖아. 근데 만약 API 서버도, 스케줄러도, etcd도, 컨트롤러도 없으면 어떡할까? 마스터가 아예 없는 거야. kubelet 혼자 독립 노드로 버텨야 해.
kubelet이 혼자서도 할 수 있는 게 있어. 바로 파드 생성이야. 서버의 특정 디렉터리에 파드 정의 파일을 넣어두면, kubelet이 주기적으로 그 디렉터리를 확인해서 파일을 읽고 파드를 만들어줘. 파드를 만들 뿐 아니라 계속 살아있게 유지해줘서, 애플리케이션이 크래시나면 재시작도 시켜줘. 파일을 수정하면 파드를 재생성하고, 파일을 삭제하면 파드를 자동으로 삭제해. API 서버나 쿠버네티스 클러스터의 다른 구성 요소 개입 없이 kubelet이 자체적으로 생성하는 이 파드들을 **정적 파드(Static Pod)**라고 불러.
이 방식으로는 파드만 만들 수 있어. 레플리카셋이나 디플로이먼트, 서비스 같은 건 안 돼. 이런 건 전체 쿠버네티스 아키텍처의 일부로, 복제 컨트롤러나 디플로이먼트 컨트롤러 같은 다른 컨트롤 플레인 구성 요소가 필요하거든. kubelet은 파드 수준에서만 작동하고 파드만 이해할 수 있어.
지정된 디렉터리는 호스트의 아무 디렉터리나 될 수 있어. 위치를 kubelet에 알려주는 방법은 두 가지야. 하나는 kubelet 서비스 파일에서 --pod-manifest-path 옵션으로 직접 지정하는 거야. 다른 하나는 --config 옵션으로 별도의 구성 파일 경로를 주고, 그 파일 안에서 **staticPodPath**로 디렉터리를 정의하는 거야. kubeadm으로 설정한 클러스터는 이 두 번째 방식을 써. 기존 클러스터를 검사할 때는 kubelet 서비스 파일에서 --pod-manifest-path를 먼저 확인하고, 없으면 --config 옵션을 찾아서 구성 파일을 열어보고, 거기서 staticPodPath를 찾으면 돼.
정적 파드가 만들어지면 kube API 서버가 없으니까 kubectl 명령으로는 볼 수 없어. docker ps 명령으로 확인해야 해.
그런데 노드가 클러스터의 일부라면 어떻게 될까? kubelet은 두 가지 소스에서 동시에 파드 생성 요청을 받을 수 있어. 하나는 정적 파드 폴더의 매니페스트 파일이고, 다른 하나는 kube API 서버를 통한 HTTP API 엔드포인트야. 둘 다 동시에 처리할 수 있어. 그리고 API 서버도 정적 파드를 알고 있어. kubelet이 정적 파드를 만들면 kube API 서버에 **미러 오브젝트(읽기 전용)**를 자동으로 만들거든. kubectl get pods로 볼 수 있지만, kubectl로 편집하거나 삭제하는 건 안 돼. 삭제하려면 노드의 매니페스트 폴더에서 파일을 직접 지워야 해. 파드 이름 끝에 자동으로 노드 이름이 붙어.
정적 파드를 왜 쓰느냐? 정적 파드는 쿠버네티스 컨트롤 플레인에 종속되지 않으니까, 컨트롤 플레인 구성 요소 자체를 파드로 배포하는 데 쓸 수 있어. 마스터 노드에 kubelet을 설치하고, API 서버, 컨트롤러 매니저, 스케줄러, etcd 같은 구성 요소의 파드 정의 파일을 매니페스트 폴더에 넣으면, kubelet이 이걸 전부 파드로 띄워줘. 바이너리 다운로드하거나 서비스 설정할 필요 없고, 크래시가 나도 kubelet이 자동으로 재시작해줘. 이게 바로 kubeadm이 쿠버네티스 클러스터를 설정하는 방식이야. 그래서 kube-system 네임스페이스에서 파드를 나열하면 컨트롤 플레인 구성 요소가 파드로 보이는 거야.
정적 파드와 데몬셋의 차이도 알아두면 좋아. 데몬셋은 kube API 서버를 통해 데몬셋 컨트롤러가 처리하는 반면, 정적 파드는 API 서버나 쿠버네티스 컨트롤 플레인의 간섭 없이 kubelet이 직접 만들어. 정적 파드는 컨트롤 플레인 구성 요소를 배포하는 데 쓰이고, 데몬셋은 모니터링이나 로깅 에이전트를 모든 노드에 배포하는 데 쓰여. 둘 다 kube 스케줄러에 의해 무시된다는 공통점이 있어.
정리
9장 읽고 기억할 거 세 가지:
- requests는 보장, limits는 상한. CPU는 스로틀링되고 메모리는 OOM Kill. 가장 이상적인 조합은 requests만 설정하고 limits는 안 거는 건데, 메모리는 회수가 안 되니까 상황에 따라 limits가 필요할 수 있어
- DaemonSet = 노드마다 하나씩. 모니터링, 로깅, kube-proxy처럼 모든 노드에 필요한 에이전트를 배포할 때 쓰고, 노드 추가/삭제에 자동 대응해
- 정적 파드가 kubeadm의 비밀. API 서버 없이 kubelet이 매니페스트 디렉터리의 파일을 읽어 파드를 직접 관리하는 방식이고, 이게 컨트롤 플레인 구성 요소가 배포되는 원리야