亚马逊AWS官方博客

Kubernetes 节点弹性伸缩开源组件 Karpenter 实践:部署GPU推理应用

1. 背景

Karpenter 是 AWS 2021 re:Invent 大会上正式发布的针对Kubernetes集群节点弹性伸缩的开源组件。在Kubernetes集群中该组件可以针对 Unscheduleable Pods 的需求,自动创建合适的新节点并加入集群中。一直以来我们在Kubernetes集群中会使用 Cluster AutoScaler (CA)组件来进行节点的弹性伸缩,通过对Node Group(节点组,在AWS上的实现即为EC2 Auto Scaling Group)的大小进行动态调整从而来实现节点的弹性伸缩。相比而言,Karpenter彻底抛弃了节点组的概念,利用EC2 Fleet API直接对节点进行管理,从而可以更为灵活地选择合适的EC2机型、可用区和购买选项(如On Demand或SPOT)等。同时,在大规模集群中,Karpenter在节点伸缩的效率上也会更加优化。

Karpenter目前已经是生产可用了,有不少用户开始利用Karpenter在AWS的EKS集群上进行节点管理。在这个博客中我们会以一个GPU推理的场景为示例,详细阐述Karpenter的工作原理、配置过程以及测试效果。

2. 架构描述

在这个博客里我们会使用EKS构建如下的一个Kubernets集群:

可以看到在Kubernetes集群中,我们会先创建一个节点组部署管理组件,部署包括 CoreDNS , AWS Load Balancer Controller和Karpenter等管理组件。一般来说这些管理组件所需要的资源比较固定,我们可以提前预估好相关组件所需要的资源,并考虑跨可用区、高可用等因素后,确定该节点组的实例类型和数量。

接下来我们会使用Karpenter来管理推理服务所需要的EC2实例。Karpenter的主要任务为Unsheduable Pod自动创建合适的Node,将Pod调度到这些新创建的Node上面,并在这些Node空闲的时候将其销毁以节省资源。在创建Node时,Karpenter会自动根据Pod的资源需求(如Pod Resource Request设置)、亲和性设置(如Node Affinity等)计算出符合要求的节点类型和数量,因此无须提前进行节点组的资源类型规划。在这个示例集群中我们不需要为GPU实例创建单独的节点组和配置Cluster Autoscaler来进行伸缩,这个工作会由Karpenter来自动完成。

最后我们会部署一个推理服务(resnet server),这个服务由多个Pod组成,每个Pod都可以独立进行工作,对外通过Service暴露到EKS集群外。通过一个Python客户端我们可以提交一张图片到resnet server并得到推理结果。后续生产部署的时候,可以配置前端负载的情况,结合HPA对Pod的数量进行自动的弹性伸缩。Karpenter会根据Pod数量的变化,来自动完成节点的创建和销毁,从而实现节点的弹性伸缩。

同时,考虑到我们会以Load Balancer方式对外暴露service,因此在示例集群中会部署AWS Load Balancer Controller,来自动根据service创建Network Load Balancer 。

3. 部署与测试

接下来我们会按照上述架构进行部署,整体的部署过程如下:

3.1 创建EKS GPU推理集群

接下来使用 eksctl 工具来进行EKS集群的创建。

首先将集群名称、区域、ID等信息配置到环境变量,以便后续命令行操作时使用:

export CLUSTER_NAME="karpenter-cluster"
export AWS_REGION="us-west-2"
export AWS_ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"

接着准备集群的配置文件以供eksctl工具使用:

cat << EOF > cluster.yaml
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
 name: ${CLUSTER_NAME}
 region: ${AWS_REGION}
 version: "1.21"
 tags:
   karpenter.sh/discovery: ${CLUSTER_NAME}
   
iam:
 withOIDC: true

managedNodeGroups:
 - name: ng-1
   privateNetworking: true
   instanceType: m5.large
   desiredCapacity: 3
EOF

通过以上配置文件创建 EKS 集群:

eksctl create cluster -f cluster.yaml

eksctl会按依次创建集群和托管节点组,在managedNodeGroups部分设置了使用m5机型配置并建立托管节点组,作为Karpenter、AWS Load Balancer Controller等功能的部署节点,以便与GPU任务区分开。

接着配置Endpoint环境变量以供后续安装 Karpenter 使用

export CLUSTER_ENDPOINT="$(aws eks describe-cluster --name ${CLUSTER_NAME} --query "cluster.endpoint" --output text)"

查看节点状态是否都已经处于 ready 状态:

3.2 安装 Karpenter

博客写作时Karpenter版本为0.6.3,这里简要对该版本的安装过程进行整理,读者可以根据需要在Karpenter官网查阅最新版本的安装部署指南:

