every Tech Blog

株式会社エブリーのTech Blogです。

GoのCI/CDを少しでも早くしたい

目次

はじめに

こんにちは、開発本部開発 1 部トモニテグループのエンジニアの rymiyamoto です。 プロダクトを安心安全に提供するに当たり、CI/CD を用いてテストやデプロイを自動化することで、手作業を取り除いているのは昨今の流れです。

しかし、CI/CD のパイプラインを長い間運用するにあたり、テストやデプロイの時間が長くなるという課題がありました。CI/CD のたった数分の遅延が、開発チーム全体の生産性の低下を引き起こすこともあります。

そこで、CI/CD のパイプラインを少しでも早くするためにいろいろ試してみたのでその備忘録となります。

背景

私が関わっている トモニテ(旧 MAMADAYS) はサービスとしては 2019 年 7 月に web サイトが公開され、10 月にはアプリのリリースを行っています。そこまでまだ年季が経っているわけではありませんが数年間開発が行われており、サービス規模も少しずつ大きくなってきています。

ある日メンバーから「最近 API サーバー の CI 遅くないですか?」と相談を受けました。

API サーバーは Go 言語で書かれており AWS CodeBuild でデプロイし、GitHub Actions でテストを回しています。

(なぜ CI/CD で別のサービスを利用しているかというと、サービス自体は AWS で稼働しており当時は認証周りが AWS で完結するほうが楽で使われていました。途中から CI だけでも GitHub Actions を試すようになった背景があります)

実際に確認したところ以下のような状況になっていました。

サービス 時間
AWS CodeBuild(デプロイ) 15 分
GitHub Actions(テスト) 10 分

時間としては 30 分や 1 時間のようにものすごくかかっているというわけではありませんが、数分の違いが開発の速度に影響してきます。また実行時間の長さがコストにも跳ね返ってもきます。

そこでこの度それぞれのパイプラインを見直してみることにしました。

現状の把握

AWS CodeBuild

トモニテでは AWS Elastic Container Service(以降 ECS) を利用しています。 そのため AWS Elastic Container Registry(以降 ECR) でイメージを管理しています。

内部としてやっていることはざっくり書いて以下のとおりです。

  • ecs-deploy のインストール
  • Docker Hub と ECR の認証
  • イメージのビルド&タグ付け
  • ECR へのプッシュ
  • ECS へのデプロイ

内部を見てみるとビルド時の都度キャッシュなしでの実行されており、ビルド時間が長くなっていました。

またビルドに影響する Docker 関連も見直してみました。 Dockerfile の中身を確認したところ、go mod download なしでビルドのステージでビルドしていることがわかりました。 ほかだと .dockerignore があまり効いておらず、コードのコピー時に影響しない部分も差分として検知されています。

GitHub Actions

テストは DB を使うテストと使わないテストを分けています。こちらはテストだけが目的なので、テストの実行自体はコンテナを用いずに actions/setup-go で環境を用意しています。

並列化も行っているのでテスト実行自体はある程度調整されており、 go mod download はキャッシュを見るようになってはいました。しかし内部で go install で追加したツールが都度入れられており時間のロスが大きいです。

デッドコード

ビルドやテスト自体への影響も考えてデッドコードがないかを調べてみました。

その結果、すでにどこからも呼ばれていないエンドポイントや、連携が止まって定期実行をやめたスクリプトなど、不要なコードが多数見つかりました。

デッドコード自体はプロダクトの成長に伴い、コード量が増えていくことでどうしても出てくるものです。リリースサイクルを高速で回していく中で実害のない後片付けをする時間が取れないことがありました。またコードを書いた人がいなくなってしまうと、そのコードが何のためのものかわからなくなってしまうのもしばしばです。

こういったものの積み重ねによってコード量が増えていくことで、次第にビルドやテストの時間に影響が出てしまいます。また開発者としても影響が一切ないコードを背景を知らないために余計な考慮をする必要が出てしまいとても不健全です。

