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 모드 입니다.
두 모드의 가장 큰 차이점은 데몬셋으로 동작하느냐 디플로이먼트로 동작하느냐 입니다. IMDS 모드는 데몬셋으로 동작하면서 각 노드에서 동작하고 노드의 메타 데이터를 읽으면서 이 노드가 곧 종료되는지 안되는지를 확인합니다. 그래서 스팟 인스턴스 종료 과정에 대해서는 잘 처리 하지만 위에 표에서 볼 수 있는 것처럼 그 외의 동작에 대해서는 처리할 수 없습니다.
Queue 모드는 디플로이먼트로 동작하면서 SQS로부터 이벤트를 받아서 각각의 이벤트에 반응하는 형태로 동작합니다. 따라서 EKS의 워커 노드를 구성하면서 스팟 인스턴스에 대한 이벤트뿐만 아니라 ASG에서 발생하는 이벤트들에 대한 처리도 가능합니다. 하지만 IMDS 모드와는 다르게 SQS와 EventBridge 같은 AWS의 서비스를 추가로 필요로 합니다. 즉 구성 자체가 IMDS에 비해 조금 더 손이 가고 복잡해집니다.
두 가지 모드 중 어떤 것을 사용하는 게 좋을까 고민하다가 Queue 모드로 구성하기로 했습니다. 처리할 수 있는 영역이 더 넓고 데몬셋으로 구성할 경우 파드 수가 노드 수만큼 생성되므로 불필요하게 리소스를 차지하는 부분이 생기기 때문입니다.
Queue 모드에서의 동작 과정
aws-node-termination-handler (이하 NTH) 를 Queue 모드에서 동작시키면 아래와 같이 동작합니다.
ASG에 Lifecycle Hook을 설정해서 워커 노드가 종료되는 이벤트에 대해 NTH가 반응하도록 설정합니다. 그리고 스팟 인터럽션 혹은 ASG 리밸런스와 같은 이벤트들은 EventBridge를 통해 SQS로 전달되고 NTH 파드가 SQS를 구독하면서 이벤트가 발생할 때마다 그에 맞는 동작을 하게 됩니다.
NTH는 모든 EKS 워커 노드에 적용되는 게 아니고 특별한 태그를 달고 있는 워커 노드들에 대해서만 동작합니다. 이때 사용하는 태그 키는 바로 key=aws-node-termination-handler/managed 입니다. 이 태그를 가지고 있는 워커 노드들에 대해서만 동작하게 됩니다.
구성도를 보면 알 수 있듯이 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의 동작을 테스트 하기 가장 좋은 방법은 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 기능은 기본이 비활성화입니다.
NTH는 EC2 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 모드로 동작 시키시길 권고 합니다. 이 글이 스팟 인스턴스 도입에 대해 고민하고 계신 분들에게 도움이 되었으면 좋겠습니다. 긴 글 읽어 주셔서 감사합니다.
'IT > DevOps' 카테고리의 다른 글
VPC Flow logs는 네트워크 문제 분석에 활용할 수 있을까? (1) | 2022.02.10 |
---|---|
docker run 과 docker exec 재현을 통해 컨테이너 이해하기 (1) | 2022.01.25 |
Logstash의 Kafka Input 성능 개선 이야기 (7) | 2021.10.28 |
Connection Timeout과 Read Timeout 살펴보기 (4) | 2021.10.07 |
jib와 Github Actions를 이용한 빌드 자동화 (0) | 2021.08.25 |