首先需要创建 Instance Profile ,配置相应的权限,以便Karpenter启动的实例可以有足够的权限进行网络配置和进行镜像拉取等动作:

TEMPOUT=$(mktemp)
curl -fsSL https://karpenter.sh/v0.6.3/getting-started/cloudformation.yaml > $TEMPOUT \
&& aws cloudformation deploy \
 --stack-name "Karpenter-${CLUSTER_NAME}" \
 --template-file "${TEMPOUT}" \
 --capabilities CAPABILITY_NAMED_IAM \
 --parameter-overrides "ClusterName=${CLUSTER_NAME}"

接着配置访问权限,以便Karpenter创建的实例可以有权限连接到EKS集群:

eksctl create iamidentitymapping \
 --username system:node:{{EC2PrivateDNSName}} \
 --cluster "${CLUSTER_NAME}" \
 --arn "arn:aws:iam::${AWS_ACCOUNT_ID}:role/KarpenterNodeRole-${CLUSTER_NAME}" \
 --group system:bootstrappers \
 --group system:nodes

接下来为Karpenter Controller 创建 IAM Role和对应的 Service Account :

eksctl utils associate-iam-oidc-provider --cluster ${CLUSTER_NAME} –approve
eksctl create iamserviceaccount \
 --cluster "${CLUSTER_NAME}" --name karpenter --namespace karpenter \
 --role-name "${CLUSTER_NAME}-karpenter" \
 --attach-policy-arn "arn:aws:iam::${AWS_ACCOUNT_ID}:policy/KarpenterControllerPolicy-${CLUSTER_NAME}" \
 --role-only \
 --approve

配置环境变量以供后续安装 Karpenter 使用:

export KARPENTER_IAM_ROLE_ARN="arn:aws:iam::${AWS_ACCOUNT_ID}:role/${CLUSTER_NAME}-karpenter"

最后通过Helm来安装karpenter:

helm repo add karpenter https://charts.karpenter.sh/
helm repo update
helm upgrade --install --namespace karpenter --create-namespace \
 karpenter karpenter/karpenter \
 --version v0.6.3 \
 --set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=${KARPENTER_IAM_ROLE_ARN} \
 --set clusterName=${CLUSTER_NAME} \
 --set clusterEndpoint=${CLUSTER_ENDPOINT} \
 --set aws.defaultInstanceProfile=KarpenterNodeInstanceProfile-${CLUSTER_NAME} \
 --set logLevel=debug \
 --wait

检查Karpenter Controller是否已经正常运行:

3.3 配置 Karpenter Provisoner

当Kubernetes集群中出现 Unscheduleable Pod 的时候,Karpenter通过Provisioner来确定所需要创建的EC2实例规格和大小。Karpenter安装的时候会定义一个名为 Provisioner 的 Custom Resource  。单个 Karpenter Provisioner 可以处理多个Pod ,Karpenter根据Pod属性(如标签、污点等)做出调度和置备决策。换句话说,使用Karpenter就无需管理多个不同的节点组。

接下来创建Provisioner的配置文件:

cat << EOF > provisioner.yaml
apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
 name: gpu
spec:
 requirements:
   - key: karpenter.sh/capacity-type
     operator: In
     values: ["on-demand"] 
   - key: node.kubernetes.io/instance-type
     operator: In
     values: ["g4dn.xlarge", "g4dn.2xlarge"]
 taints:
   - key: nvidia.com/gpu
     effect: "NoSchedule"
 limits:
   resources:
     gpu: 100
 provider:
   subnetSelector:
     karpenter.sh/discovery: karpenter-cluster  
   securityGroupSelector:
     kubernetes.io/cluster/karpenter-cluster: owned  
 ttlSecondsAfterEmpty: 30
EOF

在这个配置文件里,在spec.requirements里我们指定了Provisioner创建的购买选项和实例类型。在这个博客里我们先基于On-Demand 实例进行演示,后续的博客我们会整理Spot实例的实践小结,因此我们在spec.requirements中将 karpenter.sh/capacity-type 中指定为 on-demand 。同时,由于这是一个机器学习的推理应用,我们也将实例类型限定为 G4 , 这里我们选择 g4dn.xlargeg4dn.2xlarge 两种大小。另外我们也设置了 taints nvidia.com/gpu,后续部署的推理应用也需要容忍对应的 taints 才能部署在这个 Provisioner 提供的EC2实例上。也就是说,Karpenter 在看到 Unscheduable Pods 时,会检查这些 Pods 是否能够容忍这个 taint。如果可以,才会对应创建出 GPU 实例。

