Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Cloud Buildによる内部向けGoバイナリのリリース自動化

インフラ・ストリーミングチームの近藤 (id:udzura) です。

ミラティブのインフラ運用では、監視・自動化などさまざまなツールにGo言語を利用しています。ツールはコマンドラインツールとして提供して、バージョンごとにリリースを作成して各環境にデプロイしています。リリースの作成にはGitHubのRelease機能を利用しています。

今回は、GitHub Releaseの作成をGoogle Cloud Build(以下、単にCloud Build)で自動化したことについて、実装内容と効果を書いていきます。

なぜ Cloud Build を採用したか

ミラティブの開発はGitHub Enterprise Cloudを利用しています。対応するCI/CDのサービスとしてはGitHub ActionsやCircleCIなど*1数多くありますが、ミラティブにおいてはGCPの利用箇所が多く、既存GCPサービスとの相性を鑑みてCloud Buildでビルドなどを実施しています。

構成要素の全体像

全体的な構成は以下の通りです。

ビルドパイプラインの全体

続いて、個別の要素の中でも特徴的な点について深掘りしていきたいと思います。

GitHub App の利用

ミラティブでは、Goのアプリと関連するライブラリは、プライベートリポジトリにホストしています。そうすると、ビルドの際に認証が必要ということになります。

GitHubプライベートリポジトリにあるGoのアプリをチェックアウトする際に、まず考えられるのはGitHub deploy keyの利用です。それぞれのリポジトリでdeploy keyを発行し、sshで利用する鍵として設定した上で以下のようなgit configを設定すればチェックアウトができるようになります*2

git config --global url."ssh://git@github.com/mirrativ-example-private-org/".insteadOf "https://github.com/mirrativ-example-private-org/"

ここで問題になるのは、複数のプライベートリポジトリに依存している場合です。というのも、deploy keyはリポジトリごとにしか発行することができないので、Goのビルド時に複数のリポジトリをチェックアウトしたい場合、リポジトリごとに利用するキーを変えたいため以下のような複雑なssh configとgit configを組み合わせることになります。この場合、管理やリポジトリの追加の手間が大きいことが考えられます。

# .ssh/config
Host gh-mirrativ-example-private-foo
  HostName github.com
  IdentityFile ~/.ssh/id_rsa.gh-mirrativ-example-private-foo

Host gh-mirrativ-example-private-bar
  HostName github.com
  IdentityFile ~/.ssh/id_rsa.gh-mirrativ-example-private-bar

....

# setting gitconfig
git config --global url."ssh://git@gh-mirrativ-example-private-foo/mirrativ-example-private/infra-foo/".insteadOf "https://github.com/mirrativ-example-private/infra-foo/"
git config --global url."ssh://git@gh-mirrativ-example-private-bar/mirrativ-example-private/library-bar/".insteadOf "https://github.com/mirrativ-example-private/library-bar/"
...

また、個人アカウントに紐づく公開鍵ペアやアクセストークンの発行をすれば、認証情報が分散することはなくなりますが、今度は「個人に紐づいてしまう」ことが問題になります。

そこで今回はGitHub Appを利用しました。GitHub Appであれば、複数のリポジトリに対し適切な権限を持ったアクセストークンを発行できます。またGitHub Appの管理者には複数人をアサインできるので、属人化の問題も解決します。

GitHub Appの具体的な作成方法は公式のドキュメントに譲ります。GitHub Appでそれぞれのリポジトリの権限を設定する際には、今回は後述するようにGitHub Releaseの作成も自動で行いたいため、Permissionsの設定の際に以下のように Contents に対してRead/Writeの権限を付与するのを忘れないようにしてください。

Cloud Function によるトークン発行

GitHub Appに紐づいたアクセストークンをCloud Buildから発行するにあたり、なるべく安全にトークンを発行すべく、次に述べるような仕組みを用意しました。

まず、GitHub APIを利用してトークンを出力するCloud Functionを用意しました。APIを発行する処理周辺を抜粋するとこのようなコードです。公式にはRubyとcurlによるコード例が載っていますが、今回はGoでの実装を示します。エラーハンドリングなど一部省略しています。

import (
    "bytes"
    "crypto/rsa"
    "crypto/x509"
    "encoding/json"
    "encoding/pem"
    "fmt"
    "io/ioutil"
    "net/http"
    "strconv"
    "time"

    "github.com/lestrrat-go/jwx/v2/jwa"
    "github.com/lestrrat-go/jwx/v2/jwt"
)

func parse(key []byte) (*rsa.PrivateKey, error) {
    block, _ := pem.Decode(key)
    if block == nil || block.Type == "PUBLIC KEY" {
        return nil, fmt.Errorf("invalid block type")
    }

    pk, err := x509.ParsePKCS8PrivateKey(block.Bytes)
    if err != nil {
        pk, err = x509.ParsePKCS1PrivateKey(block.Bytes)
        if err != nil {
            return nil, err
        }
    }
    return pk.(*rsa.PrivateKey), nil
}

