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, 비용 최적화로 갈게요.