Dagger Go SDK vs Shell in GitHub Actions ~ モノレポのCIの実装をGoで実装するまでの道のり ~

サムネイル

はじめに

DMM.com 2022年度 新卒入社の N9tE9 です。現在、pospome さん率いるプラットフォーム事業本部 Developer Productivity Group 横断チームで働いています。

Developer Productivity Group では、プラットフォーム事業本部の開発生産性に関するプロダクトの開発/運用や、プロダクト品質に関するツールの導入を各チームに行っています。

例えば、プラットフォーム事業本部のバックエンドアプリケーションのコードを一元管理するモノレポの開発/運用を行っています。このモノレポで必要な CI/CD は、GitHub Actions で実装しています。

モノレポを導入することで、ポリレポでは発生しなかった以下のような課題が出てきます。

  • ソースコードを一元管理するため、他のチームのコードを意図せず変更する可能性がある。
  • GitHub Release の CHANGELOG の作成で他チームの変更が含まれてしまう。

Developer Productivity Group が提供するモノレポは、このようなモノレポの導入に伴って発生する課題を解決するようなCIがあります。具体的には以下のようなものです。

  • 新規にモノレポにアプリケーションを追加したときに CODEOWNER を追加する
  • GitHub Release を作成するとき、特定のアプリケーション変更のPRのみ抽出し、CHANGELOG を作成する

このような仕組みを Shell から Go に置き換えています。

Dagger Go SDK vs shell in GitHub Actions

コードの視認性

これは、モノレポが提供する GitHub Release を作成するワークフローで特定のアプリケーション変更のPRを抽出する処理を shell で実装したものです。

- name: Create CHANGELOG
    run: |
    gh pr list --search "merged:${{ steps.release-and-tag.outputs.latest-release-created-at }}..${{ steps.release-and-tag.outputs.current-tag-created-at }} label:${{ github.event.inputs.service_boundary }}" | sort | awk -F '\t' '{print "- " $2 " #"$1}' > CHANGELOG.md

ワンライナーで実装できますが、この実装を見たときどのような処理をしているかを把握するのは難しく、個人的には視認性が悪いと思います。

一方で、この処理を Go で実装した場合、以下のような実装になっています。

func (c *createGitHubReleaseUsecase) CreateGitHubRelease(ctx context.Context, rootDir, gitTag, serviceName, serviceBoundaryName, releaseVersion string) error {
    serviceBoundaries, err := c.filesystem.GetServiceBoundariesFromRootDir(rootDir)
    if err != nil {
        return err
    }

    services := serviceBoundaries.Services()

    if !services.HasPrefixWith(serviceName) {
        return errors.New("該当するリリース対象のサービスが存在しません")
    }

    ghReleases, err := c.gh.FetchGitHubReleases(ctx)
    if err != nil {
        return err
    }

    latestRelease := ghReleases.ExtractLastgithubRelease(serviceName)

    gitTagCreatedAt, err := c.git.FetchGitTagCreatedAt(ctx, gitTag)
    if err != nil {
        return err
    }

    prs, err := c.gh.FetchPullRequestsFilterByLabelAndBetweenTimes(ctx,
        serviceBoundaryName, latestRelease.CreatedAt, gitTagCreatedAt)
    if err != nil {
        return err
    }
    return nil
}

Usecase で呼ばれる各メソッドには、上記の shell と同様の機能を実装しています。ただし、各メソッドで実施される処理を把握しやすいため、個人的には視認性が良いと思います。

エラーハンドリング

shell スクリプトは、エラーハンドリングを行うには困難であるという点があります。エラーが発生しても後続処理を実行したい場合は、GitHub Actions 側でエラーが発生しても後続処理を実行するといった記述を job の各 step に記述する必要があります。

シークレットを GitHub Environment Secret に作成する機能を提供するワークフローの実装を考えます。シークレットの値のフォーマットは、JSON文字列や特殊文字を含んだ文字列などが考えられます。そのため、入力されるシークレットの値によって処理を分ける仕様になっています。

