IT/DevOps

aws-node-termination-handler를 활용해서 EKS 워커 노드에 스팟 인스턴스 적용하기

Aaron's papa 2021. 12. 9. 22:08
반응형

AWS 기반의 인프라를 운영하는 곳에서 가장 많은 비용을 차지하는 것들 중 하나가 바로 EC2입니다. EC2 비용 최적화를 위한 작업은 보통 워크로드에 적합한 사양의 인스턴스를 사용하고,  RI를 구매해서 온디멘드로 발생하는 비용을 줄이는 방식으로 진행하게 됩니다.  하지만 그 외에도 스팟 인스턴스를 도입하는 것도 EC2 비용을 줄일 수 있는 좋은 방법입니다. 하지만 스팟 인스턴스는 언제 종료될지 모른다는 단점을 가지고 있기 때문에 도입을 위해서는 자동화된 서비스 제외 환경이 구성되어야 합니다. 이에 대해서는 브런치를 통해서도 한 번 공유 하기도 했습니다. 오늘은 기존 글에서 조금 더 나아가 EKS에 스팟 인스턴스 도입을 하기 위해 고민했던 과정에 대해서 공유하려고 합니다.


EKS 워커 노드에 스팟 인스턴스, 적용할 수 있을까?

 

EKS의 워커 노드에 스팟 인스턴스를 적용한다면 비용을 줄일 수 있습니다. 하지만 앞에서 이야기한 것처럼 EKS 워커 노드에 스팟 인스턴스를 적용하기 위해서는 몇 가지 고려해야 할 사항들이 있습니다. 먼저 스팟 인스턴스가 종료된다는 스팟 인스턴스 인터럽션을 처리할 수 있어야 합니다. 그리고 스팟 인스턴스 인터럽션을 처리할 때 해당 워커 노드에 더 이상 파드들이 스케쥴링되지 않도록 설정해야 하고 동작 중인 파드들을 다른 워커 노드로 옮겨야 합니다.

스팟 인스턴스로 구성된 워커 노드의 종료 과정

이런 일련의 작업을 자동화 해 놓지 않으면 언제 죽을지 모르는 스팟 인스턴스의 특성상 EKS 워커 노드로 활용하기는 어려울 겁니다. 그래서 이 자동화를 직접 구현해야 하나 고민하고 있던 무렵 이미 같은 고민을 한 사람들이 만들어 놓은 오픈 소스를 만나게 됩니다.

