Karpenter는 최근 K8s 진영에서 많이 사용하고 있는 Cluster Autoscaling 툴입니다. 얼마전에 1.0 버전 출시로 개선된 부분이 많이 존재하고, 구조의 안정성도 어느정도 인정받은 것 같습니다. 이번 글에서는 Karpenter가 Operator 패턴을 어떻게 활용하고, 각 컴포넌트가 어떻게 작동하는지에 대해 설명하려 합니다.
Operator, Reconcile
Operator pattern은 단순하게 이야기해서 K8s에 직접 커스텀한 리소스와 이 리소스의 상태를 K8s 생명주기에 맞게 관리해주는 Controller를 구현하는 방식입니다.
결국 K8s는 Control Loop을 돌려서 정의된 리소스 상태를 계속 유지할 수 있도록 Controller들이 계속 “교정”해주는 과정을 거치는데, 이 과정을 Reconcile이라고 합니다. 단어 의미 자체도 의도한 바와 일치시킨다는 의미가 있어서, 그대로 직역한 의미라고 보면 됩니다.


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 발동 로직에 대해 설명하겠습니다.