另外我们也可以设置这个 Provisioner 可以部署出来的资源的上限,在这个演示环境里我们设置 gpu 数量上限为 100 。同时我们也需要通过 subnetSelector 和 securityGroupSelector 定义的Tag来告诉 Povisioner 如何去找到新创建的实例所在的 VPC 子网和安全组。

最后通过 ttlSecondsAfterEmpty 来告诉 Provisioner 如何来清理掉闲置的EC2实例以便节省成本。这里配置了30,也就是说当创建的EC2实例上有30秒没有运行任何Pod时,Provisoner会判定这个实例为闲置资源并直接进行回收,也就是Terminate EC2实例。

关于Provisioner的详细配置可以参考官网上的文档。接着我们就使用这个配置文件创建 Provisioner:

kubectl apply -f provisioner.yaml

3.4 安装 AWS Loadbalancer Controller

在这个演示中我们会利用 AWS Loadbalancer Controller 来自动为 Service 创建对应的 NLB,可以参考AWS官网查看详细的安装步骤,这里简要记录下 2.4.0 版本的安装过程:

curl -o iam_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.4.0/docs/install/iam_policy.json
 
 aws iam create-policy \
 --policy-name AWSLoadBalancerControllerIAMPolicy \
 --policy-document file://iam_policy.json
 
 eksctl create iamserviceaccount \
 --cluster=${CLUSTER_NAME} \
 --namespace=kube-system \
 --name=aws-load-balancer-controller \
 --attach-policy-arn=arn:aws:iam::${AWS_ACCOUNT_ID}:policy/AWSLoadBalancerControllerIAMPolicy \
 --override-existing-serviceaccounts \
 --approve
 
 helm repo add eks https://aws.github.io/eks-charts
 helm repo update
 helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
 -n kube-system \
 --set clusterName=${CLUSTER_NAME} \
 --set serviceAccount.create=false \
 --set serviceAccount.name=aws-load-balancer-controller

3.5 部署机器学习推理应用

接下来我们会部署一个机器学习推理应用做为示例。这个示例来自一个关于在EKS上进行vGPU部署的博客。该示例会部署一个基于 ResNet 的图片推理服务并通过负载均衡对外暴露一个服务地址,并使用一个Python编写的客户端将图片上传给这个推理服务并获得推理结果。在这里我们进行了简化,不讨论vGPU的配置,而是让这个推理服务直接使用G4实例的GPU卡。

这里是修改过后的推理服务的Deployment/Service的部署文件:

cat << EOF > resnet.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
 name: resnet
spec:
 replicas: 1
 selector:
   matchLabels:
     app: resnet-server
 template:
   metadata:
     labels:
       app: resnet-server
   spec:
     # hostIPC is required for MPS communication
     hostIPC: true
     containers:
     - name: resnet-container
       image: seedjeffwan/tensorflow-serving-gpu:resnet
       args:
       - --per_process_gpu_memory_fraction=0.2
       env:
       - name: MODEL_NAME
         value: resnet
       ports:
       - containerPort: 8501
       # Use gpu resource here
       resources:
         requests:
           nvidia.com/gpu: 1
         limits:
           nvidia.com/gpu: 1
       volumeMounts:
       - name: nvidia-mps
         mountPath: /tmp/nvidia-mps
     volumes:
     - name: nvidia-mps
       hostPath:
         path: /tmp/nvidia-mps
     tolerations:
     - key: nvidia.com/gpu
       effect: "NoSchedule"
---
apiVersion: v1
kind: Service
metadata:
 name: resnet-service
 annotations:
   service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
   service.beta.kubernetes.io/aws-load-balancer-type: external
   service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
spec:
 type: LoadBalancer
 selector:
   app: resnet-server
 ports:
 - port: 8501
   targetPort: 8501
EOF

可以看到在Deployment中我们设置了推理服务所需要的GPU资源,以及对污点 nvidia.com/gpu 的容忍,Karpenter Provisioner 会根据这些信息来进行G4实例的创建。另外在Service中我们也定义了相应的 annotation ,以便使用 AWS Load Balancer Controller 来自动创建一个公网可访问的 Network Load Balancer 。

部署完成后,可以检查相应资源是否都运行正常:

检查自动生成的NLB,该地址后续可以供客户端访问:

通过 kubectl exec -it <podname> -- nvidia-smi查看GPU容器状态

接下来我们部署一个客户端,如下是相应的Python代码,保存为 resnet_client.py :

from __future__ import print_function

import base64
import requests
import sys