바로 aws-node-termination-handler 입니다. (https://github.com/aws/aws-node-termination-handler)

 


aws-node-termination-handler의 구조

 

aws-node-termination-handler는 크게 두 가지 모드로 동작합니다. 바로 IMDS 모드와 Queue 모드 입니다.

aws-node-termination-handler의 두 가지 모드

두 모드의 가장 큰 차이점은 데몬셋으로 동작하느냐 디플로이먼트로 동작하느냐 입니다. IMDS 모드는 데몬셋으로 동작하면서 각 노드에서 동작하고 노드의 메타 데이터를 읽으면서 이 노드가 곧 종료되는지 안되는지를 확인합니다. 그래서 스팟 인스턴스 종료 과정에 대해서는 잘 처리 하지만 위에 표에서 볼 수 있는 것처럼 그 외의 동작에 대해서는 처리할 수 없습니다.

Queue 모드는 디플로이먼트로 동작하면서 SQS로부터 이벤트를 받아서 각각의 이벤트에 반응하는 형태로 동작합니다. 따라서 EKS의 워커 노드를 구성하면서 스팟 인스턴스에 대한 이벤트뿐만 아니라 ASG에서 발생하는 이벤트들에 대한 처리도 가능합니다. 하지만 IMDS 모드와는 다르게 SQS와 EventBridge 같은 AWS의 서비스를 추가로 필요로 합니다. 즉 구성 자체가 IMDS에 비해 조금 더 손이 가고 복잡해집니다.

두 가지 모드 중 어떤 것을 사용하는 게 좋을까 고민하다가 Queue 모드로 구성하기로 했습니다. 처리할 수 있는 영역이 더 넓고 데몬셋으로 구성할 경우 파드 수가 노드 수만큼 생성되므로 불필요하게 리소스를 차지하는 부분이 생기기 때문입니다.

Queue 모드에서의 동작 과정

 

aws-node-termination-handler (이하 NTH) 를 Queue 모드에서 동작시키면 아래와 같이 동작합니다.

NTH 동작 구성도

ASG에 Lifecycle Hook을 설정해서 워커 노드가 종료되는 이벤트에 대해 NTH가 반응하도록 설정합니다. 그리고 스팟 인터럽션 혹은 ASG 리밸런스와 같은 이벤트들은 EventBridge를 통해 SQS로 전달되고 NTH 파드가 SQS를 구독하면서 이벤트가 발생할 때마다 그에 맞는 동작을 하게 됩니다.

NTH는 모든 EKS 워커 노드에 적용되는 게 아니고 특별한 태그를 달고 있는 워커 노드들에 대해서만 동작합니다. 이때 사용하는 태그 키는 바로 key=aws-node-termination-handler/managed 입니다. 이 태그를 가지고 있는 워커 노드들에 대해서만 동작하게 됩니다.

NTH가 관리하는 워커 노드들에 설정되어야 하는 태그 키

구성도를 보면 알 수 있듯이 NTH 파드에는 AWS 서비스들에 대한 접근 권한이 필요합니다. SQS의 메시지를 읽을 수 있어야 하고 앞에서 언급한 것처럼 인스턴스의 태그를 읽을 수 있어야 합니다. 아래 목록은 깃헙에 정리되어 있는 NTH가 필요로 하는 IAM 권한입니다.

{
    "Version": "2012-10-17",
    "Id": "MyQueuePolicy",
    "Statement": [{
        "Effect": "Allow",
        "Principal": {
            "Service": ["events.amazonaws.com", "sqs.amazonaws.com"]
        },
        "Action": "sqs:SendMessage",
        "Resource": [
            "arn:aws:sqs:${AWS_REGION}:${ACCOUNT_ID}:${SQS_QUEUE_NAME}"
        ]
    }]
}

이 권한을 바탕으로 IRSA 롤을 만들어서 NTH의 ServiceAccount로 만들어서 디플로이먼트에 연결해야 합니다.


NTH 전체 매니페스트 파일

 

바로 NTH를 사용해 보고 싶으신 분들을 위해 준비한 매니페스트 파일입니다. 이 매니페스트는 NTH를 Queue 모드로 동작하도록 설정되어 있습니다. 중간에 있는 Queue의 값과 IRSA 값만 수정하면 바로 사용할 수 있습니다.

apiVersion: v1
kind: ServiceAccount
metadata:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::XXXXX:role/irsa-aws-node-termination-handler
  labels:
    app.kubernetes.io/instance: aws-node-termination-handler
    app.kubernetes.io/name: aws-node-termination-handler
    app.kubernetes.io/part-of: aws-node-termination-handler
    app.kubernetes.io/version: 1.14.0
    stage: dev
  name: aws-node-termination-handler
  namespace: kube-system
---
apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
  annotations:
    seccomp.security.alpha.kubernetes.io/allowedProfileNames: '*'
  labels:
    app.kubernetes.io/instance: aws-node-termination-handler
    app.kubernetes.io/name: aws-node-termination-handler
    app.kubernetes.io/part-of: aws-node-termination-handler
    app.kubernetes.io/version: 1.14.0
    stage: dev
  name: aws-node-termination-handler
spec:
  allowPrivilegeEscalation: false
  allowedCapabilities:
  - '*'
  fsGroup:
    rule: RunAsAny
  hostIPC: false
  hostNetwork: true
  hostPID: false
  privileged: false
  readOnlyRootFilesystem: false
  runAsUser:
    rule: RunAsAny
  seLinux:
    rule: RunAsAny
  supplementalGroups:
    rule: RunAsAny
  volumes:
  - '*'
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  labels:
    app.kubernetes.io/instance: aws-node-termination-handler
    app.kubernetes.io/name: aws-node-termination-handler
    app.kubernetes.io/part-of: aws-node-termination-handler
    app.kubernetes.io/version: 1.14.0
    stage: dev
  name: aws-node-termination-handler-psp
  namespace: kube-system
rules:
- apiGroups:
  - policy
  resourceNames:
  - aws-node-termination-handler
  resources:
  - podsecuritypolicies
  verbs:
  - use
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  labels:
    app.kubernetes.io/part-of: aws-node-termination-handler
    stage: dev
  name: aws-node-termination-handler
rules:
- apiGroups:
  - ""
  resources:
  - nodes
  verbs:
  - get
  - list
  - patch
  - update
- apiGroups:
  - ""
  resources:
  - pods
  verbs:
  - list
  - get
- apiGroups:
  - ""
  resources:
  - pods/eviction
  verbs:
  - create
- apiGroups:
  - extensions
  resources:
  - daemonsets
  verbs:
  - get
- apiGroups:
  - apps
  resources:
  - daemonsets
  verbs:
  - get
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  labels:
    app.kubernetes.io/instance: aws-node-termination-handler
    app.kubernetes.io/name: aws-node-termination-handler
    app.kubernetes.io/part-of: aws-node-termination-handler
    app.kubernetes.io/version: 1.14.0
    stage: dev
  name: aws-node-termination-handler-psp
  namespace: kube-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: aws-node-termination-handler-psp
subjects:
- kind: ServiceAccount
  name: aws-node-termination-handler
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  labels:
    app.kubernetes.io/part-of: aws-node-termination-handler
    stage: dev
  name: aws-node-termination-handler
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: aws-node-termination-handler
subjects:
- kind: ServiceAccount
  name: aws-node-termination-handler
  namespace: kube-system
---
apiVersion: v1
data:
  AWS_ENDPOINT: ""
  AWS_REGION: ""
  CHECK_ASG_TAG_BEFORE_DRAINING: "true"
  CORDON_ONLY: "false"
  DELETE_LOCAL_DATA: "true"
  DRY_RUN: "false"
  EMIT_KUBERNETES_EVENTS: "false"
  ENABLE_PROBES_SERVER: "false"
  ENABLE_PROMETHEUS_SERVER: "false"
  ENABLE_REBALANCE_DRAINING: "false"
  ENABLE_REBALANCE_MONITORING: "false"
  ENABLE_SCHEDULED_EVENT_DRAINING: "false"
  ENABLE_SPOT_INTERRUPTION_DRAINING: "false"
  ENABLE_SQS_TERMINATION_DRAINING: "true"
  IGNORE_DAEMON_SETS: "true"
  INSTANCE_METADATA_URL: ""
  JSON_LOGGING: "true"
  KUBERNETES_EVENTS_EXTRA_ANNOTATIONS: ""
  LOG_LEVEL: info
  MANAGED_ASG_TAG: aws-node-termination-handler/managed
  METADATA_TRIES: "3"
  NODE_TERMINATION_GRACE_PERIOD: ""
  POD_TERMINATION_GRACE_PERIOD: ""
  PROBES_SERVER_ENDPOINT: /healthz
  PROBES_SERVER_PORT: "8080"
  PROMETHEUS_SERVER_PORT: "9092"
  QUEUE_URL: https://sqs.ap-northeast-2.amazonaws.com/XXXXX/aws-node-termination-handler-queue
  TAINT_NODE: "false"
  WEBHOOK_HEADERS: ""
  WEBHOOK_PROXY: ""
  WEBHOOK_TEMPLATE: '{"text":"*[NTH][Instance Interruption]*\nEventID: {{ .EventID
    }}\n*Kind: {{ .Kind }}*\n*Instance: {{ .InstanceID }}*\nNode: {{ .NodeName }}\nDescription:
    {{ .Description }}\nStart Time: {{ .StartTime }}"}'
  WEBHOOK_URL: https://hooks.slack.com/services/XXXXX
  WORKERS: "10"
