Skip to content
Published on

Dagger로 CI/CD 파이프라인을 코드로 관리하기: 로컬에서 CI까지 동일한 파이프라인

Authors
  • Name
    Twitter

Dagger란?

Dagger는 CI/CD 파이프라인을 프로그래밍 언어(Go, Python, TypeScript 등)로 작성할 수 있게 해주는 오픈소스 도구입니다. 핵심 가치는 "Write once, run anywhere" — 로컬 노트북에서 실행한 파이프라인이 GitHub Actions, GitLab CI, Jenkins 어디서든 동일하게 동작합니다.

기존 CI/CD의 문제점

  • YAML 지옥: 복잡한 파이프라인을 YAML로 표현하면 가독성과 재사용성이 떨어짐
  • 로컬 테스트 불가: CI에서만 실행 가능 → 피드백 루프가 길어짐
  • 벤더 종속: GitHub Actions와 GitLab CI의 문법이 완전히 다름
  • 디버깅 어려움: CI 환경을 로컬에 재현하기 힘듬

Dagger의 해결책

로컬 개발 ──→ Dagger Engine ──→ 동일한 결과
GitHub Actions ──→ Dagger Engine ──→ 동일한 결과
GitLab CI ──→ Dagger Engine ──→ 동일한 결과

Dagger Engine은 모든 작업을 컨테이너에서 실행하므로 환경 차이가 없습니다.

설치 및 프로젝트 초기화

Dagger CLI 설치

# macOS
brew install dagger/tap/dagger

# Linux
curl -fsSL https://dl.dagger.io/dagger/install.sh | sh

# 버전 확인
dagger version

프로젝트 초기화

# Go SDK 사용
mkdir my-pipeline && cd my-pipeline
dagger init --sdk=go --name=my-pipeline

# 생성된 파일 구조
# ├── dagger.json
# ├── main.go
# └── go.mod

첫 번째 파이프라인: Go 프로젝트 빌드

main.go 작성

package main

import (
    "context"
    "dagger/my-pipeline/internal/dagger"
)

type MyPipeline struct{}

// Build 함수: Go 프로젝트를 빌드합니다
func (m *MyPipeline) Build(ctx context.Context, source *dagger.Directory) *dagger.Container {
    // Go 빌드 환경 설정
    builder := dag.Container().
        From("golang:1.22-alpine").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithEnvVariable("CGO_ENABLED", "0").
        WithExec([]string{"go", "mod", "download"}).
        WithExec([]string{"go", "build", "-o", "/app", "./cmd/server"})

    // 경량 런타임 이미지
    return dag.Container().
        From("alpine:3.19").
        WithFile("/app", builder.File("/app")).
        WithEntrypoint([]string{"/app"})
}

// Test 함수: 테스트 실행
func (m *MyPipeline) Test(ctx context.Context, source *dagger.Directory) (string, error) {
    return dag.Container().
        From("golang:1.22-alpine").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"go", "mod", "download"}).
        WithExec([]string{"go", "test", "-v", "-race", "./..."}).
        Stdout(ctx)
}

// Lint 함수: golangci-lint 실행
func (m *MyPipeline) Lint(ctx context.Context, source *dagger.Directory) (string, error) {
    return dag.Container().
        From("golangci/golangci-lint:v1.57").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"golangci-lint", "run", "--timeout", "5m"}).
        Stdout(ctx)
}

로컬 실행

# 빌드
dagger call build --source=.

# 테스트
dagger call test --source=.

# 린트
dagger call lint --source=.

# 빌드 결과를 Docker 이미지로 내보내기
dagger call build --source=. export --path=./image.tar

Python SDK로 파이프라인 작성

# dagger/src/main/__init__.py
import dagger
from dagger import dag, function, object_type

