스케줄링 기초
- 8.1 수동 스케줄링
- 8.2 라벨과 셀렉터
- 8.3 테인트와 톨러레이션
- 8.4 노드 셀렉터
- 8.5 노드 어피니티
- 8.6 테인트/톨러레이션 vs 노드 어피니티
파드가 어떤 노드에 배치되는지는 쿠버네티스 스케줄러가 결정해. 근데 스케줄러 없이 직접 배치하거나, 라벨로 오브젝트를 연결하거나, 테인트로 노드 접근을 제한하거나, 어피니티로 파드를 특정 노드에 유도하는 방법까지, 스케줄링의 기초를 한꺼번에 다뤄.
스케줄러가 하는 일의 본질은 되게 단순해. 모든 파드를 쭉 훑어보면서 nodeName 필드가 비어 있는 파드를 찾는 거야. 그게 "아직 스케줄링 안 된 파드"거든. 찾으면 스케줄링 알고리즘을 돌려서 적합한 노드를 골라내고, 바인딩 오브젝트를 만들어서 그 파드의 nodeName을 해당 노드 이름으로 채워넣어. 끝이야. 이게 스케줄러의 전부야.
그런데 만약 클러스터에 스케줄러가 아예 없으면? 파드를 만들어도 nodeName이 안 채워지니까 계속 Pending 상태로 머물러. 아무도 배치를 안 해주는 거지.
이때 할 수 있는 가장 간단한 방법은 파드를 처음 만들 때 직접 nodeName 필드를 지정하는 거야.
apiVersion: v1
kind: Pod
metadata:
name: my-pod
spec:
nodeName: node01
containers:
- name: nginx
image: nginx
이렇게 하면 스케줄러 없이도 파드가 지정한 노드에 배치돼. 다만 중요한 건, nodeName은 파드를 생성할 때만 지정할 수 있다는 거야. 이미 만들어진 파드의 nodeName을 나중에 바꾸는 건 쿠버네티스가 허용하지 않아.
그러면 이미 만들어진 파드를 특정 노드에 배치하고 싶을 때는 어떻게 해야 할까? 바인딩 오브젝트를 직접 만들어서 파드의 바인딩 API에 POST 요청을 보내는 거야. 스케줄러가 내부적으로 하는 걸 그대로 흉내내는 거지. 바인딩 오브젝트에 타겟 노드 이름을 넣고, 이걸 JSON 형식으로 변환해서 POST로 쏘면 돼. 구체적으로는 /api/v1/namespaces/default/pods/<pod-name>/binding 엔드포인트에 바인딩 오브젝트의 JSON 데이터를 보내는 거야. 실제 스케줄러가 바인딩 오브젝트로 하는 작업을 그대로 모방하는 셈이지.
이제 스케줄링 자체는 이해했으니까, 오브젝트끼리 어떻게 연결되는지를 알아보자. 라벨은 오브젝트에 붙이는 키-값 쌍 태그고, 셀렉터는 그 태그로 오브젝트를 골라내는 필터야. 이 조합이 쿠버네티스에서 오브젝트끼리 연결하고 그룹화하는 핵심 메커니즘이거든.
쿠버네티스 클러스터에 파드, 서비스, 레플리카셋, 디플로이먼트 같은 오브젝트가 수백, 수천 개 쌓이면 이걸 분류하고 필터링할 방법이 필요해. 그래서 각 오브젝트에 라벨을 붙이고, 셀렉터로 원하는 조건에 맞는 것만 골라내는 거야. 유튜브 영상에 태그 달아서 검색되게 하거나, 온라인 쇼핑몰에서 필터로 상품 분류하는 것과 같은 원리지.
라벨은 파드 정의 파일의 metadata 아래 labels 섹션에 넣어.
metadata:
name: my-app
labels:
app: app1
function: front-end
원하는 만큼 라벨을 추가할 수 있어. 그리고 이렇게 라벨이 붙은 파드를 CLI에서 필터링하려면 --selector 옵션을 쓰면 돼.
kubectl get pods --selector app=app1
근데 라벨과 셀렉터가 진짜 빛을 발하는 건 쿠버네티스 오브젝트끼리 내부적으로 연결할 때야. 레플리카셋을 예로 들어보자. 레플리카셋이 관리할 파드를 어떻게 찾느냐 하면 셀렉터로 찾거든. 레플리카셋 정의 파일에는 라벨이 두 군데 나와. 하나는 template 섹션 아래에 있는 파드의 라벨이고, 다른 하나는 맨 위에 있는 레플리카셋 자체의 라벨이야. 초보자들이 자주 헷갈리는 부분인데, 레플리카셋이 파드를 찾으려면 spec.selector.matchLabels에 있는 값이 template.metadata.labels에 있는 파드 라벨과 일치해야 해. 레플리카셋 자체의 상단 라벨은 또 다른 상위 오브젝트(예를 들어 서비스)가 레플리카셋을 찾을 때 쓰이는 거야. 파드를 연결하는 데는 안 쓰여.
하나의 라벨로 매칭이 가능하지만, 라벨은 같은데 기능이 다른 파드가 있을 수도 있잖아. 그런 경우에는 두 개 이상의 라벨을 지정해서 정확히 원하는 파드만 고를 수 있어.
서비스도 마찬가지야. 서비스 정의 파일의 selector에 파드의 라벨을 지정하면, 서비스가 그 라벨과 일치하는 파드들을 자동으로 연결해줘.
마지막으로 **어노테이션(annotations)**이라는 것도 있어. 라벨과 셀렉터가 오브젝트를 그룹화하고 선택하는 데 쓰인다면, 어노테이션은 참고용 정보를 기록하는 용도야. 빌드 버전, 담당자 이름, 연락처 전화번호, 이메일 같은 걸 메모해두는 거지. 통합 목적으로도 쓸 수 있고. 선택이나 필터링에는 쓰이지 않아.
그런데 라벨과 셀렉터가 오브젝트를 "연결"하는 거였다면, 이번에는 노드에 대한 "접근 제한"을 거는 방법이야. 테인트와 톨러레이션의 핵심은 이거야. 테인트는 노드에 설정하고, 톨러레이션은 파드에 설정해서, 특정 파드만 특정 노드에 올라갈 수 있게 제한하는 메커니즘이야. 보안이나 침입과는 전혀 관계없고, 순수하게 스케줄링 제한 용도야.
벌레 비유로 생각하면 쉬워. 사람(노드)한테 방충제(테인트)를 뿌리면 벌레(파드)가 못 달라붙잖아. 근데 그 방충제에 내성이 있는 벌레(톨러레이션이 있는 파드)는 달라붙을 수 있는 거지.
워커 노드 3개짜리 클러스터가 있다고 해보자. 노드 1에는 특정 애플리케이션 전용 리소스가 있어서, 그 앱의 파드만 노드 1에 올리고 싶어. 먼저 노드 1에 테인트를 걸어서 모든 파드의 진입을 막아.
kubectl taint nodes node1 app=blue:NoSchedule
app=blue가 키-값 쌍이고, **NoSchedule**이 테인트 효과야. 기본적으로 파드에는 톨러레이션이 없으니까 이제 아무 파드도 노드 1에 못 가. 그 다음, 노드 1에 올라가야 하는 파드(D라고 하자)에만 톨러레이션을 추가해.
spec:
tolerations:
- key: "app"
operator: "Equal"
value: "blue"
effect: "NoSchedule"
이 값들은 전부 큰따옴표로 감싸야 한다는 거 잊지 마.
이제 스케줄러가 파드 A를 노드 1에 올리려고 하면 테인트에 막혀서 다른 노드로 가고, 파드 B도 마찬가지고, 파드 C도 마찬가지야. 파드 D만 톨러레이션이 있으니까 노드 1에 배치될 수 있어.
테인트 효과(effect)는 세 가지가 있어. **NoSchedule**은 톨러레이션 없는 파드를 아예 스케줄링하지 않는 거야. 지금까지 얘기한 게 이거지. **PreferNoSchedule**은 가급적 안 올리려고 하지만 보장은 안 해. 리소스가 빠듯하면 올릴 수도 있다는 거야. **NoExecute**는 가장 강력한데, 새 파드 스케줄링도 막고, 이미 노드에서 실행 중인 파드 중 톨러레이션이 없는 것까지 쫓아내(evict) 버려.
NoExecute를 좀 더 설명하면, 이미 노드 3개에서 파드들이 돌고 있는 상태에서 노드 1에 NoExecute 테인트를 걸면, 톨러레이션이 없는 기존 파드(예를 들어 파드 C)는 바로 퇴출돼. 파드가 죽는 거야. 톨러레이션이 있는 파드 D만 남아서 계속 실행돼.
여기서 정말 중요한 포인트가 하나 있어. 테인트와 톨러레이션은 "이 노드에 아무나 못 오게" 하는 거지, "이 파드가 반드시 이 노드로 가게" 하는 건 아니라는 거야. 파드 D에 톨러레이션이 있어도, 다른 노드에 테인트가 안 걸려 있으면 거기로 가버릴 수 있어. 파드를 특정 노드에 강제로 보내고 싶으면 노드 어피니티(Node Affinity)를 써야 해.
참고로 마스터 노드에도 테인트가 걸려 있어. 쿠버네티스 클러스터를 처음 설정하면 마스터 노드에 자동으로 테인트가 설정돼서, 일반 워크로드 파드가 마스터에 스케줄링되지 않아. kubectl describe node <master-node-name> 실행해서 Taints 섹션을 보면 확인할 수 있어. 마스터에 애플리케이션 워크로드를 배포하지 않는 게 모범 사례야.
여기서 파드를 특정 노드에 "유도"하는 좀 더 직접적인 방법을 알아보자. 노드 셀렉터는 파드를 특정 노드에서만 실행되게 하는 가장 간단한 방법이야.
3개 노드 클러스터가 있는데, 2개는 하드웨어 리소스가 낮은 작은 노드이고 1개는 리소스가 빵빵한 큰 노드라고 해보자. 데이터 처리 같은 무거운 워크로드는 당연히 큰 노드에 올리고 싶잖아. 근데 기본 설정에서는 스케줄러가 아무 노드에나 배치할 수 있어서, 작은 노드에 가버릴 수도 있어.
이걸 해결하려면 파드 정의 파일의 spec 섹션에 **nodeSelector**를 추가하면 돼.
spec:
nodeSelector:
size: Large
containers:
- name: data-processor
image: data-processor
근데 이 size: Large는 어디서 온 거냐면, 미리 노드에 붙여둔 라벨이야. 스케줄러가 이 라벨을 기준으로 매칭해서 파드를 올릴 노드를 찾거든. 그래서 파드를 만들기 전에 먼저 노드에 라벨을 지정해야 해.
kubectl label nodes node01 size=Large
이렇게 노드에 라벨을 붙인 다음 nodeSelector가 있는 파드를 만들면, 스케줄러가 라벨이 일치하는 노드(node01)를 찾아서 거기에 배치해줘.
노드 셀렉터는 간단하고 직관적이지만 한계가 있어. "Large 또는 Medium 노드에 배치해줘"라든가 "Small이 아닌 노드에 배치해줘" 같은 복잡한 조건은 표현할 수 없어. 단일 라벨의 정확한 매칭만 가능하거든. 이런 고급 요구사항을 충족하려면 노드 어피니티를 써야 해.
이제 노드 셀렉터의 한계를 넘어서는 노드 어피니티를 보자. 노드 어피니티는 노드 셀렉터의 업그레이드 버전이야. 파드를 특정 노드에 배치하는 건 같은데, "Large 또는 Medium 노드에 배치해줘", "Small이 아닌 노드에 배치해줘" 같은 훨씬 복잡한 조건을 표현할 수 있거든. 큰 힘에는 큰 복잡성이 따르는 법이라, 문법이 좀 길어.
노드 셀렉터에서 nodeSelector: size: Large 한 줄이면 끝나던 걸, 노드 어피니티로 쓰면 이렇게 돼.
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: size
operator: In
values:
- Large
길어 보이지만 하는 일은 똑같아. "size 라벨 값이 Large인 노드에 배치해줘"라는 뜻이야.
핵심은 **operator**야. **In**을 쓰면 values 리스트 안에 있는 값 중 하나와 일치하는 노드를 찾아. Large뿐 아니라 Medium도 넣고 싶으면 values에 추가하면 돼. **NotIn**을 쓰면 반대야. key: size, operator: NotIn, values: [Small]이라고 하면 size가 Small이 아닌 노드에 배치하라는 뜻이지. Exists 연산자도 있는데, 이건 값을 비교하지 않고 그냥 해당 키의 라벨이 노드에 존재하는지만 확인해. 그래서 values 섹션이 필요 없어. 만약 Large와 Medium 노드에만 size 라벨을 붙여뒀다면, Exists만으로도 Small 노드를 제외할 수 있는 거야.
이제 진짜 중요한 부분, 저 길고 긴 프로퍼티 이름에 대해 이야기해보자. requiredDuringSchedulingIgnoredDuringExecution 이건 두 부분으로 나뉘어. DuringScheduling과 DuringExecution이야.
**DuringScheduling**은 파드가 처음 생성돼서 노드에 배치되는 단계야. 여기서 required냐 preferred냐가 갈려. **required**로 하면 조건에 맞는 노드가 없을 때 파드가 아예 스케줄링이 안 돼. Pending 상태로 남아. 파드 배치가 정말 중요한 경우에 쓰는 거야. **preferred**로 하면 조건에 맞는 노드가 없어도 스케줄러가 "최선을 다해봤는데 없으니까 아무 데나 배치할게"라고 해. 워크로드 실행 자체가 배치보다 더 중요할 때 쓰면 돼.
**DuringExecution**은 파드가 이미 돌고 있는 상태에서 노드의 라벨이 바뀌면 어떻게 할지를 정의해. 예를 들어 관리자가 노드에서 size=Large 라벨을 지워버렸다고 치자. 현재 사용 가능한 두 가지 타입 모두 여기가 Ignored로 설정되어 있어. 즉, 파드가 이미 실행 중이면 라벨이 바뀌어도 아무 영향 없이 그냥 계속 돌아가. 앞으로 RequiredDuringExecution 타입도 계획되어 있는데, 이건 라벨이 바뀌면 실행 중인 파드도 쫓아내는 거야.
정리하면 현재 쓸 수 있는 타입은 두 가지야. **requiredDuringSchedulingIgnoredDuringExecution**은 스케줄링 시 조건 필수이고 실행 중에는 변경 무시. **preferredDuringSchedulingIgnoredDuringExecution**은 스케줄링 시 조건을 선호하되 강제는 아니고, 실행 중에는 마찬가지로 변경 무시야.
여기까지 테인트/톨러레이션과 노드 어피니티를 각각 따로 배웠으니까, 이제 둘을 왜 조합해서 써야 하는지 알아보자.
파란색, 빨간색, 녹색 세 가지 색상의 노드와 파드가 각각 3개씩 있다고 해보자. 다른 팀과 같은 클러스터를 공유하고 있어서 클러스터에는 다른 노드와 다른 파드도 있어. 목표는 파란색 파드는 파란색 노드에, 빨간색은 빨간색에, 녹색은 녹색에 배치하는 거야. 그러면서 다른 팀의 파드가 우리 노드에 오는 것도 막고, 우리 파드가 다른 노드로 가는 것도 막고 싶어.
먼저 테인트와 톨러레이션만으로 시도해보자. 각 노드에 색상별 테인트를 걸고, 각 파드에 해당 색상의 톨러레이션을 설정해. 이러면 노드는 맞는 톨러레이션을 가진 파드만 받아들여. 녹색 파드는 녹색 노드에서, 파란색 파드는 파란색 노드에서 끝나. 근데 문제가 있어. 테인트와 톨러레이션은 "이 노드에 아무나 못 오게" 하는 건데, "이 파드가 반드시 이 노드로 가게" 하는 건 아니거든. 그래서 빨간색 파드가 테인트가 안 걸린 다른 노드에 가버릴 수 있어.
그러면 노드 어피니티만으로 해결해보자. 각 노드에 색상 라벨을 붙이고, 각 파드에 노드 셀렉터를 설정해서 매칭하면 파드들은 올바른 노드로 가. 근데 이번엔 반대 문제가 생겨. 다른 팀의 파드가 우리 노드에 오는 걸 막을 방법이 없어. 노드 어피니티는 "이 파드를 여기로 보내라"는 거지, "다른 파드는 여기 오지 마"가 아니니까. 그래서 다른 팀의 파드가 슬쩍 우리 노드에 배치될 수 있어.
결국 답은 둘 다 쓰는 거야. 테인트와 톨러레이션으로 다른 파드가 우리 노드에 오는 걸 막고, 노드 어피니티로 우리 파드가 반드시 해당 노드로 가게 만들어. 이 두 가지를 조합하면 특정 파드를 특정 노드에 완전히 전용으로 할당할 수 있어.
정리
8장 읽고 기억할 거 세 가지:
- 테인트/톨러레이션과 노드 어피니티는 반쪽짜리. 테인트는 "다른 파드 오지 마", 어피니티는 "이 파드 여기로 가". 둘을 조합해야 특정 파드를 특정 노드에 완전히 전용 할당할 수 있어
- 노드 어피니티의 operator를 기억해.
In은 값 목록 중 매칭,NotIn은 제외,Exists는 키 존재 여부만 확인. 노드 셀렉터로 못 하는 OR/NOT 조건을 이걸로 표현해 - 테인트 효과 세 가지: NoSchedule, PreferNoSchedule, NoExecute. NoExecute만 이미 실행 중인 파드까지 쫓아내. 마스터 노드에도 테인트가 기본으로 걸려 있어서 워크로드가 안 올라가는 거야