kind: ConfigMap
metadata:
  labels:
    app.kubernetes.io/part-of: aws-node-termination-handler
    stage: dev
  name: aws-node-termination-handler-config-fc8chmh9km
  namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/instance: aws-node-termination-handler
    app.kubernetes.io/name: aws-node-termination-handler
    app.kubernetes.io/part-of: aws-node-termination-handler
    app.kubernetes.io/version: 1.14.0
    stage: dev
  name: aws-node-termination-handler
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/instance: aws-node-termination-handler
      app.kubernetes.io/name: aws-node-termination-handler
      app.kubernetes.io/part-of: aws-node-termination-handler
      kubernetes.io/os: linux
      stage: dev
  template:
    metadata:
      annotations: null
      labels:
        app.kubernetes.io/instance: aws-node-termination-handler
        app.kubernetes.io/name: aws-node-termination-handler
        app.kubernetes.io/part-of: aws-node-termination-handler
        k8s-app: aws-node-termination-handler
        kubernetes.io/os: linux
        stage: dev
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: kubernetes.io/os
                operator: In
                values:
                - linux
              - key: kubernetes.io/arch
                operator: In
                values:
                - amd64
                - arm64
                - arm
      containers:
      - env:
        - name: NODE_NAME
          valueFrom:
            fieldRef:
              fieldPath: spec.nodeName
        - name: POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        envFrom:
        - configMapRef:
            name: aws-node-termination-handler-config-fc8chmh9km
        image: public.ecr.aws/aws-ec2/aws-node-termination-handler:v1.14.0
        imagePullPolicy: IfNotPresent
        name: aws-node-termination-handler
        resources:
          limits:
            cpu: 100m
            memory: 128Mi
          requests:
            cpu: 50m
            memory: 64Mi
        securityContext:
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          runAsGroup: 1000
          runAsNonRoot: true
          runAsUser: 1000
        volumeMounts: null
      dnsPolicy: ""
      hostNetwork: false
      nodeSelector:
        node.kubernetes.io/role: system
      priorityClassName: system-node-critical
      securityContext:
        fsGroup: 1000
      serviceAccountName: aws-node-termination-handler
      tolerations:
      - effect: NoSchedule
        key: node.kubernetes.io/role
        operator: Equal
        value: system

