[CloudNeta] EKS 워크샵 스터디 (3) - EKS Scaling Part 1 - 실습 환경 구성과 관리형 노드그룹

이번 게시글에서는 EKS 워크샵 스터디 제 3주차 내용을 작성합니다.

이번 주에는 EKS 스케일링에 대한 내용을 중점으로 다룹니다. 트래픽 증가에 대응하여 Pod를 수평/수직으로 확장하는 방법부터, 노드 자체를 자동으로 늘리고 줄이는 방법까지 전체 스케일링 파이프라인을 실습합니다.

이 글은 3부로 나누어집니다.

  1. 3주차 - EKS Scaling Part 1 - 실습 환경 구성과 관리형 노드그룹 (현재 보고계신 글)
  2. 3주차 - EKS Scaling Part 2 - Pod 스케일링 (HPA, VPA, KEDA, CPA)
  3. 3주차 - EKS Scaling Part 3 - Node 스케일링 (CAS, Karpenter, Fargate)

환경구성 (1) - 작업환경 구성하기

이번 주 코드도 마찬가지로 분석해봅시다.

코드 살펴보기

사전준비

ssm session manager로 붙는 방식을 사용합니다. public IP 배포로는 아무래도 실전으로 운영하는 것과는 거리가 멀죠. SSM session manager 배포로 처리해봅시다.

macOS라면 mise use aqua:aws/session-manager-plugin 으로 간단히 되지만, WSL2 환경이라 직접 deb 패키지로 설치해야 합니다.

실습환경 배포

애드온 확인

tf state list 하면 꽤 많습니다. 역시나 많은 것들이 배포되어있군요!

SSM Session Manager로 접속한 후 배포 확인하기

이번 장부터는 SSM Session Manager로 접속할 수 있습니다.
작업 전 해당 링크를 클릭하시어 실습중인 PC의 환경에 맞는 AWS CLI의 세션 매니저 플러그인을 설치해주시기 바랍니다.

이를 사용하기 위한 커맨드를 정리합니다:

# 먼저, 배포된 인스턴스의 고유 IP를 구합니다
$ aws ssm describe-instance-information \
  --query "InstanceInformationList[*].{InstanceId:InstanceId, Status:PingStatus, OS:PlatformName}" \
  --output text
i-0124b0cc6947a6772     Amazon Linux    Online
i-0f6dd3836bdd7fd84     Amazon Linux    Online

# 이후 각 노드에 아래와 같이 접속합니다
$ aws ssm start-session --target i-0124b0cc6947a6772

Starting session with SessionId: s3ich4n-<REDACTED>
sh-5.2$ sudo su -
[root@ip-192-168-22-157 ~]# ls
...
번외: session manager 접속 지점

번외: session manager 접속 지점은 AWS 공인 IP 대역입니다.

[root@ip-192-168-22-157 ~]#
tcp        0      0 192.168.22.157:44034    43.202.73.3:443         ESTABLISHED 16299/ssm-session-w
tcp        0      0 192.168.22.157:51938    43.202.72.118:443       ESTABLISHED 1979/ssm-agent-work
tcp        0      0 192.168.22.157:43138    43.202.74.64:443        ESTABLISHED 1979/ssm-agent-work
tcp        0    295 192.168.22.157:44046    43.202.73.3:443         ESTABLISHED 16299/ssm-session-w

EKS에서 애드온 설치여부 또한 확인하실 수 있습니다.

$ aws eks list-addons --cluster-name myeks | jq
{
  "addons": [
    "coredns",
    "external-dns",
    "kube-proxy",
    "metrics-server",
    "vpc-cni"
  ]
}

그러면 배포된 EKS addon을 살펴봅시다. external-dns는 helm 설치로 보인 것으로 보입니다.

$ kubectl get sa -n external-dns external-dns -o yaml
apiVersion: v1
automountServiceAccountToken: true
kind: ServiceAccount
metadata:
  creationTimestamp: "2026-04-01T15:05:56Z"
  labels:
    app.kubernetes.io/instance: external-dns
    app.kubernetes.io/managed-by: Helm

# 제 PC에는 helm chart가 없습니다.
$ helm list -A
NAME    NAMESPACE       REVISION        UPDATED STATUS  CHART   APP VERSION

# 하지만 원격지에는 이런 식으로 표기되어있어요.
$ kubectl describe deploy -n external-dns external-dns
...
Labels:             app.kubernetes.io/instance=external-dns
                    app.kubernetes.io/managed-by=Helm
                    app.kubernetes.io/name=external-dns
                    app.kubernetes.io/version=0.19.0
                    helm.sh/chart=external-dns-1.19.0

    Args:
      --log-level=info
      --log-format=text
      --interval=1m
      --source=service
      --source=ingress
      --policy=upsert-only  # 레코드 업데이트 정책 : upsert-only - 생성/수정만 하고 삭제는 수동
      --registry=txt
      --txt-owner-id=myeks
      --provider=aws

AWS LBC 설치해보기

그럼 이어서 AWS LBC(AWS Load Balancer Controller)도 설치해봅시다. 이번 실습에서는 EC2 노드에 부여된 IAM Role(Instance Profile)을 사용합니다. EC2 내부에는 IMDS(Instance Metadata Service)라는 메타데이터 조회 서비스가 있는데, 파드가 이 IMDS를 통해 노드의 IAM 자격증명을 자동으로 획득하여 AWS API(ALB 생성 등)를 호출하는 구조입니다.

단, 이 방식은 해당 노드 위의 모든 파드가 동일한 IAM 권한을 갖게 됩니다. 쉽게 말해 파드 입장에서는 "내가 어떤 AWS 권한이 필요한지"와 무관하게, 자기가 뜬 노드가 가진 권한을 전부 쓸 수 있는 구조입니다.