assert (len(sys.argv) == 2), "Usage: resnet_client.py SERVER_URL"
# The server URL specifies the endpoint of your server running the ResNet
# model with the name "resnet" and using the predict interface.
SERVER_URL = f'http://{sys.argv[1]}:8501/v1/models/resnet:predict'
# The image URL is the location of the image we should send to the server
IMAGE_URL = 'https://tensorflow.org/images/blogs/serving/cat.jpg'

def main():
 # Download the image
 dl_request = requests.get(IMAGE_URL, stream=True)
 dl_request.raise_for_status()

 # Compose a JSON Predict request (send JPEG image in base64).
 jpeg_bytes = base64.b64encode(dl_request.content).decode('utf-8')
 predict_request = '{"instances" : [{"b64": "%s"}]}' % jpeg_bytes

 # Send few requests to warm-up the model.
 for _ in range(3):
   response = requests.post(SERVER_URL, data=predict_request)
   response.raise_for_status()

 # Send few actual requests and report average latency.
 total_time = 0
 num_requests = 10
 for _ in range(num_requests):
   response = requests.post(SERVER_URL, data=predict_request)
   response.raise_for_status()
   total_time += response.elapsed.total_seconds()
   prediction = response.json()['predictions'][0]

 print('Prediction class: {}, avg latency: {} ms'.format(
     prediction['classes'], (total_time*1000)/num_requests))

if __name__ == '__main__':
 main()

这个Python客户端会通过命令行参数获取到服务端的地址,自动下载一张示例图片并上传至服务端进行推理,最后输出推理结果。运行这个客户端以便检查推理结果是否可以正常输出:

python resnet_client.py $(kubectl get svc resnet-service -o=jsonpath='{.status.loadBalancer.ingress[0].hostname}')

可以看到推理结果以及相应的延时:

3.6 Karpenter 节点扩缩容测试

Karpenter Controller会在后台一直循环监控Unscheduleable Pods的信息, 并计算Pod资源需求和相应的Tags/Taints等信息,自动生成符合需求的实例类型和数量。相比传统地在EKS中使用托管节点组的方式,用户不需要提前进行节点组的实例类型规划,而是由Karpenter来根据当时的资源需求自动匹配节点类型和数量,相对来说更加灵活。因此也不需要额外增加 Cluster AutoScaler 组件来进行节点扩缩容。

接下来我们增加Pod数量以便触发节点扩容:

kubectl scale deployment resnet --replicas 6

通过查询 Karpenter Controller 的日志我们可以看到扩容时的具体运行逻辑:

kubectl logs -f -n karpenter -l app.kubernetes.io/name=karpenter -c controller

由于每个Pod需求一块GPU卡,因此新增的5个Pod无法在原来的G4实例上创建而处于Pending状态。这时候Karpenter会汇总这5个Pod的资源需求,并判断使用哪种(或多种)类型的实例更加合适。结合我们前面的Provisioner的配置,Karpenter会自动额外创建5个g4.xlarge的On-Demand实例来运行这5个Pod。针对On-Demand实例,Provisioner会自动选择价格最低的可以满足需求的实例。

在实例创建出来后,Karpenter会立即将Pod绑定到相对应的节点,因此在节点启动后Pod即可开始拉取镜像等动作。传统使用托管节点组+Cluster AutoScaler的方式,需要等到节点处于Ready状态后 Kubernetes Scheduler 才会将 Pod 调度至目标节点并开始创建动作,相比之下 Karpenter 会更加高效。

接着我们删除所有的Pod,观察Karpenter如何处理节点缩容的场景:

kubectl scale deployment resnet --replicas 0

从前面的Provisioner的配置我们可以看到,在节点空闲30s后,Karpenter会自动删除空闲节点。

4. 小结

在这个博客里我们以一个机器学习的推理应用部署为例,介绍了在EKS中如何使用Karpenter来进行节点的弹性扩缩容管理。相比在EKS中使用托管节点组和Cluster AutoScaler 的方式,Karpenter 在处理节点扩缩时会更加灵活高效。Karpenter 是一个完全开源的组件,目前支持在AWS上对EKS和用户自建的Kubernetes集群进行节点扩缩容管理,通过其开放的Cloud Provider插件机制,后续可以实现对非AWS云的支持。

在后续的博客我们会进一步探讨如何利用Karpenter来对Spot实例进行管理,敬请关注!

本篇作者

邱萌

亚马逊云科技解决方案架构师,负责基于亚马逊云科技方案架构的咨询和设计,推广亚马逊云科技平台技术和解决方案。在加入亚马逊云科技之前,曾在企业上云、互联网娱乐、媒体等行业耕耘多年,对公有云业务和架构有很深的理解。

林俊

AWS解决方案架构师,主要负责企业客户的解决方案咨询与架构设计优化