type GithubTokenAccessMessage struct {
    IssuerId       int      `json:"issuer_id"`
    InstallationId int      `json:"installation_id"`
    Repositories   []string `json:"repositories"`
}

func Issue(w http.ResponseWriter, r *http.Request) {
    m := GithubTokenAccessMessage{}
    _ := json.NewDecoder(r.Body).Decode(&m)

    privateKey, _ := parse([]byte(os.Getenv("GITHUB_PRIVATE_KEY")))

    token, _ := jwt.NewBuilder().
        Issuer(strconv.Itoa(m.IssuerId)).
        IssuedAt(time.Now().Add(-1 * (60 * time.Second))).
        Expiration(time.Now().Add(time.Duration(10) * time.Minute)).
        Build()

    signed,_ := jwt.Sign(token, jwt.WithKey(jwa.RS256, privateKey))

    type tokenRequest struct {
        Repositories []string                `json:"repositories"`
        Permissions  tokenRequestPermittions `json:"permissions"`
    }
    type tokenRequestPermittions struct {
        Contents string `json:"contents"`
        // add parameters if necessary for future
    }
    param := tokenRequest{
        Repositories: m.Repositories,
        Permissions: tokenRequestPermittions{
            Contents: "write",
        },
    }
    marshaled, _ := json.Marshal(&param)
    jsonbody := bytes.NewBuffer(marshaled)

    requestPath := fmt.Sprintf(`https://api.github.com/app/installations/%d/access_tokens`, m.InstallationId)

    req, _ := http.NewRequest(http.MethodPost, requestPath, jsonbody)
    req.Header.Set(`Authorization`, fmt.Sprintf(`Bearer %s`, signed))
    req.Header.Set(`Accept`, `application/vnd.github.v3+json`)

    client := &http.Client{Timeout: 10 * time.Second}
    res, _ := client.Do(req)
    body, err := ioutil.ReadAll(res.Body)
    // ... あとはbodyをデコード
}

なお、このAPIではGitHub Appで発行する秘密鍵が必要となるため、それだけをSecret Managerに登録しCloud Functionから利用($GITHUB_PRIVATE_KEY 経由で)しています。

また、発行したトークンは1時間で自動的に失効します。この時間の長短は現状では指定できないようです。

このFunctionは以下のように実行、結果を取得することができます。トークンがアクセスできるリポジトリの一覧をデータとして与えられるようにしています。

# ID、関数名はダミーです
cat <<JSON > /tmp/params.json
{
  "issuer_id":99999, "installation_id":99999,
  "repositories":["infra-foo","library-bar"]
} 
JSON
gcloud functions call example-token-function --data "$(cat /tmp/params.json)" --format=json | \
  jq -r .result > /config/GITHUB_TOKEN
# /config 配下はCloud Configの設定でvolumeとしてマウントし、次以降のstepで使えるようにする

GitHub Actionではそのジョブのみ有効なGitHubアクセストークンが自動的に発行され、簡単に利用できます。この仕組みを用いると、Cloud Buildでも同じような感覚でアクセストークンが利用できるようになります。

Go でのビルド時設定

前の節で発行したトークンを用いて($GITHUB_TOKEN という環境変数に格納する前提で)以下のようにgit configを設定します。なお、Cloud Buildの設定でvalidな書き方にしているので、環境変数は $${} で展開されます。

export GITHUB_TOKEN="$(cat /config/GITHUB_TOKEN)"
git config --global url."https://x-access-token:$${GITHUB_TOKEN}@github.com/mirrativ-example-private-org/".insteadOf "https://github.com/mirrativ-example-private-org/"

これで go mod download && go build がジョブの中でも成功するようになります。

リリースの作成

上記手順を踏まえてGoのリリース用バイナリを作成した前提で、GitHub Releaseを作成しこのバイナリをアップロードする手順を説明します。

ここでひとつ書き忘れた前提をお話しすると、今回の構成ではリポジトリの .git 配下はCloud Buildに送っていない(転送量と時間の節約のため)ので、ビルド対象のプロジェクト自体はgitのプロジェクトとは認識されません。

一方で、Cloud Buildではマージされた際のコミットハッシュを $COMMIT_SHA というsubstitution経由で利用できます。これを用いればgitのプロジェクトの中でなくとも、ghコマンド経由でリリースを作成することができます。

おおむね以下のようなコマンドになります。

export GITHUB_TOKEN="$(cat /config/GITHUB_TOKEN)"
# Tag pushをトリガーにしていないので $TAG_NAME が使えない
VERSION=$(make -s echo-version) # バージョン番号を検知する

