0. Contents


1. Overview

stateOIDC역할·경계Account Foundation관리자가 직접 적용 · 거의 안 바뀐다노드NATEKSPR 댓글 /apply 로 로봇이 적용ArgoCDLBCESORLDArgoCD + 공통 설비처음 한 번만 관리자가 Terraform 으로webmemberagentredisAppskukie-manifest 를 ArgoCD 가 배포
이번 편은 가운데 두 층 — EKS 와 ArgoCD 다

앞 두 편과 이번 편

  • 01편과 02편은 권한 이야기였다
    • 레포를 쓰기 권한으로 나눴다
    • Terraform 을 PR 댓글로 승인했다
    • 배포 로봇이 넘을 수 없는 경계를 그었다
  • 이번 편은 그 권한 위에 실제로 무언가를 세우는 이야기다

장별 내용

  • 2장: EKS 는 PR 하나로 등록했지만 /apply 는 세 번 했다
    • 출구 비용이 워커보다 비싸서 NAT 를 바꿨다
    • 미리 바꿔 둔 입력값이 엉뚱한 plan 에 섞일 뻔했다
  • 3장: ArgoCD 는 처음 한 번만 밖에서 넣고, 나머지는 지시서까지 Git 에 두었다

2. EKS — 세 번 만에 선 클러스터

2.1 NAT — 워커보다 비싼 출구

처음 설계: NAT Gateway

  • 노드는 공인 IP 없이 private subnet 에 둔다
  • 이미지를 받으려면 밖으로 나가는 출구가 필요하다
  • 처음 설계는 NAT Gateway 였다
  • 서울 리전 가격으로 계산해 보니 이상한 숫자가 나왔다
NAT Gateway본체 $43.07$46.72+ GB 당 $0.059fck-nat t4g.micro본체$11.97GB 당 요금 없음워커 t3a.medium 1대 ≈ $34NAT Gateway 본체NAT 인스턴스 본체공인 IPv4 $3.65gp3 8GiB $0.73서울 리전 · 월 730시간 기준 — 트래픽이 없어도 월 약 $35 차이
출구 하나가 워커 한 대보다 비쌌다 — 점선을 넘는 막대가 NAT Gateway 다

fck-nat 로 교체

  • 워커 노드(t3a.medium 1대)가 월 $34 정도인데, 출구가 워커보다 비쌌다
  • EC2 한 대를 NAT 로 쓰는 fck-nat 로 바꿨다
  • 대가는 가용성이다
    • 한 대뿐이라 인스턴스가 교체되는 동안 밖으로 나가는 통신이 끊긴다
    • Auto Scaling 그룹이 같은 ENI 와 EIP 를 다시 붙이므로 라우트는 바뀌지 않는다
기존 VPCprivate subnetpublic subnet노드fck-nat인터넷 · ECR
노드는 공인 IP 없이 NAT 인스턴스를 거쳐 나간다 — 한 대라 교체 중에는 바깥 통신이 끊긴다

NAT 이미지: 직접 구운 AMI

  • NAT 인스턴스의 OS 이미지는 공개 AMI 대신 이 계정에서 Packer 로 직접 구웠다
  • 목적: 인스턴스가 교체될 때 검토하지 않은 이미지로 부팅하지 않게 한다
  • 굽는 김에 두 가지를 더했다
    • EIP 연결이 실패하면 약 2분간 재시도한다
    • NAT Gateway 처럼 TCP MSS 를 1460 으로 낮춘다

ECR 수명 규칙

  • 같은 이유로 ECR 에도 수명 규칙을 걸었다
    • prod- 태그: 6개만 남긴다
    • dev- 태그: 4개만 남긴다
    • 태그 없는 이미지: 하루만 남긴다
  • 규칙이 없으면 이미지는 지우지 않는 한 계속 쌓인다 → 저장 비용도 같이 쌓인다

2.2 Three Applies — 실패는 되돌려지지 않고 이어진다

PR 하나, apply 세 번

  • EKS 는 PR 하나로 등록했지만 /apply 는 세 번 했다
1차 /apply — 37개 생성NAT 그룹 하나는 tainted2차 /apply — NAT 그룹 교체노드 그룹은 iam:GetRole 부족으로 거부3차 /apply — 3개 생성노드 그룹 · coredns · kube-proxy높이 = 몇 번째 apply 에서 생겼나실패해도 만든 것은 남고, 다음 plan 은 남은 것만
PR 하나, /apply 세 번 — Terraform 은 실패해도 되돌리지 않고, 다음 견적서가 남은 칸을 채운다