그런 연유로 프로덕션 환경에서는 Pod 단위로 권한을 분리할 수 있는 IRSA(IAM Roles for Service Accounts)EKS Pod Identity가 권장됩니다.

성공적으로 배포되었고, 파드도 잘 떠있고 로그도 잘 나오네요!

$ helm list -n kube-system

NAME                            NAMESPACE       REVISION        UPDATED                                       STATUS          CHART                                   APP VERSION
aws-load-balancer-controller    kube-system     1               2026-04-02 01:57:20.735619839 +0900 KST       deployed        aws-load-balancer-controller-3.1.0      v3.1.0

$ kubectl get pod -n kube-system -l app.kubernetes.io/name=aws-load-balancer-controller

NAME                                            READY   STATUS    RESTARTS   AGE
aws-load-balancer-controller-7c5488d4c6-8hgfz   1/1     Running   0          2m
aws-load-balancer-controller-7c5488d4c6-pwqgt   1/1     Running   0          2m

$ k logs -n kube-system deployment/aws-load-balancer-controller -f
kubectl logs -n kube-system deployment/aws-load-balancer-controller -f

Found 2 pods, using pod/aws-load-balancer-controller-7c5488d4c6-8hgfz
{"level":"info","ts":"2026-04-01T16:57:28Z","msg":"version","GitVersion":"v3.1.0","GitCommit":"250024dbcc7a428cfd401c949e04de23c167d46e","BuildDate":"2026-02-24T18:21:40+0000"}
{"level":"info","ts":"2026-04-01T16:57:28Z","logger":"setup","msg":"adding health check for controller"}
{"level":"info","ts":"2026-04-01T16:57:28Z","logger":"setup","msg":"adding readiness check for webhook"}
{"level":"info","ts":"2026-04-01T16:57:28Z","logger":"controller-runtime.webhook","msg":"Registering webhook","path":"/mutate-v1-pod"}
{"level":"info","ts":"2026-04-01T16:57:28Z","logger":"controller-runtime.webhook","msg":"Registering webhook","path":"/mutate-v1-pod-server-id"}
...

AWS LBC의 염려사항을 점검해보기

그럼 염려했던 EC2의 IMDS를 확인해봅시다. 별도 IAM 자격증명을 설정하지 않은 파드에서도 노드의 IAM Role로 AWS API 호출이 가능하며, IMDS에서 임시 자격증명(AccessKeyId, SecretAccessKey, Token)을 직접 추출할 수도 있습니다. 이 자격증명은 파드 안뿐만 아니라 외부 어디서든 Expiration 전까지 사용 가능하므로, 파드 하나가 탈취되면 노드 전체의 AWS 권한이 노출되는 셈입니다.

실제로 AWS는 이 위험을 공식적으로 인정하고 있습니다:

aws-cli 컨테이너는 여기를 참고해주세요.

# awscli 파드를 생성합시다. 얘는 단순히 떠있기만 하는게 다에요.
$ cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: awscli-pod
spec:
  replicas: 2
  selector:
    matchLabels:
      app: awscli-pod
  template:
    metadata:
      labels:
        app: awscli-pod
    spec:
      containers:
      - name: awscli-pod
        image: amazon/aws-cli
        command: ["tail"]
        args: ["-f", "/dev/null"]
      terminationGracePeriodSeconds: 0
EOF

# 잘 떴나 봅시다.
 k get pod -owide
NAME                          READY   STATUS    RESTARTS   AGE   IP               NODE                                                NOMINATED NODE   READINESS GATES
awscli-pod-847bcdb4db-b76p2   1/1     Running   0          24s   192.168.15.101   ip-192-168-13-68.ap-northeast-2.compute.internal    <none>           <none>
awscli-pod-847bcdb4db-htkws   1/1     Running   0          24s   192.168.22.91    ip-192-168-22-157.ap-northeast-2.compute.internal   <none>           <none>

# 테스트를 위해 변수로 지정해봅시다.
 APODNAME1=$(kubectl get pod -l app=awscli-pod -o jsonpath="{.items[0].metadata.name}")
 APODNAME2=$(kubectl get pod -l app=awscli-pod -o jsonpath="{.items[1].metadata.name}")
 echo $APODNAME1, $APODNAME2
awscli-pod-847bcdb4db-b76p2, awscli-pod-847bcdb4db-htkws

# ARN 정보는 아래와 같습니다.
 kubectl exec -it $APODNAME1 -- aws sts get-caller-identity --query Arn
"arn:aws:sts::<REDACTED>:assumed-role/myeks-ng-1/i-0f6dd3836bdd7fd84"

 kubectl exec -it $APODNAME2 -- aws sts get-caller-identity --query Arn
"arn:aws:sts::<REDACTED>:assumed-role/myeks-ng-1/i-0124b0cc6947a6772"

# 어라? 이건 원하지 않았던 작업입니다. 왜 인스턴스 정보를 통으로 제공하죠?
$ kubectl exec -it $APODNAME1 -- aws ec2 describe-instances --region ap-northeast-2 --output table --no-cli-pager
------------------------------------------------------------------------------------------------------
|                                          DescribeInstances                                         |
+----------------------------------------------------------------------------------------------------+
||                                           Reservations                                           ||
|+----------------------------------------+---------------------------------------------------------+|
||  OwnerId                               |  <REDACTED>                                             ||
||  RequesterId                           |  <REDACTED>                                             ||
||  ReservationId                         |  <REDACTED>                                             ||
|+----------------------------------------+---------------------------------------------------------+|
|||                                            Instances                                           |||
||+--------------------------------+---------------------------------------------------------------+||
|||  AmiLaunchIndex                |  0                                                            |||
|||  Architecture                  |  x86_64                                                       |||
|||  BootMode                      |  uefi-preferred                                               |||
...

