들어가며
처음에는 배포 과정을 전부 수동으로 했다.
Terraform으로 EKS, RDS, CloudFront 같은 인프라를 만들고 나면 그걸로 끝일 줄 알았다. 그런데 실제로는 terraform apply가 끝난 뒤에도 해야 할 일이 많았다. EKS에 접속해야 했고, Terraform output으로 나온 값을 GitOps 레포에 반영해야 했고, ArgoCD를 설치해야 했고, ESO를 붙여야 했고, DB 접속 확인과 초기 데이터 삽입까지 해야 했다!
게다가 이 프로젝트는 AWS 비용을 줄이려고 작업이 끝나면 매일 밤 terraform destroy를 하고 다음 작업 때 다시 terraform apply를 하는 방식으로 운영했다. 인프라를 매번 새로 만들다 보니 VPC ID, CloudFront Distribution id, ALB DNS 같은 값이 계속 바뀌었다. 하루에 한 번만 해도 반복 작업이 시간을 꽤 많이 잡아먹었고 중간에 하나라도 빠뜨리면 뒤 단계에서 이상한 에러로 돌아왔다.
그래서 처음에는 배포_단계_체크리스트.md에 명령어를 정리해두고 순서대로 따라 했다. 체크리스트 자체는 도움이 됐다. 하지만 사람이 직접 복사해서 실행하는 방식은 결국 한계가 있었다. 그래서 이 수동 체크리스트를 기준으로 init.sh를 만들었다.
Terraform apply 이후 남은 배포 초기화 작업을 한 번에 실행하고 실패하면 어디서 실패했는지 바로 알 수 있게 만드려고 노력했다.
자동화 전에는 어떻게 했나
기존 수동 체크리스트의 흐름은 대략 이랬다.
terraform apply
↓
Terraform output 확인
↓
GitOps 레포 values 수정
↓
GitOps commit / push
↓
aws eks update-kubeconfig
↓
ArgoCD 설치
↓
GitOps repo 등록
↓
App-of-Apps bootstrap
↓
Istio / AWS Load Balancer Controller / Monitoring 배포 대기
↓
ESO 설치
↓
ExternalSecret 동기화 확인
↓
Secrets Manager에 DB endpoint 반영
↓
SSM EC2에서 RDS 접속 테스트
↓
DB 테이블 생성 + seed 데이터 삽입
↓
서비스 내부/외부 헬스체크
↓
CI/CD smoke test
글로만 봐도 길어보인다..
실제로는 각 단계마다 확인할 값이 달라서 훨씬 오래걸렸다.
- Terraform output에서
vpc_id,target_group_arn,alb_dns_name,db_endpoint확인 - GitOps 레포의 YAML 값 수정
- ArgoCD가 Application을 만들 때까지 대기
- AWS Load Balancer Controller webhook이 정상화될 때까지 대기
- ESO CRD 생성 후 ExternalSecret 동기화 확인
- SSM EC2가 Online 상태인지 확인
- RDS 접속 후 테이블과 초기 데이터 확인
- ClusterIP 내부 호출과 ALB/Istio 외부 호출 확인
한 번에 성공하면 괜찮다. 문제는 실제 작업에서는 한 번에 성공하지 않는다는 점이었다.
한 번에 성공한 적이 더 드물었다. 아마 아직 프로세스가 완벽하지 않다는 것이겠지..
수동 작업에서 가장 불편했던 부분
1. Terraform output 값이 매번 바뀐다
ECR, S3, CloudFront 같은 일부 리소스는 그대로지만 EKS, ALB, RDS 등 대부분의 리소스는 비용 때문에 자주 삭제하고 다시 만들었다. 그러면 GitOps 레포에 들어가는 값도 바뀌어야 하는 경우가 있다.(테라폼을 apply 할때마다 값이 바뀌어서 레포에 새 커밋을 하는게 마음에 들진 않는다. 다른 방법이 있을까 고민하다가 이 부분에 대해서는 우선순위를 뒤로 두고 진행했다.)
예를 들면 이런 값이다.
| 값 | 쓰이는 곳 |
|---|---|
vpc_id |
AWS Load Balancer Controller values |
target_group_arn |
Istio ingressgateway TargetGroupBinding |
처음에는 이 값을 직접 복사해서 YAML을 수정했다. 그런데 수동 수정은 실수하기 쉽다. 특히 Target Group ARN처럼 긴 문자열은 한 글자만 틀려도 바로 티가 안 난다. ArgoCD는 sync된 것처럼 보여도 실제 TargetGroupBinding이 올바른 대상에 붙지 않을 수 있다.
그래서 init.sh에서는 terraform output -json을 한 번 읽고, jq로 필요한 값을 파싱한 뒤 GitOps YAML에 자동 반영하도록 했다.
OUTPUT_JSON="$(
cd "$TF_DIR"
terraform output -json
)"
VPC_ID=$(echo "$OUTPUT_JSON" | jq -r '.vpc_id.value // "N/A"')
TG_ARN=$(echo "$OUTPUT_JSON" | jq -r '.target_group_arn.value // "N/A"')
DB_ENDPOINT_RAW=$(echo "$OUTPUT_JSON" | jq -r '.db_endpoint.value // "N/A"')
그리고 GitOps 레포의 값도 직접 수정한다.
sed -i "s|^vpcId: .*|vpcId: $VPC_ID|" \
"$GITOPS_DIR/infra/aws-lb-controller/values-dev.yaml"
sed -i "s|^ targetGroupARN: .*| targetGroupARN: $TG_ARN|" \
"$GITOPS_DIR/infra/istio-resources/dev/target-group-binding.yaml"
이후 변경사항이 있을 때만 commit/push한다. 변경이 없으면 push를 건너뛴다.
2. GitOps도 처음에는 수동 부트스트랩이 필요하다
GitOps 구조라고 해서 처음부터 모든 것이 자동으로 되는 것은 아니었다.
ArgoCD가 GitOps 레포를 감시하려면 먼저 ArgoCD가 클러스터에 설치되어 있어야 한다. 그리고 private GitOps 레포를 읽을 수 있도록 repo credential도 등록해야 한다. 그 다음에야 App-of-Apps 구조로 shared/dev Application들을 올릴 수 있다.
그래서 스크립트에서는 다음 순서로 처리했다.
ArgoCD namespace 생성
↓
ArgoCD manifest server-side apply
↓
argocd-server Available 대기
↓
GitOps repo credential을 Kubernetes Secret으로 등록
↓
argocd/shared, argocd/dev Application bootstrap
ArgoCD repo 등록은 CLI 로그인으로 처리할 수도 있지만 스크립트에서는 Kubernetes Secret을 직접 만든다.
kubectl create secret generic shoong-gitops-repo \
-n argocd \
--from-literal=type=git \
--from-literal=url=https://github.com/shoong-delivery/shoong-gitops \
--from-literal=username=git \
--from-literal=password="${GITHUB_PAT}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl label secret shoong-gitops-repo -n argocd \
argocd.argoproj.io/secret-type=repository \
--overwrite
이렇게 하면 ArgoCD CLI 로그인 상태에 의존하지 않아도 된다. 로컬 환경이 바뀌어도 스크립트만 실행하면 같은 방식으로 repo credential이 등록된다.
3. 대기 시간이 생각보다 중요했다
수동으로 할 때 가장 많이 만난 문제는 명령어는 성공했는데 다음 단계가 실패하는 경우였다.
예를 들어 ArgoCD Application을 만들었다고 해서 AWS Load Balancer Controller가 바로 Ready 상태가 되는 것은 아니다. Istio 리소스도 마찬가지다. ESO를 설치하려면 CRD와 webhook 상태가 준비되어 있어야 한다. 그런데 준비가 덜 된 상태에서 바로 다음 명령어를 실행하면 원인과 전혀 다른 에러가 나온다.
특히 기억에 남는 건 ESO 설치 중 만난 AWS Load Balancer Controller webhook x509 에러였다.
ESO Helm install 과정에서 Service 리소스가 만들어지는데 이때 AWS Load Balancer Controller의 webhook이 호출된다. 그런데 클러스터를 새로 만든 직후에는 Controller Pod가 Available로 보여도 webhook의 CA bundle이 아직 정상화되지 않은 경우가 있었다. 그 상태에서 ESO를 설치하면 TLS 검증 오류가 났다.
그래서 스크립트에는 단순 설치만 넣지 않고 webhook이 실제로 동작하는지 dry-run으로 확인하는 로직을 넣었다.
if ! kubectl create service clusterip eso-webhook-check --tcp=80:80 \
--dry-run=server -n kube-system >/dev/null 2>&1; then
kubectl delete mutatingwebhookconfigurations aws-load-balancer-webhook 2>/dev/null || true
kubectl delete validatingwebhookconfigurations aws-load-balancer-webhook 2>/dev/null || true
kubectl rollout restart deployment -n kube-system \
-l app.kubernetes.io/name=aws-load-balancer-controller
kubectl rollout status deployment -n kube-system \
-l app.kubernetes.io/name=aws-load-balancer-controller --timeout=120s
fi
이런 식으로 상태를 직접 확인하는 로직을 몇 군데 넣어두긴 했지만 그래도 간헐적으로 실패하는 구간이 있었다. 주로 설치 직후 바로 다음 명령을 실행할 때였는데 이런 곳은 그냥 sleep으로 약간의 여유를 두는 식으로 해결하기도 했다.
4. 실패하면 바로 멈춰야 했다
앞 단계가 틀어지면 뒤 단계가 연결되는 경우가 있어서 뒷 단계 실행이 의미 없어진다.
예를 들어 kubeconfig가 잘못 잡혔는데 ArgoCD 설치를 계속하면 엉뚱한 클러스터에 리소스를 만들 수도 있다. ESO secret이 생성되지 않았는데 앱 Pod를 재시작하면 CreateContainerConfigError 같은 에러가 날 수 있다. DB 접속이 안 되는데 API 헬스체크를 하면 앱 문제인지 네트워크 문제인지 구분하기 어렵다.
그래서 스크립트 맨 위에는 이 옵션을 넣었다.
set -euo pipefail
각 의미는 간단하다.
| 옵션 | 의미 |
|---|---|
-e |
명령어 실패 시 즉시 중단 |
-u |
정의되지 않은 변수 사용 시 중단 |
pipefail |
파이프라인 중 하나라도 실패하면 전체 실패 처리 |
덕분에 잘못된 상태로 다음 단계까지 밀고 가는 일이 줄었다. 실패한 지점에서 멈추고 로그를 보고 바로 고칠 수 있다.
init.sh의 전체 파이프라인
현재 스크립트는 총 15단계로 구성했다.
[1] Terraform output 조회
[2] GitOps 값 업데이트
[3] GitOps 레포 commit + push
[4] EKS kubeconfig 설정
[5] ArgoCD 설치
[6] GitOps repo credential 등록
[7] ArgoCD Application bootstrap
[8] Istio ingressgateway 준비 대기
[9] ESO 설치
[10] ESO 리소스 ArgoCD Sync 및 Secret 확인
[11] AWS Secrets Manager DB secret 등록
[12] SSM Instance 확인
[13] SSM을 통한 RDS 접속 테스트
[14] DB 초기화 SQL 실행
[15] 최종 헬스체크
좀 더 줄이면 이런 흐름이다.
Terraform output
→ GitOps 값 갱신
→ ArgoCD bootstrap
→ GitOps 기반 인프라/앱 배포
→ ESO로 Secret 동기화
→ RDS 연결 확인
→ DB 초기화
→ 내부/외부 API 헬스체크
이 스크립트가 CI/CD 파이프라인 자체를 대체하는 것은 아니다. 역할이 다르다.
init.sh: Terraform apply 이후 클러스터 초기 세팅과 배포 준비- GitHub Actions: 앱 코드 변경 시 이미지 빌드, Trivy 스캔, ECR push, GitOps 이미지 태그 업데이트
- ArgoCD: GitOps 레포 변경 감지 후 Helm chart 기반 배포
즉, init.sh는 "새로 만들어진 클러스터를 CI/CD가 동작 가능한 상태로 부트스트랩하는 스크립트"에 가깝다.
단계 재시작을 넣은 이유
처음에는 스크립트를 처음부터 끝까지 한 번에 실행하는 것만 생각했다. 그런데 실제로 써보니 중간 실패 후 다시 해야하는 경우가 많았고, 처음부터 돌리는 게 비효율적이었다.
예를 들어 [9] ESO 설치에서 실패했는데 앞에 [2]GitOps 값 업데이트는 다시 할 필요는 없었다.
[13] RDS 접속 테스트에서 실패했는데 ArgoCD를 다시 설치할 필요도 없다.
그래서 STEP이라는 환경변수를 넣었다.
STEP=9 ./init.sh
이렇게 실행하면 9단계부터 다시 진행할 수 있다.
스크립트 내부에서는 이런 식으로 되어 있다.
STEP="${STEP:-1}"
if [ "$STEP" -le 9 ]; then
echo "[9] ESO 설치"
...
fi
이 방식이 혼자 포트폴리오 인프라를 반복해서 띄우는 상황에서 매우 유용했다. 실패한 구간만 고쳐서 다시 실행할 수 있으니까 작업 속도가 많이 빨라졌다. 쾌적~
재실행 가능한 형태로 만들기
내가 원하는건 한 번만 성공하는 스크립트가 아니었다. 같은 스크립트를 여러 번 실행해도 최대한 안전해야 했다.
같은 명령을 또 실행해도 에러로 죽지 않는 것, 그리고 이미 만들어둔 상태를 망가뜨리지 않는 것이다. 예를 들어 kubectl create namespace foo는 두 번째 실행에서 AlreadyExists 에러로 죽지만, kubectl apply는 그렇지 않다. 이미 있으면 그대로 두고 변경분만 반영한다. 이런 식으로 두 번 실행해도 동일한 상태로 수렴하도록 만드려고 노력했다.
| 작업 | 사용한 방식 |
|---|---|
| namespace 생성 | kubectl create --dry-run=client -o yaml | kubectl apply -f - |
| ArgoCD manifest 적용 | kubectl apply --server-side |
| repo credential 등록 | Secret dry-run 후 apply |
| ESO 설치 | helm upgrade --install |
| DB 테이블 생성 | CREATE TABLE IF NOT EXISTS |
| seed 데이터 삽입 | ON CONFLICT DO NOTHING |
| GitOps commit | 변경사항이 있을 때만 commit/push |
각 패턴이 노리는 건 비슷하다.--dry-run=client -o yaml | kubectl apply -f -는 리소스가 없으면 생성, 있으면 변경분만 반영한다.helm upgrade --install은 release가 없으면 install, 있으면 upgrade로 처리한다.CREATE TABLE IF NOT EXISTS나 ON CONFLICT DO NOTHING은 SQL 레벨에서 같은 역할을 한다.
전부 이미 있어도 죽지 않게' 하려는 패턴이다
다만 이걸 "완전한 멱등성(idempotency)"이라고 말하긴 어려운거 같다.
멱등하다는 건 엄밀히 따지면 N번 실행해도 결과 상태가 1번 실행한 것과 같아야 한다는 의미인데 스크립트 안에 그 조건을 어기는 명령이 일부 섞여 있기 때문이다.
예를 들어 kubectl rollout restart는 실행할 때마다 Pod를 새로 띄운다. 클러스터 입장에서는 매번 새로운 ReplicaSet과 Pod가 생기는 셈이라 "같은 결과"라고 말하기 애매하다. aws secretsmanager put-secret-value도 호출할 때마다 새 버전(VersionId)을 만든다. 값이 동일해도 버전 히스토리는 계속 쌓인다. 그래서 "여러 번 실행해도 안전하다"는 말은 맞지만 "여러 번 실행해도 완전히 동일한 상태가 된다"라고는 말할 수 없다.
그래서 완전한 idempotent 스크립트라기보다는 반복 실행을 고려한 배포 초기화 스크립트라고 보는 게 더 정확한 표현인 것 같다.
최종 헬스체크를 길게 넣은 이유
처음에는 "Pod가 Running이면 된 거 아닌가?"라고 생각했다. 그런데 실제 배포에서는 Running만으로는 부족했다.
Pod가 Running이어도 Secret이 잘못 들어가면 앱이 요청 처리에서 실패할 수 있다. Istio VirtualService rewrite가 잘못되면 외부 경로만 실패할 수 있다. TargetGroupBinding이 잘못되면 클러스터 내부 서비스는 살아있지만 ALB를 통한 접근은 실패할 수 있다. DB 테이블이 없으면 health는 살아도 주문 생성에서 실패할 수 있다.
그래서 마지막 단계는 가능한 한 정상동작을 확인할 수 있게끔 만들었다.
확인하는 항목은 크게 나누면 이렇다.
- ArgoCD Application 상태
- ArgoCD / AWS Load Balancer Controller / Istio / ESO / Monitoring Pod 상태
- namespace label 상태
shoong-config,shoong-db-secret생성 여부- Target Group health
- TargetGroupBinding 생성 여부
- SSM을 통한 DB 테이블, 메뉴 데이터 확인
- order/kitchen/delivery/notification 내부 health
- ALB → Istio → order/notification 외부 health
- 메뉴 조회, 주문 생성, 주문 목록 조회
- ArgoCD가 보고 있는 이미지와 실제 Pod 이미지 확인
이 검증을 통과해야 "배포됐다"고 볼 수 있다고 생각했다. 단순히 리소스가 만들어진 상태와 서비스가 실제로 동작하는 상태는 다르기 때문이다.
만들면서 겪은 문제들
ArgoCD insecure 설정 타이밍
이 부분은 사실 수동 설치 때부터 계속 문제가 됐던 지점이다.
ArgoCD를 Istio 뒤에 붙이려다 보니 HTTP 라우팅을 위해 server.insecure 설정이 필요했다. 그런데 이걸 처음부터 켜둔 상태로 설치하면 초기 설정 단계에서 쓰는 로컬 port-forward + CLI 로그인 조합에서 문제가 생겼다. 특히 Windows 환경에서 종종 connection reset by peer 같은 에러가 떴는데, 프로토콜이랑 port-forward가 잘 안 맞는 거 같았다.
나중에는 internal 도메인(이름만 internal이긴 하다)을 붙이면서 port-forward 자체가 필요 없어졌다. 그래도 설치 초기에는 여전히 TLS 모드 쪽이 안전했기 때문에 초기 설치 시점에는 TLS 모드를 그대로 두고 GitOps의 argocd-insecure.yaml을 부트스트랩 이후에 적용하는 순서로 정리했다.
Istio ingressgateway의 image: auto 문제
istio-ingressgateway Deployment의 이미지가 auto로 보이는 경우가 있었다. 이건 실제 이미지명이 아니라 istiod sidecar injector가 치환하는 placeholder였다. 그런데 istio-system namespace에 injection label이 없으면 치환이 안 되고 ImagePullBackOff가 났다.
이 문제를 겪고 나서 istio-system 준비 상태와 ingressgateway rollout을 따로 기다리는 단계를 넣었다.
ESO와 AWS Load Balancer Controller webhook
앞에서 말한 x509 문제도 꽤 오래 잡고 있었다. Pod가 Available이라고 해서 webhook이 곧바로 정상이라는 뜻은 아니었다. 결국 dry-run으로 API server admission chain까지 실제로 통과하는지 확인하는 방식으로 바꿨다.
RDS는 직접 접속할 수 없었다
RDS는 private subnet에 있고 public access를 열지 않았다. 그래서 로컬에서 바로 psql로 접속할 수 없다. 대신 private subnet의 SSM EC2를 통해 접속해야 했다.
처음에는 Session Manager로 들어가서 직접 psql을 설치하고 접속했다. 나중에는 이 과정도 aws ssm send-command로 바꿔서 스크립트 안에서 처리했다. 덕분에 로컬에 DB 접근 경로를 열지 않고도 RDS 연결 테스트와 초기 SQL 실행을 자동화할 수 있었다.
보안적으로 신경 쓴 부분
자동화 스크립트라고 해서 모든 값을 파일에 박아두지는 않았다.
스크립트 실행 전에 필요한 값을 env.sh라는 파일에 환경변수로 적어두고 먼저 실행한 후 init.sh 스크립트를 실행했다.
export GITHUB_PAT="..."
export DB_USERNAME="..."
export DB_PASSWORD="..."
init.sh 스크립트 안에서는 이 값들이 없으면 바로 중단한다.
required_env GITHUB_PAT
required_env DB_USERNAME
required_env DB_PASSWORD
init.sh는 TIL 깃허브에 올려두려고 생각했기 때문에 깃에 올라가면 안되는 민감정보들을 env.sh를 따로 빼둔 것이다.
이 스크립트로 얻은 것
가장 크게 바뀐 건 작업 시작 속도다. 적어도 한시간 이상 줄어든거 같다.
예전에는 Terraform apply 후 체크리스트를 보면서 하나씩 복사하고, 중간중간 ArgoCD UI와 kubectl을 왔다 갔다 했다. 지금은 필요한 환경변수만 준비해두고 스크립트를 실행하면 된다.
./scripts/init.sh
중간에 실패하면 실패한 단계부터 다시 실행한다.
STEP=13 ./scripts/init.sh
이 스크립트를 만들면서 가장 좋았던 점은 시간절약이다. 그리고 너무너무 편했다.
그리고 수동으로 하던 작업을 코드로 옮기다 보면서 배포 과정에서 내가 뭘 전제로 하고 있었는지가 드러났다.
- 어떤 값이 Terraform에서 나오는지
- 어떤 값이 GitOps 레포에 반영되어야 하는지
- ArgoCD와 ESO의 설치 순서가 왜 중요한지
- secret이 생성된 뒤 앱 Pod를 왜 재시작해야 하는지
- Pod Running과 실제 서비스 동작 검증이 왜 다른지
- private RDS를 어떤 경로로 검증해야 하는지
수동 체크리스트는 "명령어 모음"에 가까웠다.init.sh를 만들면서 그 명령어 사이의 의존관계와 실패 처리까지 코드로 남기게 됐다.
정리
아직 완벽하진 않다.
사용하면서 추가적으로 계속 개선할 생각이다.
init.sh는 멋있어 보이려고 만든 스크립트가 아니다. 매일 같은 실수를 반복하지 않으려고 만든 스크립트다.
Terraform으로 인프라를 만들고, GitOps로 배포하고, ArgoCD와 ESO와 Istio를 붙이는 구조는 한 번 성공했다고 끝나는 게 아니었다. 매번 새로 띄워도 같은 순서로 복구되어야 했고, 실패했을 때 어디서 멈췄는지 알아야 했다.
그래서 수동 체크리스트를 자동화 스크립트로 옮겼다. 그리고 그 과정에서 배포 초기화도 하나의 파이프라인이라는 걸 알게 됐다.
내가 이 스크립트에서 보여주고 싶은 것은 반복되는 수동 작업을 줄이기 위해 실제로 겪은 실패를 기준으로 배포 초기화 과정을 코드화했다는 점이다.
'Project: Shoong-Delivery' 카테고리의 다른 글
| [Observability] EKS MSA 옵저버빌리티 통합 — Prometheus + Loki + Tempo + Grafana (0) | 2026.05.22 |
|---|---|
| [AWS] Shoong Delivery 네트워크 설계 정리 (0) | 2026.05.20 |
| [Terraform] shoong-delivery 테라폼 구조 및 회고 (0) | 2026.05.20 |
| [네트워크]EKS 트래픽 설계: ALB + Istio를 선택한 이유 (0) | 2026.05.19 |
| [아키텍처] K8S 클러스터 아키텍처 구축기 (0) | 2026.05.18 |