동작 확인 메시지

 

아래 메시지는 NTH가 동작했을 때 전송하는 슬랙 메시지입니다. NTH가 어떤 이벤트에 반응했고 대상 인스턴스가 어떤 인스턴스인지를 메시지를 통해서 확인할 수 있습니다.

NTH 모니터링 메세지 화면

NTH의 동작을 테스트 하기 가장 좋은 방법은 ASG의 인스턴스 리프레시를 통해서 노드를 하나씩 교체해 보는 것입니다. 그러면서 실제 파드들의 이동이 잘되는지 살펴보는 게 가장 좋습니다.

이렇게 잘 구축해서 사용했습니다로 끝났으면 좋았겠지만, 기본 설정대로 운영하다 보면 한 가지 이슈가 발생합니다. 바로 갑작스러운 노드들의 서비스 제외 현상입니다.

노드들의 서비스 제외 현상

 

서비스 제외 현상이라고 적었지만 실제로는 cordon과 drain에 의해 노드가 스케줄링 불가 상태가 되는 현상입니다. 아래와 같이 특별한 이유 없이 갑작스럽게 노드들이 SchedulingDisabled 상태가 되었습니다.

❯ kubectl get nodes                                                                                                     
NAME                                             STATUS                     ROLES    AGE     VERSION
ip-10-XX-X-XX.ap-northeast-2.compute.internal    Ready,SchedulingDisabled   <none>   145m    v1.21.2-13+d2965f0db10712
ip-10-XX-X-X.ap-northeast-2.compute.internal     Ready                      <none>   54d     v1.21.2-13+d2965f0db10712
ip-10-XX-X-XX.ap-northeast-2.compute.internal    Ready,SchedulingDisabled   <none>   6h29m   v1.21.2-13+d2965f0db10712
ip-10-XX-X-XX.ap-northeast-2.compute.internal    Ready                      <none>   54d     v1.21.2-13+d2965f0db10712