余談ですが今回は実際にドキュメントや当時のやり取り、ログを見て確認しましたが、Go ではデッドコードを検知するツールがあります。 こちらを使うことで実際に使ってない関数を探すことができます。ただしこのツールは偽陽性があるのであくまで参考程度の利用が望ましいと思います。

pkg.go.dev

改善したところ

上記で見つかった内容に踏まえてそれぞれ改善をしていきます。

不要なコード削除

手っ取り早いデッドコードをまず削除していきました。ただ消すと言っても一括でまとめて削除するのではなくコンテキスト単位(エンドポイント・スクリプト)で PR を作成して、レビュワーの負荷を少しでも軽減しました。

実際に削除したコードとしては 486 ファイル、134,313 行で結構な量のコードが削除されました。

この削除だけでもかなり時間短縮をすることができました。

サービス 改善前 改善後
AWS CodeBuild(デプロイ) 15 分 8 分
GitHub Actions(テスト) 10 分 8 分

キャッシュの有効活用

Codebuild

ビルド時間の短縮には、CodeBuild のインスタンスタイプを上げるという選択肢もありましたが、コスト面を考慮してキャッシュの活用を試みました。

まず Dockerfile の中身を修正します。go mod download なしでビルドのステージでビルドしていたのでステージを分けてパッケージの更新がない限りはキャッシュが効くように変更しました。

  • 変更前
FROM golang:${GO_VERSION}-alpine AS builder

COPY . .

# 以降go build...
  • 変更後
FROM golang:${GO_VERSION}-alpine AS deps

COPY go.mod .
COPY go.sum .
RUN go mod download

FROM deps AS builder

COPY . .

# 以降go build...

また .dockerignore を修正してコンテナにコピーするファイルを減らしました。 主に CI/CD の設定ファイルや昨今 AI ツールの利用でドキュメントを作成することが多くなっているため、 *.md*.mdc の変更も除外しています。

最後に Docker Buildx を利用しつつ既存のイメージのキャッシュを利用するように変えています。 Docker Buildx は Docker コマンドを拡張する CLI プラグインであり、Moby BuildKit ビルダーツールキットにより提供される機能に完全対応するものです。 Docker ビルドと同様のユーザー操作を提供し、さらにスコープ化されたビルダーインスタンス、複数ノードへの同時ビルドなど、数多くの新機能を提供します。

docker buildx build コマンドは従来の docker build によって利用できる機能はすべて対応しており、出力設定、インラインビルドキャッシング、ターゲットプラットフォーム指定といった機能にも対応します。 さらに Buildx では、いつもの docker build では実現できない新機能として、マニフェスト一覧の生成、分散キャッシング、ビルド結果の OCI イメージ tarball への出力も実現します。

matsuand.github.io

  • 変更前
version: 0.2

env:
  variables:
    REPOSITORY_URI_BASE: .dkr.ecr.ap-northeast-1.amazonaws.com/
    DOCKER_BUILDKIT: "1"

phases:
  install:
    commands:
      - GO_VERSION=$(cat .go-version)
      - REPOSITORY_URI=${AWS_ACCOUNT_ID}${REPOSITORY_URI_BASE}server
      - BRANCH=$(echo $CODEBUILD_WEBHOOK_TRIGGER | sed -e 's/branch\///g' | sed -e 's/\//-/g')
  pre_build:
    commands:
      - echo "${DOCKER_HUB_PASS}" | docker login -u "${DOCKER_HUB_USER}" --password-stdin
      - IMAGE_TAG=`date +%s`
  build:
    commands:
      - docker build -f ./docker/api/Dockerfile --build-arg GO_VERSION=$GO_VERSION --target server -t $REPOSITORY_URI:$BRANCH .
      - docker tag $REPOSITORY_URI:${BRANCH} "${REPOSITORY_URI}:${BRANCH}.${IMAGE_TAG}"
  post_build:
    commands:
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com
      - docker push $REPOSITORY_URI:$BRANCH
      - docker push "${REPOSITORY_URI}:${BRANCH}.${IMAGE_TAG}"
      # デプロイ...
  • 変更後
# 変更ない部分なので割愛

