Terraform으로 EKS 플랫폼 구축하기

엄경문·2026년 6월 5일

project

목록 보기
3/4

들어가며

앞 글에서 GitOps(Kustomize + ArgoCD)를 다뤘는데, 글을 쓰면서 한 가지가 계속 마음에 걸렸다. "그래서 그 앱들이 뜨는 클러스터 자체는 누가 만드는데?" GitOps가 앱을 책임진다면 그 무대인 EKS는 또 다른 이야기였다.

처음엔 콘솔에서 클릭클릭으로 EKS를 만들어봤는데 한 번 지웠다가 다시 만들려니 "내가 뭘 어떻게 클릭했더라"가 하나도 기억이 안 났다. 결론부터 말하면, 인프라는 기억이 아니라 코드로 남겨야 한다. 그래서 Terraform으로 VPC부터 EKS, IRSA, ALB Controller, External Secrets까지 전부 코드로 찍어낸 과정을 정리한다.

레포: github.com/DGU-CAP/infraterraform/


상태 파일을 어디에 둘 것인가

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
  }
}
  • S3: 상태 파일을 한 곳에 두고 팀이 공유. 버전 관리도 됨.
  • DynamoDB: state lock 역할. 두 명이 동시에 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 글로 연결)

마무리

포인트핵심
원격 stateS3(공유) + DynamoDB(락)로 협업 안전
서브넷 태그ALB Controller 동작의 숨은 전제
depends_onCoreDNS·컨트롤러는 노드/OIDC 뒤에
OIDC + IRSAPod 권한을 SA 단위 최소권한으로
helm providerIAM과 컨트롤러 설치를 한 코드에 묶어 순서 보장
버전 핀차트/정책 버전 고정 = 재현성


작업 방식도 한마디 보태고 싶다. .tf 코드는 에이전트로 짜고, 여러 에이전트한테 서로 다른 역할을 줘서 교차로 돌렸다. 하나는 리소스 초안을 뽑고, 하나는 보안 관점에서 IAM 정책이 과한지 리뷰하고, 하나는 depends_on 순서나 순환 참조 같은 게 없는지 본다. 리소스 블록을 한 줄씩 외워 타이핑하는 시대는 지났고, 대신 에이전트들이 내놓은 결과를 검토하고 조율하는 게 내 일이 됐다. 그래도 결국 서브넷을 몇 개 AZ로 가를지, 노드를 프라이빗에 둘지, IRSA를 어디까지 쪼갤지 같은 결정과, "이게 왜 이렇게 돼야 하는가"를 설명하는 건 사람 몫이다. 그걸 못 하면 에이전트가 뱉은 코드를 책임지고 굴릴 수가 없다.

Terraform으로 인프라를 코드화하고 나서 가장 좋았던 건, 클러스터를 통째로 지웠다 다시 만들어도 똑같이 복구된다 는 그 안도감이었다. 콘솔 클릭으로는 절대 못 얻는 감각이다. 그 위에서 GitOps가 앱을 책임진다 — 인프라는 Terraform, 앱은 ArgoCD 라는 경계가 머릿속에서 또렷해진 게 이번 프로젝트의 제일 큰 수확이었다.

0개의 댓글