Dagger 완벽 가이드 — CI/CD를 코드로, 컨테이너 기반 이식 가능한 파이프라인
이 글의 핵심
Dagger는 CI/CD 파이프라인을 YAML이 아닌 Go·Python·TypeScript 코드로 작성하게 해주는 컨테이너 기반 실행 엔진입니다. 로컬에서 돌린 파이프라인이 GitHub Actions·GitLab·Jenkins 어디서든 동일하게 동작하고, 강력한 캐시로 재실행 시간을 극적으로 줄입니다. 이 글은 설치·첫 파이프라인·Functions·모듈·CI 통합을 실전 중심으로 다룹니다.
이 글의 핵심
Dagger는 “CI/CD를 YAML 대신 실제 프로그래밍 언어로 작성하자”는 문제의식에서 시작된 오픈소스 실행 엔진입니다. Docker 창업자 Solomon Hykes가 만든 팀이 개발해 2024-2025년에 급성장했으며, 2026년 현재 dagger/dagger 저장소는 GitHub 스타 12k+를 기록하고 있습니다.
핵심 가치:
- 언어: Go / Python / TypeScript SDK — 타입 안전, IDE 자동완성, 테스트 가능
- 이식성: 로컬·GitHub Actions·GitLab·Jenkins·CircleCI 어디서든 동일 실행
- 캐시: BuildKit 기반 단계별 자동 캐싱으로 재실행 속도 극적 단축
- Functions: 재사용 가능한 파이프라인 단위, npm 모듈처럼 공유
- 디버깅: 로컬에서 CI와 100% 동일 재현 → “CI에서만 터지는” 버그 종식
이 글은 설치부터 프로덕션 파이프라인 구축·Functions 공유·CI 통합까지 실전 중심으로 정리합니다.
왜 Dagger인가
YAML의 한계
# 전형적인 GitHub Actions
jobs:
test:
steps:
- run: npm ci
- run: npm test
- run: npm run build
- 로직 분기·반복은 별도의 YAML 문법
- 로컬 재현이 불가능 (GitHub Actions 러너 환경만 정확)
- 재사용 단위는 Action이지만 파라미터·리턴값 제한
- 복잡한 파이프라인은 YAML 수천 줄이 됨
Dagger
func (m *Pkglog) Test(ctx context.Context, source *dagger.Directory) (string, error) {
return dag.Container().
From("node:20").
WithDirectory("/app", source).
WithWorkdir("/app").
WithExec([]string{"npm", "ci"}).
WithExec([]string{"npm", "test"}).
Stdout(ctx)
}
- Go 코드, 타입 체크·테스트·리팩터링 가능
dagger call test --source=.로 로컬에서 CI 잡 실행func반환값(String, Directory, File, Container)이 자동으로 다음 단계 입력으로 연결
설치
CLI
# macOS / Linux
curl -L https://dl.dagger.io/dagger/install.sh | BIN_DIR=$HOME/.local/bin sh
# 또는 Homebrew
brew install dagger/tap/dagger
# Windows (PowerShell)
iwr -useb https://dl.dagger.io/dagger/install.ps1 | iex
dagger version
Docker Desktop 또는 Docker Engine이 로컬에 설치되어 있어야 합니다(Dagger Engine 실행용).
첫 모듈 초기화
mkdir my-ci && cd my-ci
dagger init --sdk=go --name=pkglog
# 디렉터리 구조
# dagger.json
# .dagger/
# main.go
# go.mod
Python은 --sdk=python, TypeScript는 --sdk=typescript 또는 --sdk=typescript.
첫 번째 파이프라인: Node 앱 테스트 + 빌드
Go SDK
// .dagger/main.go
package main
import (
"context"
"dagger/pkglog/internal/dagger"
)
type Pkglog struct{}
// source 디렉터리로 테스트
func (m *Pkglog) Test(
ctx context.Context,
// +defaultPath="."
source *dagger.Directory,
) (string, error) {
return m.nodeBase(source).
WithExec([]string{"npm", "test"}).
Stdout(ctx)
}
// Astro 프로덕션 빌드 → dist 디렉터리 반환
func (m *Pkglog) Build(
ctx context.Context,
// +defaultPath="."
source *dagger.Directory,
) *dagger.Directory {
return m.nodeBase(source).
WithExec([]string{"npm", "run", "build"}).
Directory("/app/dist")
}
// 내부 헬퍼: node 베이스 이미지 + 의존성 설치
func (m *Pkglog) nodeBase(source *dagger.Directory) *dagger.Container {
return dag.Container().
From("node:20-alpine").
WithMountedCache("/root/.npm", dag.CacheVolume("node-npm")).
WithDirectory("/app", source).
WithWorkdir("/app").
WithExec([]string{"npm", "ci"})
}
# 로컬 실행
dagger call test --source=.
dagger call build --source=. export --path=./dist
같은 명령이 CI에서도 동작합니다.
TypeScript SDK
import { dag, Container, Directory, object, func } from "@dagger.io/dagger"
@object()
export class Pkglog {
@func()
async test(source: Directory): Promise<string> {
return this.nodeBase(source)
.withExec(["npm", "test"])
.stdout()
}
@func()
build(source: Directory): Directory {
return this.nodeBase(source)
.withExec(["npm", "run", "build"])
.directory("/app/dist")
}
private nodeBase(source: Directory): Container {
return dag
.container()
.from("node:20-alpine")
.withMountedCache("/root/.npm", dag.cacheVolume("node-npm"))
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "ci"])
}
}
Python SDK
import dagger
from dagger import dag, function, object_type, Directory
@object_type
class Pkglog:
@function
async def test(self, source: Directory) -> str:
return await self._node_base(source).with_exec(["npm", "test"]).stdout()
@function
def build(self, source: Directory) -> Directory:
return self._node_base(source).with_exec(["npm", "run", "build"]).directory("/app/dist")
def _node_base(self, source: Directory) -> dagger.Container:
return (
dag.container()
.from_("node:20-alpine")
.with_mounted_cache("/root/.npm", dag.cache_volume("node-npm"))
.with_directory("/app", source)
.with_workdir("/app")
.with_exec(["npm", "ci"])
)
캐시: 속도의 비결
WithMountedCache("/root/.npm", dag.CacheVolume("node-npm"))
CacheVolume은 여러 실행 간 공유되는 영속 볼륨입니다.
npm install에서 다운로드된 패키지가 다음 실행에 재사용됨- Maven
.m2, Go module cache, Cargo registry, pip cache 등에 적용
또 Dagger는 Container 단위로 DAG 캐시를 구축합니다. 이전과 같은 입력이면 전체 단계가 통째로 캐시되어 0초만에 결과 반환하기도 합니다.
실전: 의존성 단계 분리
func (m *Pkglog) nodeDeps(source *dagger.Directory) *dagger.Container {
return dag.Container().
From("node:20-alpine").
WithMountedCache("/root/.npm", dag.CacheVolume("node-npm")).
// package.json만 먼저 복사 → 의존성 설치
WithFile("/app/package.json", source.File("package.json")).
WithFile("/app/package-lock.json", source.File("package-lock.json")).
WithWorkdir("/app").
WithExec([]string{"npm", "ci"})
}
func (m *Pkglog) Test(ctx context.Context, source *dagger.Directory) (string, error) {
return m.nodeDeps(source).
WithDirectory("/app", source). // 소스 코드는 마지막에
WithExec([]string{"npm", "test"}).
Stdout(ctx)
}
package.json 변경 없이 테스트 코드만 수정하면 npm ci 단계가 캐시 히트되어 수 분이 수 초로 단축됩니다.
Functions: 재사용 가능한 단위
Dagger의 @func() 어노테이션이 붙은 메소드는 CLI에서 직접 호출 가능한 공개 API가 됩니다.
dagger functions # 사용 가능한 함수 목록
dagger call test --help
dagger call build --source=. export --path=./dist
함수는 다른 함수의 결과를 입력으로 받을 수 있고, Directory·File·Container 타입은 그래프 연산자처럼 연결됩니다.
# build 결과(Directory)를 publish 함수에 전달
dagger call publish \
--source=. \
--registry=ghcr.io \
--username=myteam \
--token=env:GHCR_TOKEN
모듈 의존성: npm처럼 재사용
다른 팀이 만든 Dagger 모듈을 내 파이프라인에서 함수 호출처럼 사용할 수 있습니다.
# Hello 모듈 설치
dagger install github.com/shykes/daggerverse/hello@main
# 설치된 모듈 호출
dagger -m github.com/shykes/daggerverse/hello call hello --greeting="Hi"
공식 Daggerverse(https://daggerverse.dev)에 공개된 모듈 수백 개가 있고, 내부 GitHub repo의 private 모듈도 동일하게 import 가능합니다.
GitHub Actions 통합
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dagger/dagger-for-github@v6
with:
version: "0.13.7"
verb: call
args: test --source=.
build-and-publish:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dagger/dagger-for-github@v6
with:
verb: call
args: |
publish
--source=.
--registry=ghcr.io
--username=${{ github.actor }}
--token=env:GITHUB_TOKEN
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GitHub Actions 안에서도 CLI 한 줄로 끝납니다. 로컬에서 돌린 것과 완전히 동일한 파이프라인이 실행됩니다.
GitLab CI 통합
.dagger:
image: docker:24
services:
- docker:24-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
before_script:
- apk add curl
- curl -L https://dl.dagger.io/dagger/install.sh | BIN_DIR=$HOME/.local/bin sh
- export PATH=$HOME/.local/bin:$PATH
test:
extends: .dagger
script:
- dagger call test --source=.
실전 파이프라인: 빌드 → 테스트 → Docker 이미지 → Push
func (m *Pkglog) Publish(
ctx context.Context,
source *dagger.Directory,
registry string,
username string,
// +envfile
token *dagger.Secret,
tag string,
) (string, error) {
// 1) 빌드 결과물 디렉터리
dist := m.Build(ctx, source)
// 2) 프로덕션 이미지 조립
prod := dag.Container().
From("nginx:alpine").
WithDirectory("/usr/share/nginx/html", dist)
// 3) registry 로그인 + 이미지 push
return prod.
WithRegistryAuth(registry, username, token).
Publish(ctx, fmt.Sprintf("%s/pkglog-web:%s", registry, tag))
}
# 로컬 테스트
dagger call publish \
--source=. \
--registry=ghcr.io \
--username=myteam \
--token=env:GHCR_TOKEN \
--tag=$(git rev-parse --short HEAD)
빌드→조립→push 가 하나의 함수로 표현되고, 어디서든 동일하게 실행됩니다.
비밀(Secret) 관리
func (m *Pkglog) Deploy(
ctx context.Context,
// +envfile
deployKey *dagger.Secret,
) (string, error) {
return dag.Container().
From("alpine:3").
WithSecretVariable("DEPLOY_KEY", deployKey).
WithExec([]string{"sh", "-c", "curl -X POST -H \"Auth: $DEPLOY_KEY\" https://deploy.internal"}).
Stdout(ctx)
}
dagger call deploy --deploy-key=env:DEPLOY_KEY
dagger call deploy --deploy-key=file:/tmp/key
dagger call deploy --deploy-key=cmd:"op read op://vault/key/value"
env:VAR— 환경변수에서file:/path— 파일에서cmd:"..."— 임의 명령 실행 결과
1Password CLI(op), AWS Secrets Manager CLI, Vault 등과 자연스럽게 연동됩니다.
다중 서비스 통합 테스트
백엔드 + DB + Redis를 띄우고 E2E 테스트를 돌려야 할 때:
func (m *Pkglog) E2ETest(ctx context.Context, source *dagger.Directory) (string, error) {
postgres := dag.Container().
From("postgres:16").
WithEnvVariable("POSTGRES_PASSWORD", "test").
WithExposedPort(5432).
AsService()
redis := dag.Container().
From("redis:7").
WithExposedPort(6379).
AsService()
return dag.Container().
From("node:20-alpine").
WithServiceBinding("db", postgres).
WithServiceBinding("cache", redis).
WithEnvVariable("DATABASE_URL", "postgres://postgres:test@db:5432/postgres").
WithEnvVariable("REDIS_URL", "redis://cache:6379").
WithDirectory("/app", source).
WithWorkdir("/app").
WithExec([]string{"npm", "ci"}).
WithExec([]string{"npm", "run", "test:e2e"}).
Stdout(ctx)
}
AsService() + WithServiceBinding()이 docker-compose의 서비스 링크와 같은 역할을 해주며 테스트 완료 후 자동 정리됩니다.
로컬 DX
# 모든 함수 목록
dagger functions
# 함수 파라미터 설명
dagger call test --help
# 대화형 셸로 그래프 탐색
dagger shell
# 추적(Trace) 확인 (Dagger Cloud 연결 시 UI URL 출력)
dagger call publish ... --progress=plain
dagger shell은 파이프라인을 단계별로 실행·검사할 수 있는 REPL로, 디버깅의 생산성을 크게 높입니다.
Dagger Cloud: 팀 협업
dagger login
브라우저로 계정 인증 후 로컬과 CI의 모든 실행이 Dagger Cloud Trace UI에서 시각화됩니다.
- 각 단계의 실행 시간·상태·로그
- 캐시 히트/미스 분석
- 팀원의 실행까지 공유
- 원격 캐시로 CI 간·팀원 간 캐시 공유
무료 tier로 소규모 팀도 즉시 사용 가능합니다.
문제 해결
dagger init이 느림
로컬 Docker Engine이 이미지 pull 중일 수 있음. docker ps로 확인하고 초기화 완료까지 대기.
캐시가 동작하지 않음
- 컨테이너 입력이 실제로 동일한지 확인(
dagger call ... --progress=plain로그에서 CACHED 표시 확인) .git/처럼 매번 변하는 디렉터리를WithDirectory에 통째로 넣으면 캐시 무효화됨 →.dockerignore활용
CI에서는 느리고 로컬에선 빠름
로컬 Docker의 공유 캐시를 CI 러너가 갖지 못해서 발생. Dagger Cloud 원격 캐시 또는 GitHub Actions 캐시 액션 연동으로 해결.
”context deadline exceeded”
Dagger Engine 기동 시간(초기 image pull)이 길면 발생. DAGGER_SESSION_TIMEOUT=5m 환경변수로 상향.
SDK 간 interop
Go·Python·TypeScript 모듈을 같은 프로젝트에서 혼합 사용 가능하지만 단일 SDK 유지가 유지보수에 유리.
전환 가이드: GitHub Actions에서 Dagger로
- 한 잡부터 Dagger로: 가장 오래 걸리고 자주 실행되는 테스트 잡을 먼저 이관
- 로컬 검증:
dagger call test로컬에서 통과 확인 - Actions에 삽입:
dagger/dagger-for-github@v6액션으로 치환 - 캐시 최적화:
CacheVolume·단계 분리로 CI 시간 측정 후 개선 - 점진적 확장: 빌드·배포·품질 체크 순으로 모듈화
언제 쓰지 말아야 하나
- 파이프라인이 단순:
npm test2줄짜리라면 Actions YAML이 충분 - 팀이 Docker 미숙: 컨테이너 디버깅 경험이 없다면 학습 곡선 있음
- 특수 런타임 필수: Dagger Engine이 지원 안 하는 특수 아키텍처(이미 거의 없음)
대부분의 중간 규모 이상 CI/CD는 Dagger가 장기적으로 유리합니다.
체크리스트
- 로컬 Docker/Podman +
dagger설치 - 핵심 잡(test/build/publish) 함수로 구현
-
CacheVolume으로 의존성·빌드 캐시 최적화 - 비밀 값은
dagger.Secret타입 + 외부 vault 연동 - GitHub Actions/GitLab CI에
dagger call한 줄로 연결 - Dagger Cloud 로그인으로 Trace·원격 캐시 활성화
- 모듈 공유: 사내
daggerverse/private repo에 공통 함수 퍼블리시
마무리
Dagger는 “YAML 지옥을 벗어나 CI/CD를 실제 코드로 쓰자”는 움직임의 현재 최강 실행자입니다. 로컬과 CI가 동일한 엔진을 공유하고, 강력한 캐시로 빌드 시간을 절반 이하로 줄이며, 함수 단위 재사용으로 조직의 CI/CD 자산을 축적할 수 있게 해줍니다. 테스트 잡 하나만 이관해봐도 YAML 기반 CI 대비 DX가 얼마나 다른지 체감할 수 있을 겁니다.
관련 글
- Docker 완벽 가이드
- GitHub Actions CI/CD 가이드
- Kamal 2 완벽 가이드
- Kubernetes 완벽 가이드