見出し画像

Dagger Go SDKを使ってマルチアーキテクチャイメージをビルド!試してみて感じたDaggerの可能性

この記事は、NAVITIME JAPAN Advent Calendar 2022の10日目の記事です。
https://adventar.org/calendars/7390

こんにちは、おいわです。ナビタイムジャパンで SRE(Site Reliability Engineering) を担当しています。
2022年10月に発表されたDagger Go SDKがどんなものか実際に触ってみました。その記録をここに残します。

はじめに


以下のような方を対象として本記事を書きました。

  • GitHub Actionsなどのプラットフォームに極力依存しない形で、CI/CDパイプラインを実装したいと思っている

  • CI/CDパイプラインの開発をローカルで行いたいと思っている

  • CI/CDもできたらGoで書きたいと思っている

  • Dagger Go SDKのことを知りたいと思っている

  • Dagger Go SDKをJenkinsで実行してみたいと思っている

  • Dagger Go SDKを使ってマルチアーキテクチャイメージをビルドしたいと思っている

Dagger

DaggerはDockerの創始者であるSolomon Hykes氏らが中心となって開発しているCI/CDパイプラインのポータブル開発キットです。ポータビリティをアピールしていることもあり、パイプラインはコンテナ上で実行します。パイプラインは様々な言語のSDKで実装することができます。

Dagger Go SDK

Dagger Go SDKは、CI/CDパイプラインをGoで実装し、OCI互換のコンテナランタイムで実行するために必要なSDKです。

公式ドキュメント より引用


ローカルで実行してみる


記事執筆にあたり、バージョンを明記しておきます。

# Dagger Go SDK バージョン
dagger.io/dagger v0.4.1

# Go バージョン
go1.19.2 darwin/arm64

構成は以下の通りです。

.
├── dagger.go
├── go.mod
└── go.sum

今回はDagger Engineに接続して閉じるだけのパイプラインを書いてみます。

package main

import (
	"context"
	"fmt"

	"dagger.io/dagger"
)

func main() {
	build(context.Background())
	fmt.Println("Success!")
}

func build(ctx context.Context) error {
	fmt.Println("Building with Dagger")

	// Dagger Engineに接続する
	client, err := dagger.Connect(ctx)
	if err != nil {
		return err
	}
	defer client.Close()

	return nil
}

ライブラリの追加が必要でした。

$ go get dagger.io/dagger@latest
$ go mod tidy

実行してみましょう。

$ go run dagger.go
Building with Dagger
Success!

成功しました 🥳 
実際はもっと複雑なパイプラインを実装する必要があると思います。コードをビルドしたり、コンテナイメージをビルドしたり…。もう少し複雑な実装は後ほど紹介します。


DaggerをJenkinsで動かしてみる


DaggerはいろんなCI/CDプラットフォームで実行することが可能です。
当社ではCI/CD環境としてJenkinsを利用しているので、今回はJenkinsで動かしてみようと思います。

構成は以下の通りです。

.
├── Jenkinsfile
├── dagger.go
├── go.mod
└── go.sum

Daggerのコードは変更していません。
今回追加したのJenkinsfileだけです。

pipeline {
  agent any

  stages {
    stage('Build') {
      steps {
        sh '''#!/bin/bash
        CGO_ENABLED=0 go build -o bin/dagger dagger.go
        bin/dagger
        '''
      }
    }
  }
}

こちらがジョブの実行結果です。無事成功しました 🥳

DaggerをJenkinsで使ってみて

Jenkinsから直接Daggerを起動することはできないので、Jenkinsfile自体を無くすことはできません 😢  しかし、Jenkinsfileの依存度を薄めることには成功しました 🙌 


応用編:マルチアーキテクチャイメージをビルドして、ECRにプッシュする


もう少し踏み込んでみます。応用編として以下を実装してみました。

  • Dockerfileからコンテナイメージをビルド

  • マルチアーキテクチャイメージのビルド

  • イメージをAmazon ECRへプッシュ

構成は以下の通りです。

.
├── Dockerfile
├── Jenkinsfile
├── cmd
│   └── main.go
├── dagger
│   ├── aws.go
│   └── dagger.go
├── go.mod
└── go.sum

まずはDockerfile, cmd/main.go から紹介します。

Dockerfile

ビルドして、バイナリを実行するだけのシンプルなものです。

FROM golang:1.19.2

WORKDIR /workdir

COPY . .

RUN CGO_ENABLED=0 go build -o /bin/echo ./cmd/main.go

EXPOSE 1323

CMD ["/bin/echo"]

cmd/main.go

echoを起動して Hello World! を返すだけのシンプルなものです。
公式ドキュメントのQuick Startから拝借しました。

package main

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

func main() {
	e := echo.New()
	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello, World!")
	})
	e.Logger.Fatal(e.Start(":1323"))
}

dagger/dagger.go

本題のdagger/dagger.goです。

package main

import (
	"context"
	"fmt"
	"os"

	"dagger.io/dagger"
)

func main() {
	build(context.TODO())
	fmt.Println("Success")
}