# 이것도 마찬가집니다. 왜 VPC 정보를 통으로 제공하죠?
$ kubectl exec -it $APODNAME2 -- aws ec2 describe-vpcs --region ap-northeast-2 --output table --no-cli-pager
-----------------------------------------------------------------------------------------------------------------------------
|                                                       DescribeVpcs                                                        |
+---------------------------------------------------------------------------------------------------------------------------+
||                                                          Vpcs                                                           ||
|+----------------+----------------+------------------+------------+---------------+-------------+-------------------------+|
||    CidrBlock   | DhcpOptionsId  | InstanceTenancy  | IsDefault  |    OwnerId    |    State    |          VpcId          ||
|+----------------+----------------+------------------+------------+---------------+-------------+-------------------------+|
||  <REDACTED>    |  <REDACTED>    |  default         |  False     |  <REDACTED>   |  available  |  <REDACTED>             ||
|+----------------+----------------+------------------+------------+---------------+-------------+-------------------------+|
|||                                                BlockPublicAccessStates                                                |||
...

그럼 파드로 직접 들어가서 확인해봅시다. IDMSv1는 비활성화 되어있어서 401 Unauthorized가 뜨고, IDMSv2가 활성화 되어있어서 토큰형식의 인증이 됩니다.

다시말해, 어디서든 쓸 수 있는 토큰이 탈취된다는 것입니다.

$ kubectl exec -it $APODNAME1 -- bash
# IDMSv1은 비활성화 되어있습니다.
bash-5.2# curl -s http://169.254.169.254/ -v
*   Trying 169.254.169.254:80...
* Established connection to 169.254.169.254 (169.254.169.254 port 80) from 192.168.15.101 port 53074
* using HTTP/1.x
> GET / HTTP/1.1
> Host: 169.254.169.254
> User-Agent: curl/8.17.0
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 401 Unauthorized
< Content-Length: 0
< Date: Wed, 01 Apr 2026 17:32:24 GMT
< Server: EC2ws
< Connection: close
< Content-Type: text/plain
<
* shutting down connection #0

# 그럼 IDMSv2로 토큰을 얻어봅시다.
bash-5.2# curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" ; echo
<REDACTED>
bash-5.2# TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
bash-5.2# echo $TOKEN
<REDACTED>
bash-5.2# curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" ; echo
<토큰정보가 나옴>
bash-5.2# curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" ; echo
<기존과 다른 토큰정보가 나옴>

# 그럼 본격적으로 IDMSv2를 사용해봅시다.
bash-5.2# TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
bash-5.2# echo $TOKEN
<새 토큰이 또 나옴>
bash-5.2# curl -s -H "X-aws-ec2-metadata-token: $TOKEN" –v http://169.254.169.254/ ; echo
1.0
2007-01-19
2007-03-01
...
2025-10-04
latest
bash-5.2# curl -s -H "X-aws-ec2-metadata-token: $TOKEN" –v http://169.254.169.254/latest/ ; echo
dynamic
meta-data
user-data

# Role 이름도 봅시다.
bash-5.2# curl -s -H "X-aws-ec2-metadata-token: $TOKEN" –v http://169.254.169.254/latest/meta-data/iam/security-credentials/ ; echo
myeks-ng-1

# 위의 Role 이름을 쳐서 뭐가되나 토큰을 뽑아봅니다.
#   이 토큰은 AWS API를 쓸 수 있는 어느곳이든 만료되기 전 까지 사용가능합니다.
bash-5.2# curl -s -H "X-aws-ec2-metadata-token: $TOKEN" –v http://169.254.169.254/latest/meta-data/iam/security-credentials/myeks-ng-1
{
  "Code" : "Success",
  "LastUpdated" : "2026-04-01T16:43:59Z",
  "Type" : "AWS-HMAC",
  "AccessKeyId" : "<REDACTED>",
  "SecretAccessKey" : "<REDACTED>",
  "Token" : "<REDACTED>",
  "Expiration" : "2026-04-01T22:58:26Z"
}

IMDS로 획득한 자격증명으로 사용 가능한 노드 IAM Role의 권한 정책 목록

환경구성 (2) - o11y 도구 설치

3장의 o11y 도구 활용 안내

현재 구성을 토대로 스케일링 관련 모니터링을 지속하여 처리할 예정입니다.

eks-node-viewer

eks-node-viewer를 설치합니다. 이는 노드 할당 가능 용량과 요청 request 리소스 표시해주며, 실제 파드 리소스 사용량을 표현해주진 않습니다.

설치방안은 상기 URL을 참고해주세요. 여기선 사용방법에 집중해봅시다.

아래와 같이 터미널 하나에서 이 정보를 계속 보여주는 방식입니다.

제약사항

여기 나오는 게 실제 파드의 리소스 사용량은 아닙니다. 컨테이너의 resource 합을 반환할 뿐이며, initContainers 또한 포함되어있지 않습니다.

// Requested returns the sum of the resources requested by the pod. This doesn't include any init containers as we
// are interested in the steady state usage of the pod
func (p *Pod) Requested() v1.ResourceList {
	p.mu.RLock()
	defer p.mu.RUnlock()
	requested := v1.ResourceList{}
	for _, c := range p.pod.Spec.Containers {
		for rn, q := range c.Resources.Requests {
			existing := requested[rn]
			existing.Add(q)
			requested[rn] = existing
		}
	}
	requested[v1.ResourcePods] = resource.MustParse("1")
	return requested
}
# 이런식으로 가본정보를 보여주고 extra labels까지 달 수 있습니다.
$ eks-node-viewer --resources cpu,memory --extra-labels eks-node-viewer/node-age
2 nodes (      710m/3860m) 18.4% cpu    ███████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ $0.083/hour | $60.736/month
        572Mi/6742880Ki    8.7%  memory ███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
