시작하며
최근 프로젝트에서 6개의 Go 서비스, 1개의 Spring 서비스 MSA로 분리하여 AWS EKS에 배포하는 CI/CD 파이프라인을 구축했습니다. 단순히 Docker 이미지를 빌드하는 것을 넘어서, 멀티 아키텍처 지원, 캐시 최적화, GitOps 연동까지 고려하다 보니 꽤 복잡한 구조가 되었습니다.
처음에는 "그냥 docker build 하면 되는 거 아닌가?" 라고 생각했지만, 실제로는 QEMU 에뮬레이션, BuildKit 캐시, GitHub Actions 매트릭스 전략 등 여러 개념을 이해해야 했습니다. 특히 "네이티브 빌드가 뭐지?", "캐시는 어디에 저장되는 거지?", "공통 패키지는 왜 매번 복사되는 거지?" 같은 질문들이 끊임없이 생겼습니다.
이 글에서는 저희 프로젝트의 CI 파이프라인 구조를 차근차근 설명하면서, 제가 헷갈렸던 개념들을 정리해보려고 합니다.
CI 파이프라인 전체 구조
저희 워크플로우는 크게 4단계로 구성됩니다:
1. validate-branch
└─ 브랜치 검증 (service-deploy-dev, service-deploy-prod만 허용)
2. detect-changes
└─ 변경된 서비스 감지 (dorny/paths-filter 사용)
└─ 환경 설정 (dev/prod)
3. build-platform (병렬 실행)
└─ 각 서비스 × 각 플랫폼 네이티브 빌드
└─ 다이제스트 아티팩트 업로드
4. merge-manifests (병렬 실행)
└─ 플랫폼별 이미지를 매니페스트 리스트로 통합
└─ ECR에 최종 이미지 푸시
5. update-gitops-manifests
└─ k8s-deploy-{env} 브랜치에 이미지 태그 업데이트
└─ ArgoCD 자동 동기화 트리거
하나씩 자세히 살펴보겠습니다.
Stage 1: 변경 감지 (detect-changes)
역할
Git 커밋에서 어떤 서비스가 변경되었는지 자동으로 감지합니다.
detect-changes:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.set-services.outputs.services }}
environment: ${{ steps.env-config.outputs.environment }}
steps:
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
common-package:
- 'packages/wealist-advanced-go-pkg/**'
board-service:
- 'services/board-service/**'
user-service:
- 'services/user-service/**'
# ... 다른 서비스들
동작 방식
일반 서비스 변경:
커밋 내용:
└─ services/user-service/handler.go 수정
detect-changes 결과:
└─ services: ["user-service"]
공통 패키지 변경:
커밋 내용:
└─ packages/wealist-advanced-go-pkg/logger/logger.go 수정
detect-changes 결과:
└─ services: ["board-service", "chat-service", "noti-service",
"storage-service", "user-service", "video-service"]
→ 공통 패키지를 사용하는 모든 Go 서비스 빌드!
이렇게 감지된 서비스 목록이 다음 단계의 매트릭스로 전달됩니다.
Stage 2: 멀티 아키텍처 네이티브 빌드 (build-platform)
매트릭스 전략
이 부분이 가장 핵심입니다. GitHub Actions의 매트릭스 기능을 사용해 서비스 × 플랫폼 조합으로 Job을 생성합니다:
build-platform:
runs-on: ${{ matrix.runner }}
strategy:
matrix:
service: ${{ fromJSON(needs.detect-changes.outputs.services) }}
platform: [linux/amd64, linux/arm64]
include:
- platform: linux/amd64
runner: ubuntu-latest # x86 러너
- platform: linux/arm64
runner: ubuntu-24.04-arm # ARM 러너
실제 Job 생성 예시
시나리오: user-service만 변경됨
생성되는 Job:
Job 1:
├─ service: user-service
├─ platform: linux/amd64
├─ runner: ubuntu-latest (x86 CPU)
└─ 실행: x86 러너에서 amd64 이미지 빌드
Job 2:
├─ service: user-service
├─ platform: linux/arm64
├─ runner: ubuntu-24.04-arm (ARM CPU)
└─ 실행: ARM 러너에서 arm64 이미지 빌드
⚡ 두 Job이 동시에 병렬 실행됩니다!
시나리오: board-service + user-service 변경됨
생성되는 Job:
Job 1: board-service + linux/amd64 (x86 러너)
Job 2: board-service + linux/arm64 (ARM 러너)
Job 3: user-service + linux/amd64 (x86 러너)
Job 4: user-service + linux/arm64 (ARM 러너)
⚡ 4개 Job 모두 동시에 병렬 실행!
네이티브 빌드란?
핵심 개념:
- 네이티브 빌드: CPU 아키텍처와 빌드 타겟이 일치
- 크로스 빌드 (QEMU): CPU 아키텍처와 빌드 타겟이 다름
┌─────────────────────────────────────────┐
│ x86 러너에서 amd64 이미지 빌드 │
├─────────────────────────────────────────┤
│ 러너 CPU: x86_64 (Intel/AMD) │
│ 빌드 타겟: linux/amd64 │
│ 결과: 네이티브 빌드 ✅ │
│ CPU가 명령어를 직접 실행 │
│ → 매우 빠름! │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ ARM 러너에서 arm64 이미지 빌드 │
├─────────────────────────────────────────┤
│ 러너 CPU: ARM64 (Graviton/Apple M) │
│ 빌드 타겟: linux/arm64 │
│ 결과: 네이티브 빌드 ✅ │
│ CPU가 명령어를 직접 실행 │
│ → 매우 빠름! │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ x86 러너에서 arm64 이미지 빌드 (QEMU) │
├─────────────────────────────────────────┤
│ 러너 CPU: x86_64 │
│ 빌드 타겟: linux/arm64 │
│ 결과: 에뮬레이션 빌드 ❌ │
│ QEMU가 ARM 명령어를 x86으로 변환 │
│ → 3-5배 느림! 💀 │
└─────────────────────────────────────────┘
QEMU 에뮬레이션의 오버헤드
QEMU는 서로 다른 CPU 아키텍처 간에 명령어를 실시간 변환합니다:
ARM 명령어 1개 실행 과정:
[네이티브 - ARM CPU에서 실행]
ARM 명령어 → ARM CPU 직접 실행
소요 시간: 1 사이클 ✅
[QEMU - x86 CPU에서 실행]
ARM 명령어
↓ 1. QEMU가 명령어 분석
↓ 2. 동등한 x86 명령어 찾기
↓ 3. x86 명령어 여러 개로 변환 (5-10개)
↓ 4. x86 CPU에서 실행
↓ 5. 결과를 ARM 레지스터에 반영
x86 실행 결과
소요 시간: 10-50 사이클 ❌
→ 결과: 3-5배 느려짐
실제 빌드 시간 비교:
board-service 빌드 (단일 러너에서 멀티 플랫폼 빌드 시):
├─ amd64 빌드 (네이티브): 2분
└─ arm64 빌드 (QEMU): 8분 ← 4배 느림!
총: 10분 (순차 실행)
board-service 빌드 (플랫폼별 러너 사용 시):
├─ amd64 빌드 (x86 러너, 네이티브): 2분
└─ arm64 빌드 (ARM 러너, 네이티브): 2분
총: 2분 (병렬 실행) ⚡
빌드 결과물
각 Job은 플랫폼별 이미지를 ECR에 푸시하고, 다이제스트(이미지 해시)를 아티팩트로 업로드합니다:
steps:
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
platforms: ${{ matrix.platform }}
push: true
tags: ${{ steps.tags.outputs.image_uri }}:${{ steps.tags.outputs.digest_tag }}
- name: Export digest
run: |
mkdir -p /tmp/digests/${{ matrix.service }}
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${{ matrix.service }}/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.service }}-${{ steps.platform.outputs.pair }}
path: /tmp/digests/${{ matrix.service }}/*
ECR에 생성되는 이미지:
user-service:42-abc1234-linux-amd64 ← amd64 이미지
user-service:42-abc1234-linux-arm64 ← arm64 이미지
업로드되는 아티팩트:
digests-user-service-linux-amd64
└─ sha256:abc123def456...
digests-user-service-linux-arm64
└─ sha256:789ghi012jkl...
Stage 3: 매니페스트 리스트 병합 (merge-manifests)
역할
플랫폼별로 나뉘어 있던 이미지들을 하나의 매니페스트 리스트로 통합합니다.
merge-manifests:
needs: build-platform
strategy:
matrix:
service: ${{ fromJSON(needs.detect-changes.outputs.services) }}
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
pattern: digests-${{ matrix.service }}-*
merge-multiple: true
- name: Create manifest list and push
run: |
docker buildx imagetools create \
-t ${IMAGE_URI}:${TAG} \
${IMAGE_URI}:${TAG}-linux-amd64 \
${IMAGE_URI}:${TAG}-linux-arm64
- name: Cleanup platform-specific tags
run: |
# 임시 플랫폼별 태그 삭제
aws ecr batch-delete-image \
--repository-name $(echo ${IMAGE_URI} | cut -d'/' -f2-) \
--image-ids imageTag=${TAG}-linux-amd64
aws ecr batch-delete-image \
--repository-name $(echo ${IMAGE_URI} | cut -d'/' -f2-) \
--image-ids imageTag=${TAG}-linux-arm64
매니페스트 리스트란?
매니페스트 리스트는 여러 플랫폼의 이미지를 하나로 묶은 메타데이터 파일입니다:
user-service:42-abc1234 (매니페스트 리스트)
├─ linux/amd64 → sha256:abc123def456...
│ └─ 실제 amd64 이미지를 가리키는 포인터
│
└─ linux/arm64 → sha256:789ghi012jkl...
└─ 실제 arm64 이미지를 가리키는 포인터
핵심:
- 매니페스트 리스트 자체는 이미지가 아니라 "목차" 역할
- 실제 이미지 데이터는 각 플랫폼별로 별도 저장
- Docker/Kubernetes가 pull할 때 자동으로 적절한 이미지 선택
Kubernetes에서의 자동 플랫폼 선택
이것이 매니페스트 리스트의 가장 큰 장점입니다:
EKS 클러스터:
┌──────────────────────────────────────┐
│ Intel 노드 (c5.2xlarge) │
├──────────────────────────────────────┤
│ kubelet이 이미지 pull 요청: │
│ user-service:42-abc1234 │
│ ↓ │
│ kubelet이 노드 아키텍처 확인: amd64 │
│ ↓ │
│ 매니페스트 리스트에서 amd64 선택 │
│ ↓ │
│ sha256:abc123... 이미지 pull ✅ │
│ ↓ │
│ 컨테이너 실행 (100% 성능) │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ Graviton 노드 (c6g.2xlarge) │
├──────────────────────────────────────┤
│ kubelet이 이미지 pull 요청: │
│ user-service:42-abc1234 (동일!) │
│ ↓ │
│ kubelet이 노드 아키텍처 확인: arm64 │
│ ↓ │
│ 매니페스트 리스트에서 arm64 선택 │
│ ↓ │
│ sha256:789ghi... 이미지 pull ✅ │
│ ↓ │
│ 컨테이너 실행 (100% 성능, 네이티브!) │
└──────────────────────────────────────┘
개발자는 아무것도 신경 쓸 필요가 없습니다!
- Kubernetes manifest에는 image: user-service:42-abc1234만 적으면 됨
- 노드 타입에 관계없이 항상 네이티브 이미지가 실행됨
만약 단일 아키텍처(amd64만)였다면?
EKS 클러스터 (단일 아키텍처 이미지):
Intel 노드:
└─ user-service:42-abc1234 (amd64만 존재)
└─ 네이티브 실행 ✅ (100% 성능)
Graviton 노드:
└─ user-service:42-abc1234 (amd64만 존재)
└─ QEMU 에뮬레이션으로 실행 ❌
└─ 50-70% 성능만 나옴
└─ Graviton의 비용 이점이 상쇄됨 💸
BuildKit 캐시 시스템 이해하기
Dockerfile 레이어 구조
Docker 이미지는 여러 레이어의 스택입니다:
FROM public.ecr.aws/docker/library/golang:1.24-bookworm AS builder
# Layer 1
RUN apt-get update && apt-get install -y git ca-certificates tzdata
# Layer 2
WORKDIR /workspace
# Layer 3
COPY packages/wealist-advanced-go-pkg/ ./packages/
# Layer 4
COPY services/board-service/go.mod go.sum ./
# Layer 5
RUN go mod download
# Layer 6
COPY services/board-service/ ./
# Layer 7
RUN go build -o board-api ./cmd/api
레이어 캐싱 원리:
빌드 1회차:
├─ Layer 1-7 모두 실행
└─ 각 레이어를 캐시에 저장
빌드 2회차 (소스 코드만 변경):
├─ Layer 1-5: CACHED ✅ (재사용)
├─ Layer 6: 변경됨! ❌ (재실행)
└─ Layer 7: Layer 6이 변경되어 재실행 ❌
레이어 배치 전략
핵심 원칙: 변경 빈도가 낮은 것부터 높은 순서로 배치
변경 빈도 (낮음 → 높음):
베이스 이미지 < 시스템 패키지 < 공통 패키지 < go.mod < 소스 코드
Dockerfile 순서:
FROM golang:1.24 ← 거의 안 바뀜
RUN apt-get install ← 거의 안 바뀜
COPY packages/ ← 가끔 바뀜
COPY go.mod go.sum ← 가끔 바뀜
RUN go mod download ← go.mod 변경 시만
COPY source/ ← 자주 바뀜
RUN go build ← 소스 변경 시만
BuildKit 캐시 마운트: 레이어 캐싱의 한계 극복
일반 레이어 캐싱의 문제:
# Layer 5
COPY services/board-service/ ./ ← 소스 변경!
# Layer 6
RUN go mod download ← Layer 5가 변경되어 재실행
/go/pkg/mod가 비어있음
의존성 전체 재다운로드 ❌
BuildKit 캐시 마운트 사용:
# Layer 5
COPY services/board-service/ ./ ← 소스 변경!
# Layer 6
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# BuildKit이 /go/pkg/mod를 별도 캐시 영역에 마운트
# Layer 5가 변경되어도 /go/pkg/mod 내용은 유지됨 ✅
# 의존성 재다운로드 불필요!
두 가지 캐시 시스템:
┌──────────────────────────────────────┐
│ Docker 레이어 캐시 │
├──────────────────────────────────────┤
│ - 각 RUN, COPY 명령어가 레이어 │
│ - 명령어 변경 시 이후 레이어 무효화 │
│ - 전체 레이어를 캐시 │
└──────────────────────────────────────┘
┌──────────────────────────────────────┐
│ BuildKit 캐시 마운트 │
├──────────────────────────────────────┤
│ - 특정 디렉토리만 캐시 │
│ - 레이어 무효화와 독립적 │
│ - /go/pkg/mod, /root/.cache/go-build │
└──────────────────────────────────────┘
GitHub Actions 캐시 통합
BuildKit 캐시를 GitHub Actions 캐시 스토리지에 저장:
- uses: docker/build-push-action@v5
with:
cache-from: type=gha,scope=board-service-linux-amd64-dev
cache-to: type=gha,mode=max,scope=board-service-linux-amd64-dev
캐시 저장 위치:
┌────────────────────────────────────────┐
│ GitHub Actions Cache API │
│ (GitHub 클라우드 스토리지) │
├────────────────────────────────────────┤
│ board-service-linux-amd64-dev: │
│ ├─ layer-001.tar.gz (golang:1.24) │
│ ├─ layer-002.tar.gz (apt-get) │
│ ├─ layer-003.tar.gz (공통 패키지) │
│ ├─ layer-004.tar.gz (go.mod) │
│ ├─ layer-005.tar.gz (go mod download) │
│ └─ ... │
│ 총: ~600MB │
│ 보관 기간: 7일 (미사용 시 삭제) │
└────────────────────────────────────────┘
mode=max vs mode=min:
mode=min (기본값):
└─ 최종 이미지 레이어만 저장
└─ 캐시 크기: 50MB
└─ 중간 빌드 레이어 없음
└─ 다음 빌드 시 대부분 재실행 ❌
mode=max (권장):
└─ 모든 중간 레이어 저장
└─ 캐시 크기: 600MB
└─ golang 이미지, apt-get, go mod download 모두 포함
└─ 다음 빌드 시 최대한 재사용 ✅
빌드 로그에서 캐시 확인하기
실제 로그를 보면 어떤 부분이 캐시되었는지 알 수 있습니다:
#12 [builder 2/11] RUN apt-get update && apt-get install ...
#12 CACHED ← 캐시 사용!
#13 [builder 3/11] WORKDIR /workspace
#13 CACHED ← 캐시 사용!
#14 [builder 4/11] COPY packages/wealist-advanced-go-pkg/ ...
#14 DONE 2.2s ← 실제 실행 (캐시 미스 또는 변경됨)
#18 [builder 8/11] RUN --mount=type=cache,target=/go/pkg/mod go mod download
#18 DONE 2.9s ← 실제 실행하지만 /go/pkg/mod는 캐시 마운트에서 가져옴
공통 패키지 중복 문제
문제 상황
9개 서비스 모두 packages/wealist-advanced-go-pkg/ 공통 패키지를 사용합니다:
# 모든 서비스의 Dockerfile에 공통적으로 존재
COPY packages/wealist-advanced-go-pkg/ ./packages/wealist-advanced-go-pkg/
현재 캐시 구조:
GitHub Actions Cache (10GB 제한):
board-service-linux-amd64-dev: 600MB
├─ golang:1.24 (450MB)
├─ apt-get (30MB)
├─ 공통 패키지 (50MB) ← 중복!
└─ ...
user-service-linux-amd64-dev: 580MB
├─ golang:1.24 (450MB)
├─ apt-get (30MB)
├─ 공통 패키지 (50MB) ← 중복!
└─ ...
storage-service-linux-amd64-dev: 620MB
├─ golang:1.24 (450MB)
├─ apt-get (30MB)
├─ 공통 패키지 (50MB) ← 중복!
└─ ...
... 9개 서비스 × 2개 플랫폼 = 18개 캐시
공통 패키지만: 50MB × 18 = 900MB 중복!
빌드 로그에서 확인
#14 [builder 4/11] COPY packages/wealist-advanced-go-pkg/ ./packages/wealist-advanced-go-pkg/
#14 DONE 2.2s
← 이 단계가 모든 서비스 빌드에서 매번 실행됨!
비효율:
- 9개 서비스 × 2.2초 = 약 20초 낭비
- 900MB 캐시 공간 낭비
왜 캐시가 공유되지 않을까?
캐시 스코프가 서비스별로 분리되어 있기 때문:
cache-from: type=gha,scope=board-service-linux-amd64-dev
cache-to: type=gha,mode=max,scope=board-service-linux-amd64-dev
↑
서비스별로 다른 스코프
board-service는 board-service-* 캐시만 읽음
user-service는 user-service-* 캐시만 읽음
→ 공통 패키지 레이어가 각 캐시에 중복 저장됨
베이스 이미지의 이해
현재 사용 중인 베이스 이미지
FROM public.ecr.aws/docker/library/golang:1.24-bookworm AS builder
이것은:
- AWS ECR Public Gallery에서 제공하는 공식 golang 이미지
- Docker Hub의 golang:1.24-bookworm과 동일
- AWS가 미러링해서 제공 (빠른 다운로드)
포함 내용:
golang:1.24-bookworm:
├─ Debian Bookworm OS
├─ Go 1.24 컴파일러 및 도구체인
├─ 기본 빌드 도구
└─ /workspace 디렉토리 (비어있음)
❌ packages/wealist-advanced-go-pkg/ 없음!
ECR Public vs Docker Hub
Docker Hub:
├─ URL: docker.io/library/golang:1.24-bookworm
├─ 속도: 보통
└─ Rate Limit: 익명 100 pulls/6시간
AWS ECR Public:
├─ URL: public.ecr.aws/docker/library/golang:1.24-bookworm
├─ 속도: 빠름 (AWS 인프라 사용 시) ⚡
└─ Rate Limit: 더 관대함
GitOps 연동: ArgoCD 자동 배포
update-gitops-manifests Job
빌드가 완료되면 k8s manifest를 업데이트하여 ArgoCD가 자동으로 배포하도록 합니다:
update-gitops-manifests:
needs: [detect-changes, merge-manifests]
steps:
- name: Checkout k8s branch
run: |
# k8s-deploy-dev 또는 k8s-deploy-prod 브랜치 체크아웃
git fetch origin ${K8S_BRANCH}
git checkout ${K8S_BRANCH}
- name: Update manifest files
run: |
for service in $(echo $SERVICES | jq -r '.[]'); do
MANIFEST_FILE="k8s/argocd/apps/${ENV}/${service}.yaml"
# image.tag 업데이트
sed -i '/- name: image\.tag/{
n
s|value: ".*"|value: "'"${NEW_TAG}"'"|
}' "$MANIFEST_FILE"
done
- name: Commit and push
run: |
git add .
git commit -m "🚀 Update image tags (Build #${BUILD_NUMBER})"
git push origin ${K8S_BRANCH}
전체 흐름
1. 코드 변경 (service-deploy-dev 브랜치에 push)
└─ services/user-service/handler.go 수정
2. GitHub Actions 워크플로우 트리거
├─ detect-changes: user-service 감지
├─ build-platform: amd64, arm64 이미지 빌드 (병렬)
├─ merge-manifests: 매니페스트 리스트 생성
└─ update-gitops-manifests
3. k8s-deploy-dev 브랜치 업데이트
└─ k8s/argocd/apps/dev/user-service.yaml
image.tag: "78-2ffb73c" (변경)
4. ArgoCD가 변경 감지
└─ 자동으로 Git과 클러스터 동기화
5. EKS 클러스터에 배포
└─ 새로운 이미지로 Pod 재시작
마치며
GitHub Actions로 Docker CI 파이프라인을 구축하면서 가장 많이 헷갈렸던 것은 "어떤 캐시가 어디에 저장되는지", "네이티브 빌드가 정확히 무엇인지" 같은 개념들이었습니다.
이 글에서는 기술적인 최적화보다는, 저희 프로젝트의 CI 구조와 각 단계가 어떻게 동작하는지에 초점을 맞춰 설명했습니다. 특히:
- 매트릭스 전략으로 서비스 × 플랫폼 Job 생성
- 네이티브 빌드 vs QEMU 에뮬레이션의 차이
- 매니페스트 리스트와 Kubernetes의 자동 플랫폼 선택
- BuildKit 캐시 마운트와 GitHub Actions 캐시의 관계
- 공통 패키지 중복 저장 문제
이런 개념들을 이해하고 나니, 로그를 보는 눈도 달라지고 문제를 파악하기도 훨씬 쉬워졌습니다.
여러분의 프로젝트에도 비슷한 구조가 있다면, 이 글이 조금이나마 도움이 되길 바랍니다. 궁금한 점이나 다른 경험이 있으시면 댓글로 공유해주세요!
참고 자료
- https://github.com/OrangesCloud/wealist-project-advanced-k8s
- BuildKit - Cache Mounts
- Docker Buildx - Cache
GitHub - OrangesCloud/wealist-project-advanced-k8s: 심화프로젝트 docker-compose, kind-k8s 완성버전(v1.1210)
심화프로젝트 docker-compose, kind-k8s 완성버전(v1.1210). Contribute to OrangesCloud/wealist-project-advanced-k8s development by creating an account on GitHub.
github.com
'Network' 카테고리의 다른 글
| Istio로 대규모 트래픽 관리하기: 실전 적용 사례와 확장 전략 (2) | 2026.01.10 |
|---|---|
| EKS에서 ArgoCD로 GitOps 구축하기: 순환 의존성 해결부터 Single Sync까지 (0) | 2026.01.05 |
| 실전 Kubernetes 보안: 4C 모델을 프로덕션 클러스터에 적용하며 배운 것들 (0) | 2026.01.04 |
| SealSecret vs AWS Secret Manager (0) | 2026.01.02 |
| Prometheus 디스코드 웹훅 (0) | 2025.10.02 |