func build(ctx context.Context) error {

	// 対応するプラットフォームを指定します
	// `go tool dist list` で表示するプラットフォームを設定することが可能です
	var platforms = []dagger.Platform{
		"linux/amd64",
		"linux/arm64",
	}

	// Dagger Engineに接続する
	client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
	if err != nil {
		panic(err)
	}
	defer client.Close()

	// プラットフォーム単位でコンテナイメージをビルドする
	platformVariants := make([]*dagger.Container, 0, len(platforms))
	for _, platform := range platforms {

		// ホストにあるDockerfileからイメージをビルドする
		src := client.Host().Directory(".")
		image := client.Container(dagger.ContainerOpts{Platform: platform}).Build(src)

		// イメージをリストに格納します
		platformVariants = append(platformVariants, image)
	}

	// ECRにログインする
	// AssumeRoleをする場合↓
	roleARN := "arn:aws:iam::999999999999:role/YOUR_ROLE_NAME"
	sessionName := "Session"
	ECRLoginWithAssumeRole(ctx, &roleARN, &sessionName)
	// AssumeRoleが不要な場合↓。今回はAssumeRoleを行います
	// ECRLogin(ctx)  

	// イメージをECRにプッシュする
	// ここで上段で用意したイメージリストを使います
	repo := "999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/your_repo_name:latest"
	imageDigest, err := client.Container().
		Publish(ctx, repo, dagger.ContainerPublishOpts{
			PlatformVariants: platformVariants,
		})
	if err != nil {
		panic(err)
	}
	fmt.Printf("Pushed multi-platform image. digest: %s\n", imageDigest)

	return nil
}

大筋の説明はコードコメントを読んでください。いくつか掘り下げて説明します。

ピックアップ1 イメージのビルド

src := client.Host().Directory(".")
image := client.Container(dagger.ContainerOpts{Platform: platform}).Build(src)

client.Container()でコンテナを起動しているのですが、プラットフォーム(linux/amd64, linux/arm64)を指定して、Build(src) でDockerfileからイメージをビルドしています。楽ですね!

Dockerfileはなくてもいい
Dockerfileを使用しなくても、以下のようにDagger上でイメージをビルドすることが可能です(公式ガイドのコード)。

builder := client.Container().
	From("golang:latest").
	WithMountedDirectory("/src", project).
	WithWorkdir("/src").
	WithEnvVariable("CGO_ENABLED", "0").
	WithExec([]string{"go", "build", "-o", "myapp"})

prodImage := client.Container().
	From("alpine")

prodImage = prodImage.WithRootfs(
	prodImage.Rootfs().WithFile("/bin/myapp",
		builder.File("/src/myapp"),
	)).
	WithEntrypoint([]string{"/bin/myapp"})

ただ、DaggerによってDockerfileが淘汰されていくとは感じませんでした。Docker ComposeをはじめとしたDockerのエコシステムはローカル開発に浸透しており、Daggerに置き換えるメリットまでは感じませんでした。
そのため、DaggerからDockerfileを読み込むのがちょうど良いのでは?と感じています。

ピックアップ2 イメージのプッシュ

// イメージをECRにプッシュする
// ここで上段で用意したイメージリストを使います
repo := "999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/your_repo_name:latest"
imageDigest, err := client.Container().
	Publish(ctx, repo, dagger.ContainerPublishOpts{
		PlatformVariants: platformVariants,
	})

dagger.ContainerPublishOpts{} にイメージリストを設定して、Publishすることで、マルチアーキテクチャイメージをリポジトリにアップロードすることができます。想像以上に楽でした。

dagger/aws.go

こちらはdagger/dagger.goの下記コードの実装になります。ECRLoginWithAssumeRole(), ECRLogin()のところになります。

// ECRにログインする
// AssumeRoleをする場合↓
roleARN := "arn:aws:iam::999999999999:role/YOUR_ROLE_NAME"
sessionName := "Session"
ECRLoginWithAssumeRole(ctx, &roleARN, &sessionName)
// AssumeRoleが不要な場合↓。今回はAssumeRoleを行います
// ECRLogin(ctx)  

実際のaws.goはこちらです。

package main

import (
	"context"
	"encoding/base64"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/ecr"
	"github.com/aws/aws-sdk-go-v2/service/sts"
)

func AssumeRole(roleARN *string, sessionName *string) (*sts.AssumeRoleOutput, error) {
	if *roleARN == "" || *sessionName == "" {
		return nil, fmt.Errorf("invalid parameters")
	}

	cfg, err := config.LoadDefaultConfig(context.TODO())
	if err != nil {
		panic("configuration error, " + err.Error())
	}
	client := sts.NewFromConfig(cfg)

	result, err := client.AssumeRole(context.TODO(), &sts.AssumeRoleInput{
		RoleArn:         roleARN,
		RoleSessionName: sessionName,
	})
	if err != nil {
		return nil, fmt.Errorf("got an error assuming the role: %s", err)
	}

	fmt.Println(result.AssumedRoleUser)
	return result, nil
}

