K8s 실전 #1 EKS 클러스터 셋업 — Terraform / eksctl / IRSA / 애드온
K8s 실전 시리즈의 첫 글입니다. 기초,중급,고급 트랙(20편)이 매니페스트 한 장의 모델부터 정책 엔진과 옵저버빌리티까지 K8s를 객체 차원에서 따라간 길이었다면, 실전 시리즈 6편은 그 위에 진짜 서비스를 하나 올리고 운영하는 흐름입니다. 가상의 백엔드 서비스 myshop-api를 EKS에 올리고, RDS와 연결하고, CI/CD로 배포하고, 모니터링,운영 단계까지 한 묶음으로 따라가겠습니다. 이번 글은 그 흐름의 출발점 — EKS 클러스터를 처음부터 띄우는 단계입니다. Terraform으로 VPC와 클러스터를 선언하고, 노드 그룹과 IRSA를 셋업하고, 필수 애드온까지 얹는 흐름을 정리하겠습니다.
이번 시리즈는 K8s 실전 6편입니다.
- #1 EKS 클러스터 셋업 — Terraform / eksctl / IRSA / 애드온 ← 이번 글
- #2 앱 배포 골격 — Deployment / Service / Ingress / Helm
- #3 DB 연동 — RDS / Secrets Manager / External Secrets / 커넥션 풀
- #4 CI/CD 파이프라인 — GitHub Actions / ECR / ArgoCD
- #5 모니터링,알람 — Prometheus / CloudWatch / Alertmanager
- #6 운영 체크리스트 — 업그레이드 / 백업,복구 / 비용 / 보안
kubectl apply 가 의도와 다른 에러를 뱉어, 원인을 클러스터 단에서 거꾸로 짚어 가게 됩니다. 매니페스트를 적용하기 전에 utilrepo 의 YAML 검증기 에 한 번 붙여 넣으면 구문 에러를 줄,열 번호로 짚어 줍니다. utilrepo 는 브라우저에서 동작하는 가벼운 웹 유틸리티 모음으로, 비밀 정보가 외부로 나가지 않고 --- 로 묶인 다중 문서 매니페스트와 탭,스페이스 혼용 같은 자주 만나는 함정까지 함께 잡아 줍니다.myshop-api — 시리즈를 관통하는 가상 서비스 #
6편을 묶는 시나리오를 한 줄로 잡아 두겠습니다. 가상의 회사 myshop이 자체 백엔드 API 서비스 myshop-api를 EKS에 올린다고 가정합니다. 사양은 단순합니다.
- Python(FastAPI) 또는 Go로 짠 작은 REST API
- RDS PostgreSQL을 데이터 저장소로 사용
- HTTPS로 외부 노출
- prod / dev 두 환경
- 트래픽 변동에 따라 Pod 개수 자동 조정
이 시나리오를 #1의 클러스터부터 #6의 운영 사이클까지 일관되게 따라갑니다. 각 글이 다음 글의 입력이 되는 구조이고, 마지막 글까지 따라온 시점이라면 실제 운영 클러스터의 한 사이클이 손에 들어옵니다.
셋업 도구의 선택 — Terraform vs eksctl #
EKS 클러스터를 만드는 선택지는 여럿입니다.
| 도구 | 모델 | 적합한 곳 |
|---|---|---|
| AWS 콘솔 | 클릭 | 학습 / 한 번 보기 |
| eksctl | YAML 한 장 + CLI | PoC / 빠른 셋업 / 학습 |
| Terraform | HCL로 선언 | 운영 클러스터 / 멀티 환경 / IaC 표준 |
| AWS CDK / Pulumi | 코드(TypeScript, Python 등)로 선언 | 코드 친화 팀 / 복잡한 분기 |
운영 클러스터의 사실상 표준은 Terraform입니다. VPC,IAM,EKS 컨트롤 플레인,노드 그룹,애드온,RDS,Route53까지를 한 코드베이스로 선언하고 git에 보관할 수 있어서, 클러스터 자체가 코드로 재현 가능해집니다. 같은 매니페스트가 dev / prod 두 환경에 동일하게 적용되고, 변경은 PR 리뷰를 거쳐 들어갑니다.
eksctl이 그 위치를 대체하기에는 추상화의 결이 EKS에 너무 한정적이지만, 빠른 셋업과 학습에는 가장 직관적입니다. 이 글에서는 Terraform을 메인으로 다루고, eksctl을 비교 옵션으로 짧게 짚겠습니다.
Terraform 프로젝트 구조 #
myshop-api 인프라의 Terraform 코드 구조부터 잡겠습니다.
terraform/
├── modules/
│ ├── network/ # VPC, 서브넷, NAT, 라우팅
│ ├── eks/ # EKS 클러스터 + 노드 그룹
│ └── addons/ # VPC CNI, EBS CSI, IRSA roles
├── envs/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf
│ └── ...
└── versions.tfmodules/에 재사용 가능한 단위를 두고, envs/에서 dev / prod를 다르게 인스턴스화하는 구조입니다. dev / prod의 차이는 인스턴스 타입, 노드 개수, 멀티 AZ 정도이고 모듈 자체는 공유합니다.
Provider와 backend #
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "myshop-tfstate"
key = "eks/prod/terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "myshop-tfstate-lock"
encrypt = true
}
}
provider "aws" {
region = "ap-northeast-2"
default_tags {
tags = {
Project = "myshop"
Environment = "prod"
ManagedBy = "terraform"
}
}
}state 파일을 S3에, 잠금을 DynamoDB에 두는 표준 패턴입니다. default_tags가 이 provider로 만든 모든 자원에 자동으로 태그를 붙여 주므로 비용 추적이 간단해집니다.
VPC — EKS의 토대 #
EKS는 자기 VPC를 만들지 않습니다. 사용자가 만든 VPC 위에 컨트롤 플레인의 ENI를 꽂는 구조입니다. 따라서 클러스터를 만들기 전에 VPC와 서브넷부터 정해야 합니다.
VPC 모듈 — terraform-aws-modules/vpc/aws #
VPC를 처음부터 직접 정의하지 않고 커뮤니티 모듈을 쓰는 게 표준입니다.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "${var.project}-${var.env}"
cidr = "10.10.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
private_subnets = ["10.10.1.0/24", "10.10.2.0/24"]
public_subnets = ["10.10.101.0/24", "10.10.102.0/24"]
database_subnets = ["10.10.201.0/24", "10.10.202.0/24"]
enable_nat_gateway = true
single_nat_gateway = var.env == "dev"
one_nat_gateway_per_az = var.env == "prod"
enable_dns_hostnames = true
enable_dns_support = true
public_subnet_tags = {
"kubernetes.io/role/elb" = "1"
}
private_subnet_tags = {
"kubernetes.io/role/internal-elb" = "1"
}
}EKS와 묶일 때 핵심은 마지막의 두 태그입니다.
kubernetes.io/role/elb = 1— public 서브넷에 붙여, AWS Load Balancer Controller가 외부용 LB를 이 서브넷에 만듭니다.kubernetes.io/role/internal-elb = 1— private 서브넷에 붙여, 내부용 LB(클러스터 내부 + VPC 안)를 이쪽에 만듭니다.
이 태그가 없으면 LB가 어느 서브넷에 들어갈지 알 수 없어 #2에서 만들 Ingress가 동작하지 않습니다.
single NAT vs NAT per AZ #
single_nat_gateway는 dev에서만 켭니다. NAT Gateway 한 개의 비용이 적지 않아서(시간당 + 데이터 전송량당) dev 환경에서는 한 개로 묶고, prod는 AZ별로 한 개씩 둬서 한 AZ가 죽어도 다른 AZ의 워크로드가 NAT를 잃지 않게 합니다.
EKS 모듈 — 컨트롤 플레인 + 노드 그룹 #
VPC가 준비되면 EKS 클러스터 자체를 정의합니다.
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.0"
cluster_name = "${var.project}-${var.env}"
cluster_version = "1.30"
vpc_id = var.vpc_id
subnet_ids = var.private_subnet_ids
cluster_endpoint_public_access = true
cluster_endpoint_private_access = true
enable_irsa = true
cluster_addons = {
coredns = {
most_recent = true
}
kube-proxy = {
most_recent = true
}
vpc-cni = {
most_recent = true
}
aws-ebs-csi-driver = {
most_recent = true
service_account_role_arn = module.ebs_csi_irsa.iam_role_arn
}
}
eks_managed_node_groups = {
general = {
desired_size = var.env == "prod" ? 3 : 2
min_size = 2
max_size = 10
instance_types = ["t3.medium"]
capacity_type = var.env == "prod" ? "ON_DEMAND" : "SPOT"
labels = {
role = "general"
}
}
}
}이 한 모듈이 다음을 모두 만들어 냅니다.
- EKS 컨트롤 플레인 (관리형 K8s 1.30)
- 클러스터의 IAM Role
- OIDC provider (IRSA의 기반 —
enable_irsa = true) - Managed Node Group (EC2 인스턴스로 워커 노드 생성)
- 표준 애드온 4종 (VPC CNI, CoreDNS, kube-proxy, EBS CSI Driver)
고급 #2 IRSA 절에서 다룬 OIDC provider가 여기서 자동으로 활성화되고, 이후 #3에서 ServiceAccount를 IAM Role과 묶을 때 그 OIDC가 신뢰의 기반이 됩니다.
Managed Node Group이라는 표준 #
EKS의 워커 노드는 세 갈래로 구성할 수 있습니다.
| 종류 | 모델 |
|---|---|
| Managed Node Group | EKS가 EC2 노드의 라이프사이클을 관리. 가장 표준적인 길. |
| Self-managed Node Group | 사용자가 직접 EC2 그룹 운영. 고급 커스터마이징 시. |
| Fargate | 서버리스. 노드 자체를 신경 쓰지 않음. 비용,제약 있음. |
Managed Node Group이 첫 도입의 표준입니다. 인스턴스 타입,크기,capacity_type(ON_DEMAND / SPOT)을 매니페스트로 정의하면 EKS가 노드의 가입,제거,업그레이드를 자동으로 관리합니다. 노드 자체의 OS는 Amazon Linux 2 또는 Bottlerocket이고, kubelet,containerd,CNI 에이전트가 미리 설치된 AMI를 받습니다.
클러스터 endpoint의 두 모드 #
cluster_endpoint_public_access와 cluster_endpoint_private_access 두 플래그의 조합이 클러스터 API 서버 접근을 제어합니다.
| public | private | 의미 |
|---|---|---|
| true | false | 인터넷에서 누구든 접근 (RBAC만이 보안 경계) |
| true | true | 인터넷 + VPC 내부 모두 (가장 흔한 설정) |
| false | true | VPC 내부에서만. 가장 엄격. bastion이나 VPN 필요. |
prod 환경의 보안 가이드는 보통 마지막(private only)이지만, GitHub Actions에서 직접 kubectl을 부르려면 public이 켜져 있어야 합니다. 일반적으로 public + IP 제한(cluster_endpoint_public_access_cidrs)을 같이 거는 절충이 자주 쓰입니다.
IRSA — 애드온의 IAM Role 부여 #
EBS CSI Driver, AWS Load Balancer Controller, External Secrets 같은 애드온은 클러스터 안에서 AWS API를 호출합니다. 이 호출에 IRSA로 권한을 부여하는 패턴을 짚겠습니다.
module "ebs_csi_irsa" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "~> 5.0"
role_name = "${var.project}-${var.env}-ebs-csi"
attach_ebs_csi_policy = true
oidc_providers = {
main = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
}
}
}이 모듈이 다음을 자동으로 만듭니다.
- IAM Role (이름이
myshop-prod-ebs-csi) - 정책 (EBS 디스크 생성,삭제,attach 권한)
- Trust policy (고급 #2 IRSA의 그 trust policy —
kube-system네임스페이스의ebs-csi-controller-saServiceAccount만 이 Role을 가져갈 수 있도록 제한)
이 IAM Role의 ARN이 위 cluster_addons.aws-ebs-csi-driver.service_account_role_arn에 전달되어, EKS가 EBS CSI 애드온의 ServiceAccount에 자동으로 annotation을 붙여 줍니다. 결과적으로 PVC를 만들면 EBS 볼륨이 자동으로 프로비저닝되는 흐름이 동작합니다.
같은 패턴이 #3의 External Secrets에도, #5에서 다루는 CloudWatch 쪽에도 적용됩니다. 워크로드별로 ServiceAccount + IAM Role 1:1 매핑이 K8s 실전의 표준 보안 구조입니다.
kubeconfig — 클러스터에 접근 #
Terraform으로 클러스터를 만들고 나면 로컬에서 kubectl이 그 클러스터를 부를 수 있도록 kubeconfig를 받아와야 합니다.
aws eks update-kubeconfig \
--region ap-northeast-2 \
--name myshop-prodkubectl get nodes
kubectl get pods -ANAME STATUS ROLES AGE VERSION
ip-10-10-1-145.ap-northeast-2.compute.internal Ready <none> 3m v1.30.0
ip-10-10-2-201.ap-northeast-2.compute.internal Ready <none> 3m v1.30.0
ip-10-10-2-83.ap-northeast-2.compute.internal Ready <none> 3m v1.30.0세 노드가 떠 있고 kube-system 네임스페이스의 시스템 Pod(coredns, kube-proxy, aws-node, ebs-csi-controller)가 모두 Running 상태이면 클러스터가 정상입니다.
접근 권한 — aws-auth ConfigMap의 역할 #
EKS 클러스터를 만든 IAM 사용자/Role이 자동으로 system:masters 권한을 갖습니다. 다른 IAM 사용자에게 접근 권한을 주려면 aws-auth ConfigMap을 수정하거나, 1.23+의 새 모델인 EKS Access Entries를 사용합니다. Access Entries가 더 표준이고 Terraform 모듈에서도 권장됩니다.
access_entries = {
developers = {
principal_arn = "arn:aws:iam::123456789012:role/Developer"
policy_associations = {
view = {
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"
access_scope = {
type = "cluster"
}
}
}
}
}IAM Role 한 개를 클러스터 전체의 view 권한과 묶는 매니페스트입니다. K8s RBAC의 view ClusterRole이 자동으로 매핑되어, 이 Role을 가진 사용자는 클러스터의 모든 객체를 읽을 수 있게 됩니다.
eksctl — 빠른 셋업의 길 #
학습이나 PoC에서 빠른 클러스터가 필요할 때 eksctl이 한 줄에 끝납니다.
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: myshop-dev
region: ap-northeast-2
version: "1.30"
vpc:
nat:
gateway: Single
managedNodeGroups:
- name: general
instanceType: t3.medium
desiredCapacity: 2
minSize: 2
maxSize: 5
spot: true
addons:
- name: vpc-cni
- name: coredns
- name: kube-proxy
- name: aws-ebs-csi-driver
iam:
withOIDC: trueeksctl create cluster -f cluster.yaml이 한 명령으로 VPC,EKS,노드 그룹,OIDC provider,기본 애드온까지 자동으로 만들어집니다. 15~20분 정도 걸리고, 끝나면 kubeconfig도 자동으로 갱신됩니다.
eksctl의 결을 정리하면 다음과 같습니다.
- 장점 — 학습 곡선이 가장 낮음, 한 매니페스트로 클러스터 한 대 통째로
- 단점 — 멀티 환경 / RDS / Route53 같은 주변 자원과 한 묶음으로 관리하기 어려움. CloudFormation을 내부적으로 쓰므로 Terraform과 state가 분리됨.
운영 클러스터의 도착점은 거의 항상 Terraform이지만, 첫 학습이나 일회성 PoC에는 eksctl이 가장 빠릅니다.
Karpenter — 노드 오토스케일링의 새 길 #
Managed Node Group은 자체 오토스케일링(Cluster Autoscaler)이 가능하지만, 인스턴스 타입이 미리 정해진 몇 가지로 제한됩니다. 트래픽 변동이 크고 워크로드의 자원 요구가 다양한 환경에서는 Karpenter가 새 표준으로 자리 잡고 있습니다.
Karpenter는 고급 #5에서 다룬 메트릭 기반 오토스케일링과 다른 결입니다. Pending 상태인 Pod를 보고, 그 Pod의 자원 요구에 맞는 인스턴스 타입을 실시간으로 골라 새 노드를 띄웁니다. 정해진 인스턴스 풀이 아니라 AWS의 모든 EC2 타입에서 가장 적합한 것을 선택합니다.
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: node.kubernetes.io/instance-type
operator: In
values: ["t3.medium", "t3.large", "m5.large", "m5.xlarge"]
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
limits:
cpu: 100
disruption:
consolidationPolicy: WhenEmptyOrUnderutilizedKarpenter 도입은 #1의 출발점에서는 부담스러우므로, Managed Node Group으로 시작해서 트래픽 패턴이 정착되면 Karpenter로 전환하는 흐름이 자연스럽습니다. 시리즈에서는 #6 운영 체크리스트에서 다시 짚겠습니다.
클러스터 셋업 후 첫 점검 #
클러스터가 떠오른 직후 한 번씩 돌려 두면 좋은 점검 명령들입니다.
kubectl version --short
kubectl get nodes -o widekubectl get pods -n kube-systemaws eks describe-cluster \
--name myshop-prod \
--region ap-northeast-2 \
--query "cluster.identity.oidc.issuer" \
--output textaws eks list-addons --cluster-name myshop-prod --region ap-northeast-2
aws eks describe-addon --cluster-name myshop-prod \
--addon-name vpc-cni --region ap-northeast-2이 네 명령으로 클러스터,노드,시스템 Pod,OIDC,애드온이 모두 정상인지 확인됩니다. 이상이 보이면 다음 글로 넘어가기 전에 잡아 두는 게 안전합니다.
비용의 첫 인상 #
EKS 클러스터의 비용은 크게 셋입니다.
| 항목 | 비용 (ap-northeast-2 기준) |
|---|---|
| EKS 컨트롤 플레인 | 시간당 $0.10 (≈ 월 $73) |
| EC2 노드 (t3.medium 3대) | 월 약 $80 (ON_DEMAND) / $25 (SPOT) |
| NAT Gateway | 월 약 $35 + 데이터 전송 |
| EBS / Load Balancer / 데이터 전송 | 사용량에 따라 |
가장 작은 prod 클러스터의 시작 비용이 월 $200~$300 정도입니다. dev 환경에서 SPOT 인스턴스 + Single NAT를 쓰면 그 절반 이하로 떨어집니다. 비용은 #6에서 다시 다루겠지만, 클러스터를 띄우는 시점부터 인지하고 시작하는 게 좋습니다.
마무리 #
K8s 실전 시리즈의 첫 글을 마무리하겠습니다. Terraform으로 VPC,EKS 컨트롤 플레인,노드 그룹,IRSA,표준 애드온까지를 한 코드베이스로 선언하는 흐름을 따라갔고, eksctl로 빠르게 시작하는 길과 Karpenter로 노드 오토스케일링을 넓히는 결까지 짚었습니다. 이 시점에서 클러스터는 비어 있는 상태입니다 — 노드는 떠 있고 시스템 Pod는 살아 있지만, 우리가 올리려는 myshop-api는 아직 한 줄도 매니페스트로 들어가지 않았습니다. 다음 글에서는 그 빈 곳을 채우겠습니다 — Deployment / Service / Ingress / ConfigMap / Secret을 한 묶음으로 정리하고, Helm 차트로 묶어 환경별 배포까지 연결하는 흐름입니다.