13 pods (0 pending 13 running 13 bound)

ip-192-168-22-157.ap-northeast-2.compute.internal cpu    ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  18% (6 pods) t3.medium/$0.0416 On-Demand - Ready 171m
                                                  memory ███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   8%
ip-192-168-13-68.ap-northeast-2.compute.internal  cpu    ███████░░░░░░░░░░░░░░░░░░░░░░░░░░░░  19% (7 pods) t3.medium/$0.0416 On-Demand - Ready 171m
                                                  memory ███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   9%

# i.e. AZ : node 에 labels도 추가사용이 됩니다.
eks-node-viewer --extra-labels topology.kubernetes.io/zone
eks-node-viewer --extra-labels kubernetes.io/arch

# 정렬도 할 수 있습니다.
eks-node-viewer --node-sort=eks-node-viewer/node-cpu-usage=dsc

# 추후 Karpenter로 관리되는 노드들도 별도로 볼 수 있습니다. 이는 챕터 3에서 Karpenter를 설명할 때 사용될 예정입니다.
eks-node-viewer --node-selector "karpenter.sh/provisioner-name"

kube-ops-view

그럼 이어서 kube-ops-view 를 배포하고 이를 ALB Ingress 로 호출 가능하게 구성해봅시다.

# helm chart를 등록하고 배포해봅시다.
$ helm repo add geek-cookbook https://geek-cookbook.github.io/charts/
$ helm install kube-ops-view geek-cookbook/kube-ops-view --version 1.2.2 --set service.main.type=ClusterIP --set env.TZ="Asia/Seoul" --namespace kube-system
NAME: kube-ops-view
LAST DEPLOYED: Thu Apr  2 03:03:40 2026
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
...

# 떠있나 봅시다. 좋습니다!
$ kubectl get deploy,pod,svc,ep -n kube-system -l app.kubernetes.io/instance=kube-ops-view
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME                            READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/kube-ops-view   1/1     1            1           35s

NAME                                 READY   STATUS    RESTARTS   AGE
pod/kube-ops-view-5c64986f74-tb55d   1/1     Running   0          35s

NAME                    TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)    AGE
service/kube-ops-view   ClusterIP   10.100.12.8   <none>        8080/TCP   35s

NAME                      ENDPOINTS             AGE
endpoints/kube-ops-view   192.168.23.242:8080   35s

^aews-03-wildcard-cert

이어서 kube-ops-view를 볼 Ingress를 프로비전 합니다. 아래 YAML로 배포합니다.

# kubeopsview 용 Ingress 설정 : group 설정으로 1대의 ALB를 여러개의 ingress 에서 공용 사용
cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    alb.ingress.kubernetes.io/certificate-arn: $CERT_ARN
    alb.ingress.kubernetes.io/group.name: study
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}, {"HTTP":80}]'
    alb.ingress.kubernetes.io/load-balancer-name: myeks-ingress-alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/ssl-redirect: "443"
    alb.ingress.kubernetes.io/success-codes: 200-399
    alb.ingress.kubernetes.io/target-type: ip
  labels:
    app.kubernetes.io/name: kubeopsview
  name: kubeopsview
  namespace: kube-system
spec:
  ingressClassName: alb
  rules:
  - host: kubeopsview.$MyDomain
    http:
      paths:
      - backend:
          service:
            name: kube-ops-view
            port:
              number: 8080
        path: /
        pathType: Prefix
EOF
ingress.networking.k8s.io/kubeopsview created

# service, ep, ingress 확인해보고, 잘 떠있으면 브라우저로 봅시다.
$ k get ingress,svc,ep -n kube-system
Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME                                    CLASS   HOSTS                    ADDRESS                                                        PORTS   AGE
ingress.networking.k8s.io/kubeopsview   alb     kubeopsview.s3ich4n.me   myeks-ingress-alb-832760386.ap-northeast-2.elb.amazonaws.com   80      81s

NAME                                        TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                  AGE
service/aws-load-balancer-webhook-service   ClusterIP   10.100.130.252   <none>        443/TCP                  102m
service/eks-extension-metrics-api           ClusterIP   10.100.51.134    <none>        443/TCP                  3h39m
service/kube-dns                            ClusterIP   10.100.0.10      <none>        53/UDP,53/TCP,9153/TCP   3h33m
service/kube-ops-view                       ClusterIP   10.100.12.8      <none>        8080/TCP                 35m
service/metrics-server                      ClusterIP   10.100.47.49     <none>        443/TCP                  3h33m

NAME                                          ENDPOINTS                                                            AGE
endpoints/aws-load-balancer-webhook-service   192.168.13.73:9443,192.168.21.138:9443                               102m
endpoints/eks-extension-metrics-api           172.0.32.0:10443                                                     3h39m
endpoints/kube-dns                            192.168.12.99:9153,192.168.20.88:9153,192.168.12.99:53 + 3 more...   3h33m
endpoints/kube-ops-view                       192.168.23.242:8080                                                  35m
endpoints/metrics-server                      192.168.13.100:10251,192.168.23.181:10251                            3h33m

브라우저로 구동을 확인할 수 있습니다. 잘 되네요!

kube-ops-view 브라우저 구동 확인

어노테이션 추가설명

ALB Ingress 어노테이션 설명

여러 Ingress 리소스를 하나의 ALB로 공유하게 해주는 설정입니다.

이렇게 여러 Ingress가 같은 ALB를 공유할 수 있습니다:

kubeopsview.example.com ─┐
grafana.example.com     ─┼─→ myeks-ingress-alb (ALB 1개)
argocd.example.com      ─┘

group.name을 안 쓰면 Ingress마다 ALB가 각각 생성됩니다.

생성되는 ALB의 이름을 명시적으로 지정합니다.