두 실패 비교

  • 두 실패를 한 장에 놓으면 이렇다
1차 /apply — 너무 빨랐다출입증(instance profile)을 만들자마자 NAT 서버를 켰는데, 출입증이 아직 퍼지지 않았다IAM 이 모든 곳에 퍼지는 중 — 수 초 ~ 수십 초시간① 출입증 발급✕ ② NAT 서버 시작 — 너무 이르다Authentication Failure → tainted 딱지✓ ③ 2차 /apply 가 다시 만든다이미 퍼진 뒤라 성공2차 /apply — 너무 꽉 묶었다EKS 는 노드 그룹을 만들기 전에, AWS 기본 도우미 역할이 있는지 로봇 권한으로 먼저 조회한다apply 로봇이 조회해도 되는 역할· kukie-dev-cluster· kukie-dev-node· kukie-dev-cni· kukie-dev-alb· kukie-dev-secrets· kukie-dev-nat+ EKS 노드그룹 도우미 역할 (#32)EKS: 노드 그룹 생성만들기 전에 확인부터도우미 역할 있나?AWSServiceRoleForAmazonEKSNodegroup✕ 목록에 없음 → 조회 거부 → 노드 그룹 생성 실패✓ 한 줄 추가 후 3차 /apply 성공
1차는 너무 빨라서, 2차는 너무 꽉 묶어서 — 둘 다 PR 댓글엔 '명령이 실패했습니다' 한 줄뿐이었다

1차 실패: IAM 최종 일관성

  • NAT 모듈이 instance profile 을 만들자마자 그걸 쓰는 인스턴스를 띄웠다
  • 권한이 아직 퍼지지 않아 인스턴스 시작이 Authentication Failure 로 실패했다
  • Terraform 은 그 Auto Scaling 그룹을 tainted 로 표시했다 → 다음 plan 이 교체로 잡아 해결했다

2차 실패: 너무 좁게 잡은 권한

  • EKS 는 노드 그룹을 만들 때 노드 그룹용 서비스 연결 역할이 있는지 호출자 권한으로 먼저 조회한다
  • 그 역할이 계정에 아직 없었다
  • apply 역할의 iam:GetRole 은 클러스터 역할 6개로만 묶여 있었다
  • 오류는 이랬다
Failed to validate if SLR: AWSServiceRoleForAmazonEKSNodegroup already exists
due to missing permissions for 'iam:GetRole'

조치: 역할 하나에만 iam:GetRole 추가

  • 그 역할 하나에 대한 iam:GetRole 만 foundation 에 더했다
  • 역할이 아직 없을 때 IAM 이 어떤 ARN 으로 평가하는지는 확인하지 못했다 → 경로가 있는 ARN 과 없는 ARN 을 둘 다 적었다
  • CloudTrail 의 거부 기록에는 경로 없이 이름만 남아 있었다

결과 댓글: 리소스별 표시

  • 두 실패 모두 PR 댓글만 보고는 원인을 알 수 없었다
    • 실패 댓글에는 “Terraform/AWS 명령이 실패했습니다” 한 줄뿐이었다
  • 이후 결과 댓글을 리소스별로 바꿨다. 보여 주는 것은 다음뿐이다
    • 성공·실패한 리소스 주소
    • AWS 오류 코드
    • 고정된 분류(권한 부족, 전파 지연 가능 등)
  • ARN, IP, 오류 원문은 여전히 내보내지 않는다

2.3 Shared Input — 미리 바꾼 Secret 은 다음 plan 에 섞인다

미리 넣어 둔 관리자 IP

  • 두 번째 관리자의 IP 를 KUKIE_ADMIN_CIDRS 에 미리 넣어 두었다
  • 출입증 PR(02편 4장 ②) 때 같이 반영될 거라 생각했다
  • 리뷰가 짚은 함정은 이랬다
Secret 갱신 (코드 diff 없음)
  └─▶ 다음 eks-platform plan 전부에 aws_eks_cluster.this 변경 1 이 붙는다
        ├─ 출입증 PR 이 먼저면      의도대로
        └─ 다른 PR(예: 버킷)이 먼저면 그 PR diff 에 없는 IP 변경이 몰래 적용된다

원인: diff 밖의 입력값

  • 코드 리뷰는 diff 를 본다
  • 입력값은 diff 에 나오지 않는다
  • 조치: README 에 “출입증 PR 보다 먼저 다른 eks-platform PR 을 /apply 하지 않는다” 를 적었다
    • 출입증 PR 을 적용한 뒤 그 문장을 지우는 것까지 할 일로 남겼다
  • 한계: 코드로 막는 장치는 없고 문장으로 막은 것이다
    • 다음에는 입력값을 바꾸는 시점을 그 값을 쓰는 PR 직전으로 붙이는 편이 낫다

3. ArgoCD — 처음 한 번만 밖에서 넣는다

3.1 Bootstrap — 관리인은 누가 들이나

ArgoCD 의 동작 방식

  • ArgoCD 는 클러스터 안에서 Git 을 읽어 클러스터를 Git 대로 맞춘다
  • 안에서 밖으로 가져오는 방식이다 → 한번 들어가면 밖에서 클러스터로 들어올 일이 없다
  • 그래서 02편 5장의 IP 제한과 출입 명부에 걸리지 않는다

문제: ArgoCD 자신의 설치

  • 빈 클러스터에는 Git 을 읽어 줄 것이 없다
  • 누군가는 밖에서 들어가 ArgoCD 를 설치해야 한다 — 그건 출입 명부에 있는 사람이다
  • 그래서 ArgoCD 설치 root 는 댓글 배포 대상에서 빼고 관리자가 로컬에서 적용한다
kukie-dev 클러스터GitHub (main)inframanifestArgoCD② 안에서 밖으로 읽는다관리자① 1회 설치 (Terraform)LBCESORLD밖→안은 관리자 IP · 출입 명부만
관리인은 한 번만 밖에서 들이고, 그다음부터는 안에서 Git 을 읽어 클러스터를 맞춘다
# terraform/argocd-bootstrap — Terraform 이 helm install 을 대신 한다
resource "helm_release" "argocd" {
  namespace  = "argocd"
  repository = "https://argoproj.github.io/argo-helm"
  chart      = "argo-cd"
  values     = [file("argocd-values.yaml")]   # 노드 1대용: 복제 1개씩, UI 비공개
}

resource "helm_release" "argocd_apps" {
  chart = "argocd-apps"
  values = [yamlencode({
    projects     = local.projects
    applications = var.enable_gitops ? local.root : {}   # ★ 스위치
  })]
  depends_on = [helm_release.argocd]
}

두 번에 나눈 설치

  1. 1차: ArgoCD 와 프로젝트만 만든다
  2. 관리자가 ArgoCD 화면에서 레포 읽기 전용 토큰을 등록한다
  3. 2차: enable_gitops = true 로 상위 지시서를 넣는다
  • 비공개 레포를 읽을 수 없는 상태로 지시서부터 주면 ArgoCD 는 오류만 낸다
  • 토큰은 Terraform 변수에 넣지 않는다 — state 에 남기 때문이다

함정: 한 release 안의 CRD

  • 처음에는 ArgoCD 차트와 AppProject 를 같은 Helm release 에 넣었다
  • argo-cd 차트는 CRD 를 일반 템플릿으로 설치한다
  • 첫 설치 때 Helm 은 아직 존재하지 않는 AppProject 종류를 매핑하지 못해 실패한다
    • atomic 이라 통째로 롤백된다
  • 해결: argocd-apps 를 별도 release 로 떼고 depends_on 으로 순서를 잡았다

3.2 App of Apps — 지시서도 Git 에 둔다

지시서(Application)의 네 항목

  • ArgoCD 는 지시서(Application)가 없으면 아무 일도 하지 않는다
  • 지시서에는 네 가지를 적는다
kind: Application
spec:
  project: kukie-apps                 # 어떤 울타리 안에서
  source:                             # 무엇을 보고
    repoURL: https://github.com/durokukie/kukie-manifest.git
    targetRevision: main
    path: envs/dev
  destination:                        # 어디에
    server: https://kubernetes.default.svc
    namespace: kukie-dev
  syncPolicy:                         # 어떻게 맞출지
    automated: {prune: true, selfHeal: true}

지시서도 Git 에

  • 지시서도 쿠버네티스 리소스다
  • 그래서 지시서를 Git 에 두면 ArgoCD 가 다른 리소스처럼 읽어서 만든다
  • 사람이 넣는 것은 맨 위 지시서 하나뿐이다
webmemberagentredisLBCESORLDkukie-dev-appsrootTerraform1층: Terraform 이 root 하나관리자 로컬 · 처음 한 번2층: kukie-infra 의 지시서들병합하면 ArgoCD 가 바로 반영3층: kukie-manifest 의 앱kukie-apps 울타리 안에서만
사람이 넣는 건 맨 위 하나 — 나머지 층은 Git 에서 생기고, 지워져도 되살아난다

손으로 넣을 때와 Git 에 둘 때

  • 손으로 kubectl apply 한 지시서
    • 누가 언제 넣었는지 남지 않는다
    • 지워지면 다시 생기지 않는다
    • 클러스터를 새로 만들면 무엇을 넣었었는지 기억해야 한다
  • Git 에 둔 지시서
    • 커밋이 곧 기록이다
    • root 하나만 다시 넣으면 나머지가 줄줄이 복구된다

대가: 병합이 곧 반영

  • 이 구조의 대가는 01편 3.1의 댓글 승인과 정반대의 성질이다
  • GitOps 를 켠 뒤에는 argocd/dev/platform 을 바꾼 PR 이 main 에 병합되는 순간 반영된다 — /apply 가 없다
  • 그래서 이 폴더의 PR 은 병합 전에 검사한다
    • 렌더링 결과를 검사한다(check-gitops.rb)
    • 지시서 목록이 정확히 맞는지까지 확인한다
  • 이유: root 가 prune 을 켜 두었다 → 차트에서 지시서 하나가 빠지면 그 설비가 클러스터에서 지워진다

3.3 Platform Controllers — 문, 열쇠, 새로고침

공통 설비 세 가지

  • root 가 깔아 주는 공통 설비는 셋이다
  • 셋 다 앱이 클러스터 밖의 AWS 와 이어지게 도와준다
문Ingresskukie-manifest YAMLLB Controllerinternal ALBAWS 에 생성없으면 밖에서 앱에 닿는 문이 없다열쇠Secrets Managerkukie-dev/*External SecretsSecret → Pod환경변수로없으면 비밀 값을 Git 이나 손으로새로고침Secret 변경값이 바뀜ReloaderDeployment 재시작새 값으로없으면 앱은 옛 값을 쥐고 있다셋 다 앱과 클러스터 밖의 AWS 를 잇는다 — ArgoCD 가 Git 을 보고 설치한다
문(LB Controller), 열쇠(External Secrets), 새로고침(Reloader)

External Secrets 의 범위

  • kukie-dev/* 이름의 Secret 만 읽을 수 있는 IRSA 역할을 쓴다
  • kukie-dev 네임스페이스에서만 일하도록 범위를 묶었다
  • Git 에는 “금고의 몇 번 칸을 가져와라” 는 위치만 적힌다
  • 쿠버네티스 Secret 은 base64 일 뿐이다 → 이 구조에서 실제로 지켜야 하는 것은 누가 클러스터에서 Secret 을 읽을 수 있느냐다
    • 02편 4장에서 관리자를 늘릴 때 두 단계를 거친 이유이기도 하다

ALB 의 함정: 보안 그룹 Name 태그

  • kukie-manifest 의 Ingress 는 보안 그룹을 이름(kukie-dev-internal-alb)으로 지정한다
  • LB Controller 는 이름을 Name 태그로 찾는다
  • Terraform 의 name 인자만으로는 태그가 붙지 않는다
  • 앱을 붙이기 전에 태그 한 줄을 더해 두었다

3.4 kukie-apps — 앱 쪽 울타리

설비 울타리와 앱 울타리

  • 설비용 프로젝트 kukie-platform 은 CRD 와 클러스터 RBAC 까지 설치해야 한다 → 클러스터 전체 권한이다
  • 앱을 여기에 올리면 앱 YAML 을 쓰는 사람이 클러스터 전체를 건드릴 수 있다
  • 그래서 앱에는 별도의 좁은 울타리를 둔다
kind: AppProject
metadata:
  name: kukie-apps
spec:
  sourceRepos: [https://github.com/durokukie/kukie-manifest.git]   # 이 레포만
  destinations: [{namespace: kukie-dev}]                            # 이 네임스페이스만
  clusterResourceWhitelist: []                                     # 클러스터 범위 리소스 금지
  namespaceResourceWhitelist:                                      # 여섯 종류만
    - {group: apps, kind: Deployment}
    - {group: "", kind: Service}
    - {group: "", kind: ConfigMap}
    - {group: networking.k8s.io, kind: Ingress}
    - {group: external-secrets.io, kind: ExternalSecret}
    - {group: external-secrets.io, kind: SecretStore}

허용 목록에 없는 Secret

  • 허용 목록에 Secret 이 없다는 점을 눈여겨볼 만하다
  • kukie-manifest 로는 비밀 값을 직접 만들 수 없다
  • ExternalSecret 으로 금고 위치를 가리키는 것만 된다
kukie-dev 클러스터설비 영역kukie-platform · 전체 권한DeploymentServiceIngressExternalSecretmanifest✕✕CRD · ClusterRole · Secret초록 울타리: kukie-appskukie-manifest 만 읽고kukie-dev 에 여섯 종류만빨강: 막힌 것다른 네임스페이스 · 클러스터 범위Secret 직접 생성
앱 쪽에는 좁은 울타리를 따로 둔다 — 울타리 정의는 관리자 레포에, 울타리 안의 내용만 앱 레포에

지시서의 위치: kukie-infra

  • 이 지시서도 kukie-infra 에 둔다(01편 2장)
  • kukie-manifest 는 앱 CI 봇이 쓰는 레포고, 무료 플랜이라 브랜치 보호가 없다
  • 지시서까지 거기 있으면 봇이 project 필드를 kukie-platform 으로 바꾸는 것만으로 울타리를 넘는다
  • 나누는 기준
    • 울타리와 지시서: 관리자 레포
    • 울타리 안의 내용: 앱 레포

Draft 로 묶어 둔 연결 PR

  • 이 연결은 PR 로 만들어 두었지만 Draft 로 묶어 두었다
  • 병합하는 순간 ArgoCD 가 배포를 시작하는데, 아직 준비가 덜 됐다
    • 이미지 digest 가 pending 이다
    • 회원 서버의 Secret 값과 DB 접근 허용이 없다
  • 빈 가게를 한꺼번에 열면 빨간불만 켜진다
  • 순서: 회원 서버와 Redis 를 먼저 켜고, 에이전트와 화면은 이미지가 준비되는 대로 붙인다

4. Discussion

EKS: 실패가 남긴 것

  • EKS 는 한 번에 서지 않았다
  • 다만 실패할 때마다 만든 것은 남았고, 다음 견적서가 남은 칸만 채웠다
  • 실패를 리소스별로 보여 주도록 결과 댓글을 바꾼 것이 가장 오래 남을 수확이다

ArgoCD: 반대 방향의 교훈

  • 한번 들이면 병합이 곧 적용이 된다 → 지시서 목록과 울타리를 병합 전에 검사해야 한다

남은 일

  • ArgoCD 를 실제로 설치한다
  • 회원 서버부터 앱을 올린다
  • CloudFront 로 HTTPS 입구를 연다

노드 용량: Pod 17개 한도

  • 노드 1대는 VPC CNI 기본값으로 Pod 17개가 한도다
  • 시스템 Pod 4개 + ArgoCD 4개 + 설비 5개에 앱 4개를 올리면 꽉 찬다
  • LGTM 을 올리기 전에 노드부터 키워야 한다

핵심 메시지

NAT
출구가 워커보다 비쌌다. EC2 한 대로 바꾸고 교체 중 끊김을 받아들였다
세 번의 apply
실패는 되돌려지지 않고 이어진다. 오류를 리소스별로 보여야 고친다
입력값
diff 에 안 나오는 Secret 은 다음 plan 에 섞인다
ArgoCD
처음 한 번만 밖에서 넣고, 지시서까지 Git 에 둔다
울타리
앱에는 좁은 프로젝트. 정의는 관리자 레포에

클러스터를 세우는 일은 한 번에 끝나지 않았고, 그래서 실패가 무엇을 남기는지가 중요했다. ArgoCD 를 들인 뒤부터는 반대로, 병합 한 번이 곧 배포다. 다음 편은 이 위에 회원 서버부터 앱을 올리고, 바이브코더의 코드를 받아 줄 호스팅 클러스터를 설계하는 이야기다.



5. References

EKS 출입 항목이 무엇인지는 EKS 사용 설명서의 개요 첫 문단에 있다.
"Fundamentally, an EKS access entry associates a set of Kubernetes permissions with an IAM identity, such as an IAM role."
[해석] "근본적으로 EKS 출입 항목은 쿠버네티스 권한 묶음을 IAM 역할 같은 IAM 자격 증명에 연결한다." — 3.1 에서 ArgoCD 설치가 출입 명부에 있는 사람 몫이 되는 근거다.
→ Amazon EKS User Guide — Grant IAM users access to Kubernetes with EKS access entries
App of Apps 의 상위 레포를 누가 써야 하는지는 Argo CD 의 Cluster Bootstrapping 문서에 경고로 적혀 있다.
"The ability to create Applications in arbitrary Projects is an admin-level capability. Only admins should have push access to the parent Application's source repository."
[해석] "임의의 프로젝트에 Application 을 만들 수 있는 능력은 관리자 수준의 권한이다. 상위 Application 의 소스 레포에는 관리자만 push 할 수 있어야 한다." — 3.4 에서 kukie-apps 지시서를 kukie-manifest 가 아니라 kukie-infra 에 둔 근거다.
→ Argo CD — Cluster Bootstrapping