Published on

GitHub Actions로 EKS 무중단 배포 - Blue-Green CI/CD

Authors

서버를 멈추지 않고 배포하는 건 “배포 스크립트를 잘 짠다” 수준을 넘어, 트래픽 전환(라우팅), 헬스체크, 관측(모니터링), 롤백이 한 덩어리로 설계되어야 합니다. 특히 EKS에서는 Deployment rollingUpdate만으로도 무중단에 가까워질 수 있지만, 장애 격리/검증/즉시 롤백까지 포함한 운영 품질을 만들려면 Blue-Green이 훨씬 명확합니다.

이 글에서는 GitHub Actions + ECR + EKS 조합으로, 다음을 만족하는 실전 CI/CD를 구축합니다.

  • PR/메인 브랜치 머지 시 자동 빌드/푸시
  • EKS에 green 환경을 먼저 올리고 헬스 검증
  • 검증 통과 시 Service/Ingress 전환으로 트래픽 스위치
  • 실패 시 자동 롤백
  • 운영 중 자주 터지는 권한/ALB 오류 포인트까지 포함

> EKS에서 AWS 권한 문제로 배포가 막히는 경우가 많습니다. Pod/컨트롤러가 STS를 못 받아 403이 나는 케이스는 아래 글이 진짜 자주 도움됩니다: EKS Pod에서 STS 403 AccessDenied 원인 8가지

아키텍처: Blue-Green을 EKS에서 구현하는 2가지 방식

EKS에서 Blue-Green은 보통 둘 중 하나로 구현합니다.

1) Service Selector 스위칭(가장 단순)

  • myapp-blue Deployment, myapp-green Deployment 두 세트를 유지
  • Service myapp의 selector를 version=blueversion=green으로 변경
  • ALB Ingress는 Service만 바라보므로, Service만 바꾸면 트래픽이 이동

장점: 단순, 쿠버네티스 표준 오브젝트만으로 가능 단점: 전환 순간의 연결 드레인/세션/리드니스 설계가 중요

2) ALB 가중치(카나리/점진 전환)

  • AWS Load Balancer Controller의 advanced routing(가중치/조건)로 점진 전환

장점: 10%→50%→100% 같은 점진 전환 단점: 설정 복잡, 컨트롤러/리소스 의존도 증가

이 글은 1) Service Selector 스위칭으로 “가장 재현 가능한” 파이프라인을 먼저 완성합니다.

사전 준비: ECR, OIDC, kubectl 접근

필수 구성요소

  • EKS 클러스터 + kubectl 접근 가능
  • ECR 리포지토리
  • GitHub Actions에서 AWS에 접근할 OIDC(권장) 또는 액세스키
  • 클러스터에 배포할 Namespace (예: prod)

GitHub Actions → AWS OIDC 설정(권장)

액세스키를 GitHub Secrets에 넣는 방식은 운영에서 회전/유출 리스크가 큽니다. OIDC로 AssumeRoleWithWebIdentity를 쓰면 훨씬 안전합니다.

  • AWS IAM Role 생성
  • Trust policy에 GitHub OIDC provider 추가
  • Role 정책에 ECR push, EKS describe, (필요 시) SSM, CloudWatch 권한 부여

권한이 부족하면 배포는 “성공”인데 실제로는 ALB/ExternalDNS/Pod가 AWS API를 못 쳐서 장애가 납니다. ExternalDNS가 Route53 레코드를 못 만드는 문제는 IRSA 점검이 핵심입니다: EKS ExternalDNS가 Route53 생성 실패할 때 IRSA 점검

쿠버네티스 매니페스트: Blue/Green 분리

아래는 가장 단순한 형태입니다.

1) Deployment (blue)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-blue
  namespace: prod
  labels:
    app: myapp
    version: blue
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
      version: blue
  template:
    metadata:
      labels:
        app: myapp
        version: blue
    spec:
      containers:
        - name: myapp
          image: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:PLACEHOLDER
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 10

2) Deployment (green)

blue와 동일하되 name/version만 green으로.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-green
  namespace: prod
  labels:
    app: myapp
    version: green
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
      version: green
  template:
    metadata:
      labels:
        app: myapp
        version: green
    spec:
      containers:
        - name: myapp
          image: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myapp:PLACEHOLDER
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 20
            periodSeconds: 10

3) Service: selector만 바꿔서 트래픽 전환

초기에는 blue를 바라보게 합니다.

apiVersion: v1
kind: Service
metadata:
  name: myapp
  namespace: prod
spec:
  selector:
    app: myapp
    version: blue
  ports:
    - name: http
      port: 80
      targetPort: 8080

4) Ingress(ALB): Service만 바라보게 유지

AWS Load Balancer Controller를 쓴다는 가정입니다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp
  namespace: prod
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/healthcheck-path: /actuator/health/readiness
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp
                port:
                  number: 80