주의 - 이름 지정을 올바르게 해주기

같은 group.name을 쓰는 Ingress들은 load-balancer-name도 동일해야 합니다.
다르면 충돌이 발생할 수 있습니다.

kube-prometheus-stack

그럼 이어서 kube-prometheus-stack을 배포해보겠습니다.

마찬가지로 위에서 작업한 도메인을 통해 ALB Ingress(MyDomain, HTTPS → HTTP)까지 구성해봅시다.

살펴보기 - EKS 컨트롤 플레인 원시지표(메트릭)을 Prometheus 형식으로 가져오기

아래와같이 가지고올 수 있습니다.

┌─────────────────────────────────────────────────────────────────────┐
│                 EKS Control Plane Observability                     │
├──────────────────┬──────────────────┬────────────────┬──────────────┤
│ ① CloudWatch     │ ② Prometheus    │ ③ Control      │ ④ Cluster   │
│    Vended Metrics│    Metrics       │    Plane       │    Insights  │
│                  │    Endpoint      │    Logging     │              │
├──────────────────┼──────────────────┼────────────────┼──────────────┤
│ AWS/EKS 네임스페이스│ KCM/KSH/etcd     │ API/Audit/     │ Upgrade      │
│ (자동, 무료)       │ (Prometheus      │ Auth/CM/Sched  │ Readiness    │
│                  │  호환 K8s API)    │ (CloudWatch    │ Health Issues│
│                  │                  │  Logs)         │ Addon Compat │
├──────────────────┼──────────────────┼────────────────┼──────────────┤
│ v1.28+ 자동       │ v1.28+ 수동       │ 모든 버전        │ 모든 버전 자동 │
└──────────────────┴──────────────────┴────────────────┴──────────────┘

도전과제 - 컨트롤플레인의 요소까지 모니터링 하도록 배포하기

도전과제 도전에 대해

해당 링크 를 적극 참고하였습니다!

우선 prometheus helm chart 배포를 위한 repo부터 추가합시다

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts

그리고 아래 role부터 추가합시다. 아래 권한이 필요하기 때문입니다:

rules:
  - apiGroups: ["metrics.eks.amazonaws.com"]
    resources: ["kcm/metrics", "ksh/metrics", "etcd/metrics"]
    verbs: ["get"]

배포파일은 아래와 같습니다:

선배포합시다.

$ k apply -f eks-metrics-rbac.yaml
clusterrole.rbac.authorization.k8s.io/eks-metrics-reader created
clusterrolebinding.rbac.authorization.k8s.io/eks-metrics-reader-binding created

그리고 URL 경로와 certificate-arn을 현재 문맥에 맞게끔 수정한 YAML 파일은 아래와 같습니다. 수정사항은 크게 아래 두 가지 입니다:

배포 명령을 내려볼까요!

helm install kube-prometheus-stack prometheus-community/kube-prometheus-stack --version 80.13.3 \
  -f monitor-values.yaml --create-namespace --namespace monitoring \
  --set grafana.sidecar.datasources.defaultDatasourceEnabled=true
NAME: kube-prometheus-stack
LAST DEPLOYED: Thu Apr  1 14:27:32 2026
NAMESPACE: monitoring
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
NOTES:
kube-prometheus-stack has been installed. Check its status by running:
  kubectl --namespace monitoring get pods -l "release=kube-prometheus-stack"

Get Grafana 'admin' user password by running:

  kubectl --namespace monitoring get secrets kube-prometheus-stack-grafana -o jsonpath="{.data.admin-password}" | base64 -d ; echo

Access Grafana local instance:

  export POD_NAME=$(kubectl --namespace monitoring get pod -l "app.kubernetes.io/name=grafana,app.kubernetes.io/instance=kube-prometheus-stack" -oname)
  kubectl --namespace monitoring port-forward $POD_NAME 3000

# 제 계정 비번
Get your grafana admin user password by running:

  kubectl get secret --namespace monitoring -l app.kubernetes.io/component=admin-secret -o jsonpath="{.items[0].data.admin-password}" | base64 --decode ; echo


Visit https://github.com/prometheus-operator/kube-prometheus for instructions on how to create & configure Alertmanager and Prometheus instances using the Operator.

배포가 되었으니 버전만 살펴보고 바로 보시죠.

kubectl exec -it sts/prometheus-kube-prometheus-stack-prometheus -n monitoring -c prometheus -- prometheus --version
prometheus, version 3.9.1 (branch: HEAD, revision: 9ec59baffb547e24f1468a53eb82901e58feabd8)
  build user:       root@61c3a9212c9e
  build date:       20260107-16:08:09
  go version:       go1.25.5
  platform:         linux/amd64
  tags:             netgo,builtinassets

Prometheus 접속 후 살펴보면 etcd, kcm, kch 모두 정상조회가 가능하며,

Prometheus Targets - apiserver, etcd, kcm, kch 메트릭 수집 상태 확인

그럼 Grafana 대시보드 세팅을 해줍시다.

# 대시보드 다운로드
curl -O https://raw.githubusercontent.com/dotdc/grafana-dashboards-kubernetes/refs/heads/master/dashboards/k8s-system-api-server.json

# 신규 데이터소스의 uid를 추가기재
kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- curl -s -u admin:prom-operator localhost:3000/api/datasources | python3 -m json.tool
[
    {
        "id": 1,
        "uid": "<신규UID값>",
        "orgId": 1,
        "name": "prometheus",
        "type": "prometheus",
        "typeName": "Prometheus",
        "typeLogoUrl": "public/plugins/prometheus/img/prometheus_logo.svg",
        "access": "proxy",
        "url": "http://kube-prometheus-stack-prometheus.monitoring:9090",
        "user": "",
        "database": "",
        "basicAuth": false,
        "isDefault": true,
        "jsonData": {
            "pdcInjected": false
        },
        "readOnly": false
    }
]

