DevOps

Terraform으로 EKS 올리고 Jenkins CI/CD 붙이기까지

중대경 2026. 9. 10. 11:09

 

 

EKS에 앱 하나 띄우고 Jenkins 파이프라인 연동하는 일, 가이드나 튜토리얼만 따라 할 땐 참 쉬워 보였습니다.

 

하지만 직접 Terraform으로 하나씩 코드를 짜고, 권한을 분리하며 클러스터를 세워보니… 진짜 문제는 terraform apply 성공 이후부터 터져 나왔습니다.

IAM과 K8s RBAC 사이의 권한 레이어 혼선부터 EKS 20.x 모듈의 보안 기본값, 그리고 Jenkins 컨테이너 내부 계정의 권한 매핑까지.

이번 글은 단순히 구축 순서만 나열한 튜토리얼이 아닙니다.

 

‘일단 되게만 만드는 구축’을 넘어, 프로덕션 수준의 트레이드오프를 고민하고 AccessDenied 에러와 싸우며 배운 EKS/IaC/CI-CD 실전 트러블슈팅의 기록입니다.

 

 

아키텍처( Architecture )

 
GitHub → Jenkins(컨테이너) → docker build → ECR push → kubectl set image
                                  ↓
                       EKS (Terraform 프로비저닝)
                                  ↓
                        LoadBalancer → 외부 접근

 

 

 

EKS — 클러스터를 직접 세우면서 부딪힌 권한 레이어terraform-aws-modules/eks/aws 모듈로 클러스터와 노드그룹을 올리는 과정에서, "EKS를 쓴다"는 게 단순히 kubectl apply 한 번으로 끝나는 일이 아니라는 걸 직접 부딪히며 깨달았습니다.

 

처음엔 AmazonEKSClusterPolicy, AmazonEKSServicePolicy, AmazonEKSWorkerNodePolicy를 제 IAM 사용자에 직접 붙였습니다.

 

terraform apply는 통과했지만, 정작 eks:CreateCluster 호출 시점에 AccessDenied 에러가 발생했습니다.

 

 클러스터 생성 권한과 클러스터 접속 권한은 완전히 다른 레이어입니다.

 

Plaintext
 
error: You must be logged in to the server (the server has asked for the client to provide credentials)

여기서 개념이 명확히 정리되었습니다.

 

EKS:CreateCluster를 호출할 수 있는 권한(AWS IAM 레벨)과, 그렇게 만들어진 클러스터의 쿠버네티스 API 서버에 로그인할 수 있는 권한(K8s RBAC 레벨)은 서로 다른 문제라는 점입니다.

 

EKS는 기본값으로 보안·가시성 구성을 요구합니다.

 

이 두 리소스에 대한 권한(kms:TagResource, logs:TagResource)이 부족하여 각각 에러가 났었고, AWSKeyManagementServicePowerUser와 CloudWatchLogsFullAccess 정책을 추가로 부여하여 해결했습니다.

 

"일단 되게만" 만드는 것이 아니라, 프로덕션급 보안·운영 구성이 기본값으로 켜져 있다는 것을 체감할 수 있었습다.

 

Deployment(2 replicas) + Service(type: LoadBalancer)를 배포하여 AWS ELB가 자동 프로비저닝되는 것을 확인했습니다.

또한 Jenkins가 kubectl set image로 롤링 업데이트를 트리거하면 새 Pod가 뜨고 이전 Pod가 정리되는 과정을 직접 관찰했습니다.

 

YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: devops-portfolio-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: devops-portfolio-app
  template:
    metadata:
      labels:
        app: devops-portfolio-app
    spec:
      containers:
        - name: app
          image: <AWS_ACCOUNT_ID>.dkr.ecr.ap-northeast-2.amazonaws.com/devops-portfolio-app:latest
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: devops-portfolio-app-svc
spec:
  type: LoadBalancer
  selector:
    app: devops-portfolio-app
  ports:
    - port: 80
      targetPort: 8080

