はじめに
GitHub ActionsやCircleCIを使ったことがある人が Google Cloud Build を触って思うこと・・・。
ステータスのバッジが付けられない!!
調べてみると似たようなこと思っている方はいるようで「ビルドの結果からバッジを自動で作成する」的なものはOSSで何個か見つけたのですが、どうにもかゆいところに手が届かない感じで使いにくい・・・。
ということで、自分で作ってみました。
こんな感じにREADME上にバッジを表示できます( [build|success]
の部分)。
以下一連のシステムを構築するまでのまとめです。
結構長めなので話とかどうでもいいからさっさと試してみたい!という方は こちら へどうぞ。
Google Cloud Buildとは
CI/CDを実現するためのマネージドサービスです(CI/CDってなんぞや?という方はググってください・・・)。
GitHub Actions 、 AWS CodeBuild 、 AWS CodePipeline 、 CircleCI なんかが親戚にあたります。
マネージドでないサービスも挙げると Jenkins や GitLab CI/CD が有名どころかと。
・・・ここら辺の名前を知っている方は、それらのGoogle版だと思ってもらえれば間違いありません。
で、例えばGitHub Actionsの場合 こんな感じ にビルドステータスのバッジを生成してくれるのですが、どうもGoogle Cloud Buildにはこの機能がないっぽい(ドキュメントあさったけど見つけられなかったです・・・)。
CI/CDでビルドやテスト、デプロイが自動化できるのはいいことですが、最終的に開発者にフィードバックが来ないというのは困りものですよね。
もちろんコンソールから結果は確認できますが、いちいちコンソール開くのもダルいですし、作業していてよく目につくリポジトリのREADMEに、バッジとして貼っておきたいと思うのは自然な感覚だと思います。
バッジがないなら作ればいいじゃない!
その前に一応探してみた
プログラマなら自作すればいいじゃん!って思考に至りますよね。
でもまぁその前に、一応似たようなものが無いか調べてみたところそれっぽいものが見つかりました。
なんとなくやりたいことは実現できてそうな感じですが、いくつか問題点がありました。
問題1:固定のバッジしか生成できない
バッジのメッセージ部分がビルドのステータスによって固定なのは分かりますが、ラベルの部分は動的に変更したくないですか?
例えば [test|success]
や [deploy|success]
みたいな感じです。
既存のものだとバッジのラベル部分が固定になるので、実際には使いづらいような気がします。
問題2:ブランチに対して複数のバッジが発行できない
上の問題とからみますが、既存のものだとリポジトリのブランチの単位でバッジが保存されるため、例えば開発環境用のブランチで、テストとデプロイ用のビルドを回している場合、それら2つのバッジは同時に保存できないことになります(既存のものはブランチに対して1つしかバッジが発行できないので、ラベル部分が固定だったと考えられます)。
その他にも、静的解析や脆弱性試験などブランチに対して様々なビルドを設定していることは十分ありえますし、実際Cloud Buildではそのような設定ができるので、この制限はかなり痛いです・・・。
問題3:テンプレートとなるバッジの用意および配置の作業が面倒
既存のものはテンプレートのバッジを予め用意しておき、ビルドの結果に応じてそのテンプレートをコピーするという動きになっていました。
諸々の必要な作業を実施するためのスクリプトを用意しておけなくもないですが、導入のための作業はなるだけ単純にしておきたいところです。
要件定義
既存のものを使うのは難しいと判断したので、以上のことも踏まえてこんな感じのものを目指しました(太字が改善部分です)。
- バッジの ラベル部分は設定によって動的に変えられる ようにする。
- バッジのメッセージ部分はビルドのステータスによって切り替わるようにする。
- リポジトリ/ブランチに対して 複数のバッジを生成・保存できる ようにする。
- バッジは完全に動的に生成 し、テンプレート等の事前準備は不要にする。
- システム構築にはマネージドなサービスを使い、極力メンテナンスが不要になるようにする。
- なるべくお金がかからない構成にする(←大事)。
システム構成
全体の流れは既存のプロダクトを参考にさせてもらいました。
GCP上では、Cloud Source Repository、Cloud Build、Cloud Pub/Sub、Cloud Functions、Cloud Storageを連携させます(Cloud Source Repositoryの部分は代わりにGitHubでも大丈夫です)。
リポジトリにPushすると、それをトリガにCloud Buildが実行されます。
Cloud Buildの実行に合わせて、Cloud Pub/Subへメッセージが送信されます。
Cloud Pub/Subのメッセージをトリガに、Cloud Functionsが発火し、Cloud Functions内でバッジの画像を生成し、それをCloud Storageへ保存するという流れです。
マネージドのサービスを利用しているのでメンテナンスもほとんど不要ですし、各サービスとも無料枠があったり課金も実際に使った分だけなのでお財布にも優しいです。
実際に作っていきます
Cloud Functionsによるバッジ(SVG画像)の生成
Cloud Functionsですが、自分がPythonistaなのでPythonで作るのは確定事項・・・。
で、Cloud Functions内でバッジを動的に生成したいので、SVG形式の画像を作成できるライブラリとかいろいろ探してみましたが、最終的にもっとヤバい pybadges というものを見つけました。
こちら名前の通りバッジを生成できるライブラリで、正にやりたかったことそのもの!
ありがたく使わせてもらいます。
使い方はいたってシンプル。
さすがPython、簡単すぎてバカになりそう・・・。
$ pip install pybadges==2.2.1
from pybadges import badge
# バッジを生成する、これだけ・・・。
svg_image = badge(left_text="build", right_text="success", right_color="green")
print(svg_image[:64]) # '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.'
バッジの動的生成の部分についてはこれにて解決ですね!
Cloud Functionsからのバッジの保存
バッジを保存するためにCloud FunctionsからCloud Storageを操作する必要があります。
公式のクライアントライブラリ があるので、それを使いましょう。
$ pip install google-cloud-storage==1.29.0
from google.cloud import storage
# Cloud Functions上で実行する分には、認証情報などの指定は不要です。
gcs_client = storage.Client()
bucket = gcs_client.bucket("your-bucket-name")
blob = bucket.blob("any/path/badge.svg")
# キャッシュの設定を明示的に行わないと、公開コンテンツの場合1時間キャッシュされます・・・。
blob.cache_control = "max-age=60, s-maxage=60"
# ファイルでなくても文字列をそのまま保存できるらしいです。
# コンテンツタイプを指定しないと、外からアクセスした際に画像ではなくただのテキストファイルになるため注意!
blob.upload_from_string(svg_image, content_type="image/svg+xml")
バッジの保存についてもこれで問題なさそうです。
リポジトリの接続とCloud Buildの設定
メインの処理部分の目処がついたので、システムの設定周りを準備していきたいと思います。
とりあえず、GitHubなどの外部サービスを使っている場合はリポジトリを接続したり、Cloud Buildの設定をする必要がありますが、ここら辺については割愛します。
冒頭にCloud Buildに関する説明は入れましたが、一応Cloud Buildを使ったことがある方、もしくは触ったことがなくても ドキュメント を読めば使えるレベルのGCPユーザを対象としているので、既にリポジトリの接続やビルドの設定はしてある前提で先に進みます。
システムを構築するにあたり、ここで確認しておく必要があるのはCloud Buildのトリガについてです。
Cloud Buildのトリガ
Cloud Buildではトリガというものを設定してビルドを自動化します。
こちらはトリガの設定画面の抜粋です。
トリガにはビルドの処理を定義した構成ファイル(俗に言う cloudbuild.yaml
)を1つ設定できます。
このビルドの処理の結果を1つのバッジとして生成します。
トリガはリポジトリに対して設定しますが、リポジトリに対してトリガは複数設定可能です(1対多の関係)。
つまり、テストやデプロイなど、複数のビルドが設定できるということです。
リポジトリ
|
+-- テストのトリガ
|
+-- 負荷試験のトリガ
|
+-- デプロイのトリガ
そして、トリガが実行された際の対象のブランチも同時に設定しますが、正規表現になっていることからも分かるようにトリガに対してブランチは複数設定可能です(1対多の関係)。
つまり、一つのトリガで開発環境や本番環境など異なる環境に対するビルドが設定できるということです。
リポジトリ
|
+-- テストのトリガ
| |
| +-- masterブランチ
| |
| +-- developブランチ
|
+-- 負荷試験のトリガ
| |
| +-- developブランチ
|
+-- デプロイのトリガ
|
+-- masterブランチ
|
+-- developブランチ
しかし実際には、下記のように環境ごとにトリガを作成し、環境ごとに異なる値はトリガの代入変数(Cloud Buildのトリガに設定できる環境変数のようなもので、ビルドの処理を定義した構成ファイル内で参照できます)に設定するという構成が一般的かと思われます。
リポジトリ
|
+-- テストのトリガ(本番環境用)
| |
| +-- masterブランチ
|
+-- テストのトリガ(開発環境用)
| |
| +-- developブランチ
|
+-- 負荷試験のトリガ(開発環境用)
| |
| +-- developブランチ
|
+-- デプロイのトリガ(本番環境用)
| |
| +-- masterブランチ
|
+-- デプロイのトリガ(開発環境用)
|
+-- developブランチ
このリポジトリ、トリガ、ブランチの関係を把握すれば、自ずとビルド結果のバッジをどのような階層で保存すべきかが見えてきますね。
Cloud Pub/Subのトピック
Cloud Buildの実行をトリガにCloud Functionsが実行できれば良いのですが、それはできないのでCloud Pub/Subを経由させます(GCPのサービスは大体そんな感じです)。
というわけで、Cloud Buildの実行をトリガにCloud Pub/Subへメッセージを送信する必要がありますが、これは特に作業いりません!
ドキュメント にある通り、実はCloud Buildからの通知はCloud Pub/Subを有効にした段階でGCP側で勝手に設定されています。
Cloud Build がビルド更新メッセージを公開する Pub/Sub トピックは
cloud-builds
です。Pub/Sub API を有効にすると、cloud-builds
トピックが自動的に作成されます。
ということで cloud-builds
というトピックが利用できるので、これをCloud Functions発火のトリガとして設定しましょう。
Cloud Storageのバケット作成
Cloud Functionsの作業に入る前に、先にバッジの保存先であるバケットを用意しておきます。
GUIでポチポチしても良いですが、コマンドでサクッと作れます。
$ BUCKET_NAME='your-bucket-name'
$ gsutil mb -c standard -l us-central1 gs://${BUCKET_NAME}
$ gsutil iam ch allUsers:objectViewer gs://${BUCKET_NAME}
リージョンは asia-northeast1
とかでも大丈夫ですが us-central1
にすると 無料枠 が適用されます!
少しでも安く済ませたい方にはオススメです。
3つ目のコマンドは全ユーザに対してバケットの読み取り権限を付与しています。
これをしないとREADME上にバッジをペタペタしても外から見れません・・・。
バケットの準備については以上です。
Cloud Functionsの作成
cloud-builds
トピックをトリガに発火するCloud Functionsを作成します。
Cloud Pub/Subから受け取ったメッセージはJSON形式で取り出せるため、Cloud Buildに関する情報はそこから頑張って抽出します。
・・・で、出来上がったものがこちらです。
1/3くらいはコメントやログ出力なのでコードの量ほど複雑ではないはずです。
google-cloud-storage==1.29.0
pybadges==2.2.1
"""Cloud Build Badge
Cloud Buildの情報からバッジを生成し、GCSへ保存する。
GCSのバケットは予め作成し、環境変数 ``_CLOUD_BUILD_BADGE_BUCKET`` にバケット名を設定しておくこと。
"""
import base64
from collections import defaultdict
import dataclasses
import json
import logging
import os
import sys
from typing import List, Optional, overload
from google.cloud import storage
import pybadges
@dataclasses.dataclass(frozen=True)
class Build:
"""ビルドの情報。
Parameters
----------
status : str
ステータス。
trigger : str
トリガID。
repository : str, optional
リポジトリ名。
branch : str, optional
ブランチ名。
"""
status: str
trigger: str
repository: Optional[str] = None
branch: Optional[str] = None
@dataclasses.dataclass(frozen=True)
class Badge:
"""バッジオブジェクト。
Parameters
----------
label : str
ラベル。
message : str
メッセージ。
color : str
16進数カラーコード。
logo : str, optional
Data URI形式のロゴ画像。
"""
label: str
message: str
color: str
logo: Optional[str] = None
def to_svg(self) -> str:
"""SVG形式のバッジを生成する。
Returns
-------
str
SVG形式の画像データ。
"""
return pybadges.badge(
logo=self.logo,
left_text=self.label,
right_text=self.message,
right_color=self.color,
)
def entry_point(event, context):
"""エントリポイント。"""
try:
return run(event, context)
except Exception as e:
logging.error(e)
sys.exit(1)
def run(event, context):
"""メイン処理。"""
# Pub/Subのメッセージを取得する。
pubsub_msg = base64.b64decode(event["data"]).decode("utf-8")
pubsub_msg_dict = json.loads(pubsub_msg)
# 関係ないステータスの場合は抜ける。
build = parse_build_info(pubsub_msg_dict)
if build.status not in {
"WORKING", "SUCCESS", "FAILURE", "CANCELLED", "TIMEOUT", "FAILED"
}:
return
# 設定でバッジの生成が無効化されている場合は抜ける。
badge_generation_setting = get_setting(
"_CLOUD_BUILD_BADGE_GENERATION", pubsub_msg_dict, default="enabled"
)
if badge_generation_setting == "disabled":
logging.info("The badge generation setting is disabled.")
return
# 保存先のGCSバケットが設定されていることを確認する。
bucket_name = get_setting("_CLOUD_BUILD_BADGE_BUCKET", pubsub_msg_dict)
if not bucket_name:
raise RuntimeError(
"Bucket name is not set. "
"Set the value to the environment variable '_CLOUD_BUILD_BADGE_BUCKET'."
)
if not build.repository:
logging.info("Unknown repository.")
if not build.branch:
logging.info("Unknown branch.")
# バッジを生成し、GCSへ保存する。
badge = create_badge(pubsub_msg_dict)
uploaded_badges = upload_badge_to_gcs(badge, bucket_name, build)
for url in uploaded_badges:
logging.info(f"Uploaded the badge to '{url}'.")
def parse_build_info(msg: dict) -> Build:
"""Cloud Buildの情報から必要なデータを取り出す。
Parameters
----------
msg : dict
Pub/Subから受け取ったメッセージ。
Returns
-------
Build
Pub/Subのメッセージをパースしたデータ。
"""
status = msg["status"]
trigger = msg["buildTriggerId"]
# ビルドの定義ファイル自体が壊れていた場合など、情報が取得できないことがある。
repository, branch = None, None
if "substitutions" in msg:
repository = msg["substitutions"].get("REPO_NAME")
branch = msg["substitutions"].get("BRANCH_NAME")
return Build(
status=status, trigger=trigger, repository=repository, branch=branch
)
def create_badge(msg: dict) -> Badge:
"""バッジを生成する。
Parameters
----------
msg : dict
Pub/Subから受け取ったメッセージ。
Returns
-------
Badge
ビルドのステータスを示すバッジ。
"""
status = msg["status"]
label = get_setting("_CLOUD_BUILD_BADGE_LABEL", msg, default="build")
logo = get_setting("_CLOUD_BUILD_BADGE_LOGO", msg)
status_to_color = defaultdict(lambda: "#9f9f9f")
status_to_color["WORKING"] = "#dfb317"
status_to_color["SUCCESS"] = "#44cc11"
status_to_color["FAILURE"] = "#e05d44"
return Badge(
label=label,
message=status.lower(),
color=status_to_color[status],
logo=logo,
)
def upload_badge_to_gcs(badge: Badge, bucket_name: str, build: Build) -> List[str]:
"""バッジをGCSに保存する。
Parameters
----------
badge : Badge
バッジ。
bucket_name : str
バケット名。
build : Build
ビルドの情報。
Returns
-------
list of str
GCSに保存したバッジのURLを格納したリスト。
"""
def upload(path: str) -> None:
bucket = gcs_client.get_bucket(bucket_name)
blob = bucket.blob(path)
blob.cache_control = "max-age=60, s-maxage=60"
blob.upload_from_string(badge.to_svg(), content_type="image/svg+xml")
def to_url(path: str) -> str:
return f"https://storage.googleapis.com/{bucket_name}/{path}"
uploaded = []
gcs_client = storage.Client()
path = f"triggers/{build.trigger}/badge.svg"
upload(path)
uploaded.append(to_url(path))
if not build.repository or not build.branch:
return uploaded
branch = build.branch.replace("/", "_") # スラッシュは使えないので置換する。
path = f"repositories/{build.repository}/triggers/{build.trigger}/branches/{branch}/badge.svg"
upload(path)
uploaded.append(to_url(path))
return uploaded
@overload
def get_setting(key: str, msg: dict) -> Optional[str]: ...
@overload
def get_setting(key: str, msg: dict, default: None) -> Optional[str]: ...
@overload
def get_setting(key: str, msg: dict, default: str) -> str: ...
def get_setting(key, msg, default=None):
"""設定値を取得する。
Parameters
----------
key : str
設定値取得のためのキー。
msg : dict
Pub/Subから受け取ったメッセージ。
default : str, optional
設定値が存在しなかった際のデフォルト値。
Returns
-------
str or None
設定値(存在しない場合は `None` もしくは `default` に指定した値)。
"""
value = None
if "substitutions" in msg:
value = msg["substitutions"].get(key)
if not value:
value = os.getenv(key)
if not value:
value = default
return None if value is None else str(value)
デプロイはこちらもコマンドで行なえます。
なお、リージョンが us-central1
なのは保存先のバケットと近い方が効率いいかなぁ・・・というちょっとした配慮です。
$ FUNCTION_NAME='any-function-name'
$ BUCKET_NAME='your-bucket-name'
$ gcloud functions deploy ${FUNCTION_NAME} \
--runtime python38 \
--entry-point entry_point \
--trigger-topic cloud-builds \
--region us-central1 \
--set-env-vars _CLOUD_BUILD_BADGE_BUCKET=${BUCKET_NAME}
以上で準備は完了です。
あとはCloud Buildが実行されればバッジが自動で生成されます!
使い方について
対象となるビルドの指定
不要です!
プロジェクト内で走る全てのビルドに対して自動でバッジが作成されます。
どんどんビルドを回してガンガンバッジを作りましょう!
もし課金周りのことが心配で不要なバッジの生成は極力抑えたいということがあれば、設定でバッジの生成およびCloud Storageのバケットへの書き込みを抑制できます。
Cloud Buildのトリガの代入変数(もしくは cloudbuild.yaml
内の substitutions
句)の _CLOUD_BUILD_BADGE_GENERATION
に disabled
を設定してください。
なお、抑制できるのはバケットへの書き込みだけでCloud Functions自体はビルドの度に実行されてしまうため注意してください。
バッジの種類
ビルドのステータス に合わせてこんなバッジがほぼリアルタイムに生成されます。
種類はステータスごとに全部で6つです。
- ビルド中:working
- ビルドの成功:success
- ビルドの失敗:failure
- ビルドのキャンセル:cancelled
- ビルドのタイムアウト:timeout
- ステップのタイムアウト:failed
バッジの参照
Cloud Buildのトリガの起動イベントが「ブランチへのpush」で対象のブランチが1つしか設定されていない場合、もしくはトリガの起動イベントが「タグのpush」の場合はこちらを参照ください。
https://storage.googleapis.com/<BUCKET>/triggers/<TRIGGER>/badge.svg
Cloud Buildのトリガの起動イベントが「ブランチへのpush」で対象のブランチが複数設定されている場合はこちらを参照ください(ビルドの転け方によっては更新されない場合があります・・・)。
https://storage.googleapis.com/<BUCKET>/repositories/<REPOSITORY>/triggers/<TRIGGER>/branches/<BRANCH>/badge.svg
-
BUCKET
:Cloud Storageのバケット名。 -
TRIGGER
:Cloud BuildのトリガID。
Cloud Buildのトリガの編集画面を開いた際のURLに含まれるUUIDっぽい値がそれに当たります。
例えばhttps://console.cloud.google.com/cloud-build/triggers/edit/ca8fc3a9-f8cd-4b0e-ab1a-267e27eba1d3?project=your-gcp-project
のca8fc3a9-f8cd-4b0e-ab1a-267e27eba1d3
の部分です。 -
REPOSITORY
:ビルド対象のリポジトリ名。 -
BRANCH
:ビルド対象のブランチ名。
最初は下の長い方の保存先だけで十分かなと考えていたのですが、ビルドの失敗具合によってはCloud Functions内でリポジトリ名やブランチ名が取得できない場合がありました(ビルドの構成ファイル自体のシンタックスエラーとか)。
加えてビルドのトリガが「ブランチへのpush」ではなく「タグのpush」の場合、そもそもブランチ名の情報は存在しませんでした。
そこでどんな場合でも最低限情報が取れて、かつ一意のビルドを示す場所ということで上のパターンと、「リポジトリ/ブランチに対して複数のバッジを生成・保存できるようにする」という要件を満たすため、下のパターンの2個所で保存するようにしました。
ラベルのカスタマイズ
自分で言うのもあれですが、個人的にはかなり便利だなぁと思う機能です。
バッジのラベル部分を自由かつ簡単にカスタマイズできます。
例えば「build」でなく「cloud build」をデフォルトにしたいという場合は、Cloud Functionsの環境変数に _CLOUD_BUILD_BADGE_LABEL
を設定してください。
さらに、ビルドがテストやデプロイなど明確なタスクを持っていて「test」や「deploy」というラベルにしたいという場合は、Cloud Buildのトリガの代入変数(もしくは cloudbuild.yaml
内の substitutions
句)に _CLOUD_BUILD_BADGE_LABEL
を設定してください。
substitutions:
_CLOUD_BUILD_BADGE_LABEL: deploy
なお、設定は重複しても構いません。
Cloud Functions側とCloud Build側で同じ設定がある場合、Cloud Build側の設定が優先されます。
なので、Cloud Functions側でデフォルトのラベルを「cloud build」と設定し、ビルドにデプロイという明確なタスクがある場合、Cloud Build側の設定でラベルを「deploy」に変更するということが自然に行なえます。
・・・さらにさらに、ロゴを入れたいなぁという場合は _CLOUD_BUILD_BADGE_LOGO
にData URI形式の画像を設定できます!
本当はデフォルトでCloud Buildのロゴ付けたいなと思ったんですが、なんか権利関係の問題とかいろいろありそうなので、ロゴを出したい方は自分でカスタマイズしてください・・・。
ちなみに、アーキテクチャ図用ですが公式が アイコンセット 出してくれています。
で、カスタマイズを施すとこんな感じになります。
これはもはや公式のバッジですね。
まとめ
GCPのサービスを使ってCloud Buildのステータスバッジを自動生成するシステムを構築できました。
GitHub に置いておくので、同じようにCloud Buildのバッジをペタペタ貼りたい方がいたらぜひ使ってみてください。
それでは良きCI/CDライフを!