# sed 명령어로 uid 일괄 변경 : 기본 데이터소스의 uid 'prometheus' 사용
sed -i -e 's/${DS_PROMETHEUS}/신규UID값/g' k8s-system-api-server.json

# my-dashboard 컨피그맵 생성 : Grafana 포드 내의 사이드카 컨테이너가 grafana_dashboard="1" 라벨 탐지!
kubectl create configmap my-dashboard --from-file=k8s-system-api-server.json -n monitoring
kubectl label configmap my-dashboard grafana_dashboard="1" -n monitoring

# 대시보드 경로에 추가 확인
kubectl exec -it -n monitoring deploy/kube-prometheus-stack-grafana -- ls -l /tmp/dashboards

Grafana 대시보드로 접속하면 이렇게 뜹니다!

Grafana API Server 대시보드 - SLI/SLO 메트릭

Grafana API Server 대시보드 - Health Status 및 HTTP 요청 통계

배포이후 - 권한 살펴보기

Prometheus의 ServiceAccount에 어떤 RBAC 권한이 부여되어 있는지 확인해봅시다.

두 가지 kubectl 플러그인을 사용합니다. 둘 다 krew (kubectl 플러그인 매니저)로 설치합니다.

kubectl krew install rbac-tool
kubectl krew install rolesum

먼저 rbac-tool로 해당 SA에 어떤 RoleBinding/ClusterRoleBinding이 연결되어 있는지 조회합니다.

kubectl rbac-tool lookup kube-prometheus-stack-prometheus

다음으로 rolesum을 사용하면 해당 SA의 권한을 리소스별로 한눈에 요약해서 볼 수 있습니다.

kubectl rolesum kube-prometheus-stack-prometheus -n monitoring

출력 예시:

Policies:
• [CRB] */kube-prometheus-stack-prometheus ⟶  [CR] */kube-prometheus-stack-prometheus
  Resource                         Name  Exclude  Verbs  G L W C U P D DC
  endpoints                        [*]     [-]     [-]   ✔ ✔ ✔ ✖ ✖ ✖ ✖ ✖
  endpointslices.discovery.k8s.io  [*]     [-]     [-]   ✔ ✔ ✔ ✖ ✖ ✖ ✖ ✖
  ingresses.networking.k8s.io      [*]     [-]     [-]   ✔ ✔ ✔ ✖ ✖ ✖ ✖ ✖
  nodes                            [*]     [-]     [-]   ✔ ✔ ✔ ✖ ✖ ✖ ✖ ✖
  nodes/metrics                    [*]     [-]     [-]   ✔ ✔ ✔ ✖ ✖ ✖ ✖ ✖
  pods                             [*]     [-]     [-]   ✔ ✔ ✔ ✖ ✖ ✖ ✖ ✖
  services                         [*]     [-]     [-]   ✔ ✔ ✔ ✖ ✖ ✖ ✖ ✖

헤더의 약자는 각각 Kubernetes RBAC verb에 대응합니다.

약자 의미
G Get (단건 조회)
L List (목록 조회)
W Watch (변경 감시)
C Create
U Update
P Patch
D Delete
DC DeleteCollection

결과를 보면, Prometheus SA는 endpoints, pods, services, nodes 등에 대해 Get/List/Watch만 가능(읽기 전용)하고 Create/Update/Delete는 불가합니다.

Prometheus가 메트릭 수집 대상을 서비스 디스커버리하기 위해 필요한 최소 권한만 부여된 상태로, 적절한 least-privilege 설정입니다.

get rolebinding, get clusterrolebinding을 일일이 확인하는 것보다 rolesum 한 줄이 훨씬 편합니다. RBAC 디버깅 시 적극 활용해봅시다.

EKS 관리형 노드그룹

여러 노드그룹을 하나의 클러스터에서 관리할 때 사용할 수 있습니다. 먼저 현재 배포된 서비스의 관리형 노드그룹에 대해 살펴봅시다.

# 배포된 노드들도 살펴볼까요?
kubectl get nodes --label-columns eks.amazonaws.com/nodegroup,kubernetes.io/arch,eks.amazonaws.com/capacityType
NAME                                                STATUS   ROLES    AGE    VERSION               NODEGROUP    ARCH    CAPACITYTYPE
ip-192-168-13-68.ap-northeast-2.compute.internal    Ready    <none>   5h3m   v1.35.2-eks-f69f56f   myeks-ng-1   amd64   ON_DEMAND
ip-192-168-22-157.ap-northeast-2.compute.internal   Ready    <none>   5h3m   v1.35.2-eks-f69f56f   myeks-ng-1   amd64   ON_DEMAND

# 클러스터 정보를 살펴봅시다
$ eksctl get nodegroup --cluster myeks
CLUSTER NODEGROUP       STATUS  CREATED                 MIN SIZE        MAX SIZE        DESIRED CAPACITY        INSTANCE TYPE     IMAGE ID                ASG NAME                                                TYPE
myeks   myeks-ng-1      ACTIVE  2026-04-01T15:03:59Z    1               4               2                       t3.mediumAL2023_x86_64_STANDARD   eks-myeks-ng-1-3ccea4c9-49e6-0272-6401-aca31051d91b     managed