func ECRLoginWithAssumeRole(ctx context.Context, roleARN *string, sessionName *string) error {

	// AssumeRole
	creds, err := AssumeRole(roleARN, sessionName)
	if err != nil {
		panic("configuration error, " + err.Error())
	}
	os.Setenv("AWS_SECRET_ACCESS_KEY", *creds.Credentials.SecretAccessKey)
	os.Setenv("AWS_ACCESS_KEY_ID", *creds.Credentials.AccessKeyId)
	os.Setenv("AWS_SESSION_TOKEN", *creds.Credentials.SessionToken)

	// ECR ログイン
	if err = ECRLogin(ctx); err != nil {
		return err
	}

	return nil
}

func ECRLogin(ctx context.Context) error {
	// ECR クライアント作成
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		panic(err)
	}
	ecrClient := ecr.NewFromConfig(cfg)

	// 認証トークン取得
	output, err := ecrClient.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{})
	if err != nil {
		return err
	}

	// 認証トークンのデコード
	authData := output.AuthorizationData[0]
	tokenData, err := base64.StdEncoding.DecodeString(*authData.AuthorizationToken)
	if err != nil {
		return err
	}

	// ECRに対して docker login する
	token := strings.Split(string(tokenData), ":")[1]
	cmd := exec.CommandContext(ctx, "docker", "login", "--username", "AWS", "--password-stdin", *authData.ProxyEndpoint)
	stdin, err := cmd.StdinPipe()
	if err != nil {
		return err
	}
	defer stdin.Close()

	if err := cmd.Start(); err != nil {
		return err
	}

	if _, err := io.WriteString(stdin, token); err != nil {
		return err
	}

	if err := stdin.Close(); err != nil {
		return err
	}

	if err := cmd.Wait(); err != nil {
		return err
	}

	return nil
}

詳細説明は割愛しますが、やっていることは「AssumeRole」と「ECRログイン」のみです。aws/aws-sdk-go-v2を利用して実装しています。

ハマったこと
DaggerはDockerエンジンがレジストリに使用するクレデンシャルと同じものを使用します。なので、イメージレジストリにPublishしたい場合は、事前にdocker loginを行う必要があります。なので、今回 aws.go を実装する必要がありました。
自分はこのことを知らず、Publishでハマり続けました…(ちなみにDaggerのDiscordサーバーで知りました)。

Jenkinsfile

前述のものから少しだけ変えています。

pipeline {
  agent any

  stages {
    stage('Build') {
      steps {
        sh '''#!/bin/bash
        # dagger配下にファイルを追加したのでコマンドをdagger/*.goに修正
        CGO_ENABLED=0 go build -o bin/dagger dagger/*.go
        bin/dagger
        '''
      }
    }
  }
}

Jenkinsで実行してみる

結果は無事成功!

ECRにPublishされているかも確認します。

<untagged> となっているのは、linux/amd64, linux/arm64のイメージです。この2つはタグを指定せずにPublishすることになるので、このような表記になってしまいました。ここはもう少し良いやり方があるのかもしれません。

実際にローカルにPullして実行してみましょう。

$ docker pull 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/your_repo_name:latest
$ docker run -p 1323:1323 --rm -itd --name echo 999999999999.dkr.ecr.ap-northeast-1.amazonaws.com/your_repo_name:latest

$ curl -i http://localhost:1323/
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Date: Wed, 30 Nov 2022 14:00:10 GMT
Content-Length: 13

Hello, World!

起動も無事成功しました 🥳

レジストリのURLやAssumeRoleのARN等を引数として渡すように作る…など、改善点はたくさん浮かびますが今回はこの辺で終わりにします🙏


まとめ


メリット

実装してみて感じたメリットは以下です。

  • BuildKitのおかげで、マルチアーキテクチャのビルドが簡単にできる

    • Arm64アーキテクチャで動くイメージをビルドするために、Armインスタンスを用意する必要がなくなる。

  • ローカルでパイプラインの実行結果のフィードバックがすぐ得られるので、開発効率が良い。

  • プログラマブル!モジュールを工夫して作っていけば、CI/CDパイプラインの実装が楽になりそう。

  • CI/CDプラットフォームに依存したパイプラインの実装を薄くすることができそう。

気をつけたいポイント

実装してみて分かったのは「ローカル環境とCI/CD環境の間にある環境差分を吸収する必要がある」ということです。

  • イメージプッシュ先のレジストリにアクセス制限が掛かっており、ローカル環境からプッシュできない。

  • ローカル環境ではAssumeRoleしないようにしたい。

  • 大容量データを扱うバッチなどはローカルで実行するには限界がある。

実際の導入では、このようなシチュエーションに出くわしそうで、そのための工夫が必要になりそうです。

おわりに

今回はDagger Go SDKのデモコードを書いてみました。メリットにも書いた通り、個人的に可能性を感じるソフトウェアでした。これからもウォッチしていきたいと思います!

今回の記事では紹介できませんでしたが、公式ドキュメントやYouTubeには参考になるコードが他にもありますのでチェックしてみてください。

最後まで読んでいただいてありがとうございました( ˘ω˘)