Jenkins vs GitHub Actions — Docker 빌드부터 Kubernetes 배포까지 자동화 실전 비교
CI/CD 도구를 처음 고르는 분, 또는 Jenkins 만 알다가 GitHub Actions 로 넘어가려는 분, 반대로 Actions 만 써본 분이 사내 Jenkins 와 마주쳤을 때 — 결국 같은 질문에 부딪힙니다. “내 코드 push 가 어떻게 컨테이너가 되고, 어떻게 Pod 으로 돌아가나?”
이 글은 그 한 줄을 끝까지 따라가는 글입니다. 30 개 서비스를 GitHub Actions + GHCR + ArgoCD 로 운영하면서 정리한 실전 파이프라인, 그리고 같은 일을 Jenkins Declarative Pipeline 으로 짰을 때의 비교까지 다룹니다.
이 글에서 다루는 것
- CI/CD 파이프라인의 4 단계 (Build → Test → Push → Deploy) 를 도구 무관하게 이해하기
- GitHub Actions 실전 워크플로우 (실제로 30 개 서비스에 쓰는 패턴)
- Jenkins Declarative Pipeline 으로 같은 일을 짜는 법
- 두 도구의 차이를 8 가지 관점에서 비교 (러너, 시크릿, 캐싱, 비용, …)
- Docker 이미지 빌드 모범 사례 (multi-stage, buildx, cache mount, immutable tags)
- CD 의 두 갈래 —
kubectl applypush 모델 vs ArgoCD pull (GitOps) 모델- 보안 — OIDC, registry credentials, image pull secret, SOPS
1. 큰 그림 — CI/CD 는 4 단계의 컨베이어 벨트
도구가 무엇이든 (Jenkins, GitHub Actions, GitLab CI, ArgoCD, Tekton, …) 결국 다음 4 단계입니다:
[1. Build] ──> [2. Test] ──> [3. Push] ──> [4. Deploy]
소스 컴파일 유닛/통합 레지스트리 클러스터로
+ 이미지 생성 테스트 통과 이미지 업로드 롤아웃
이 4 단계 중 1~3 을 CI(Continuous Integration), 4 를 CD(Continuous Delivery/Deployment) 라고 부릅니다. 도구를 비교할 때는 항상 “그 도구가 이 4 단계 중 어디까지 책임지나” 를 먼저 물어야 합니다.
- Jenkins: 1~4 전부 가능 (전통적으로
kubectl apply까지 직접 했음). - GitHub Actions: 1~3 까지 자연스럽고, 4 는 가능하지만 부자연스러움. CD 는 ArgoCD/Flux 같은 GitOps 컨트롤러에 위임하는 추세.
- ArgoCD: 오직 4. CI 가 뱉어낸 이미지/매니페스트를 클러스터로 흘려보내는 일에 특화.
→ 모던 스택은 GitHub Actions(CI) + ArgoCD(CD) 조합이 표준이 되어가는 중. 이 글에서도 이 패턴이 중심입니다.
2. GitHub Actions 실전 — 1 개 파일로 끝나는 CI
제가 운영 중인 inter-asat 리포의 실제 워크플로우입니다.
# .github/workflows/k3s-images.yml
name: Build K3s images
on:
push:
branches: [master, main]
workflow_dispatch:
env:
REGISTRY: ghcr.io
BACKEND_IMAGE: ghcr.io/myoungsoo7/inter-asat-backend
FRONTEND_IMAGE: ghcr.io/myoungsoo7/inter-asat-frontend
jobs:
backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # ghcr.io 푸시 권한
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: $
username: $
password: $ # ← 시크릿 발급 불필요
- uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.backend
push: true
tags: |
$:latest
$:$
cache-from: type=gha,scope=inter-asat-backend
cache-to: type=gha,mode=max,scope=inter-asat-backend
frontend:
runs-on: ubuntu-latest
# ... 동일 패턴 ...
2.1 이 한 파일이 하는 일
master에 push 되는 순간 GitHub 가 ubuntu-latest VM 한 대를 띄움 (무료, 2 코어/7GB).- buildx 로 Docker 이미지를 multi-platform 빌드.
GITHUB_TOKEN으로 자동 로그인 (별도 PAT 발급/관리 X).ghcr.io/myoungsoo7/inter-asat-backend:latest와:<sha>두 태그로 push.type=gha캐시 — 이전 레이어를 GitHub Actions 자체 캐시에 저장 → 두 번째 빌드부터 절반 이상 빨라짐.
파일 1 개 = 무료 러너 + 자동 인증 + 빌드 캐시 + 이미지 푸시. Jenkins 로 같은 일을 하려면 (a) Jenkins 서버, (b) Docker-in-Docker agent, (c) credentials 관리, (d) buildx 설치, (e) 캐시 볼륨까지 직접 세팅해야 합니다. 신규 프로젝트 1 개당 30 분~1 시간이 GitHub Actions 에서는 5 분.
2.2 매트릭스 빌드와 병렬화
strategy:
matrix:
service: [api, worker, scheduler]
jobs:
build:
steps:
- uses: docker/build-push-action@v6
with:
file: $/Dockerfile
tags: ghcr.io/foo/$:$
3 개 서비스가 병렬 로 빌드됩니다. Jenkins 의 parallel { } 블록과 동등한 일이지만, 선언적이라 읽기 쉬워요.
3. Jenkins 실전 — Declarative Pipeline 으로 같은 파이프라인
같은 일을 Jenkins 로 짜면 이렇습니다.
// Jenkinsfile
pipeline {
agent {
kubernetes {
yaml '''
apiVersion: v1
kind: Pod
spec:
containers:
- name: buildkit
image: moby/buildkit:latest
securityContext: { privileged: true }
- name: kubectl
image: bitnami/kubectl:1.30
command: ['cat']
tty: true
'''
}
}
environment {
REGISTRY = 'ghcr.io'
BACKEND_IMAGE = 'ghcr.io/myoungsoo7/inter-asat-backend'
GIT_SHA = "${env.GIT_COMMIT.take(7)}"
}
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Build & Push backend') {
steps {
container('buildkit') {
withCredentials([usernamePassword(
credentialsId: 'ghcr-creds',
usernameVariable: 'GHCR_USER',
passwordVariable: 'GHCR_PASS')]) {
sh '''
mkdir -p ~/.docker
echo "{\\"auths\\":{\\"$REGISTRY\\":{\\"auth\\":\\"$(printf %s "$GHCR_USER:$GHCR_PASS" | base64)\\"}}}" > ~/.docker/config.json
buildctl build \\
--frontend dockerfile.v0 \\
--local context=. \\
--local dockerfile=. \\
--opt filename=Dockerfile.backend \\
--output type=image,name=$BACKEND_IMAGE:$GIT_SHA,push=true \\
--export-cache type=registry,ref=$BACKEND_IMAGE:cache \\
--import-cache type=registry,ref=$BACKEND_IMAGE:cache
'''
}
}
}
}
stage('Deploy') {
steps {
container('kubectl') {
withKubeConfig([credentialsId: 'k3s-prod-kubeconfig']) {
sh '''
kubectl set image -n asat-prod \\
deployment/inter-asat-backend \\
backend=$BACKEND_IMAGE:$GIT_SHA
kubectl rollout status -n asat-prod deployment/inter-asat-backend --timeout=5m
'''
}
}
}
}
}
post {
failure {
mail to: 'iamipro@naver.com', subject: "FAIL: ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: "${env.BUILD_URL}"
}
}
}
3.1 Jenkins 가 더 잘하는 것
- 에이전트 토폴로지의 자유도: 위 예시처럼 빌드 단계는 buildkit 컨테이너에서, 배포 단계는 kubectl 컨테이너에서 — 한 파이프라인 안에서 다른 환경을 자연스럽게 섞을 수 있음.
- 공유 라이브러리(Shared Library): Groovy 로 짠 공통 함수를 모든 파이프라인이 import. 매트릭스 빌드 100 개가 있을 때 GitHub Actions 의 reusable workflow 보다 더 표현력이 높음.
- 장기 빌드/플로팅 빌드: 12 시간짜리 e2e, 야간 회귀 테스트 같은 거 — Jenkins 가 더 안정적. GitHub Actions 의 job 최대 시간은 6 시간 (self-hosted runner 만 무제한).
- 온프레미스/에어갭 환경: 외부 인터넷 차단된 사내망에서 CI 가 필요할 때 GitHub Actions 는 self-hosted runner + private GHCR 미러까지 손이 많이 가는 반면, Jenkins 는 그냥 깔면 됨.
3.2 Jenkins 가 더 못하는 것
- 러너 관리 부담: Jenkins controller + agent 노드 자체를 운영해야 함. GitHub-hosted runner 처럼 “그냥 무제한으로 띄움” 이 불가능.
- 선언적 vs 명령형의 어중간함: Declarative Pipeline 도 결국 Groovy. YAML 만큼 단순하지 않고, 그렇다고 풀 Groovy 만큼 자유롭지도 않은 어중간한 영역.
- 플러그인 카오스: 1,800 개 플러그인, 호환성 매트릭스가 악몽. Jenkins 업그레이드 한 번에 사용 중인 플러그인 5 개가 깨지는 일이 흔함.
- 시크릿 UX: Credentials 플러그인 UI 가 2010년대 후반에서 멈춰있음. GitHub Actions 의
secrets.*+ OIDC 조합이 훨씬 모던.
4. 8 가지 관점에서 비교
| 관점 | Jenkins | GitHub Actions |
|---|---|---|
| 인프라 부담 | 컨트롤러 + 에이전트 직접 운영 | 0 (GitHub hosted) ~ self-hosted 옵션 |
| 러너 비용 | 직접 서버 비용 | public repo 무료, private repo 분당 과금 (Linux $0.008) |
| 워크플로우 정의 | Groovy (Declarative/Scripted) | YAML |
| 시크릿 | Credentials 플러그인 | secrets.* + OIDC (AWS/GCP/Azure 무자격증명) |
| 레지스트리 인증 | 별도 토큰 관리 | GITHUB_TOKEN 으로 GHCR 자동 |
| 캐시 | 외부 (S3, registry) 직접 구성 | type=gha 내장 + 외부 옵션 |
| 병렬화 | parallel { }, matrix 가능 |
strategy.matrix 매우 자연스러움 |
| 외부 통합 | 1,800 개 플러그인 (양날) | 100,000+ Action (양날) |
→ 신규 프로젝트 / 클라우드 네이티브: GitHub Actions 가 압도적으로 빠르고 가벼움.
→ 레거시 / 사내 보안망 / 복잡한 빌드 토폴로지: Jenkins 가 여전히 의미 있음.
5. Docker 이미지 빌드 모범 사례 (도구 무관)
어느 도구를 쓰든 결국 docker build 입니다. 이 단계의 시간이 곧 CI 시간이라 다음 4 가지는 반드시 챙기세요.
5.1 Multi-stage build
# Dockerfile.backend
FROM gradle:8.10-jdk21 AS builder
WORKDIR /app
COPY build.gradle.kts settings.gradle.kts ./
COPY gradle ./gradle
RUN gradle dependencies --no-daemon # ← 의존성만 먼저 (캐시 효율)
COPY src ./src
RUN gradle bootJar --no-daemon
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
- builder 단계: JDK + Gradle 포함, 800MB+.
- 최종 단계: JRE alpine 만, 180MB.
- 부수효과: 빌드 도구가 런타임 이미지에 남지 않아 공격 표면 축소.
5.2 의존성 → 소스 순서로 COPY
위 예시처럼 build.gradle.kts → gradle dependencies → src 순서. 소스만 바뀌어도 의존성 레이어는 그대로 캐시 히트.
5.3 BuildKit 캐시 마운트 (게임 체인저)
# syntax=docker/dockerfile:1.7
RUN --mount=type=cache,target=/root/.gradle \
gradle bootJar --no-daemon
--mount=type=cache 는 빌드 사이에 Gradle 캐시(~/.gradle) 를 유지. CI 환경에서 의존성 다시 다운로드하지 않게 됨. 빌드 시간이 5 분 → 1 분으로 줄어드는 일이 흔합니다.
5.4 Immutable 태그 — :latest 만 쓰지 마세요
docker build -t myimg:latest -t myimg:$(git rev-parse --short HEAD) .
:latest만 쓰면 “어제 잘 됐는데 오늘 안 돼” 라는 가장 흔한 사고가 발생. Pod 이 재기동될 때 다른 이미지를 받아옴.- 항상 commit SHA 같은 immutable 태그를 함께 push.
- Kubernetes manifest 에는
:latest가 아닌:<sha>또는:<semver>만 참조 → Pod 재기동 시 같은 이미지가 보장됨.
6. CD — 두 갈래 길
이제 이미지가 레지스트리에 올라왔습니다. 이걸 어떻게 클러스터로 흘려보낼 것인가 가 CD 의 핵심.
6.1 Push 모델 — CI 가 kubectl apply
전통적인 Jenkins 패턴입니다.
stage('Deploy') {
steps {
sh '''
kubectl set image -n asat-prod \
deployment/backend backend=$IMAGE:$GIT_SHA
kubectl rollout status -n asat-prod deployment/backend --timeout=5m
'''
}
}
장점:
- 직관적.
git push→ CI 종료 시점에 이미 배포 완료. - 외부 도구 없이 끝남.
단점:
- CI 가 클러스터 자격증명(kubeconfig) 을 가져야 함 → 보안 표면.
- CI 환경이 클러스터에 IP/방화벽으로 도달 가능해야 함.
- 클러스터 실제 상태와 Git 의 desired 상태가 일치한다는 보장이 없음 (누가
kubectl edit하면 끝).
6.2 Pull 모델 — GitOps (ArgoCD/Flux)
[GitHub Actions] → [GHCR]
│
↓ (이미지 빌드 후 manifest repo 의 image tag 만 갱신)
[helm-deploy repo] ←─ watch ─→ [ArgoCD in cluster]
│
↓ sync
[Kubernetes 클러스터]
CI 는 이미지를 push 하고 끝. 클러스터로 가는 push 가 없습니다. 대신:
- 클러스터 안의 ArgoCD 가 helm-deploy 리포를 watch.
- 매니페스트의 image tag 가 바뀌면 자동 sync (또는 PR 머지 시).
- 클러스터 상태 = git 상태 (selfHeal).
장점:
- CI 에 클러스터 자격증명 0개. CI 가 털려도 클러스터에 직접 영향 없음.
- desired state 가 git 에 명시적으로 박혀있음 → 누가 손으로 바꿔도 ArgoCD 가 되돌림.
- 멀티 클러스터 / 멀티 환경 확장이 자연스러움 (각 클러스터의 ArgoCD 가 같은 repo 의 다른 path 를 봄).
단점:
- 배포가 즉시 아님. 짧게는 30 초, 길게는 ArgoCD polling 주기(3 분) 후 적용.
- 초기 학습 곡선. Application CR, App-of-Apps, sync wave, hooks…
- “왜 안 됐지” 디버깅 시 CI 로그 + ArgoCD 로그 + 클러스터 이벤트 3 군데 봐야 함.
6.3 어느 쪽을 골라야 하나
| 상황 | 권장 |
|---|---|
| 1~3 개 서비스, 1 개 클러스터 | Push 모델로 충분 |
| 10+ 서비스 또는 멀티 클러스터 | GitOps (Pull) 로 가야 함 |
| 빠른 핫픽스가 필수인 환경 | Push (CD 5 초 vs 30+ 초) |
| 감사 추적/규제 산업 | Pull (git = audit log) |
| CI 에 클러스터 자격증명 주기 싫음 | Pull |
저는 30 개 서비스 운영 시점부터 Pull(ArgoCD) 로 갈아탔고, 더는 push 모델로 돌아갈 일이 없을 것 같습니다.
7. 실전: GitHub Actions + GHCR + ArgoCD 풀스택 흐름
제 홈랩에서 실제로 흘러가는 파이프라인 전체:
개발자 git push
│
↓
inter-asat 리포 (GitHub)
│
↓ workflow trigger
GitHub Actions (ubuntu-latest)
├── docker buildx build
├── ghcr.io 로그인 (GITHUB_TOKEN)
└── push ghcr.io/.../backend:<sha>
│
↓ (선택) Image Updater 또는 PR 봇이
helm-deploy 리포의 charts/asat/values.yaml 의 tag 갱신
│
↓ ArgoCD root-app 가 watch
ArgoCD (K3s in cluster)
├── helm-deploy/charts/asat 새 manifest 적용
├── selfHeal: true
└── sync: kubectl apply 동등
│
↓
asat-prod ns 의 Deployment 가 새 이미지로 롤링 업데이트
│
↓ Pod imagePullSecret(ghcr-pull) 사용
GHCR 에서 :<sha> 이미지 pull
│
↓
새 Pod Ready, 옛 Pod 종료 (maxSurge/maxUnavailable 따라)
이 흐름에서 가장 중요한 두 가지 보안 포인트:
7.1 OIDC 로 클라우드 자격증명 제거
permissions:
id-token: write # OIDC 발급용
contents: read
packages: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123:role/github-actions
aws-region: ap-northeast-2
GitHub Actions 의 OIDC 토큰으로 AWS IAM 역할을 가정. AWS access key 를 GitHub secrets 에 넣지 마세요. OIDC 가 표준입니다.
7.2 ImagePullSecret
private GHCR 이미지를 pull 하려면 클러스터에 인증이 필요합니다.
kubectl -n asat-prod create secret docker-registry ghcr-pull \
--docker-server=ghcr.io \
--docker-username=myoungsoo7 \
--docker-password=<PAT_with_read:packages> \
--docker-email=iamipro@naver.com
그리고 Deployment 에서:
spec:
template:
spec:
imagePullSecrets:
- name: ghcr-pull
containers:
- name: backend
image: ghcr.io/myoungsoo7/inter-asat-backend:abc1234
이 secret 자체도 SOPS 로 암호화해서 helm-deploy 리포에 commit 하면 GitOps 일관성 유지. (제 SOPS 글 참고.)
8. 자주 만나는 함정 6 가지
8.1 :latest 만으로 배포
앞에서 강조했지만 가장 흔합니다. K8s 의 imagePullPolicy: IfNotPresent 때문에 같은 태그면 노드 캐시에서 옛 이미지를 그대로 씀. 새 이미지가 push 됐어도 Pod 가 재기동돼야만 적용되고, 재기동돼도 노드별로 다른 이미지가 뜨는 일이 생김.
→ 항상 immutable 태그.
8.2 CI 가 통과했는데 prod 에서만 실패
- CI 의 Docker 베이스 이미지와 prod 베이스 이미지가 다르거나 (예: alpine vs debian)
- CI 의 환경변수와 prod 의 ConfigMap/Secret 이 다름
- CI 가 root, prod 가 non-root (read-only filesystem 등)
→ CI 에서도 prod 와 동일한 이미지로 smoke test 실행. 가능하면 K3s/kind 띄워서 진짜 K8s 위에서 한 번 띄워봄.
8.3 빌드 캐시가 안 먹힘
COPY . .한 줄을 너무 위에 둠. 소스 한 줄만 바뀌어도 모든 후속 레이어가 무효화.RUN apt-get update && apt-get install한 줄을 분리.update만 캐시 되면 stale 패키지 인덱스로 깨짐.
→ 의존성 → 소스 순서, install/update 는 항상 한 RUN 안에서.
8.4 GitHub Actions 시크릿이 fork PR 에서 새는 줄 알았는데
fork 에서 온 pull_request 이벤트는 secrets 가 자동으로 마스킹/제거됩니다. 그러나 pull_request_target 트리거는 base 브랜치 권한으로 돌기 때문에 fork 의 악성 코드가 secrets 에 닿을 수 있음.
→ pull_request_target 은 매우 신중하게. checkout 할 때 SHA 를 명시적으로 고정.
8.5 Jenkins agent 가 Docker socket 마운트
빌드를 위해 호스트의 /var/run/docker.sock 을 마운트하는 경우, 그 agent 가 털리면 호스트 자체를 장악당함.
→ rootless docker, 또는 Kaniko/BuildKit 같은 daemonless 빌더 사용.
8.6 Rollout 실패 시 CI 가 success 로 끝남
kubectl set image 만 하고 rollout status 를 안 부르면, 새 ReplicaSet 이 CrashLoopBackOff 인 채로 CI 는 초록색.
→ 반드시 kubectl rollout status --timeout=5m. 실패하면 자동 rollback 까지 (kubectl rollout undo).
9. 결론 — 어디서부터 시작할까
지금 막 시작하는 분께 권하는 순서:
- GitHub Actions 한 파일 로 빌드 + GHCR push 까지 (이 글 §2).
- K8s 매니페스트는
kubectl apply로 손으로 한 번 만들어보기. CI 에 넣지 말고. - 매니페스트가 익숙해지면 Helm 차트 로 변환. values.yaml 에 image tag 분리.
- 5 개 이상 서비스가 되면 ArgoCD 도입.
kubectl apply한 번으로 root-app 부트스트랩. - 이미지 tag 자동 갱신이 필요해지면 ArgoCD Image Updater 또는 GitHub Actions 에서 helm-deploy 리포에 PR 자동 발행.
Jenkins 는 굳이 안 만나는 게 좋지만, 회사에서 만나게 되면:
- Declarative Pipeline 만 씁니다 (Scripted 는 유지보수 지옥).
- 가능하면 Kubernetes plugin 으로 agent 를 Pod 으로 띄움 (위 §3 예시).
- 시크릿은 HashiCorp Vault 연동 또는 외부 SecretManager 로 빼고, Credentials 플러그인은 부트스트랩 자격증명만 두기.
CI/CD 의 본질은 도구가 아니라 “내 한 줄 commit 이 5 분 뒤 prod 에 안전하게 도달하는 컨베이어 벨트가 있는가” 입니다. Jenkins 든 GitHub Actions 든, 그 컨베이어 벨트의 한 부품일 뿐이에요. 도구를 고를 때는 “내가 운영할 수 있는 가장 단순한 조합” 부터 시작하시고, 한계가 명확해질 때만 다른 도구를 더하세요.