この仕様を踏まえて、GitHub Actions を利用した実装にすると以下になります。

jobs:
  create-secret:
    runs-on: ubuntu-latest
    steps:
      - name: parse input string to json
        continue-on-error: true #
        run: |
          echo '${{ github.event.inputs.secret_value }}' | jq . > /dev/null
      - name: Result For Check JSON Format
        id: result-for-check-json-format
        run: |
          echo "outcome=${{ steps.check-json-format.outcome }}"
            if [[ ${{ steps.check-json-format.outcome }} == "failure" ]]; then
              echo "シークレットの値は、JSON形式ではありません。"
              echo "is_json=false" >> $GITHUB_OUTPUT
            else
              echo "シークレットの値は、JSON形式です。"
              echo "is_json=true" >> $GITHUB_OUTPUT
            fi
      - name: Create or Update String Secret
        id: create-or-update-json-secret
        if: steps.result-for-check-json-format.outputs.is_json == 'false'
        env:
          GH_TOKEN: ${{ secrets.GH_TOKEN }}
        run: |
          echo "action=文字列形式のシークレットの登録" >> $GITHUB_ENV
          echo "error_message=シークレットのサイズが48KB未満になっているか確認してください。\n。また、JSON形式で登録したのに文字列と判定された場合は、JSONのフォーマットを確認してください。" >> $GITHUB_ENV
          gh secret set ${{ github.event.inputs.secret_name }} -e ${{ needs.set-environment-name.outputs.environment_name }} < secret_value.txt
    
      - name: Create or Update JSON Secret
        id: create-or-update-string-secret
        if: steps.result-for-check-json-format.outputs.is_json == 'true'
        env:
          GH_TOKEN: ${{ secrets.GH_TOKEN }}
        run: |
          echo "action=JSON形式のシークレットの登録" >> $GITHUB_ENV
          echo "error_message=シークレットのサイズが48KB未満になっているか確認してください。\nまた、JSONのフォーマットが正しい確認してください。" >> $GITHUB_ENV
          gh secret set ${{ github.event.inputs.secret_name }} -e ${{ needs.set-environment-name.outputs.environment_name }} -b '${{ github.event.inputs.secret_value }}'

上記の実装でモノレポに必要な機能を提供できますが、これを運用する側としては以下のような課題があります。

  • 実装されている shell スクリプトにおいてエラーが発生する可能性のある箇所が分かりにくく、レビューがしにくい
  • continue-on-error で想定していないエラーが発生した場合、処理を継続してしまう可能性がある

一方で Go だとプログラム上でエラーハンドリングが可能なので、エラーが発生した際にログ出力するだけの場合とエラーで処理自体を終了させるといったハンドリングが可能になります。
以下に usecase 層で GitHub Environment Secret に作成する実装を記載します。

type secret struct {
  daggerClient infra.Dagger
}

func (s *secret) Create(logger *zap.Logger, key, value string) error {
  var binded any
  if err := json.Unmarshal([]byte(value), &binded); err != nil {
    // ログを出力するのみ
    logger.Info("シークレットの値はJSON形式ではありません")
  } else {
    logger.Info("シークレットの値はJSON形式です")
  }

  if err := s.daggerClient.CreateSecret(key, value); err != nil {
    // エラーで処理自体を終了させる
    return err
  }

    return nil
}

単体テスト

単体テストを実現するには、以下のような機能を満たす必要があります。

  • 正常系および異常系の両方の結果を考慮してテストケースを実装できる
  • テスト対象のテストケースが他のテストケースの実行に依存しない

前述した通り、shell でのエラーハンドリングは困難であるため、単体テストにおいて異常系のテストケースの設計が難しくなります。
さらに shell は、変数のスコープ管理が困難です。例えば、if文のブロックで宣言した変数はif文のブロックを抜けても変数の値を持っています。
shell で単体テストを設計する際は、各テストケースが他のテストケースに影響を及ぼさないようにテストを設計する必要があります。
変数スコープの課題を解決するために Bats や shUnit2 といったテスティングフレームワークがあります。ただし、Developer Productivity Group では、これらのツールに対して知見が無く、Go と比べると学習コストが高いといった懸念がありました。
このような理由から、shell で単体テストを実装することは難しい一方で、Go だと Table Driven Test で以下のように単体テストを実装できます。

