[CloudNeta] EKS 워크샵 스터디 (1) - EKS 소개 #2
이번 게시글에서는 EKS 워크샵 스터디 제 1주차 내용을 작성합니다.
이 글은 2부로 나누어집니다.
- 1주차 - EKS 소개 #1
- 1주차 - EKS 소개 #2 (현재 보고계신 글)
EKS Cluster Endpoint Access에 대해
Cluster Endpoint Access는 EKS 생성 시 선택할 수 있는 옵션입니다. 이는 쿠버네티스 API서버(kube-apiserver)에 접근가능 제어를 어디까지 열어둘 것인지 지정하는 설정을 의미합니다. 서비스 구성에 앞서 두 가지 주체를 생각해야합니다.
-
사람의 kubectl 트래픽.
aws eks update-kubeconfig으로 kubeconfig 을 수신한 후에 API서버의 엔드포인트 URL이 추가되는 것은 확인하셨습니다. 그렇다면 이 Endpoint Access의 종류에 따라 퍼블릭 인터넷으로도 도달 가능한지, VPC 내부에서만 도달 가능한지 정하는 것입니다. -
워커 노드의 kubelet 트래픽. 노드 등록 시 VPC 외부의 퍼블릭 엔드포인트를 쓸지, VPC 내부의 프라이빗 엔드포인트를 쓸지 결정합니다.
쿠버네티스 클러스터 관리에 대해
Cluster Endpoint Access 모드를 살펴보기 전에, EKS가 쿠버네티스 클러스터 운영의 일부를 어떻게 관리하는지 살펴봅시다.
AWS가 운영하는 컨트롤 플레인 살펴보기
먼저 사진을 봅시다.

- AZ각각에 ASG가 각각 하나씩 있고, API서버와 etcd 에 대한 ASG구성이네요. 그리고 API 서버는
kube-apiserver,kube-controller-manager,kube-scheduler를 갖고있을 겁니다. 그리고 etcd는 별도로 관리하고요 - etcd는 쿠버네티스 운영에 쓰이는 키-밸류 저장소입니다. 이곳은 값을 영속적으로 저장하는 곳이기 때문에 API 서버장애와 격리시켰네요
- 그리고 이 서비스들에 접근하는 건 NLB(for API servers), ELB(for etcd)로 노출[1]시켜 두었습니다. 이는 관리형 쿠버네티스 API 서버 엔드포인트 인데요. 설정에 따라 인터넷에 공개됩니다. 참고링크
그럼 AWS managed 컨트롤 플레인과는 어떻게 통신해요?
[2]앞서 말씀드렸듯, AWS 가 운영하는 컨트롤 플레인은 고가용성을 위해 여러 AZ에 배포해두었습니다. 아예 별도의 VPC에 구성된다는 뜻이지요. 어쨌거나 별도의 VPC니까, 후술할 어떤 모드를 사용하든간에 워커 노드와 컨트롤플레인이 통신하기 위한 별도의 EKS owned ENI(NIC라고 생각하면 됨)를 클러스터에 구성해줍니다.

그럼 두 가지 경로가 있습니다. 이 둘은 이렇게 쓰입니다.
- 퍼블릭 엔드포인트(NLB): VPC에서 NAT Gateway를 타고 인터넷을 경유. 이후 NLB를 타고 API 서버로 접근
- EKS owned ENI: AWS가 관리하는 계정과 연결된 ENI를 통해 API 서버로 접근
그렇다면, 실제로 유저의(AWS 입장에서는 고객의) 데이터 플레인은 이렇게 구성됩니다.

그림에서 녹색(AWS 책임)과 주황색(고객 책임)이 구분되어 있습니다. 컨트롤 플레인과 X-ENI는 AWS가 관리하고, Customer VPC 안의 노드 운영(kubelet, kube-proxy, Pod)은 고객이 관리합니다. Endpoint Access는 이 두 영역 사이의 네트워크 도달 경로를 퍼블릭/프라이빗 중 어떻게 열 것인가를 결정하는 설정입니다.
Cluster Endpoint Access 모드에 대해
상술한 두 가지 경로가 있으니, 운영하는 요구사항에 따라 각각 다르게 구성할 수 있습니다.
Public Only

