K8s 실전 #4 CI/CD 파이프라인 — GitHub Actions / ECR / ArgoCD

8 분 소요

K8s 실전 시리즈의 네 번째 글입니다. #3까지 거쳐 myshop-api는 EKS,RDS,Secrets,커넥션 풀까지 갖춘 완전한 서비스이지만, 새 버전이 들어오는 과정은 여전히 사람의 손에 묶여 있습니다. 누가 컨테이너를 빌드해 push하고, 누가 매니페스트의 이미지 태그를 바꾸고, 누가 helm upgrade를 돌립니다. 이번 글에서는 이 흐름 전체를 코드로 자동화하겠습니다. GitHub Actions가 OIDC 신뢰로 정적 키 없이 ECR에 이미지를 push하고, 매니페스트 repo의 Helm values를 자동 commit하고, 고급 #6에서 다룬 ArgoCD가 그 변경을 watch해 클러스터로 동기화하는 흐름입니다.

이번 시리즈는 K8s 실전 6편입니다.

두 repo 모델 — 코드와 매니페스트의 분리 #

GitOps의 가장 흔한 패턴은 repo 두 개의 분리입니다.

repo역할
myshop-api (애플리케이션 repo)소스 코드, Dockerfile, GitHub Actions workflow
myshop-manifests (매니페스트 repo)Helm values, Application 매니페스트, 환경별 설정

이 분리의 이점이 셋입니다.

  • 권한의 분리 — 코드 변경과 인프라/배포 변경의 리뷰어가 다를 수 있음
  • 변경의 명확성 — git log를 보면 “이 시점에 어느 버전이 prod에 떠 있었는가"가 명확
  • ArgoCD가 한곳만 보면 됨 — 매니페스트 repo만 watch하면 모든 환경의 desired state가 잡힘

코드 push의 흐름이 다음 한 줄로 표현됩니다.

GitOps의 한 사이클
[개발자 push] → [GitHub Actions: 빌드/테스트/ECR push]
              → [매니페스트 repo의 image tag 자동 commit]
              → [ArgoCD가 변경 감지]
              → [클러스터에 새 버전 배포]

각 단계를 한 절씩 풀어 보겠습니다.

GitHub Actions — OIDC로 AWS 자격 증명을 동적으로 #

GitHub Actions에서 AWS API를 부르는 옛 방법은 IAM 사용자의 access key / secret key를 GitHub Secrets에 저장하는 것이었습니다. 이 방식의 문제는 명백합니다 — 키가 정적이라 회전이 어렵고, 한 번 새면 영향이 큽니다.

새 표준은 OIDC trust입니다. GitHub Actions가 JWT 토큰을 발급하고, AWS IAM이 그 토큰을 검증해 임시 자격 증명을 발급해 주는 모델 — 고급 #2 IRSA와 같은 결입니다.

OIDC provider 등록 (Terraform) #

terraform/modules/github-oidc/main.tf
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

resource "aws_iam_role" "github_actions_ecr_push" {
  name = "github-actions-myshop-api-ecr-push"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = {
        Federated = aws_iam_openid_connect_provider.github.arn
      }
      Action = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
        }
        StringLike = {
          "token.actions.githubusercontent.com:sub" = "repo:myshop/myshop-api:ref:refs/heads/main"
        }
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "ecr_push" {
  role       = aws_iam_role.github_actions_ecr_push.name
  policy_arn = aws_iam_policy.ecr_push.arn
}

Conditionsub가 핵심입니다 — myshop/myshop-api repo의 main 브랜치에서 트리거된 워크플로만 이 Role을 가져갈 수 있습니다. 다른 repo, 다른 브랜치, 다른 fork는 모두 거부됩니다.

Workflow — 빌드와 push #

.github/workflows/build.yml
name: Build and push

on:
  push:
    branches: [main]
    tags: ['v*']

permissions:
  id-token: write    # OIDC 토큰 발급에 필요
  contents: read

