Karpenter 작동 구조 (1)

Yany Choi
11 min readNov 9, 2024

Karpenter는 최근 K8s 진영에서 많이 사용하고 있는 Cluster Autoscaling 툴입니다. 얼마전에 1.0 버전 출시로 개선된 부분이 많이 존재하고, 구조의 안정성도 어느정도 인정받은 것 같습니다. 이번 글에서는 Karpenter가 Operator 패턴을 어떻게 활용하고, 각 컴포넌트가 어떻게 작동하는지에 대해 설명하려 합니다.

Operator, Reconcile

Operator pattern은 단순하게 이야기해서 K8s에 직접 커스텀한 리소스와 이 리소스의 상태를 K8s 생명주기에 맞게 관리해주는 Controller를 구현하는 방식입니다.

결국 K8s는 Control Loop을 돌려서 정의된 리소스 상태를 계속 유지할 수 있도록 Controller들이 계속 “교정”해주는 과정을 거치는데, 이 과정을 Reconcile이라고 합니다. 단어 의미 자체도 의도한 바와 일치시킨다는 의미가 있어서, 그대로 직역한 의미라고 보면 됩니다.

출처: Kubernetes Operators 101, Part 2: How operators work
Karpenter Controller가 K8s Node와 EC2 Instance의 생애주기를 관리하고 있습니다.

NodeClass, NodePool

우선 어떤 종류의 노드를 사용할지 NodeClass로 정의할 수 있습니다. 특정 AMI, 커스텀한 cloud-init userdata, kubelet 설정 변경 등을 삽입할 수 있습니다.

NodePool은 여기서 특정 NodeClass으로 이루어진 노드 그룹입니다. 여기서는 노드 크기, 개수, 유지시간 등을 조절해서 관리할 수 있습니다.

유의할 점이 있다면 NodePool와 NodeClass은 별도의 레포에서 관리되고 있습니다. NodePool을 비롯해 Karpenter 자체에 종속된 컴포넌트들은 https://github.com/kubernetes-sigs/karpenter에서 관리되는 반면, NodeClass같이 특정 Cloud Provider의 특징을 관리하는 경우 Provider 별로 분리해서 관리하고 있습니다. 현재 지원하는 Provider는 AWS, Azure만 있습니다.

# This example NodePool will provision instances using a custom EKS-Optimized AMI that belongs to the
# AL2 AMIFamily. If your AMIs are built off https://github.com/awslabs/amazon-eks-ami and can be bootstrapped
# by Karpenter, this may be a good fit for you.
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
annotations:
kubernetes.io/description: "General purpose NodePool for generic workloads"
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: kubernetes.io/os
operator: In
values: ["linux"]
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["2"]
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: al2
---
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
name: al2
annotations:
kubernetes.io/description: "EC2NodeClass for running Amazon Linux 2 nodes with a custom AMI"
spec:
amiFamily: AL2 # Amazon Linux 2
role: "KarpenterNodeRole-${CLUSTER_NAME}" # replace with your cluster name
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "${CLUSTER_NAME}" # replace with your cluster name
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "${CLUSTER_NAME}" # replace with your cluster name
amiSelectorTerms:
- id: ami-123
- id: ami-456
userData: |
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="EXAMPLEBOUNDARY"

--EXAMPLEBOUNDARY
Content-Type: text/x-shellscript; charset="us-ascii"

#!/bin/bash
echo "Running a custom user data script"

--EXAMPLEBOUNDARY--

NodeClaim

CA의 핵심인 Provisioning, Disruption을 위해서 Karpenter는 노드를 NodeClaim이라는 새로운 리소스로 관리합니다.

Provisioning을 위해서 스케줄링할 수 있는 노드가 없는 Pending 상태의 파드를 확인하고, 해당 파드들을 NodeClaim 생성을 통해 충족할 수 있는지 체크하고 생성 단계로 넘어갑니다.
여기서 NodePool의 규칙을 지키면서 노드를 생성해야 하면 NodeClaim을 생성하면서 NodeClaim과 실제 노드가 1:1로 매핑되면서 Provisioning이 실행됩니다.