왜 이런 현상이 발생한 걸까요? 해답은 바로 EC2 Instance Rebalance Recommendation 이벤트 때문입니다. 공식 문서에 보면 이 이벤트는 아래와 같이 정의되어 있습니다.

An EC2 Instance rebalance recommendation is a signal that notifies you when a Spot Instance is at elevated risk of interruption. The signal can arrive sooner than the two-minute Spot Instance interruption notice, giving you the opportunity to proactively manage the Spot Instance. You can decide to rebalance your workload to new or existing Spot Instances that are not at an elevated risk of interruption.

출처 : https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/rebalance-recommendations.html

설명을 통해 알 수 있듯이 EC2 Instance Rebalance Recommendation 이벤트는 스팟 인스턴스가 종료될 가능성이 높을 경우 미리 교체 할 수 있도록 알려주는 이벤트 입니다. 원래 스팟 인스턴스는 종료되기 2분 전에 Interruption 메시지를 보내게 되는데 2분 안에 조치하기가 힘들 수도 있으니 그전에 종료될 가능성이 높아질 경우 EC2 Instance Rebalance Recommendation 이벤트를 먼저 보내게 됩니다. 그리고 ASG의 기능 중 Capacity rebalance 기능이 활성화되어 있다면 이 이벤트를 받고 스팟 인스턴스를 바로 교체합니다. 하지만 안타깝게도 Capacity rebalance 기능은 기본이 비활성화입니다.

Capacity rebalance 기능 비활성화

NTHEC2 Instance Rebalance Recommendation 이벤트에 반응해서 대상 노드들을 서비스에서 제외시켰지만 정작 ASG는 Capacity rebalance 기능이 비활성화되어 있어서 실제로 종료되기 전까지 그냥 살려 둔 겁니다. 그래서 SchedulingDisabled 상태의 노드들이 발생하게 되었습니다.

이 문제를 해결하기 위해서는 ASG의 Capacity rebalance 기능을 활성화하거나 NTH가 대응해야 할 이벤트 중 EC2 Instance Rebalance Recommendation 이벤트에 대해서는 반응하지 않도록 EventBridge 에서 제외하는 겁니다. 보통은 Rebalance Recommendation 이벤트를 받아도 스팟 인스턴스가 금방 교체되진 않아서 저희는 EventBridge에서 제외하는 방법을 선택 했습니다. 만약 ASG의 Capacity rebalance 기능을 활성화 시킨다면 인스턴스의 교체가 너무 자주 일어날 것 같기도 해서 죽을 것 같은 때 교체하기 보다는 그냥 종료되면 자연스럽게 교체되는 방식으로 운영 하기로 했습니다.


마치며

 

스팟 인스턴스를 도입하는 건 비용을 줄일 수 있는 좋은 방법 중에 하나 입니다. 하지만 스팟 인스턴스의 종료 처리에 대해 자동화 되어 있지 않다면 득보다는 실이 많을 수 있습니다. EKS 워커 노드의 경우 종료되기 전에 파드들을 잘 옮겨 놓기만 하면 되기 때문에 NTH를 도입한다면 스팟 인스턴스를 도입하는 것도 EC2 비용을 줄이기 위한 좋은 선택지 중에 하나가 될 수 있습니다. 특히 NTH를 Queue 모드로 동작 시키면 스팟 인스턴스 종료 뿐 아니라 ASG 상에서 발생하는 노드들의 변동에 대해서도 적절하게 대응할 수 있기 때문에 가능하다면 Queue 모드로 동작 시키시길 권고 합니다. 이 글이 스팟 인스턴스 도입에 대해 고민하고 계신 분들에게 도움이 되었으면 좋겠습니다. 긴 글 읽어 주셔서 감사합니다.

반응형