> ALB에서 502/504가 간헐적으로 뜨면 “애플리케이션 문제”로 오해하기 쉽지만, 타겟그룹 헬스체크/리드니스/드레인/보안그룹/서브넷 라우팅이 원인인 경우가 많습니다. 운영에서 자주 보는 케이스는 아래 글을 참고하세요. > - EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지 > - EKS ALB Ingress 504(5xx) 간헐 발생 원인·해결

GitHub Actions 워크플로우: 빌드 → 배포 → 검증 → 스위치

핵심은 “green에 먼저 올리고, readiness가 OK면 Service selector를 바꾼다”입니다.

아래 예시는:

  • Docker 이미지 빌드 후 ECR 푸시
  • 현재 Service가 바라보는 색(blue/green)을 읽고 반대색을 TARGET_COLOR로 결정
  • TARGET Deployment 이미지 업데이트
  • rollout 완료 대기
  • (선택) 내부 헬스 엔드포인트를 포트포워딩으로 확인
  • Service selector 전환
  • 실패 시 Service를 원래 색으로 복구

.github/workflows/deploy-blue-green.yml

name: deploy-blue-green

on:
  push:
    branches: [ "main" ]

permissions:
  id-token: write
  contents: read

env:
  AWS_REGION: ap-northeast-2
  EKS_CLUSTER: my-eks
  NAMESPACE: prod
  APP_NAME: myapp
  ECR_REPO: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myapp

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to ECR
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build & Push
        id: build
        run: |
          set -euo pipefail
          IMAGE_TAG=${GITHUB_SHA::12}
          docker build -t $ECR_REPO:$IMAGE_TAG .
          docker push $ECR_REPO:$IMAGE_TAG
          echo "image=$ECR_REPO:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Setup kubectl
        run: |
          set -euo pipefail
          aws eks update-kubeconfig --name "$EKS_CLUSTER" --region "$AWS_REGION"
          kubectl version --client

      - name: Decide target color
        id: color
        run: |
          set -euo pipefail
          CURRENT=$(kubectl -n "$NAMESPACE" get svc "$APP_NAME" -o jsonpath='{.spec.selector.version}')
          if [ "$CURRENT" = "blue" ]; then
            TARGET="green"
          else
            TARGET="blue"
          fi
          echo "current=$CURRENT" >> $GITHUB_OUTPUT
          echo "target=$TARGET" >> $GITHUB_OUTPUT

      - name: Deploy to target (no traffic)
        run: |
          set -euo pipefail
          TARGET=${{ steps.color.outputs.target }}
          IMAGE=${{ steps.build.outputs.image }}

          kubectl -n "$NAMESPACE" set image deploy/${APP_NAME}-${TARGET} ${APP_NAME}=$IMAGE
          kubectl -n "$NAMESPACE" rollout status deploy/${APP_NAME}-${TARGET} --timeout=300s

      - name: Smoke test target (port-forward)
        run: |
          set -euo pipefail
          TARGET=${{ steps.color.outputs.target }}

          # target color의 Pod 하나를 골라 포트포워딩 후 readiness 확인
          POD=$(kubectl -n "$NAMESPACE" get pod -l app=$APP_NAME,version=$TARGET -o jsonpath='{.items[0].metadata.name}')
          kubectl -n "$NAMESPACE" port-forward pod/$POD 18080:8080 >/tmp/pf.log 2>&1 &
          PF_PID=$!
          sleep 2
          curl -fsS http://127.0.0.1:18080/actuator/health/readiness | cat
          kill $PF_PID

      - name: Switch traffic (Service selector)
        run: |
          set -euo pipefail
          TARGET=${{ steps.color.outputs.target }}

          kubectl -n "$NAMESPACE" patch svc "$APP_NAME" \
            -p '{"spec":{"selector":{"app":"'"$APP_NAME"'","version":"'"$TARGET"'"}}}'

      - name: Verify after switch
        run: |
          set -euo pipefail
          # 전환 직후 간단 검증(실서비스라면 synthetic check/외부 모니터링 연동 권장)
          kubectl -n "$NAMESPACE" get endpoints "$APP_NAME" -o wide

      - name: Rollback on failure
        if: failure()
        run: |
          set -euo pipefail
          CURRENT=${{ steps.color.outputs.current }}
          kubectl -n "$NAMESPACE" patch svc "$APP_NAME" \
            -p '{"spec":{"selector":{"app":"'"$APP_NAME"'","version":"'"$CURRENT"'"}}}'
          echo "Rolled back service selector to $CURRENT"

운영 품질을 결정하는 디테일 7가지

1) readinessProbe는 “트래픽 받아도 되는가”를 정확히 표현해야 함