phases:
  install:
    commands:
      - GO_VERSION=$(cat .go-version)
      - REPOSITORY_URI=${AWS_ACCOUNT_ID}${REPOSITORY_URI_BASE}server
      - BRANCH=$(echo $CODEBUILD_WEBHOOK_TRIGGER | sed -e 's/branch\///g' | sed -e 's/\//-/g')
      - docker buildx create --use --name server-builder || docker buildx use server-builder
      - docker buildx inspect --bootstrap
  pre_build:
    commands:
      - aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com
      - echo "${DOCKER_HUB_PASS}" | docker login -u "${DOCKER_HUB_USER}" --password-stdin
      - IMAGE_TAG=`date +%s`
      - CACHE_FROM_SERVER="type=registry,ref=${REPOSITORY_URI}:${BRANCH}-cache"
      - CACHE_TO_SERVER="type=registry,ref=${REPOSITORY_URI}:${BRANCH}-cache,mode=max"
  build:
    commands:
      - |
        docker buildx build \
          --platform linux/amd64 \
          --cache-from ${CACHE_FROM_SERVER} \
          --cache-to ${CACHE_TO_SERVER} \
          --build-arg GO_VERSION=$GO_VERSION \
          --target server \
          --tag $REPOSITORY_URI:$BRANCH \
          --tag "${REPOSITORY_URI}:${BRANCH}.${IMAGE_TAG}" \
          --push \
          -f ./docker/api/Dockerfile .
  post_build:
    commands:
      # デプロイ...

今回 Buildx を使ってコマンドをまとめつつ、キャッシュに関するオプションを追加しています。--cache-fromでは前回作成したイメージを基に構築用に外部のキャッシュソースを使用し、--cache-toでは構築キャッシュを外部のキャッシュ先へ出力しています。それぞれオプションがいくつかありますが、今回は type=registry を利用しています。

type=registry を利用することで、Docker Hub や ECR などのレジストリーからキャッシュを取得し、また mode=max を指定することでコンテナ内の構成を最大限キャッシュできます。

ちなみに max の場合キャッシュ構築の時間がかかります、今回はやりませんでしたが min を指定することでステージの最終段のみキャッシュするようになり軽量化することもできます。

docs.docker.jp

docs.docker.jp

これらの修正によりビルド時間はキャッシュが効いてコードの変更がない場合は 3 分程度で終わるようになりました。

GitHub Actions

テストの実行時間を短縮するためにツールのインストールを都度ムダにしないように actions/cache を利用してキャッシュを利用するようにしました。

変更前

on: [push]

env:
  DB_USER: root
  DB_PASSWORD: test
  DB_ADDRESS: 127.0.0.1
  GO_ENV: test
  TEST_PARALLEL: true
  NUM_OF_PARALLEL: 4
  SQL_MIGRATE_VERSION: v1.8.0
  GOVERALLS_VERSION: v0.0.12

jobs:
  use-rds-test:
    runs-on: ubuntu-latest
    services:
      db:
        image: mysql:8.0.40
        ports:
          - 3306:3306
        env:
          MYSQL_ROOT_PASSWORD: test
        options: >-
          --health-cmd "mysqladmin ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: setup go
        uses: actions/setup-go@v5
        id: setup-go-db
        with:
          go-version-file: "go.mod"
      - name: download go modules (if cache miss)
        shell: bash
        if: ${{ steps.setup-go-db.outputs.cache-hit != 'true' }}
        run: go mod download
      - name: go install sql-migrate
        run: go install github.com/rubenv/sql-migrate/...@${{ env.SQL_MIGRATE_VERSION }}
      # 以降マイグレーションとテスト実行&カバレッジレポート生成
  not-use-rds-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: setup go
        uses: actions/setup-go@v5
        id: setup-go-no-db
        with:
          go-version-file: "go.mod"
      - name: download go modules (if cache miss)
        shell: bash
        if: ${{ steps.setup-go-no-db.outputs.cache-hit != 'true' }}
        run: go mod download
      # 以降テスト実行&カバレッジレポート生成
  upload-goveralls:
    runs-on: ubuntu-latest
    needs: [use-rds-test, not-use-rds-test]
    steps:
      # 分離したカバレッジレポート(rdsありとrdsなし)の結合
      - name: setup go
        uses: actions/setup-go@v5
        id: setup-go-cv
        with:
          go-version-file: "go.mod"
      - name: Install goveralls
        run: go install github.com/mattn/goveralls@${{ env.GOVERALLS_VERSION }}
      - name: upload coverage
        env:
          COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }}
        run: goveralls -coverprofile=./coverage.out -service=github
  • 変更後