유저의 kubectl 트래픽은 public ip 로 공개된 주소에 붙고, 데이터 플레인도 igw 를 타고 public 로 향하는 것을 보실 수 있습니다.
Public + Private

유저의 kubectl 트래픽은 public ip로 공개된 주소에, 데이터 플레인에서는 생성된 EKS owned ENI로 통신하는 것을 보실 수 있습니다.
Private Only

유저의 kubectl 트래픽도 VPN을 사용하거나 bastion 에서 구동하도록 해야하고, 데이터 플레인에서도 마찬가지로 생성된 EKS owned ENI로 통신합니다.
실습해보기 (1) - Endpoint Access 설정 살펴보기
기존 코드에서 아래와 같이 변경되면 public and private 옵션으로 배포됩니다.
endpoint_public_access = true
# 아래 코드들을 살펴보고 재구동을 하면 public, public+private 옵션 구동을 확인할 수 있습니다.
endpoint_private_access = true
endpoint_public_access_cidrs = [
var.ssh_access_cidr
]
내부에서 직접 변경 후 스크립트를 구동해보면 아래와 같이 변경되었음을 확인할 수 있습니다:
# kubelet 은 노드에서 systemctl restart kubelet으로 적용해보자 : ss에 kubelet peer IP 변경 확인
for i in $NODE1 $NODE2; do echo ">> node $i <<"; ssh ec2-user@$i sudo systemctl restart kubelet; echo; done
ssh ec2-user@$NODE1 sudo ss -tnp | grep -v ssh
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.1.212:49442 3.37.98.54:443 users:(("aws-k8s-agent",pid=2456,fd=7))
ESTAB 0 0 192.168.1.212:42834 192.168.2.185:443 users:(("kubelet",pid=19469,fd=30))
ESTAB 0 0 192.168.1.212:37782 192.168.1.202:443 users:(("kube-proxy",pid=19048,fd=6))
# aws-node
kubectl rollout restart ds/aws-node -n kube-system
ssh ec2-user@$NODE1 sudo ss -tnp | grep -v ssh
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 192.168.1.212:32912 192.168.2.185:443 users:(("aws-k8s-agent",pid=20094,fd=7))
ESTAB 0 0 192.168.1.212:48058 52.95.193.80:443 users:(("aws-k8s-agent",pid=20094,fd=8))
ESTAB 0 0 192.168.1.212:42834 192.168.2.185:443 users:(("kubelet",pid=19469,fd=30))
ESTAB 0 0 192.168.1.212:37782 192.168.1.202:443 users:(("kube-proxy",pid=19048,fd=6))
실습해보기 (2) - Fully private cluster 설정 살펴보기
기존 리포지토리의 eks-private 디렉터리를 구동하시고 작업해주세요.
Fully private cluster 구동 시 필수적으로 오픈해야하는 VPCE가 있는데요. 그 목록은 여기서 확인하실 수 있습니다.
Fully private cluster에서 서비스 하나를 배포해보기
외부로 통신하기 위해서는 NLB를 사용하고, 라우팅을 하기 위한 태그값을 올바르게 추가해주어야 합니다. AWS 링크
# (실습을 위해) NAT Gateway로 외부 컨테이너 레지스트리(public ECR도 포함)에서 컨테이너를 pull할 수 있는 루트를 만듭니다.
enable_nat_gateway = true
single_nat_gateway = true
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = 1
}
# 라우팅을 위한 태그값은 이렇게 기재합니다.
public_subnet_tags = {
"kubernetes.io/role/elb" = 1
}
복잡한 운영을 위해 Ingress 형태로 사용하려면 ALB 배포를, 간단히 nginx 하나만 노출하기 위해선 NLB만으로도 충분하죠. 그러니 이번 글에서는 NLB 배포를 테스트해보고자 합니다.
또한 Fully private cluster에서 외부 배포를 하려면 AWS Load Balancer Controller를 사용합니다. 여기서는 직접 bastion에 붙어서 배포작업을 수행할 예정입니다.
사전준비
먼저 현재 클러스터가 fully private cluster인지 확인해봅시다.

