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されているかも確認します。
実際にローカルに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には参考になるコードが他にもありますのでチェックしてみてください。
最後まで読んでいただいてありがとうございました( ˘ω˘)