func TestServices_HasPrefixWith(t *testing.T) {
    tests := []struct {
        name        string
        services    code_universe.Services
        serviceName string
        want        bool
    }{
        {
            name: "指定したサービス名が含まれている場合、trueを返す",
            services: code_universe.Services{
                &code_universe.Service{
                    Name: "pf-hoge-service",
                },
            },
            serviceName: "pf-hoge-service",
            want:        true,
        }, {
            name: "指定したサービス名が含まれていない場合、falseを返す",
            services: code_universe.Services{
                &code_universe.Service{
                    Name: "pf-hoge-service",
                },
            },
            serviceName: "pf-fuga-service",
            want:        false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            actual := tt.services.HasPrefixWith(tt.serviceName)
            if diff := cmp.Diff(actual, tt.want); diff != "" {
                t.Errorf("期待した値と実際の値に差異があります: %s\n", diff)
                return
            }
        })
    }
}

モノレポが提供するCIは複雑なものが多く、新規CIの実装時や既存CIの変更時に既存の実装に影響を与えていないか?を早期に発見する目的で単体テストを実装しています。

ワークフローを実装するにあたって

shell と Go で実装量を比べる

モノレポで管理しているアプリケーションコードは、以下のように services ディレクトリ直下にアプリケーション単位で管理されています。
services 以下に配置されている hoge-service および fuga-service は、サービス境界と定義しています。サービス境界は、各チームが管理するアプリケーションコードのルートディレクトリに対応します。

services/
      ├ hoge-service/
      └ fuga-service/