root@bastion-EC2:~# aws eks --region ap-northeast-2 update-kubeconfig --name eks-private
# 이 경우에는
# AWS 권한을 bastion에 부여합니다. 키를 부여하는 것보다 권한을 부여하는 쪽이 좋겠지요.
```shell
aws: [ERROR]: An error occurred (NoCredentials): Unable to locate credentials. You can configure credentials by running "aws login".
root@bastion-EC2:~# k get nodes
E0318 18:54:45.320947 2113 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"http://localhost:8080/api?timeout=32s\": dial tcp 127.0.0.1:8080: connect: connection refused"
E0318 18:54:45.321221 2113 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"http://localhost:8080/api?timeout=32s\": dial tcp 127.0.0.1:8080: connect: connection refused"
E0318 18:54:45.322620 2113 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"http://localhost:8080/api?timeout=32s\": dial tcp 127.0.0.1:8080: connect: connection refused"
E0318 18:54:45.322888 2113 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"http://localhost:8080/api?timeout=32s\": dial tcp 127.0.0.1:8080: connect: connection refused"
E0318 18:54:45.324235 2113 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"http://localhost:8080/api?timeout=32s\": dial tcp 127.0.0.1:8080: connect: connection refused"
The connection to the server localhost:8080 was refused - did you specify the right host or port?
Bastion에 "테스트용으로" IAM 규칙을 굉장히 넓게 부여하고
######################
# Bastion IAM Role #
######################
resource "aws_iam_role" "bastion" {
name = "bastion-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}]
})
}
resource "aws_iam_role_policy_attachment" "bastion_admin" {
role = aws_iam_role.bastion.name
policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}
resource "aws_iam_instance_profile" "bastion" {
name = "bastion-ec2-profile"
role = aws_iam_role.bastion.name
}
권한을 붙여줍니다.
# EKS 클러스터 관리용 Bastion Host EC2 인스턴스를 생성.
resource "aws_instance" "eks_bastion" {
ami = data.aws_ssm_parameter.ami.value
...
iam_instance_profile = aws_iam_instance_profile.bastion.name
...이하 생략
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.11"
cluster_name = local.name
cluster_version = "1.34"
# Optional: Adds the current caller identity as an administrator via cluster access entry
enable_cluster_creator_admin_permissions = true
access_entries = {
bastion = {
principal_arn = aws_iam_role.bastion.arn
policy_associations = {
admin = {
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy"
access_scope = {
type = "cluster"
}
}
}
}
}
... 이하 생략
}
root@bastion-EC2:~# aws eks --region ap-northeast-2 update-kubeconfig --name eks-private
Added new context arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private to /root/.kube/config
다시호출하니 또 안됩니다. 클러스터 SG에 bastion을 허용해주어야 하죠. public -> private 경로를 의미합니다.
# 이 경우에는
# bastion이 public subnet에 있고,
# EKS API 엔드포인트에 붙으려면 sg를 열어줘야 통신이 되겠죠.
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# k get nodes
E0318 18:58:36.100432 2237 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://<REDACTED>.gr7.ap-northeast-2.eks.amazonaws.com/api?timeout=32s\": dial tcp 10.0.41.251:443: i/o timeout"
E0318 18:59:06.101499 2237 memcache.go:265] "Unhandled Error" err="couldn't get current server API group list: Get \"https://<REDACTED>.gr7.ap-northeast-2.eks.amazonaws.com/api?timeout=32s\": dial tcp 10.0.22.205:443: i/o timeout"
main.tf 에서 아래코드로 재배포합니다.
# Bastion(public subnet)에서 EKS API endpoint 접근 허용
cluster_additional_security_group_ids = []
cluster_security_group_additional_rules = {
bastion_ingress = {
description = "Bastion to EKS API"
protocol = "tcp"
from_port = 443
to_port = 443
type = "ingress"
source_security_group_id = aws_security_group.eks_sec_group.id
}
}
좋습니다, 잘 나오는군요. 그럼 본격적으로 배포해볼까요.
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# k get nodes
NAME STATUS ROLES AGE VERSION
ip-10-0-45-219.ap-northeast-2.compute.internal Ready <none> 35m v1.34.4-eks-f69f56f
ip-10-0-6-231.ap-northeast-2.compute.internal Ready <none> 35m v1.34.4-eks-f69f56f
배포해보기
IAM policy와 IRSA를 만듭시다. 이때 eksctl을 활용합니다.
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# aws iam create-policy \
> --policy-name AWSLoadBalancerControllerIAMPolicy \
> --policy-document file://iam_policy.json
{
"Policy": {
"PolicyName": "AWSLoadBalancerControllerIAMPolicy",
"PolicyId": "ANPATQGTWHICHXQQAJYPS",
"Arn": "arn:aws:iam::<REDACTED>:policy/AWSLoadBalancerControllerIAMPolicy",
"Path": "/",
"DefaultVersionId": "v1",
"AttachmentCount": 0,
"PermissionsBoundaryUsageCount": 0,
"IsAttachable": true,
"CreateDate": "2026-03-18T10:07:51+00:00",
"UpdateDate": "2026-03-18T10:07:51+00:00"
}
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# export AWS_DEFAULT_REGION=ap-northeast-2
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# eksctl create iamserviceaccount --cluster=eks-private --namespace=kube-system --name=aws-load-balancer-controller --role-name AmazonEKSLoadBalancerControllerRole --attach-policy-arn=arn:aws:iam::${ACCOUNT_ID}:policy/AWSLoadBalancerControllerIAMPolicy --approve
2026-03-18 19:10:48 [ℹ] 1 iamserviceaccount (kube-system/aws-load-balancer-controller) was included (based on the include/exclude rules)
2026-03-18 19:10:48 [!] serviceaccounts that exist in Kubernetes will be excluded, use --override-existing-serviceaccounts to override
2026-03-18 19:10:48 [ℹ] 1 task: {
2 sequential sub-tasks: {
create IAM role for serviceaccount "kube-system/aws-load-balancer-controller",
create serviceaccount "kube-system/aws-load-balancer-controller",
} }2026-03-18 19:10:48 [ℹ] building iamserviceaccount stack "eksctl-eks-private-addon-iamserviceaccount-kube-system-aws-load-balancer-controller"
2026-03-18 19:10:48 [ℹ] deploying stack "eksctl-eks-private-addon-iamserviceaccount-kube-system-aws-load-balancer-controller"
2026-03-18 19:10:48 [ℹ] waiting for CloudFormation stack "eksctl-eks-private-addon-iamserviceaccount-kube-system-aws-load-balancer-controller"
2026-03-18 19:11:18 [ℹ] waiting for CloudFormation stack "eksctl-eks-private-addon-iamserviceaccount-kube-system-aws-load-balancer-controller"
2026-03-18 19:11:18 [ℹ] created serviceaccount "kube-system/aws-load-balancer-controller"
이어서 helm으로 AWS Load balancer를 설치해봅시다.
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# helm repo add eks https://aws.github.io/eks-charts
"eks" has been added to your repositories
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# helm repo update
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "eks" chart repository
Update Complete. ⎈Happy Helming!⎈
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
-n kube-system \
--set clusterName=eks-private \
--set serviceAccount.create=false \
--set serviceAccount.name=aws-load-balancer-controller
NAME: aws-load-balancer-controller
LAST DEPLOYED: Wed Mar 18 19:13:16 2026
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
AWS Load Balancer controller installed!
좋습니다. 그럼 저는 아래 웹 사이트를 배포하려고 해요.
nginx.yaml 파일 예시
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 2
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:latest
ports:
- containerPort: 80
command: ["/bin/sh", "-c"]
args:
- |
cat <<'HTMLEOF' > /usr/share/nginx/html/index.html
<!DOCTYPE html>
<html>
<head><title>s3ich4n rules!</title>
<style>
body { font-family: monospace; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.box { text-align: center; font-size: 2em; }
#time { color: #00d2ff; }
</style>
</head>
<body>
<div class="box">
<p>s3ich4n rules!</p>
<p>(time: <span id="time"></span>)</p>
</div>
<script>
function update() { document.getElementById('time').textContent = new Date().toLocaleTimeString('ko-KR', {timeZone:'Asia/Seoul'}); }
update(); setInterval(update, 1000);
</script>
</body>
</html>
HTMLEOF
nginx -g 'daemon off;'
---
apiVersion: v1
kind: Service
metadata:
name: nginx
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: external
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
spec:
type: LoadBalancer
selector:
app: nginx
ports:
- port: 80
targetPort: 80
배포되어있나 확인해볼까요?
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# k apply -f nginx.yaml
deployment.apps/nginx created
service/nginx created
# 이건 바로 올라옵니다.
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# k get deploy -n kube-system aws-load-balancer-controller
NAME READY UP-TO-DATE AVAILABLE AGE
aws-load-balancer-controller 2/2 2 2 47s
그럼 bastion 쉘 대신 로컬 쉘에서 확인해봅시다.
# 하지만 AWS의 NLB가 올라오기까지는 2~3분이 걸리니, 이후 확인해보세요.
➜ nslookup k8s-default-nginx-5c2c562c82-42b9509722fa43c1.elb.ap-northeast-2.amazonaws.com
Server: 10.255.255.254
Address: 10.255.255.254#53
** server can't find k8s-default-nginx-5c2c562c82-42b9509722fa43c1.elb.ap-northeast-2.amazonaws.com: NXDOMAIN
잠시 기다리는동안 콘솔에 들어가서 상황을 봅시다.

이제 될 것 같은데요? 그럼 커맨드로 때려보고 화면도 봅시다.
# 2~3분을 기다리면 이렇게 뜹니다.
❯ curl k8s-default-nginx-5c2c562c82-42b9509722fa43c1.elb.ap-northeast-2.amazonaws.com
curl: (6) Could not resolve host: k8s-default-nginx-5c2c562c82-42b9509722fa43c1.elb.ap-northeast-2.amazonaws.com
~
❯ curl k8s-default-nginx-5c2c562c82-42b9509722fa43c1.elb.ap-northeast-2.amazonaws.com
<!DOCTYPE html>
<html>
<head><title>s3ich4n rules!</title>
성공적으로 배포했음을 알 수 있습니다!

삭제하기
- Nginx와 NLB를 먼저 지웁니다
- Helm 삭제를 통해 LB Controller를 지웁니다
- IRSA를 지웁니다
- IAM Policy를 지웁니다
# 1번 과정
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# k delete -f nginx.yaml
deployment.apps "nginx" deleted from default namespace
service "nginx" deleted from default namespace
# 2번 과정
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# helm uninstall aws-load-balancer-controller -n kube-system
release "aws-load-balancer-controller" uninstalled
# 3번 과정
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# eksctl delete iamserviceaccount \
> --cluster=eks-private \
> --namespace=kube-system \
> --name=aws-load-balancer-controller
2026-03-18 19:30:14 [ℹ] 1 iamserviceaccount (kube-system/aws-load-balancer-controller) was included (based on the include/exclude rules)
2026-03-18 19:30:14 [ℹ] 1 task: {
2 sequential sub-tasks: {
delete IAM role for serviceaccount "kube-system/aws-load-balancer-controller" [async],
delete serviceaccount "kube-system/aws-load-balancer-controller",
} }2026-03-18 19:30:14 [ℹ] will delete stack "eksctl-eks-private-addon-iamserviceaccount-kube-system-aws-load-balancer-controller"
2026-03-18 19:30:15 [ℹ] deleted serviceaccount "kube-system/aws-load-balancer-controller"
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# echo $ACCOUNT_ID
<REDACTED>
# 4번 과정
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~# aws iam delete-policy --policy-arn arn:aws:iam::${ACCOUNT_ID}:policy/AWSLoadBalancerControllerIAMPolicy
(arn:aws:eks:ap-northeast-2:<REDACTED>:cluster/eks-private:N/A) root@bastion-EC2:~#
이후 배포한 eks-private 을 tf destroy -auto-approve 로 삭제합니다.