Skip to content

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

✨ Learn with Quiz
|

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

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 옵션을 사용합니다.

Managing CI/CD Pipelines as Code with Dagger: Same Pipeline from Local to CI

What is Dagger?

Dagger is an open-source tool that lets you write CI/CD pipelines in programming languages such as Go, Python, and TypeScript. Its core value proposition is "Write once, run anywhere" — a pipeline you run on your local laptop works identically on GitHub Actions, GitLab CI, or Jenkins.

Problems with Traditional CI/CD

  • YAML hell: Complex pipelines expressed in YAML suffer from poor readability and reusability
  • No local testing: Pipelines can only run in CI, resulting in long feedback loops
  • Vendor lock-in: GitHub Actions and GitLab CI have completely different syntax
  • Difficult debugging: Reproducing the CI environment locally is hard

How Dagger Solves This

Local development ──→ Dagger Engine ──→ Same result
GitHub Actions ──→ Dagger Engine ──→ Same result
GitLab CI ──→ Dagger Engine ──→ Same result

The Dagger Engine runs all tasks in containers, eliminating environment differences.

Installation and Project Initialization

Installing the Dagger CLI

# macOS
brew install dagger/tap/dagger

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

# Verify version
dagger version

Project Initialization

# Using the Go SDK
mkdir my-pipeline && cd my-pipeline
dagger init --sdk=go --name=my-pipeline

# Generated file structure
# ├── dagger.json
# ├── main.go
# └── go.mod

First Pipeline: Building a Go Project

Writing main.go

package main

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

type MyPipeline struct{}

// Build function: Builds the Go project
func (m *MyPipeline) Build(ctx context.Context, source *dagger.Directory) *dagger.Container {
    // Configure Go build environment
    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"})

    // Lightweight runtime image
    return dag.Container().
        From("alpine:3.19").
        WithFile("/app", builder.File("/app")).
        WithEntrypoint([]string{"/app"})
}

// Test function: Runs tests
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 function: Runs 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)
}

Running Locally

# Build
dagger call build --source=.

# Test
dagger call test --source=.

# Lint
dagger call lint --source=.

# Export build result as a Docker image
dagger call build --source=. export --path=./image.tar

Writing a Pipeline with the 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:
        """Test a Python project"""
        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:
        """Build a Docker image"""
        # Dependency caching
        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:
        """Build image and push to registry"""
        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

Caching Strategy

Dagger's caching dramatically improves build speed:

func (m *MyPipeline) BuildWithCache(ctx context.Context, source *dagger.Directory) *dagger.Container {
    // Go module cache
    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"})
}

Caching Performance Comparison

First build:        2 min 30 sec (including dependency download)
Second build:       15 sec (cache hit)
Source-only change: 20 sec (dependency cache reused)

Secret Management

func (m *MyPipeline) Deploy(
    ctx context.Context,
    source *dagger.Directory,
    kubeconfig *dagger.Secret,
    registryToken *dagger.Secret,
) (string, error) {
    // Secrets are never exposed in logs
    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)
}
# Passing secrets during local execution
dagger call deploy \
  --source=. \
  --kubeconfig=file:$HOME/.kube/config \
  --registry-token=env:REGISTRY_TOKEN

CI Integration

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

Multi-Platform Build

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"})
    }

    // Push multi-platform image
    digest, err := dag.Container().
        Publish(ctx, "ghcr.io/my-org/my-app:latest",
            dagger.ContainerPublishOpts{
                PlatformVariants: platformVariants,
            })

    return digest, err
}

Integrated Pipeline: Full CI/CD

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

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

    // 3. Build
    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 Traditional CI Tools Comparison

FeatureGitHub ActionsGitLab CIDagger
Pipeline definitionYAMLYAMLGo/Python/TS
Local executionact (limited)Not possibleFully supported
DebuggingDifficultDifficultIDE debugger available
Vendor lock-inGitHubGitLabNone
CachingManual setupManual setupAutomatic (content-based)
ReusabilityMarketplaceTemplatesPackages/Modules

Troubleshooting

Common Issues and Solutions

# Check Dagger Engine status
docker ps | grep dagger-engine

# Reset cache
dagger query <<< '{ defaultPlatform }'

# Verbose log output
dagger call --debug test --source=.

# Shell access at a specific step (debugging)
dagger call build --source=. terminal

Review Quiz (5 Questions)

Q1. What is Dagger's biggest advantage over traditional CI/CD tools?

You can run the same pipeline in both local and CI environments. Since pipelines are written in programming languages, you get IDE support, type safety, and debugging capabilities.

Q2. How do you implement caching in Dagger?

Create cache volumes with dag.CacheVolume() and mount them to containers with WithMountedCache(). Caching is automatic and content-based.

Q3. How do you securely pass secrets in Dagger?

Accept dagger.Secret types as function parameters and pass them to containers using WithMountedSecret or WithSecretVariable. They are never exposed in logs.

Q4. What action do you use to run Dagger in GitHub Actions?

The dagger/dagger-for-github@v6 action.

Q5. What options do you use for multi-platform builds in Dagger?

The Platform field in dagger.ContainerOpts and the PlatformVariants option in Publish.

Quiz

Q1: What is the main topic covered in "Managing CI/CD Pipelines as Code with Dagger: Same Pipeline from Local to CI"?

Learn how to write CI/CD pipelines in a programming language using Dagger and run them identically in both local and CI environments. Integration with GitHub Actions and GitLab CI is also covered.

Q2: What is Dagger?? Dagger is an open-source tool that lets you write CI/CD pipelines in programming languages such as Go, Python, and TypeScript.

Q3: What are the key steps for Installation and Project Initialization? Installing the Dagger CLI Project Initialization

Q4: What are the key aspects of First Pipeline: Building a Go Project? Writing main.go Running Locally

Q5: How does Caching Strategy work? Dagger's caching dramatically improves build speed: Caching Performance Comparison