Blue-Green에서 전환은 빠르지만, readiness가 부정확하면 전환 순간부터 5xx가 납니다.

  • DB migration 중이면 readiness는 실패해야 함
  • 외부 의존(캐시/메시지브로커) 연결이 준비되지 않으면 실패해야 함
  • 단순히 프로세스가 살아있음(liveness)과 다름

2) terminationGracePeriod + preStop으로 커넥션 드레인

Service selector가 바뀌면 기존 Pod로의 신규 연결은 줄지만, 이미 맺어진 연결은 남습니다.

  • terminationGracePeriodSeconds를 충분히
  • preStop 훅에서 서버가 graceful shutdown 하도록

예: Spring Boot라면 server.shutdown=graceful, spring.lifecycle.timeout-per-shutdown-phase 등을 고려합니다.

3) HPA/리소스는 “green 배포 순간”을 고려해 여유를 둬야 함

Blue와 Green을 동시에 띄우는 동안 일시적으로 리소스가 2배 필요합니다.

  • 노드 오토스케일러(Cluster Autoscaler/Karpenter)와 함께 설계
  • PDB(PodDisruptionBudget)로 가용성 하한 설정

4) 데이터 스키마 변경은 ‘호환성’이 핵심

무중단 배포에서 가장 흔한 사고는 앱은 green으로 갔는데 DB 스키마가 아직/이미 바뀐 케이스입니다.

  • Expand/Contract(확장/수축) 방식
  • 컬럼 추가 → 양쪽 버전 호환 → 트래픽 전환 → 구버전 제거 → 컬럼 제거

5) 관측(Observability) 없이는 자동 롤백이 오히려 위험

워크플로우의 smoke test만으로는 부족합니다.

  • 전환 후 1~5분 동안 5xx, latency, 주요 비즈니스 지표를 관찰
  • CloudWatch Alarm + GitHub Actions(또는 Argo Rollouts)로 자동 롤백 연동 고려

6) ALB 헬스체크와 readiness를 일치시키기

ALB healthcheck path가 앱의 readiness와 다르면 “쿠버네티스는 준비됨”인데 “ALB는 비정상”이 됩니다.

  • Ingress annotation의 alb.ingress.kubernetes.io/healthcheck-path
  • Security Group inbound(노드/파드 대상)
  • target-type ip/instance에 따른 네트워크 경로

7) IAM/IRSA는 CI/CD와 런타임을 분리해서 설계

  • GitHub Actions Role: ECR push, eks:DescribeCluster, (필요 시) sts:AssumeRole
  • Pod(ServiceAccount) Role(IRSA): S3/SQS/SecretsManager/Route53 등 런타임 권한

이걸 섞으면 “배포는 되는데 런타임이 터지는” 혹은 그 반대가 됩니다.

자주 겪는 장애 시나리오와 빠른 체크

배포 후 ALB 502/504가 발생

  • readiness는 OK인데 ALB는 unhealthy: healthcheck path/port 불일치
  • target group deregistration delay/드레인 부족
  • Security Group, NACL, 서브넷 라우팅

위 케이스는 실제로 매우 빈번합니다: EKS에서 ALB Ingress 502 Bad Gateway 원인 9가지

GitHub Actions에서 kubectl이 클러스터에 못 붙음

  • OIDC role trust 정책의 sub/aud 조건 불일치
  • aws eks update-kubeconfig는 되지만 kubectl이 Unauthorized: aws-auth ConfigMap 매핑 누락
  • 네트워크(프라이빗 엔드포인트)로 인해 러너에서 접근 불가

Pod가 AWS API 호출하다 STS 403

  • IRSA 설정/ServiceAccount annotation 누락
  • OIDC provider, trust policy 조건 오류
  • 토큰 마운트/환경변수 충돌

이 경우는 아래 체크리스트로 빨리 좁힐 수 있습니다: EKS Pod에서 STS 403 AccessDenied 원인 8가지

마무리: “전환”을 자동화하면 배포는 운영이 된다

Blue-Green의 본질은 새 버전을 격리된 상태로 충분히 준비시킨 뒤, 라우팅만 바꿔서 즉시 전환하는 것입니다. GitHub Actions로 이를 자동화하면 배포는 더 이상 이벤트성 작업이 아니라, 재현 가능한 운영 프로세스가 됩니다.

다음 단계로는 아래 확장을 추천합니다.

  • 전환 후 SLO 기반 자동 롤백(CloudWatch Alarm/Prometheus)
  • 점진 전환(가중치 기반)으로 카나리 + 블루그린 혼합
  • Argo Rollouts/Flagger 도입으로 배포 전략 표준화

원하시면 현재 사용 중인 구성(예: ALB Ingress 여부, target-type, 프라이빗 클러스터, HPA/Karpenter 사용)을 기준으로 위 워크플로우를 그대로 적용 가능한 형태로 커스터마이징해드릴게요.