# 変更ない部分なので割愛

jobs:
  use-rds-test:
    # 変更ない部分なので割愛
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: setup go
        uses: actions/setup-go@v5
        id: setup-go-db
        with:
          go-version-file: "go.mod"
      - name: download go modules (if cache miss)
        shell: bash
        if: ${{ steps.setup-go-db.outputs.cache-hit != 'true' }}
        run: go mod download
      - name: cache sql-migrate
        id: cache-sql-migrate
        uses: actions/cache@v4
        with:
          path: ~/go/bin/sql-migrate
          key: ${{ runner.os }}-sql-migrate-${{ env.SQL_MIGRATE_VERSION }}
          restore-keys: |
            ${{ runner.os }}-sql-migrate-
      - name: go install sql-migrate (if cache miss)
        if: steps.cache-sql-migrate.outputs.cache-hit != 'true'
        run: go install github.com/rubenv/sql-migrate/...@${{ env.SQL_MIGRATE_VERSION }}
      # 以降マイグレーションとテスト実行&カバレッジレポート生成
  not-use-rds-test:
    runs-on: ubuntu-latest
    steps:
      # 変更ない部分なので割愛
  upload-goveralls:
    runs-on: ubuntu-latest
    needs: [use-rds-test, not-use-rds-test]
    steps:
      # 分離したカバレッジレポート(rdsありとrdsなし)の結合
      - name: setup go
        uses: actions/setup-go@v5
        id: setup-go-cv
        with:
          go-version-file: "go.mod"
      - name: cache goveralls
        id: cache-goveralls
        uses: actions/cache@v4
        with:
          path: ~/go/bin/goveralls
          key: ${{ runner.os }}-goveralls-${{ env.GOVERALLS_VERSION }}
          restore-keys: |
            ${{ runner.os }}-goveralls-
      - name: Install goveralls (if cache miss)
        if: steps.cache-goveralls.outputs.cache-hit != 'true'
        run: go install github.com/mattn/goveralls@${{ env.GOVERALLS_VERSION }}
      - name: upload coverage
        env:
          COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }}
        run: goveralls -coverprofile=./coverage.out -service=github

今回マイグレーションツールとして利用している sql-migrate とテストのカバレッジを送っている goveralls のバイナリをキャッシュしています。基本的にツールのバージョン変わらない限りはキャッシュを用いたままでいいのでキーは ${{ runner.os }}-name-${{ env.TOOL_VERSION }} としています。

これにより基本ツールはキャッシュされる状態となり 1 分程度のテスト実行時間短縮をすることができました。

まとめ

当初 10 分超えをしていた CI/CD の時間が短縮されました。

サービス 改善前 デッドコード削除後 キャッシュ改善後(最終形)
AWS CodeBuild(デプロイ) 15 分 8 分 8 分 (変更ない場合は最速 3 分)
GitHub Actions(テスト) 10 分 8 分 7 分

ビルドやテストの時間を短縮するために、デッドコードの削除から始まり Docker 自体のレイヤーキャッシュの見直し、ツールのインストールを都度ムダにしないようにキャッシュを利用するようにしました。

正直デッドコードの削除が一番効力を発揮していたので、これをやるだけでもかなり効果出ると思います。 また自分の勉強不足でまだ改善できるところはきっとあるはずなので更に突き詰めて、開発サイクルを早くしていくことに努めていきたいと思います。

最後まで読んでいただきありがとうございました、皆様の CI/CD 高速化の参考になれば幸いです。