env:
  AWS_REGION: ap-northeast-2
  ECR_REPOSITORY: myshop-api

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        id: meta
        run: |
          if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
            echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
          else
            echo "tag=main-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
          fi

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

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

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/${{ env.ECR_REPOSITORY }}:${{ steps.meta.outputs.tag }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Update manifest repo
        env:
          GH_TOKEN: ${{ secrets.MANIFESTS_REPO_TOKEN }}
        run: |
          gh api repos/myshop/myshop-manifests/dispatches \
            -f event_type=update-image \
            -F client_payload[app]=myshop-api \
            -F client_payload[tag]=${{ steps.meta.outputs.tag }} \
            -F client_payload[env]=dev

핵심 단계 셋입니다.

  • Configure AWS credentials (OIDC) — 위에서 만든 IAM Role을 OIDC로 AssumeRoleWithWebIdentity. 이 한 단계로 정적 키 없이 임시 자격 증명을 받음.
  • Build and push — Docker buildx로 멀티 플랫폼 빌드 + ECR push. GHA cache로 layer 캐싱.
  • Update manifest repo — repository_dispatch 이벤트로 매니페스트 repo의 다른 워크플로를 트리거. 이 워크플로가 Helm values를 자동 commit합니다.

매니페스트 repo의 자동 commit #

매니페스트 repo에는 위 dispatch를 받아 values 파일을 갱신하는 워크플로를 둡니다.

myshop-manifests/.github/workflows/update-image.yml
name: Update image tag

on:
  repository_dispatch:
    types: [update-image]

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Update values
        run: |
          APP=${{ github.event.client_payload.app }}
          TAG=${{ github.event.client_payload.tag }}
          ENV=${{ github.event.client_payload.env }}

          yq -i ".image.tag = \"$TAG\"" charts/$APP/values-$ENV.yaml

      - name: Commit and push
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add charts/
          git commit -m "chore: bump ${{ github.event.client_payload.app }} to ${{ github.event.client_payload.tag }} (${{ github.event.client_payload.env }})"
          git push

이 commit이 매니페스트 repo의 main 브랜치에 들어가면, ArgoCD가 그 변경을 watch하고 있다가 클러스터에 자동 동기화합니다.

ArgoCD — 매니페스트 repo의 watcher #

고급 #6에서 다룬 ArgoCD를 그대로 쓰겠습니다. Application CRD 매니페스트 한 장이 myshop-api 한 환경의 배포를 담당합니다.

ArgoCD 설치 #

ArgoCD Helm 설치
helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd \
  -n argocd --create-namespace \
  --values argocd-values.yaml
argocd-values.yaml — 일부
server:
  ingress:
    enabled: true
    ingressClassName: alb
    annotations:
      alb.ingress.kubernetes.io/scheme: internet-facing
      alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
      alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:...
    hosts:
      - argocd.myshop.example.com

configs:
  cm:
    timeout.reconciliation: 30s

ArgoCD UI는 argocd.myshop.example.com으로 노출됩니다. 운영 환경에서는 SSO(GitHub, Google)와 묶어 두는 게 표준입니다.

myshop-api Application #

argocd/applications/myshop-api-prod.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: myshop-api-prod
  namespace: argocd
spec:
  project: myshop

  source:
    repoURL: https://github.com/myshop/myshop-manifests.git
    targetRevision: main
    path: charts/myshop-api
    helm:
      valueFiles:
        - values.yaml
        - values-prod.yaml

  destination:
    server: https://kubernetes.default.svc
    namespace: myshop

  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
      - PrunePropagationPolicy=foreground
      - ServerSideApply=true
    retry:
      limit: 5
      backoff:
        duration: 5s
        maxDuration: 3m
  • automated — git의 변경이 즉시 클러스터에 반영. dev에 적합.
  • selfHeal: true — 누가 kubectl edit로 직접 수정해도 git 매니페스트로 자동 복구.
  • prune: true — git에서 사라진 객체는 클러스터에서도 삭제.

dev vs prod — 자동 sync 분기 #

prod는 자동 sync를 끄고 수동 트리거로 가는 패턴이 자주 쓰입니다.

myshop-api-prod.yaml — 수동 sync
syncPolicy:
  syncOptions:
    - CreateNamespace=true
    - ServerSideApply=true
  # automated 절을 제거 → 수동 sync 모드

배포 흐름이 다음과 같이 분기됩니다.

dev vs prod 배포 흐름
[dev]
git push → GitHub Actions 빌드 → ECR push
        → 매니페스트 repo commit (values-dev.yaml)
        → ArgoCD 자동 sync → dev 클러스터 배포

[prod]
git tag v1.5.0 → GitHub Actions 빌드 → ECR push
              → 매니페스트 repo commit (values-prod.yaml)
              → ArgoCD UI에서 사람이 "Sync" 클릭
              → prod 클러스터 배포

prod 배포의 사람 게이트가 안전장치입니다. 매니페스트 자체는 git PR로 리뷰되고, 실제 적용은 운영자가 한 번 더 확인합니다.

Application 묶음의 표준 — App of Apps #

ArgoCD에 Application 매니페스트를 손으로 적용하지 않고, 한 루트 Application이 다른 Application들을 watch하게 만드는 패턴입니다.

argocd/root.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: root
  namespace: argocd
spec:
  source:
    repoURL: https://github.com/myshop/myshop-manifests.git
    targetRevision: main
    path: argocd/applications
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true

argocd/applications/ 디렉터리에 새 Application을 만들면 자동으로 ArgoCD에 등록되고, 그 Application이 자기 매니페스트를 동기화합니다. 클러스터 자체의 운영도 GitOps 안으로 들어옵니다.

Image Updater — image tag 갱신을 ArgoCD로 #

위 흐름은 GitHub Actions가 매니페스트 repo에 commit해 image tag를 갱신했습니다. ArgoCD Image Updater는 이 단계를 ArgoCD로 옮기는 옵션입니다.

myshop-api-prod.yaml — Image Updater annotation
metadata:
  annotations:
    argocd-image-updater.argoproj.io/image-list: api=123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myshop-api
    argocd-image-updater.argoproj.io/api.update-strategy: semver
    argocd-image-updater.argoproj.io/write-back-method: git
    argocd-image-updater.argoproj.io/write-back-target: helmvalues:./charts/myshop-api/values-prod.yaml

ArgoCD Image Updater가 ECR을 정기적으로 polling하다가 새 태그를 발견하면 매니페스트 repo에 자동 commit합니다. GitHub Actions의 commit 단계가 필요 없어지지만, polling 주기가 5분 단위라 즉시성은 떨어집니다. 코드 push와 매니페스트 commit의 순서를 git에 명확히 남기고 싶다면 GitHub Actions commit 모델이 더 직관적입니다.

카나리,블루-그린 — Argo Rollouts #

표준 Deployment의 RollingUpdate는 가장 단순한 무중단 배포 모델입니다. 더 정교한 패턴(카나리, 블루-그린, 자동 분석 후 promote)은 Argo Rollouts가 풀어 줍니다.

rollout.yaml — 5% 카나리 → 분석 → 100%
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: myshop-api
  namespace: myshop
spec:
  replicas: 10
  strategy:
    canary:
      canaryService: myshop-api-canary
      stableService: myshop-api-stable
      trafficRouting:
        alb:
          ingress: myshop-api
          servicePort: 80
      steps:
        - setWeight: 5
        - pause: { duration: 5m }
        - analysis:
            templates:
              - templateName: success-rate
        - setWeight: 25
        - pause: { duration: 10m }
        - setWeight: 50
        - pause: { duration: 10m }
        - setWeight: 100
  selector:
    matchLabels:
      app.kubernetes.io/name: myshop-api
  template:
    spec:
      containers:
        - name: api
          image: 123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/myshop-api:1.5.0
          # ... (Deployment와 동일한 spec)

새 버전이 5% 트래픽으로 5분 → 자동 분석(Prometheus 메트릭 쿼리) → 통과하면 25% → 50% → 100% 순서로 점진 전환합니다. 분석 단계에서 실패가 감지되면 자동으로 롤백됩니다.

analysistemplate.yaml — 성공률 분석
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  metrics:
    - name: success-rate
      provider:
        prometheus:
          address: http://prometheus.monitoring.svc:9090
          query: |
            sum(rate(http_requests_total{app="myshop-api",status=~"2.."}[5m]))
              / sum(rate(http_requests_total{app="myshop-api"}[5m]))
      successCondition: result[0] >= 0.99
      failureLimit: 1

#5에서 다룰 Prometheus 메트릭이 이 단계에서 카나리의 자동 promote / rollback 결정에 직접 들어갑니다. Rollouts는 #5에서 다룬 옵저버빌리티 스택과 묶일 때 진가가 드러납니다.

PR 흐름의 표준 — environments + required reviewers #

GitHub Actions의 운영 표준 게이트도 잡아 두겠습니다.

.github/workflows/build.yml — environment 사용
jobs:
  build-prod:
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://api.myshop.example.com
    steps:
      - ...

environment: production을 GitHub Settings에서 만들고 Required reviewers를 지정하면, 그 환경으로 가는 워크플로는 사람의 승인 없이는 시작되지 않습니다. tag 한 번에 prod 배포가 자동 시작되는 것을 막는 표준 패턴입니다.

첫 사이클의 점검 #

GitHub Actions push → ECR → 매니페스트 commit → ArgoCD sync까지가 한 번 돈 시점에서 점검할 항목들입니다.

ECR 이미지 확인
aws ecr describe-images \
  --repository-name myshop-api \
  --region ap-northeast-2 \
  --query 'imageDetails[*].[imageTags,imagePushedAt]' \
  --output table
ArgoCD Application 상태
argocd app get myshop-api-prod
argocd app sync myshop-api-prod   # 수동 sync (prod의 경우)
argocd app history myshop-api-prod
배포된 이미지 태그가 맞는가
kubectl get deployment myshop-api -n myshop \
  -o jsonpath='{.spec.template.spec.containers[0].image}'

세 명령이 일관되게 새 태그를 가리키면 한 사이클이 정상 동작 중입니다. ArgoCD UI에서는 같은 정보가 시각적으로 표시되고, 매니페스트와 클러스터 사이의 drift도 한눈에 보입니다.

한 가지 함정 — 컨테이너 이미지 태그의 mutability #

운영 표준은 이미지 태그를 immutable로 두는 것입니다. 같은 태그가 다른 이미지를 가리키게 하면 ArgoCD의 drift detection이 의미를 잃습니다. 다음 셋업이 필수입니다.

  • ECR repository에 immutable tags 활성화 — Terraform으로 image_tag_mutability = "IMMUTABLE"
  • latest 태그를 prod에서 절대 사용하지 않기 — 항상 git SHA 또는 semver
  • 이미지 태그 = git commit hash 또는 git tag — 어느 commit이 어느 환경에 떠 있는가가 한눈에 보임

이 셋업이 빠지면 “어제까지 동작하던 그 태그가 오늘은 다른 이미지"라는 사고가 발생합니다. GitOps의 source of truth가 깨지는 지점입니다.

마무리 #

myshop-api의 새 버전이 클러스터에 들어오는 흐름을 코드로 자동화했습니다. GitHub Actions가 OIDC 신뢰로 정적 키 없이 ECR에 push하고, 매니페스트 repo의 Helm values를 자동 commit하고, ArgoCD가 그 변경을 watch해 클러스터로 동기화하는 한 사이클입니다. dev는 자동 sync, prod는 PR 리뷰 + GitHub environments + ArgoCD 수동 sync의 이중 게이트를 거치는 패턴까지 짚었고, Argo Rollouts로 카나리 자동 promote / rollback의 더 정교한 결도 봤습니다. 이 시점에서 myshop-api는 코드 push 한 번에 dev에 자동 배포되고, git tag 한 번에 prod 배포가 큐에 들어가는 흐름이 정착했습니다. 다음 글에서는 이 모든 동작을 들여다보는 한 층을 얹겠습니다 — Prometheus + Grafana + Alertmanager + CloudWatch로 구성하는 운영 클러스터의 옵저버빌리티 스택과 핵심 알람 룰셋을 다루겠습니다.

X