각 설정에는 명확한 이유가 있습니다.

  • replicas: 2: Pod 하나가 다운되어도 서비스가 중단되지 않도록 하는 최소한의 이중화 구성입니다. LoadBalancer가 두 Pod로 트래픽을 분산시킵니다.
  • selector.matchLabels(Deployment)와 selector(Service): 두 설정 모두 동일한 app: devops-portfolio-app 라벨을 참조합니다. Deployment는 이 라벨로 자신이 관리하는 Pod를 추적하고, Service는 같은 라벨을 통해 트래픽을 전달할 Pod를 찾습니다. 이 라벨이 어긋나면 Service가 정상적으로 생성되어도 트래픽이 Pod로 전달되지 않습니다.
  • containerPort: 8080: Dockerfile의 EXPOSE 8080 및 실제 Flask 앱이 리스닝하는 포트와 일치시킨 값입니다.
  • port: 80 → targetPort: 8080: 외부에서는 표준 HTTP 포트(80)로 접근하지만, 실제 컨테이너 내부에서는 8080 포트로 리스닝하고 있기 때문에 Service가 해당 포트 간 매핑을 처리해 줍니다.
  • 이미지 태그 latest 고정: 이는 의도된 구성입니다. 이 YAML 파일 자체는 "최초 구조"이며, 실제 배포되는 이미지는 Jenkins가 매 빌드마다 kubectl set image deployment/devops-portfolio-app app=<ECR_REPO>:<BUILD_NUMBER> 명령어로 덮어씁니다. 다만, 이 YAML을 다시 apply하면 latest 태그로 되돌아가는 한계가 있어, 완전한 "선언형 GitOps"라기보다는 "명령형 배포"에 가깝습니다. 향후 ArgoCD나 Flux 같은 GitOps 도구를 도입하면 이 문제를 깔끔하게 해결할 수 있습니다.
  • livenessProbe / readinessProbe 및 resource requests/limits 생략: 현재는 최소 기능 검증 단계이므로 의도적으로 제외하였으며, 추후 K8s 리소스 다루는 범위를 넓히면서 보완할 예정입니다.

Terraform — 인프라를 전부 코드로 관리하기원격 상태 관리

 

Terraform
terraform {
  backend "s3" {
    bucket         = "name-tfstate-devops-project"
    key            = "devops-portfolio/terraform.tfstate"
    dynamodb_table = "terraform-lock-devops-project"
    encrypt        = true
  }
}

VPC 모듈과 비용 트레이드오프모듈 의존관계 설계AWS — 계정 및 권한 운영

  • 프로젝트 전용 IAM 분리: 루트 계정이나 기존 계정을 사용하지 않고, 본 프로젝트 전용 IAM 사용자 및 CLI 프로파일(devops-project)을 처음부터 분리하여 적용했습니다.
  • 관리형 정책 10개 한도 이슈: 정책을 추가하던 중 Cannot exceed quota for PoliciesPerUser: 10 에러에 직면했습니다. 권한을 정리하던 과정에서 AmazonVPCFullAccess가 불필요하다는 점을 확인했습니다. VPC 관련 API(CreateVpc, CreateSubnet 등)는 모두 ec2: 네임스페이스에 포함되어 있어 이미 부여되어 있던 AmazonEC2FullAccess로 충분히 커버 가능했기 때문입니다. 중복을 제거하여 정책 슬롯을 확보했습니다.
  • ECR 연동: 빌드된 이미지를 저장할 리포지토리를 Terraform으로 프로비저닝하고, Jenkins 파이프라인 내에서 로그인 및 푸시 작업이 이뤄지도록 연결했습니다.

컨테이너화 및 CI/CD 구축Jenkins는 기존 사내 환경을 쓰지 않고 컨테이너로 직접 구축했습니다. 이 과정에서 Jenkins 프로세스가 jenkins 계정(uid 1000)으로 동작하므로, 호스트의 docker.sock 권한(--group-add <GID>) 및 자격증명 경로(/var/jenkins_home/.aws, /var/jenkins_home/.kube)를 맞춰주어야 했습니다. 이를 통해 컨테이너 내부 계정이 호스트와 네임스페이스 레벨에서 완전히 격리되어 있다는 점을 깊이 이해할 수 있었습니다.실전에서 활용한 EKS/K8s 명령어 정리클러스터 접속 설정

  • aws eks update-kubeconfig: 로컬(또는 서버)의 ~/.kube/config에 해당 EKS 클러스터의 접속 정보(API 서버 주소, 인증 방식 등)를 등록합니다. kubectl은 이 설정 파일을 기준으로 클러스터와 통신하므로, 클러스터 생성 직후나 멀티 클러스터를 전환할 때 가장 먼저 실행해야 하는 명령어입니다.
Bash
 
aws eks update-kubeconfig \
  --region ap-northeast-2 \
  --name devops-portfolio-eks \
  --profile devops-project
  • aws sts get-caller-identity: 현재 AWS CLI가 어떤 신원(Identity)으로 인증되어 있는지 확인합니다. kubectl 관련 문제가 발생할 때 가장 먼저 점검해야 하는 명령어로, 의도한 IAM 사용자가 아닌 다른 역할(예: EC2 기본 IAM Role)로 인증되어 있으면 전체 클러스터 접근권이 어긋날 수 있습니다. 실제로 Jenkins 컨테이너 내부의 인증 주체 오동작을 잡는 데 결정적인 역할을 했습니다.