NodeClaim이 생성되는대로 Controller가 해당 Claim을 Cloud Provider API콜로 변경해서 원하는 Instance를 생성합니다. 여기서 해당 요청이 실패하면 NodeClaim을 삭제하고 재생성하기도 합니다.

// github.com/kubernetes-sigs/karpenter/blob/main/pkg/controllers/nodeclaim/lifecycle/launch.go#L72
func (l *Launch) launchNodeClaim(ctx context.Context, nodeClaim *v1.NodeClaim) (*v1.NodeClaim, error) {
created, err := l.cloudProvider.Create(ctx, nodeClaim)
...
return created, nil
}
// github.com/aws/karpenter-provider-aws/blob/main/pkg/cloudprovider/cloudprovider.go#L81
// Create a NodeClaim given the constraints.
// nolint: gocyclo
func (c *CloudProvider) Create(ctx context.Context, nodeClaim *karpv1.NodeClaim) (*karpv1.NodeClaim, error) {
nodeClass, err := c.resolveNodeClassFromNodeClaim(ctx, nodeClaim)
...
// *** 노드 생성 ***
instance, err := c.instanceProvider.Create(ctx, nodeClass, nodeClaim, instanceTypes)
...
nc := c.instanceToNodeClaim(instance, instanceType, nodeClass)
nc.Annotations = lo.Assign(nc.Annotations, map[string]string{
v1.AnnotationEC2NodeClassHash: nodeClass.Hash(),
v1.AnnotationEC2NodeClassHashVersion: v1.EC2NodeClassHashVersion,
})
return nc, nil
}
// github.com/aws/karpenter-provider-aws/blob/main/pkg/providers/instance/instance.go#L100
func (p *DefaultProvider) Create(ctx context.Context, nodeClass *v1.EC2NodeClass, nodeClaim *karpv1.NodeClaim, instanceTypes []*cloudprovider.InstanceType) (*Instance, error) {
...
fleetInstance, err := p.launchInstance(ctx, nodeClass, nodeClaim, instanceTypes, tags)
...
return NewInstanceFromFleet(fleetInstance, tags, efaEnabled), nil
}
// github.com/aws/karpenter-provider-aws/blob/main/pkg/providers/instance/instance.go#L213
func (p *DefaultProvider) launchInstance(ctx context.Context, nodeClass *v1.EC2NodeClass, nodeClaim *karpv1.NodeClaim, instanceTypes []*cloudprovider.InstanceType, tags map[string]string) (ec2types.CreateFleetInstance, error) {
...
// Create fleet
createFleetInput := &ec2.CreateFleetInput{
Type: ec2types.FleetTypeInstant,
Context: nodeClass.Spec.Context,
LaunchTemplateConfigs: launchTemplateConfigs,
TargetCapacitySpecification: &ec2types.TargetCapacitySpecificationRequest{
DefaultTargetCapacityType: ec2types.DefaultTargetCapacityType(capacityType),
TotalTargetCapacity: aws.Int32(1),
},
TagSpecifications: []ec2types.TagSpecification{
{ResourceType: ec2types.ResourceTypeInstance, Tags: utils.MergeTags(tags)},
{ResourceType: ec2types.ResourceTypeVolume, Tags: utils.MergeTags(tags)},
{ResourceType: ec2types.ResourceTypeFleet, Tags: utils.MergeTags(tags)},
},
}
...
return createFleetOutput.Instances[0], nil
}

여기서도 NodeClaim에서 NodeClass를 파악해야 하고, Provider를 통해 실제 Instance를 생성해야 하기 때문에 Provisioning 파트의 대부분의 실제 로직과 API 연결은 각 Provider 소스코드에 포함되어 있는 부분을 볼 수 있습니다.

다음 글에서는 실제 Provisioning 발동 로직에 대해 설명하겠습니다.

Sign up to discover human stories that deepen your understanding of the world.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

No responses yet

Write a response