gh release create \
  --repo mirrativ-example-private-org/infra-foo \
  --title "Release v$${VERSION}" \
  --target ${COMMIT_SHA} \
  v$${VERSION}
gh release upload \
  --repo mirrativ-example-private-org/infra-foo \
  v$${VERSION} /pkg/*.zip

ちなみに一緒にバイナリを含むイメージをビルドしてGCRにもプッシュしています。この部分は素直にビルド→プッシュをしているだけなので、今回は深掘りはしません。

トリガーの作成

ビルドとリリースのタイミングについて説明をします。今回のリポジトリの場合、以下のようなブランチ運用をしています。

  • X.Y.Z というバージョン名のブランチ(e.g. 1.10.12 )を作成し、開発時はそこに対しPull Requestを作成する
  • リリースのタイミングで、X.Y.Z ブランチからmasterへPull Requestを作成する。マージされたらタグを打ちリリース
  • リリースをしたら X.Y.Z ブランチは削除し、インクリメントして新しくmasterから X.Y.Z’ ブランチを作成する

なので「X.Y.Z ブランチからmasterへのマージ」のタイミングでトリガーされてほしいということになります。現状他のブランチはmasterにマージしておらず、直接のpushも制限しているので「^master$」という名前のブランチへのpushをトリガーにすれば大丈夫です。

以下は設定例です。

createTime: '2022-05-17T05:58:18.718844351Z'
description: ...
filename: cloudbuild/release.yaml
github:
  name: infra-foo
  owner: mirrativ-example-private-org
  push:
    branch: ^master$
id: 9adb75c1-xxxxxx...
...

ここまで設定すれば、masterマージのたびにリリースが自動化され、必要なバイナリも自動でアップロードされるようになります。

自動化の効果検証

ここまで、インフラ用ツールのソフトウェアデリバリの(一部)自動化について述べてきました。

ソフトウェアデリバリの自動化に関しては、以下の3点がメリットではないかと考えられます。

  • 1) デリバリのための手間や失敗が減ることにつながる
  • 2) リードタイムの短縮につながる
  • 3) 作業がコード化されるので、高速化のための工夫ができるようになる

実際に今の段階でどの程度効果が出ているかを最後に紹介します。

まず、手動で実施していた頃の作業内容です。それぞれのタスク単位では自動化されていましたが、実行のトリガーは手動です。

  • マージした結果を手元にcheckoutし、docker buildする
  • ビルドしたイメージをpushする
  • ビルドしたイメージからバイナリを取得しzipにする
  • リリースを作成し、zipファイルをアップロードする

実際に筆者が手動でやっていた時代の作業時間を確認すると、例えばあるバージョンブランチのマージをした時刻が 12:26 で、それに対応するリリースの作成が完了したのは 12:35 でした。9分間の作業ではあります。とはいえ、その間は他の作業はできません。

これに対し、リリース作成まで自動化が完了した直近3回分のタイムスタンプをお見せします。

マージ完了 リリース作成完了 所要時間
15:54:07 15:58:40 4:33
19:00:08 19:04:40 4:32
19:25:09 19:29:47 4:38

単純にリリース作成までの時間が半分程度になっていました。

もう少し深掘りすると、過去20回分のマージ推定時間(GitHubのIssue APIから、masterへのPull Requestを抽出しclosed_atの時刻を取得)と、対応するリリースの作成時間(同じくRelease APIからpublished_atの時刻を取得)を計測し、所要時間をグラフにしたのが以下です。直近3回(緑)は自動化されています。

マージ推定時刻からリリース作成までの所要時間

作業の状況によりバラつきがあり、また多くの場合8 ~ 9分必要になっていたところ、安定して4分半でリリースを作成できるようになっています。

他にも、Cloud Buildのトリガで全て行われるので作業による時間拘束がなくなったこと、人の手を介在しなくなったのでヒューマンエラーがなくなったことも効果として挙げられると思います。

これは筆者の意見ではありますが、手間や失敗が減りリードタイムが短くなることは試行錯誤の回数を増やすことに繋がり、ひいては運用やサービス全体の改善に影響すると考えています。

今後のアクション

最後に今後のアクションについてお話しします。

実際にはリリースの作成だけがリリース作業ではありませんので、バイナリを各種サーバへデプロイする作業の方も自動化を進める予定です。また、同時にビルドの高速化のための工夫(goビルド結果のキャッシュなど)や、必要に応じ全体のリードタイムの計測や可視化などを考えています。

デプロイやリリースの高速化に関して、本記事が何かの参考になれば幸いです。

We are hiring!

ミラティブでは自動化や計測が好きなエンジニアを全方面で募集しています!少しでも興味を持っていただいた方はお話を聞いていただくだけでも結構ですので、気軽にご連絡ください。

www.mirrativ.co.jp

*1:一部はCircleCIも使っています

*2:mirrativ-example-private-org というオーガニゼーション名、リポジトリ名は仮名です