GitHub Actions · Docker · Terraform · Helm · GitOps(ArgoCD)
- 모노레포 표준: apps/api(FastAPI),apps/web(Next.js),infra/terraform,deploy/helm
- Docker: 멀티스테이지, non-root, SBOM(스빔) 생성 + 이미지 서명(cosign)
- CI: lint/test → 이미지 빌드 & 취약점 스캔(Trivy) → SBOM(syft) → GHCR/ECR 푸시
- CD: GitOps(Argo CD). Actions가 ops 저장소에 이미지 태그만 PR → 승인 → 자동 배포
- IaC: Terraform으로 VPC/RDS/Redis/EKS(또는 ECS) + 원격상태(S3+DynamoDB)
- 롤아웃: Argo Rollouts 카나리(90/10→100), 자동 분석(p95/에러율)
- 마이그: Alembic 확장-이행-수축(expand→migrate→contract) 패턴, PreSync 훅
- 시크릿: GitHub OIDC→클라우드 롤, External Secrets Operator(ESO)로 Secret 동기화
1) 리포지토리 구조(권장)
repo/
  apps/
    api/         # FastAPI
    web/         # Next.js
  deploy/
    helm/
      api/
      web/
      base/      # 공통 템플릿 (HPA/PDB/네임스페이스 등)
  infra/
    terraform/
      global/    # 네트워크, ECR
      envs/
        dev/
        prod/
  .github/workflows/
2) Docker 베스트프랙티스
FastAPI (멀티스테이지 + non-root)
# apps/api/Dockerfile
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
RUN addgroup --system app && adduser --system --ingroup app app
WORKDIR /app
COPY apps/api/requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt gunicorn uvicorn
COPY apps/api/ .
USER app
EXPOSE 8000
CMD ["gunicorn","-k","uvicorn.workers.UvicornWorker","-w","4","-b","0.0.0.0:8000","app.main:app"]
Next.js (standalone 런타임)
# apps/web/Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY apps/web/package*.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY apps/web/ .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER node
EXPOSE 3000
CMD ["node","server.js"]
.dockerignore는 node_modules, .git, tests, .next/cache 등 반드시 제외.
3) GitHub Actions: 빌드·스캔·서명·푸시
공통 재사용 워크플로(s) 예시 — .github/workflows/build.yml
name: build-and-push
on:
  push:
    branches: [main]
    paths: [ "apps/**", "deploy/helm/**", ".github/workflows/**" ]
  workflow_dispatch:
jobs:
  matrix-build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        app: [api, web]
    permissions:
      contents: read
      packages: write
      id-token: write  # OIDC (클라우드 권한 부여 시)
    steps:
    - uses: actions/checkout@v4
    - name: Set up QEMU & Buildx
      uses: docker/setup-qemu-action@v3
    - uses: docker/setup-buildx-action@v3
    - name: Login GHCR
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}
    - name: Build image
      uses: docker/build-push-action@v6
      with:
        context: .
        file: apps/${{ matrix.app }}/Dockerfile
        push: true
        tags: |
          ghcr.io/${{ github.repository }}/${{ matrix.app }}:${{ github.sha }}
          ghcr.io/${{ github.repository }}/${{ matrix.app }}:main
        cache-from: type=gha
        cache-to: type=gha,mode=max
        provenance: true
    - name: Trivy scan
      uses: aquasecurity/trivy-action@0.24.0
      with:
        image-ref: ghcr.io/${{ github.repository }}/${{ matrix.app }}:${{ github.sha }}
        format: sarif
        output: trivy-${{ matrix.app }}.sarif
    - name: Upload SARIF
      uses: github/codeql-action/upload-sarif@v3
      with: { sarif_file: trivy-${{ matrix.app }}.sarif }
    - name: Generate SBOM (syft)
      uses: anchore/sbom-action@v0
      with:
        image: ghcr.io/${{ github.repository }}/${{ matrix.app }}:${{ github.sha }}
        format: spdx-json
        output-file: sbom-${{ matrix.app }}.spdx.json
    - name: Cosign sign (keyless)
      uses: sigstore/cosign-installer@v3
    - run: cosign sign ghcr.io/${{ github.repository }}/${{ matrix.app }}@${{ steps.meta.outputs.digest }} --yes
      env: { COSIGN_EXPERIMENTAL: "true" }