@object_type
class MyPipeline:
    @function
    async def test(self, source: dagger.Directory) -> str:
        """Python 프로젝트 테스트"""
        return await (
            dag.container()
            .from_("python:3.12-slim")
            .with_directory("/src", source)
            .with_workdir("/src")
            .with_exec(["pip", "install", "-r", "requirements.txt"])
            .with_exec(["pip", "install", "pytest", "pytest-cov"])
            .with_exec(["pytest", "-v", "--cov=app", "--cov-report=term-missing"])
            .stdout()
        )

    @function
    async def build(self, source: dagger.Directory) -> dagger.Container:
        """Docker 이미지 빌드"""
        # 의존성 캐싱
        pip_cache = dag.cache_volume("pip-cache")

        return (
            dag.container()
            .from_("python:3.12-slim")
            .with_mounted_cache("/root/.cache/pip", pip_cache)
            .with_directory("/app", source)
            .with_workdir("/app")
            .with_exec(["pip", "install", "--no-cache-dir", "-r", "requirements.txt"])
            .with_entrypoint(["python", "main.py"])
        )

    @function
    async def publish(
        self,
        source: dagger.Directory,
        registry: str = "ghcr.io",
        image_name: str = "my-app",
        tag: str = "latest",
        registry_user: dagger.Secret | None = None,
        registry_pass: dagger.Secret | None = None,
    ) -> str:
        """이미지 빌드 및 레지스트리 푸시"""
        container = await self.build(source)

        if registry_user and registry_pass:
            container = container.with_registry_auth(
                registry,
                await registry_user.plaintext(),
                registry_pass,
            )

        ref = f"{registry}/{image_name}:{tag}"
        digest = await container.publish(ref)
        return digest

캐싱 전략

Dagger의 캐싱은 빌드 속도를 극적으로 개선합니다:

func (m *MyPipeline) BuildWithCache(ctx context.Context, source *dagger.Directory) *dagger.Container {
    // Go 모듈 캐시
    goModCache := dag.CacheVolume("go-mod-cache")
    goBuildCache := dag.CacheVolume("go-build-cache")

    return dag.Container().
        From("golang:1.22-alpine").
        WithMountedCache("/go/pkg/mod", goModCache).
        WithMountedCache("/root/.cache/go-build", goBuildCache).
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"go", "build", "-o", "/app", "./cmd/server"})
}

캐싱 효과 비교

첫 번째 빌드:  230 (의존성 다운로드 포함)
두 번째 빌드:  15 (캐시 히트)
소스만 변경:   20 (의존성 캐시 재사용)

시크릿 관리

func (m *MyPipeline) Deploy(
    ctx context.Context,
    source *dagger.Directory,
    kubeconfig *dagger.Secret,
    registryToken *dagger.Secret,
) (string, error) {
    // 시크릿은 로그에 노출되지 않음
    return dag.Container().
        From("bitnami/kubectl:latest").
        WithMountedSecret("/root/.kube/config", kubeconfig).
        WithSecretVariable("REGISTRY_TOKEN", registryToken).
        WithDirectory("/manifests", source.Directory("k8s")).
        WithExec([]string{"kubectl", "apply", "-f", "/manifests/"}).
        Stdout(ctx)
}
# 로컬 실행 시 시크릿 전달
dagger call deploy \
  --source=. \
  --kubeconfig=file:$HOME/.kube/config \
  --registry-token=env:REGISTRY_TOKEN

CI 통합

GitHub Actions

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dagger/dagger-for-github@v6
        with:
          version: 'latest'
          verb: call
          args: test --source=.
      - uses: dagger/dagger-for-github@v6
        with:
          version: 'latest'
          verb: call
          args: lint --source=.
      - uses: dagger/dagger-for-github@v6
        with:
          version: 'latest'
          verb: call
          args: build --source=.

GitLab CI

# .gitlab-ci.yml
stages:
  - test
  - build

test:
  stage: test
  image: registry.dagger.io/engine:latest
  services:
    - docker:dind
  script:
    - dagger call test --source=.

build:
  stage: build
  image: registry.dagger.io/engine:latest
  services:
    - docker:dind
  script:
    - dagger call build --source=. export --path=image.tar
  artifacts:
    paths:
      - image.tar