モノレポは複数サービス境界のファイル変更があった場合、サービス境界を管理しているチームに対してSlackに通知するCIがあります。
各サービスのアプリケーションコードを管理するディレクトリについて、サービス境界という命名でモノレポを運用しています。
PRの作成時や、push時に services ディレクトリ以下に配置されている複数のサービス境界内のファイルを変更していないか検証します。
この要件を満たすワークフローを GitHub Actions の shell での実装は以下です。

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check Service Boundary In Services Directory
        id: check-service-boundary-in-services-directory
        run: |
          # services 以下の変更があったサービス境界を取得する
          git fetch origin ${{ github.event.pull_request.head.ref }}
          changed_service_boundaries=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} HEAD | awk -F'/' '{if( $1 == "services" ){ print $2 }}' | uniq)
          echo "changed_service_boundaries=$changed_service_boundaries)" >> $GITHUB_ENV

      - name: Check Service Boundary In Workflows Directory
        id: check-service-boundary-in-workflows-directory
        run: |
          # .github/workflows 以下の変更があったサービス境界を取得する
          changed_service_boundaries=$(echo "${{ env.changed_service_boundaries }}" | tr ' ' '\n')
          service_boundaries=$(find services/* -maxdepth 1 -type d | awk -F '/' '{if (NF == 3) {print $2}}' | sort | uniq)
          for service_boundary in $service_boundaries
          do
              services=$(find services/$service_boundary/cmd -maxdepth 1 -mindepth 1 -type d | awk -F '/' '{print $NF}')
              for service in $services
              do
                all_changed_files=$(git diff --name-only HEAD~1)
                change_files=$(echo "$all_changed_files" | grep "^.github/workflows/$service" || true)
                if [ -n "$change_files" ] && ! echo "$changed_service_boundaries" | grep -q "^$service_boundary$"; then
                  changed_service_boundaries+="$service_boundary"
                fi
              done
          done
          echo "changed_service_boundaries=$changed_service_boundaries"
          echo "changed_service_boundaries=$(echo $changed_service_boundaries)" >> $GITHUB_ENV
    
      - name: Validate Service Boundary
        id: validate-service-boundary
        run: |
          # 複数のサービス境界内のファイルを変更していないか検証する
          changed_service_boundaries=$(echo "${{ env.changed_service_boundaries }}" | tr ' ' '\n')
          if [ $(echo "$changed_service_boundaries" | wc -l) -gt 1 ]; then
            echo "is_valid=false" >> $GITHUB_OUTPUT
            echo "changed_service_boundaries=$(echo "$changed_service_boundaries" | tr '\n' ' ')" >> $GITHUB_OUTPUT
          fi

Go でこの仕様を実装するとき、以下のようなモジュール設計をしました。

  • ファイル変更を表現するドメインを管理するモジュール
  • ディレクトリのファイル操作をするモジュール
  • Dagger コンテナからコマンドを実行するモジュール

それぞれのモジュールを利用した、最終的なCIの要件を表現する usecase の実装は以下です。

type checkServiceBoundariesUsecase struct {
    dagger        infrastructure.Dagger
    filesystem  infrastructure.FileSystem
}


func (c *checkServiceBoundariesUsecase) CheckServiceBoundariesModification(ctx context.Context, rootDir, baseRef, headRef string) error {
    // 変更されたファイル一覧を取得する
    changes, err := c.dagger.GetChanges(ctx, baseRef, headRef)
    if err != nil {
        return err
    }

    // モノレポに登録されているサービス境界一覧を取得する
    allServiceBoundaries, err := c.filesystem.GetServiceBoundariesFromRootDir(rootDir)
    if err != nil {
        return err
    }

    // 複数のサービス境界内のファイルを変更していないか検証する
    if !changes.IsSingleServiceBoundaryChanged(allServiceBoundaries) {
        return errors.New("サービス境界を跨いだ変更があります")
    }

    return nil
}

usecase の実装は、簡潔でCIの要件を表現した実装になっていますが、それぞれのモジュールの実装を考えると実装量は多いです。

例えば、ファイル変更を表現するドメインを管理するモジュールは、以下のようなロジックを持ちます。

  • /services 以下の変更されたファイル/ディレクトリの一覧取得
  • /services 以下で複数のアプリケーションの変更を検証する

実装は以下です。

type Change string

var serviceBoundaryPathReg = regexp.MustCompile(`services/([^/]+)`)

func (c Change) isServiceBoundaryPath() bool {
    return serviceBoundaryPathReg.MatchString(c.string())
}

func (c Change) serviceBoundaryUnderWorkflowDirectory(serviceBoundaries ServiceBoundaries) string {
    for _, serviceBoundary := range serviceBoundaries {
        for _, service := range serviceBoundary.Services {
            workflowServiceReg := regexp.MustCompile(`.github/workflows/` + service.Name + `\S+.ya?ml`)
            if workflowServiceReg.MatchString(c.string()) {
                return serviceBoundary.Name
            }
        }
    }
    return ""
}

type Changes []Change

func (c Changes) IsSingleServiceBoundaryChanged(allServiceBoundaries ServiceBoundaries) bool {
    return len(c.ServiceBoundaries(allServiceBoundaries)) == 1
}

func (c Changes) ServiceBoundaryNames(allServiceBoundaries ServiceBoundaries) []string {
    uniqueServiceBoundaries := make(map[string]struct{})
    for _, change := range c {
        if change.isServiceBoundaryPath() {
            uniqueServiceBoundaries[change.serviceBoundaryUnderServicesDirectory()] = struct{}{}
        }
    }

    serviceBoundaries := make([]string, 0, len(uniqueServiceBoundaries))
    for serviceBoundaryName := range uniqueServiceBoundaries {
        serviceBoundaries = append(serviceBoundaries, serviceBoundaryName)
    }
    return serviceBoundaries
}

func (c Changes) ServiceBoundaryName(allServiceBoundaries ServiceBoundaries) (string, error) {
    changedServiceBoundaries := c.ServiceBoundaryNames(allServiceBoundaries)

    if len(changedServiceBoundaries) > 1 {
        return "", errors.New("複数のサービス境界が変更されています")
    }

    if len(changedServiceBoundaries) == 1 {
        return changedServiceBoundaries[0], nil
    }
    return "", nil
}

type ServiceBoundary struct {
    Name      string
    Services  Services
    Workflows Workflows
}

type ServiceBoundaries []*ServiceBoundary

Dagger コンテナからコマンドを実行する実装は以下です。

func (d *dagger) GetChanges(ctx context.Context, baseRef, headRef string) (code_universe.Changes, error) {
  container, err := d.factory.DaggerContainerByMachineAccountToken(ctx)
  if err != nil {
    return nil, err
  }

  container = container.
    WithExec([]string{"git", "fetch", "origin", baseRef}).
    WithExec([]string{"git", "fetch", "origin", headRef})

  diff, err := container.WithExec([]string{"git", "diff",
    "--name-only", fmt.Sprintf("origin/%s", baseRef), fmt.Sprintf("origin/%s", headRef)}).
    Stdout(ctx)

  if err != nil {
    return nil, err
  }

  diffSlice := strings.Split(diff, "\n")

  return code_universe.NewChanges(diffSlice), nil
}

最終的に Go で置き換えた場合のワークフローファイルは以下になります。複雑さが伴う shell による実装を Go で代替したため、ワークフローファイルは Go のバイナリをビルドして実行する形になっています。

jobs:
  service-boundary-check:
    name: Service Boundary Check
    runs-on: ubuntu-latest
    env:
      GH_TOKEN: ${{ secrets.GH_TOKEN }}
    steps:
      - uses: actions/setup-go@v3
        with:
          go-version: '1.20'

      - uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Check Service Boundary
        env:
          MACHINE_ACCOUNT_GH_TOKEN: ${{ secrets.GH_TOKEN }}
        working-directory: services/pf-star-ship
        run: |
          go build -o pf-star-ship cmd/pf-star-ship/main.go
          ./pf-star-ship check-service-boundaries \
            --base-ref ${{ github.event.pull_request.base.ref }} \
            --head-ref ${{ github.event.pull_request.head.ref }} \
            --root-dir ../..

このように同様の処理の実装を shell と Go で比較した場合、shell だと実装量が少なく機能を実現できることが分かります。Go でワークフローを実装する場合は、shell で実装するよりも 1.5 倍の量を実装している感じがします。
実装量が増える背景は、アーキテクチャの知識や shell で扱えなかった構造体やメソッドをコードで表現している点です。開発が進むにつれて構造体やメソッドを再利用できるようになるので、shell ほどではないですが開発速度は上がっていると思っています。

保守/運用のしやすさ

サービス境界を跨いで変更が存在するかの検証する部分について shell と Go を比較してみます。
shell は以下のような実装になっており、コマンドを利用して実装されています。サービス境界を跨いだ変更が存在するかの検証は、大きく分けて5つのロジックで構成されています。

  • 変更されたサービス境界を取得する
  • モノレポに登録されている全てのサービス境界を取得する
  • 変更されたサービス境界のディレクトリが1つであるかの検証する
  • ワークフローファイルのみの変更の場合、変更があったワークフローファイル名のprefixに含まれるサービス名は1つのサービス境界に含まれることを検証する
  • サービス境界およびワークフローファイルの両方に変更があった場合、ワークフローファイル名のprefixに含まれるサービス名は変更があったサービス境界名に含まれること検証する
changed_service_boundaries=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} HEAD | awk -F'/' '{if( $1 == "services" ){ print $2 }}' | uniq)
service_boundaries=$(find services/* -maxdepth 1 -type d | awk -F '/' '{if (NF == 3) {print $2}}' | sort | uniq)
for service_boundary in $service_boundaries
  do
  services=$(find services/$service_boundary/cmd -maxdepth 1 -mindepth 1 -type d | awk -F '/' '{print $NF}')
  for service in $services
  do
    all_changed_files=$(git diff --name-only HEAD~1)
    change_files=$(echo "$all_changed_files" | grep "^.github/workflows/$service" || true)
    if [ -n "$change_files" ] && ! echo "$changed_service_boundaries" | grep -q "^$service_boundary$"; then
        changed_service_boundaries+="$service_boundary"
    fi
  done
done
if [ $(echo "$changed_service_boundaries" | wc -l) -gt 1 ]; then
  echo "is_valid=false" >> $GITHUB_OUTPUT
  echo "changed_service_boundaries=$(echo "$changed_service_boundaries" | tr '\n' ' ')" >> $GITHUB_OUTPUT
fi

この5つのロジックを shell の実装から読み解くのは、時間がかかります。5つのロジックを各 step に分けることで、実行されるロジックに対して命名できますが、step 間で変数を共有する必要があり、共有している値を認知する必要が出てきます。

一方で、Goで同様の処理を実装するとき、ワークフローの仕様を構成する各ロジックを構造体のメソッドとして切り出せるため、以下のような実装です。

IsSingleServiceBoundaryChanged メソッドは、以下の3つのロジックをカプセル化したメソッドになっています。

  • 変更されたサービス境界のディレクトリが1つであるかの検証する
  • ワークフローファイルのみの変更の場合、変更があったワークフローファイル名のprefixに含まれるサービス名は1つのサービス境界に含まれることを検証する
  • サービス境界およびワークフローファイルの両方に変更があった場合、ワークフローファイル名のprefixに含まれるサービス名は変更があったサービス境界名に含まれること検証する
func (c *checkServiceBoundariesUsecase) CheckServiceBoundariesModification(ctx context.Context, rootDir, baseRef, headRef string) error {
    changes, err := c.dagger.GetChanges(ctx, baseRef, headRef)
    if err != nil {
        return err
    }

    allServiceBoundaries, err := c.filesystem.GetServiceBoundariesFromRootDir(rootDir)
    if err != nil {
        return err
    }

    if !changes.IsSingleServiceBoundaryChanged(allServiceBoundaries) {
        return errors.New("サービス境界を跨いだ変更があります")
    }

    return nil
}

各構造体の実装を考慮すると Go の方が実装として重くなりますが、ワークフローの仕様を実現する箇所に関しての認知負荷が下がります。

まとめ

まとめると、Go で実装するよりも Shell の方が実装量少なく機能を実装できるため、実装量/実装速度は Shell の方が速い印象を受けました。
一方で、Go の方がワークフローを実現するためにモノレポに必要な概念を構造体やメソッドとして作ることができます。
モノレポは、長期的に運用するプロダクトであるため、Go で置き換えるメリットは大きい印象を受けました。

コードの視認性 エラーハンドリング 実装量/実装速度 保守/運用のしやすさ 単体テスト
Shell in GitHub Actions × ワンライナーで記述できるが、複雑な実装になるため視認性が個人的には悪い △ GitHub Actions Workflow や set -e を使ってエラーハンドリングすることになる ○ 実装量は少なくできる △ step ごとで細かくロジックを実装することはできるが、step 間で共有している値を認知する必要がある △ エラーハンドリングが難しく、単体テストを実装するにはテスティングフレームワークが必要になる
Dagger Go SDK ○ 各メソッドに複雑な実装は散るが、仕様を実行する Usecase の視認性は個人的には良い ○ Go にエラーハンドリングの機能がある △ 構造体やメソッドの再利用はできるようになるが、実装量は Shell と比べると多い ○ 適切に構造体やメソッドにロジックを切り出し/集約できる ○ Table Driven Test で単体テストを実装できる

宣伝

Developer Productivity グループでは、DMMプラットフォームの開発効率とセキュリティレベルを向上目指して一緒に取り組むメンバーを募集しています。興味がある方は、以下記事も合わせてご確認ください。

DMMプラットフォームのマイクロサービスアーキテクトグループのエンジニア募集について