포인트
- provenance true: SLSA 빌드 출처 제공
- OIDC로 클라우드 자격증명 없이 권한 위임(배포 단계에서 사용)
4) GitOps CD(Argo CD) — “코드로만 배포”
흐름
- 앱 저장소(repo): 위 Actions가 이미지 태그를 ops 저장소로 PR
- ops 저장소(예: ex-ops): Helm values의 image.tag만 변경 → 승인
- Argo CD가 주기적으로 풀링 → 차이 감지 → Sync(자동/수동)
태그 바꿔주는 스텝(ops 리포 PR)
- name: Bump image tag in ops repo
  uses: peter-evans/create-pull-request@v6
  with:
    token: ${{ secrets.GH_TOKEN_WITH_REPO_SCOPE }}
    commit-message: "chore(${ {matrix.app} }): bump to ${{ github.sha }}"
    title: "bump(${ {matrix.app} }): ${{ github.sha }}"
    branch: bump/${{ matrix.app }}-${{ github.sha }}
    base: main
    body: "Update image tag"
    add-paths: deploy/helm/${{ matrix.app }}/values-prod.yaml
5) Helm 차트(핵심만)
values.yaml (api)
image:
  repository: ghcr.io/org/repo/api
  tag: "sha-PLACEHOLDER"
  pullPolicy: IfNotPresent
service:
  port: 8000
resources:
  requests: { cpu: "200m", memory: "256Mi" }
  limits:   { cpu: "1",   memory: "512Mi" }
env:
  - name: OTLP_ENDPOINT
    value: http://otel-collector:4317
securityContext:
  runAsNonRoot: true
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
Deployment (발췌)
livenessProbe:  { httpGet: { path: /healthz, port: 8000 }, initialDelaySeconds: 10, periodSeconds: 10 }
readinessProbe: { httpGet: { path: /readyz,  port: 8000 }, initialDelaySeconds: 5,  periodSeconds: 5 }
podDisruptionBudget:
  minAvailable: 1
hpa:
  min: 2
  max: 10
  cpu: 70
보안/시크릿: External Secrets Operator(ESO) 사용
# ExternalSecret 예시
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
spec:
  refreshInterval: 1h
  secretStoreRef: { name: aws-sm, kind: ClusterSecretStore }
  target: { name: api-secrets }
  data:
    - secretKey: DB_URL
      remoteRef: { key: prod/api/DB_URL }
6) 롤아웃: 카나리 + 자동분석
Argo Rollouts(요지)
strategy:
  canary:
    steps:
    - setWeight: 10
    - pause: { duration: 2m }
    - analysis:
        templates:
        - templateName: apislo
    - setWeight: 50
    - pause: { duration: 3m }
    - analysis:
        templates:
        - templateName: apislo
    - setWeight: 100
AnalysisTemplate (Prometheus로 p95/에러율 검증)
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
spec:
  metrics:
  - name: api-p95
    interval: 1m
    successCondition: result < 0.35   # 350ms 미만
    provider:
      prometheus:
        address: http://prometheus:9090
        query: >
          histogram_quantile(0.95, sum by (le)
          (rate(http_request_duration_seconds_bucket{app="api"}[1m])))
  - name: api-error-rate
    interval: 1m
    successCondition: result < 0.005  # 0.5% 미만
    provider:
      prometheus:
        address: http://prometheus:9090
        query: >
          sum(rate(http_requests_total{app="api",status=~"5.."}[1m])) /
          sum(rate(http_requests_total{app="api"}[1m]))
7) DB 마이그레이션(무중단 규칙)
- 확장(Expand): 컬럼/인덱스 추가(널 허용, 동시 인덱스)
- 이행(Migrate): 앱이 새 필드 읽기/쓰기 시작 (롤아웃)
- 수축(Contract): 구 필드 제거(검증 기간 후)
Helm PreSync 훅으로 Alembic
apiVersion: batch/v1
kind: Job
metadata:
  annotations: { "argocd.argoproj.io/hook": PreSync }
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - name: migrate
        image: ghcr.io/org/repo/api:{{ .Values.image.tag }}
        command: ["alembic","upgrade","head"]
        envFrom: [{ secretRef: { name: api-secrets } }]