Bash
 
aws sts get-caller-identity --profile devops-project

클러스터 및 워크로드 상태 확인

  • kubectl get nodes: 클러스터 내 워커 노드들의 수량과 Ready 상태 여부를 점검합니다. 이 명령어가 실패할 경우 가장 먼저 RBAC 및 Access Entry 등 인증 문제부터 의심해 봐야 합니다.
  • kubectl get pods: 배포된 애플리케이션 Pod들의 상태를 확인합니다. ContainerCreating에서 Running으로 전환되는 과정을 모니터링할 때 사용합니다.
  • kubectl get svc: Pod로 트래픽을 라우팅하는 Service 상태와 LoadBalancer 타입 적용 시 AWS에서 할당받은 EXTERNAL-IP(ELB DNS)를 확인합니다. Pod와 Service는 서로 다른 K8s 오브젝트이며 장애 포인트도 다르기 때문에 항상 함께 상태를 체크해야 합니다.
Bash
 
kubectl get nodes
kubectl get pods
kubectl get svc

배포 및 롤아웃

  • kubectl apply -f <파일>: YAML 매니페스트를 클러스터에 선언형(Declarative) 방식으로 적용합니다. 최초 Deployment 및 Service를 프로비저닝할 때 활용했습니다.
Bash
 
kubectl apply -f deployment.yaml
  • kubectl set image: 실행 중인 Deployment의 컨테이너 이미지 태그만 직접 변경하여 롤링 업데이트를 트리거합니다(명령형 방식). Jenkins 파이프라인의 Deploy 단계에서 매 빌드마다 실행되어 무중단 배포를 수행합니다.
Bash
 
kubectl set image deployment/devops-portfolio-app \
  app=<ECR_REPO>/devops-portfolio-app:<BUILD_NUMBER>

이미지 저장소(ECR) 연동

  • aws ecr get-login-password | docker login: ECR의 임시 인증 토큰을 발급받아 Docker 클라이언트를 해당 리포지토리에 로그인시킵니다. 이 과정이 완료되어야 docker push를 통해 EKS가 가져갈 이미지를 상위 저장소로 올릴 수 있습니다.
Bash
 
aws ecr get-login-password --region ap-northeast-2 --profile devops-project \
  | docker login --username AWS --password-stdin <ECR_REPO>

로그 확인

  • aws logs tail: EKS 컨트롤 플레인이 CloudWatch에 남기는 감사(Audit) 로그를 실시간으로 스트리밍하여 확인합니다. 내부 통신 시스템 로그가 다수 포함되어 있으므로 system:/eks:와 같은 사용자 로그는 필터링해서 살펴보는 것이 효율적입니다.
Bash
 
aws logs tail /aws/eks/devops-portfolio-eks/cluster \
  --profile devops-project --since 1h

배운 점

  1. EKS 권한 구조의 분리: "AWS 리소스를 생성하는 권한"과 "K8s API에 접근하는 권한"은 완전히 별개입니다. 두 레이어의 구분이 명확해야 클러스터 생성 이후의 정상 작동을 보장할 수 있습니다.
  2. 매니지드 정책의 대상 구분: Role 전용 정책과 User 전용 정책은 다릅니다. 유사한 이름이라도 부착되는 대상에 따라 적용 여부가 결정됩니다.
  3. Terraform 모듈의 암묵적 기본값: 모듈 내부에서 명시적 설정 외에 KMS, CloudWatch 등의 보안/가시성 옵션이 기본 프로비저닝됩니다. 단순히 "작동한다"를 넘어 내부 동작 원리를 이해하는 것이 중요함을 느꼈습니다.
  4. 컨테이너 격리 특성: 컨테이너 내부의 계정, 네트워크, 파일시스템은 호스트 환경과 독립적으로 분리되어 작동한다는 점을 깊이 체감했습니다.
  5. 실전 경험의 가치: 자격증 취득 이상의 가치는 에러 메시지를 다각도로 분석하고 원인을 추적해 가는 트러블슈팅 경험에서 나온다는 점을 다시금 깨달았습니다.

다음 진행 과제

  • Jenkins Build Trigger(Poll SCM) 연동을 통한 Git Push 시 자동 배포 파이프라인 완성하기
  • EKS 환경 내 Liveness/Readiness Probe, HPA 등 K8s 고도화 리소스 적용하기
  • CloudWatch 감사 로그 분석을 통한 배포 이벤트 추적 체계 구축하