들어가며
앞 글에서 GitOps(Kustomize + ArgoCD)를 다뤘는데, 글을 쓰면서 한 가지가 계속 마음에 걸렸다. "그래서 그 앱들이 뜨는 클러스터 자체는 누가 만드는데?" GitOps가 앱을 책임진다면 그 무대인 EKS는 또 다른 이야기였다.
처음엔 콘솔에서 클릭클릭으로 EKS를 만들어봤는데 한 번 지웠다가 다시 만들려니 "내가 뭘 어떻게 클릭했더라"가 하나도 기억이 안 났다. 결론부터 말하면, 인프라는 기억이 아니라 코드로 남겨야 한다. 그래서 Terraform으로 VPC부터 EKS, IRSA, ALB Controller, External Secrets까지 전부 코드로 찍어낸 과정을 정리한다.
레포: github.com/DGU-CAP/infra 의 terraform/
상태 파일을 어디에 둘 것인가
Terraform을 처음 쓰면 terraform.tfstate라는 파일이 로컬에 생기는데, 이게 함정이다. 팀 프로젝트에서 이걸 각자 로컬에 들고 있으면 상태가 바로 꼬인다. 그래서 S3 + DynamoDB 원격 백엔드로 뺐다.
# backend.tf
terraform {
backend "s3" {
bucket = "dgu-cap-terraform-state"
key = "terraform.tfstate"
region = "ap-northeast-2"
dynamodb_table = "dgu-cap-terraform-locks" # 동시 apply 방지 락
encrypt = true
}
}
apply를 때리면 한쪽을 막아서 상태가 깨지는 걸 방지한다.여기서 닭과 달걀 문제가 하나 있다. state를 담을 S3/DynamoDB 자체는 누가 만드나? 이건 별도 bootstrap/으로 먼저 만들어두고 본 코드에서 그걸 백엔드로 참조하게 했다.
네트워크 — EKS는 사실 네트워크 설계가 절반이다
EKS를 만들면서 가장 의외였던 게, 정작 클러스터 자체보다 VPC와 서브넷 설계에 시간을 훨씬 많이 썼다는 점이다. 퍼블릭/프라이빗 서브넷을 2개 AZ에 분산하고 노드는 프라이빗에 숨긴다.
# main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr # 10.0.0.0/16
enable_dns_support = true
enable_dns_hostnames = true
}
resource "aws_subnet" "public" {
count = 2
map_public_ip_on_launch = true
tags = {
"kubernetes.io/role/elb" = "1" # 외부 ALB용
"kubernetes.io/cluster/${local.eks_cluster_name}" = "shared"
}
}
resource "aws_subnet" "private" {
count = 2
tags = {
"kubernetes.io/role/internal-elb" = "1" # 내부 LB용
"kubernetes.io/cluster/${local.eks_cluster_name}" = "shared"
}
}
여기서 두 가지는 모르면 진짜 한참 헤맨다.
첫째, 서브넷 태그가 ALB Controller의 동작 조건이다. kubernetes.io/role/elb=1 태그가 있어야 컨트롤러가 "아, 여기에 ALB를 붙이면 되는구나"를 안다. 이 태그가 빠지면 Ingress를 만들어도 ALB가 안 뜨는데, 에러도 친절하지 않아서 원인 찾는 데 시간 다 쓴다.
둘째, 프라이빗 노드의 외부 통신은 NAT Gateway를 거친다. 노드가 프라이빗 서브넷에 있으니 직접 인터넷이 안 된다. 그런데 이미지 Pull이나 EKS API 통신은 해야 하니, 라우팅 테이블의 0.0.0.0/0을 NAT로 보내준다.
resource "aws_route_table" "private" {
count = 2
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id # 프라이빗 → NAT → IGW
}
}
사실 CJ 프로젝트 트러블슈팅에서 "노드가 클러스터에 안 붙는" 문제의 원인이 딱 이 라우팅이었다. 그때 데인 덕에 이번엔 처음부터 신경 썼다.
EKS 클러스터 + 애드온
resource "aws_eks_cluster" "main" {
name = local.eks_cluster_name
version = var.k8s_version # 1.32
role_arn = aws_iam_role.eks_cluster.arn
vpc_config {
subnet_ids = concat(aws_subnet.public[*].id, aws_subnet.private[*].id)
endpoint_public_access = true
endpoint_private_access = true
}
enabled_cluster_log_types = ["api", "audit"] # 컨트롤플레인 로그 → CloudWatch
}
애드온(vpc-cni, kube-proxy, coredns)은 aws_eks_addon으로 선언적으로 관리한다. 여기서 한 번 삽질했는데, CoreDNS는 노드가 있어야 스케줄되기 때문에 노드그룹보다 먼저 만들려고 하면 Pending에서 안 넘어간다. depends_on으로 순서를 잡아줘야 한다.
resource "aws_eks_addon" "coredns" {
cluster_name = aws_eks_cluster.main.name
addon_name = "coredns"
depends_on = [aws_eks_node_group.main] # ★ 노드그룹 뒤에
}
노드 그룹은 프라이빗 서브넷에 ON_DEMAND로 띄운다.
resource "aws_eks_node_group" "main" {
subnet_ids = aws_subnet.private[*].id
instance_types = ["t3.medium"]
capacity_type = "ON_DEMAND"
scaling_config { desired_size = 3, min_size = 2, max_size = 3 }
update_config { max_unavailable = 1 } # 롤링 업데이트 시 1대씩만
}
IRSA의 출발점 — OIDC Provider
EKS에서 Pod에 IAM 권한을 주는 표준 방법이 IRSA(IAM Roles for Service Accounts) 인데 처음엔 이게 왜 이렇게 복잡한가 싶었다. 노드 Role에 권한 다 몰아주면 안 되나? 싶었는데, 그러면 그 노드의 모든 Pod가 같은 권한을 갖게 되니 위험하다는 걸 알고 생각이 바뀌었다.
IRSA의 전제는 클러스터의 OIDC Provider를 IAM에 등록하는 것이다.
data "tls_certificate" "eks" {
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
resource "aws_iam_openid_connect_provider" "eks" {
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
locals {
oidc_provider = replace(aws_iam_openid_connect_provider.eks.url, "https://", "")
}
이걸 깔고 나면, "특정 네임스페이스의 특정 ServiceAccount만" 역할을 빌릴 수 있게 신뢰 정책을 건다. 아래가 그 핵심 조건이다.
condition {
test = "StringEquals"
variable = "${local.oidc_provider}:sub"
values = ["system:serviceaccount:kube-system:aws-load-balancer-controller"]
}
kube-system 네임스페이스의 aws-load-balancer-controller라는 SA, 딱 그 Pod만 이 역할을 쓸 수 있다는 뜻이다. 권한을 SA 단위로 잘게 쪼개니 최소권한이 자연스럽게 지켜진다.
컨트롤러까지 Terraform이 직접 깐다 (helm provider)
이 부분이 개인적으로 제일 만족스러웠다. ALB Controller랑 External Secrets Operator를 kubectl이나 helm으로 따로 깔지 않고, Terraform이 helm_release로 직접 설치하면서 IRSA 역할 ARN까지 꽂아준다. IAM(권한)과 설치(Helm)가 한 코드에 묶여 있으니 순서가 자동으로 보장된다.
# alb-controller.tf
resource "helm_release" "alb_controller" {
name = "aws-load-balancer-controller"
repository = "https://aws.github.io/eks-charts"
chart = "aws-load-balancer-controller"
namespace = "kube-system"
set { name = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
value = aws_iam_role.alb_controller.arn } # ← IRSA 주입
set { name = "clusterName", value = local.eks_cluster_name }
set { name = "vpcId", value = aws_vpc.main.id }
depends_on = [aws_eks_node_group.main, aws_iam_openid_connect_provider.eks]
}
ALB Controller가 요구하는 IAM 정책은 양이 꽤 많은데 이걸 손으로 복붙하면 오타도 나고 버전 따라 바뀐다. 그래서 AWS 공식 정책 JSON을 http data source로 가져와 그대로 쓴다. 버전도 박아둬서 재현된다.
data "http" "alb_controller_policy" {
url = "https://raw.githubusercontent.com/.../v2.9.0/docs/install/iam_policy.json"
}
resource "aws_iam_policy" "alb_controller" {
policy = data.http.alb_controller_policy.response_body
}
External Secrets Operator도 같은 패턴이다. 다만 여기엔 최소권한을 한 번 더 챙겼다 — dgu-cap/* 프리픽스가 붙은 시크릿만 읽게
# external-secrets.tf
data "aws_iam_policy_document" "external_secrets" {
statement {
actions = ["secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret",
"secretsmanager:ListSecretVersionIds"]
resources = ["arn:aws:secretsmanager:${var.aws_region}:${account_id}:secret:${var.project_name}/*"]
}
}
resource "helm_release" "external_secrets" {
chart = "external-secrets"
version = "0.10.7" # 차트 버전 고정 = 재현성
set { name = "installCRDs", value = "true" }
set { name = "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn"
value = aws_iam_role.external_secrets.arn }
}
이렇게 깐 ESO를 GitOps 쪽에서 ClusterSecretStore로 받아 쓰는 흐름은 앞 글에서 다뤘다. 인프라(Terraform)와 앱(ArgoCD)이 ESO라는 지점에서 맞물리는 셈이다.
전체를 한 장으로
S3/DynamoDB backend (bootstrap)
│
▼
VPC ─ Subnet(public/private) ─ NAT ─ Route Table
│
▼
EKS Cluster ── OIDC Provider ──┐
│ │ (IRSA 신뢰관계)
▼ ▼
Node Group IAM Role (ALB / ESO)
│ │
├── addon: vpc-cni, kube-proxy, coredns
│
▼
helm_release: ALB Controller, External Secrets Operator
│
▼
(이후 ArgoCD가 앱 배포 → GitOps 글로 연결)
마무리
| 포인트 | 핵심 |
|---|---|
| 원격 state | S3(공유) + DynamoDB(락)로 협업 안전 |
| 서브넷 태그 | ALB Controller 동작의 숨은 전제 |
| depends_on | CoreDNS·컨트롤러는 노드/OIDC 뒤에 |
| OIDC + IRSA | Pod 권한을 SA 단위 최소권한으로 |
| helm provider | IAM과 컨트롤러 설치를 한 코드에 묶어 순서 보장 |
| 버전 핀 | 차트/정책 버전 고정 = 재현성 |

작업 방식도 한마디 보태고 싶다. .tf 코드는 에이전트로 짜고, 여러 에이전트한테 서로 다른 역할을 줘서 교차로 돌렸다. 하나는 리소스 초안을 뽑고, 하나는 보안 관점에서 IAM 정책이 과한지 리뷰하고, 하나는 depends_on 순서나 순환 참조 같은 게 없는지 본다. 리소스 블록을 한 줄씩 외워 타이핑하는 시대는 지났고, 대신 에이전트들이 내놓은 결과를 검토하고 조율하는 게 내 일이 됐다. 그래도 결국 서브넷을 몇 개 AZ로 가를지, 노드를 프라이빗에 둘지, IRSA를 어디까지 쪼갤지 같은 결정과, "이게 왜 이렇게 돼야 하는가"를 설명하는 건 사람 몫이다. 그걸 못 하면 에이전트가 뱉은 코드를 책임지고 굴릴 수가 없다.
Terraform으로 인프라를 코드화하고 나서 가장 좋았던 건, 클러스터를 통째로 지웠다 다시 만들어도 똑같이 복구된다 는 그 안도감이었다. 콘솔 클릭으로는 절대 못 얻는 감각이다. 그 위에서 GitOps가 앱을 책임진다 — 인프라는 Terraform, 앱은 ArgoCD 라는 경계가 머릿속에서 또렷해진 게 이번 프로젝트의 제일 큰 수확이었다.