8) Terraform(IaC) 골격
원격 상태
# infra/terraform/envs/prod/backend.tf
terraform {
  backend "s3" {
    bucket = "ex-tfstate"
    key    = "prod/eks.tfstate"
    region = "ap-northeast-2"
    dynamodb_table = "ex-tf-lock"
    encrypt = true
  }
}
EKS + RDS + ElastiCache (요지)
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  name    = "ex-vpc"
  cidr    = "10.0.0.0/16"
  azs     = ["ap-northeast-2a","ap-northeast-2b"]
  private_subnets = ["10.0.1.0/24","10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24","10.0.102.0/24"]
}
module "eks" {
  source          = "terraform-aws-modules/eks/aws"
  cluster_name    = "ex-eks"
  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.private_subnets
  manage_aws_auth = true
  eks_managed_node_groups = {
    default = { desired_size = 3, instance_types = ["m7g.large"] }
  }
}
module "rds" {
  source  = "terraform-aws-modules/rds/aws"
  identifier = "ex-pg"
  engine = "postgres"
  instance_class = "db.m6g.large"
  allocated_storage = 100
  multi_az = true
  username = "ex"
  create_random_password = true
  subnet_ids = module.vpc.private_subnets
}
module "redis" {
  source  = "terraform-aws-modules/elasticache/aws"
  engine  = "redis"
  cluster_mode = false
  node_type = "cache.t4g.medium"
  num_cache_nodes = 1
  subnet_group_name = "ex-redis-subnet"
}
배포자격: GitHub OIDC → AWS IAM으로 롤연동(액세스키 무사용).
시크릿: AWS Secrets Manager에 저장 → ESO로 K8s에 주입.
9) 테스트 파이프라인(요약)
- API: pytest -q+ 커버리지, DB는 Testcontainers/SQLite 대체 금지
- Web: jest(유닛) +playwright(E2E), PR마다 프리뷰 환경
- 계약 테스트: BFF ↔ API에 OpenAPI 스냅샷 비교
10) 보안·컴플라이언스
- 컨테이너 non-root, readOnlyRootFilesystem, Cap Drop ALL
- 네임스페이스 분리(dev/stage/prod), NetworkPolicy 적용
- 서명 검증: 레지스트리/Admission에서 cosign verify 강제
- 런타임 보호: Falco/Datadog CWS(선택)
11) 48시간 액션 플랜
- GHCR로 이미지 빌드/푸시 워크플로 적용(Trivy, syft, cosign 포함)
- ops 저장소 분리 + Helm values에서 image.tag만 변경하는 PR 자동 생성
- Argo CD 설치 후 apps/api,apps/web배포 파이프라인 연결
- Alembic PreSync Job로 마이그 테스트(Dev)
- Terraform 원격상태(S3+DynamoDB) 구성, VPC/ECR/EKS 프로비저닝
12) 2주 액션 플랜
- Argo Rollouts + AnalysisTemplate로 카나리 본가동
- External Secrets Operator로 DB_URL/REDIS_URL 주입 전환
- GitHub OIDC→IAM 역할 연결(액세스키 전면 제거)
- Playwright E2E + 프리뷰환경 자동화(웹)
- SLSA/서명 검증 Admission(cosign-policy) 적용
- DB 마이그 expand→migrate→contract 체크리스트 문서화 & CI 게이트
13) “복붙” 모음
A. 브랜치 보호 & 환경 승인(요약)
- main보호, 필수 체크: 테스트·스캔·SBOM·서명
- prod배포는 환경 승인자 필요(GitHub Environments)
B. K8s 보안 컨텍스트(공통 템플릿)
securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false
  capabilities: { drop: ["ALL"] }
C. ServiceMonitor(메트릭 스크레이프)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
spec:
  selector: { matchLabels: { app: api } }
  endpoints: [{ port: http, path: /metrics, interval: 30s }]
마무리
- CI는 “빠른 피드백·공급망 보안(SBOM/서명/스캔)”,
- CD는 “GitOps로 안전한 변경·점진적 롤아웃”,
- IaC는 “재현 가능한 인프라·권한 최소화(OIDC)”.
 이 3박자를 고정하면 대규모 배포에서도 속도와 안정성을 동시에 잡을 수 있습니다.
다음은 10편. 쿠버네티스 운영 — 오토스케일링(HPA/KEDA), 롤아웃, Mesh, PDB, 비용 최적화로 갈게요.
답글 남기기