# 노드그룹이 어떻게 배포되어있나 봅시다.
$ aws eks describe-nodegroup --cluster-name myeks --nodegroup-name myeks-ng-1 | jq
{
  "nodegroup": {
    "nodegroupName": "myeks-ng-1",
...

ARM 아키텍처 노드그룹 활용

AWS Graviton 프로세서는 64-bit Arm 프로세서 코어 기반의 AWS 커스텀 반도체입니다. 20~40% 향상된 가격대비 성능을 보여주고있고, 다양한 기업들에서 시범적으로 도입하여 적은 비용으로 높은 성능을 달성해본 바 있습니다.

그러면, Second 노드그룹을 추가해서 배포하는 것 까지 확인해봅시다.

기존 코드분석에서 살펴본 secondary 블록을 주석해제하고, 노드의 taint도 구성하여 배포 후(tf apply -auto-approve), 환경을 살펴봅시다.

# 신규 노드그룹이 생긴 것을 봅시다. arm64가 잘 보이는군요!
$ kubectl get nodes --label-columns eks.amazonaws.com/nodegroup,kubernetes.io/arch,eks.amazonaws.com/capacityType
NAME                                                STATUS   ROLES    AGE    VERSION               NODEGROUP    ARCH    CAPACITYTYPE
ip-192-168-13-68.ap-northeast-2.compute.internal    Ready    <none>   5h9m   v1.35.2-eks-f69f56f   myeks-ng-1   amd64   ON_DEMAND
ip-192-168-22-157.ap-northeast-2.compute.internal   Ready    <none>   5h9m   v1.35.2-eks-f69f56f   myeks-ng-1   amd64   ON_DEMAND
ip-192-168-23-170.ap-northeast-2.compute.internal   Ready    <none>   29s    v1.35.2-eks-f69f56f   myeks-ng-2   arm64   ON_DEMAND

# 노드그룹이 두개지요
$ eksctl get nodegroup --cluster myeks
CLUSTER NODEGROUP       STATUS  CREATED                 MIN SIZE        MAX SIZE        DESIRED CAPACITY        INSTANCE TYPE   IMAGE ID                ASG NAME        TYPE
myeks   myeks-ng-1      ACTIVE  2026-04-01T15:03:59Z    1               4               2                       t3.medium       AL2023_x86_64_STANDARD  eks-myeks-ng-1-3ccea4c9-49e6-0272-6401-aca31051d91b      managed
myeks   myeks-ng-2      ACTIVE  2026-04-01T20:13:40Z    1               1               1                       t4g.medium      AL2023_ARM_64_STANDARD  eks-myeks-ng-2-68cea557-0d9b-0438-fd52-2bdfb557260a      managed

# taint 조건을 살펴봅시다
$ aws eks describe-nodegroup --cluster-name myeks --nodegroup-name myeks-ng-2 | jq .nodegroup.taints
[
  {
    "key": "cpuarch",
    "value": "arm64",
    "effect": "NO_EXECUTE"
  }
]

# 노드도 직접 살펴보시죠.
$ kubectl describe node -l tier=secondary | grep -i taint

Taints:             cpuarch=arm64:NoExecute

# SSM 관리대상 인스턴스 목록을 조회합시다.
aws ec2 describe-instances \
  --instance-ids $(aws ssm describe-instance-information \
    --query "InstanceInformationList[?PingStatus=='Online'].InstanceId" \
    --output text) \
  --query "Reservations[].Instances[].{
    InstanceId:InstanceId,
    Type:InstanceType,
    Arch:Architecture,
    AMI:ImageId,
    State:State.Name
  }" \
  --output table  
-------------------------------------------------------------------------------------
|                                 DescribeInstances                                 |
+------------------------+---------+-----------------------+----------+-------------+
|           AMI          |  Arch   |      InstanceId       |  State   |    Type     |
+------------------------+---------+-----------------------+----------+-------------+
|  ami-<AMD64>           |  x86_64 |  i-<REDACTED>         |  running |  t3.medium  |
|  ami-<AMD64>           |  x86_64 |  i-<REDACTED>         |  running |  t3.medium  |
|  ami-<ARM64>           |  arm64  |  i-<REDACTED>         |  running |  t4g.medium |
+------------------------+---------+-----------------------+----------+-------------+

# SSM관리 대상이니 쉘을 타고가서 arch를 봅시다.
aws ssm start-session --target i-<REDACTED>

Starting session with SessionId: s3ich4n-oasu4hlnorlqpuyysgpxo9x7bq
sh-5.2$ arch
aarch64

배포 테스트 - Taint and Tolerations

1번 샘플은 taint / toleration이 없어 스케줄링이 되지 못하는 모습입니다.

$ kubectl describe pod -l app=sample-app
Tolerations:                 node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  11s   default-scheduler  0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

2번 샘플은 스케줄링 이후 구동된 것을 확인할 수 있었습니다.

Tolerations:                 cpuarch=arm64:NoExecute
                             node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                             node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  84s   default-scheduler  Successfully assigned default/sample-app-74559985fd-khxfw to ip-192-168-23-170.ap-northeast-2.compute.internal
  Normal  Pulling    84s   kubelet            Pulling image "nginx:alpine"
  Normal  Pulled     78s   kubelet            Successfully pulled image "nginx:alpine" in 5.188s (5.188s including waiting). Image size: 25839126 bytes.
  Normal  Created    78s   kubelet            Container created
  Normal  Started    78s   kubelet            Container started

다시말해, 아키텍처에 맞게끔 컨테이너 또한 되어 있어야함을 알 수 있습니다.

# 배포는 되었는데 에러가 뜹니다!
$ kubectl get events -w --sort-by '.lastTimestamp'

# 확인
$ kubectl get pod -l app=mario
NAME                     READY   STATUS   RESTARTS      AGE
mario-7cb97489b5-zk9gb   0/1     Error    1 (10s ago)   22s

# 컨테이너 이미지 매니페스트가 호환되지 않습니다.
#   아래 링크를 들어가시면 OS/ARCH에 linux/amd64 밖에 존재하지 않네요.
#   https://hub.docker.com/r/pengbai/docker-supermario/tags
$ stern -l app=mario -n defaul
+ mario-7cb97489b5-zk9gb mario
mario-7cb97489b5-zk9gb mario exec /usr/local/tomcat/bin/catalina.sh: exec format error
- mario-7cb97489b5-zk9gb mario

spot instance 활용

적절한 인스턴스 고르기

EC2 용 인스턴스는 정말 수많이 존재합니다. 이 인스턴스를 선택하기 위해선 ec2-instance-selector 같은 도구를 활용하기도 합니다.

# 설치하기
curl -Lo ec2-instance-selector https://github.com/aws/amazon-ec2-instance-selector/releases/download/v2.4.1/ec2-instance-selector-`uname | tr '[:upper:]' '[:lower:]'`-amd64 && chmod +x ec2-instance-selector
mv ec2-instance-selector /usr/local/bin/
ec2-instance-selector --version

# 아래와 같이 조건을 주면 그에 해당하는 내용이 나옵니다.
ec2-instance-selector --vcpus 2 --memory 4 --gpus 0 --current-generation -a x86_64 --deny-list 't.*' --output table-wide
Instance Type   VCPUs   Mem (GiB)  Hypervisor  Current Gen  Hibernation Support  CPU Arch  Network Performance  ENIs    GPUs    GPU Mem (GiB)  GPU Info  On-Demand Price/Hr  Spot Price/Hr (30d avg)
-------------   -----   ---------  ----------  -----------  -------------------  --------  -------------------  ----    ----    -------------  --------  ------------------  -----------------------
c5.large        2       4          nitro       true         true                 x86_64    Up to 10 Gigabit     3       0       0              none      $0.096              $0.02837
c5a.large       2       4          nitro       true         false                x86_64    Up to 10 Gigabit     3       0       0              none      $0.086              $0.04022
c5d.large       2       4          nitro       true         true                 x86_64    Up to 10 Gigabit     3       0       0              none      $0.11               $0.03265
c6i.large       2       4          nitro       true         true                 x86_64    Up to 12.5 Gigabit   3       0       0              none      $0.096              $0.03425
c6id.large      2       4          nitro       true         true                 x86_64    Up to 12.5 Gigabit   3       0       0              none      $0.1155             $0.03172
c6in.large      2       4          nitro       true         true                 x86_64    Up to 25 Gigabit     3       0       0              none      $0.1281             $0.04267
c7i-flex.large  2       4          nitro       true         true                 x86_64    Up to 12.5 Gigabit   3       0       0              none      $0.09576            $0.02872
c7i.large       2       4          nitro       true         true                 x86_64    Up to 12.5 Gigabit   3       0       0              none      $0.1008             $0.02977

# 내부적으로 DescribeInstanceTypes 를 호출해서, 타이핑한 조건을 확인합니다.
#    타이핑한 조건은 아래와 같습니다:
- Instances with no GPUs
- of x86_64 Architecture (no ARM instances like A1 or m6g instances for example)
- Instances that have 2 vCPUs and 4 GB of RAM
- Instances of current generation (4th gen onwards)
- Instances that don’t meet the regular expression t.* to filter out burstable instance types

배포해보기

배포에 앞서 Spot Instance 자체를 처음 사용하시면 아래 내용을 꼭 읽어주세요.

Spot Instance 를 처음 사용하신 경우

# EC2 Spot Fleet의 service-linked-role 생성 확인 : 만들어있는것을 확인하는 거라 아래 에러 출력이 정상!
# 목적 : AWS 서비스(여기서는 Spot)가 사용자를 대신하여 다른 AWS 리소스(EC2 등)를 조작할 수 있는 권한을 가진 전용 역할을 만듭니다
# 이유 : 스팟 인스턴스를 요청하면, AWS 내부 시스템이 알아서 인스턴스를 띄우고 회수해야 합니다. 이 역할을 수행하려면 AWSServiceRoleForEC2Spot이라는 이름의 역할이 계정에 반드시 존재해야 합니다.
# If the role has already been successfully created, you will see:
# An error occurred (InvalidInput) when calling the CreateServiceLinkedRole operation: Service role name AWSServiceRoleForEC2Spot has been taken in this account, please try a different suffix.
**aws iam create-service-linked-role --aws-service-name spot.amazonaws.com || true**

그럼 이번엔 third 그룹을 주석 해제하고 배포해봅시다. 이후 샘플 앱을 하나 배포해보죠.

apiVersion: v1
kind: Pod
metadata:
  name: busybox
spec:
  terminationGracePeriodSeconds: 3
  containers:
  - name: busybox
    image: busybox
    command:
    - "/bin/sh"
    - "-c"
    - "while true; do date >> /home/pod-out.txt; cd /home; sync; sync; sleep 10; done"
  # 이건 nodeSelector를 잘 걸어야하겠죠.
  #   내부적으론 이런 어노테이션을 활용하면 됩니다.
  nodeSelector:
    eks.amazonaws.com/capacityType: SPOT
# 잘 뜬걸 확인했으니,
$ kubectl get pod -owide
NAME                          READY   STATUS    RESTARTS   AGE     IP               NODE                                                NOMINATED NODE   READINESS GATES
awscli-pod-847bcdb4db-b76p2   1/1     Running   0          3h11m   192.168.15.101   ip-192-168-13-68.ap-northeast-2.compute.internal    <none>           <none>
awscli-pod-847bcdb4db-htkws   1/1     Running   0          3h11m   192.168.22.91    ip-192-168-22-157.ap-northeast-2.compute.internal   <none>           <none>
busybox                       1/1     Running   0          34s     192.168.13.228   ip-192-168-15-163.ap-northeast-2.compute.internal   <none>           <none>

# 보내줍시다.
$ kubectl delete pod busybox
pod "busybox" deleted from default namespace

Bottlerocket

Bottlerocket - 공격표면을 최소한 배포방법

Bottlerocket: 컨테이너 전용 OS도 함께 읽어보세요.