멀티 플랫폼 빌드

func (m *MyPipeline) BuildMultiPlatform(
    ctx context.Context,
    source *dagger.Directory,
) (string, error) {
    platforms := []dagger.Platform{
        "linux/amd64",
        "linux/arm64",
    }

    platformVariants := make([]*dagger.Container, len(platforms))
    for i, platform := range platforms {
        platformVariants[i] = dag.Container(dagger.ContainerOpts{Platform: platform}).
            From("golang:1.22-alpine").
            WithDirectory("/src", source).
            WithWorkdir("/src").
            WithEnvVariable("CGO_ENABLED", "0").
            WithExec([]string{"go", "build", "-o", "/app", "./cmd/server"})
    }

    // 멀티 플랫폼 이미지 푸시
    digest, err := dag.Container().
        Publish(ctx, "ghcr.io/my-org/my-app:latest",
            dagger.ContainerPublishOpts{
                PlatformVariants: platformVariants,
            })

    return digest, err
}

통합 파이프라인: 전체 CI/CD

func (m *MyPipeline) CI(ctx context.Context, source *dagger.Directory) error {
    // 1. 린트
    _, err := m.Lint(ctx, source)
    if err != nil {
        return fmt.Errorf("lint failed: %w", err)
    }
    fmt.Println("✅ Lint passed")

    // 2. 테스트
    _, err = m.Test(ctx, source)
    if err != nil {
        return fmt.Errorf("test failed: %w", err)
    }
    fmt.Println("✅ Tests passed")

    // 3. 빌드
    container := m.Build(ctx, source)
    _, err = container.Sync(ctx)
    if err != nil {
        return fmt.Errorf("build failed: %w", err)
    }
    fmt.Println("✅ Build succeeded")

    return nil
}

Dagger vs 기존 CI 도구 비교

특성GitHub ActionsGitLab CIDagger
파이프라인 정의YAMLYAMLGo/Python/TS
로컬 실행act (제한적)불가완전 지원
디버깅어려움어려움IDE 디버거 사용
벤더 종속GitHubGitLab없음
캐싱수동 설정수동 설정자동 (콘텐츠 기반)
재사용성마켓플레이스템플릿패키지/모듈

트러블슈팅

자주 발생하는 문제와 해결 방법

# Dagger Engine 상태 확인
docker ps | grep dagger-engine

# 캐시 초기화
dagger query <<< '{ defaultPlatform }'

# 상세 로그 출력
dagger call --debug test --source=.

# 특정 단계에서 쉘 접속 (디버깅)
dagger call build --source=. terminal

📝 확인 퀴즈 (5문제)

Q1. Dagger가 기존 CI/CD 도구 대비 가장 큰 장점은?

로컬과 CI 환경에서 동일한 파이프라인을 실행할 수 있으며, 프로그래밍 언어로 파이프라인을 작성하므로 IDE 지원, 타입 안전성, 디버깅이 가능합니다.

Q2. Dagger에서 캐싱을 구현하는 방법은?

dag.CacheVolume()으로 캐시 볼륨을 생성하고 WithMountedCache()로 컨테이너에 마운트합니다. 콘텐츠 기반으로 자동 캐싱됩니다.

Q3. Dagger에서 시크릿을 안전하게 전달하는 방법은?

함수 파라미터로 dagger.Secret 타입을 받고, WithMountedSecret이나 WithSecretVariable로 컨테이너에 전달합니다. 로그에 노출되지 않습니다.

Q4. GitHub Actions에서 Dagger를 사용하려면 어떤 액션을 사용하나요?

dagger/dagger-for-github@v6 액션을 사용합니다.

Q5. Dagger에서 멀티 플랫폼 빌드를 하려면 어떤 옵션을 사용하나요?

dagger.ContainerOpts의 Platform 필드와 Publish의 PlatformVariants 옵션을 사용합니다.