GitHub と AWS CodeBuild を連携させるサーバーレスなツールを作りました [中身編]

紹介編の続きです. 本記事では github-codebuild-integration の構成や実装などについて掘り下げてみます.

このあと何回も github-codebuild-integration と書くと疲れそうなので、以下 gci と書きます. また、本記事中でコードやファイルに対して張られているリンクはすべて記事公開時点で最新の v0.1.1 のものです.

目次

gci リポジトリの中身の話

一部省略してますが、リポジトリの中身を順に見ていきます.

.sam ディレクトリ

gci/.sam

├── .sam
│   └── .gitkeep

デプロイコマンド(後述)の処理中に AWS SAM 形式で書かれたテンプレートを普通の AWS CloudFormation の形式に変換しますが、その変換済みテンプレートを保存するためのディレクトリです. 保存の際にディレクトリがないと AWS CLI に怒られちゃうので空ディレクトリだけ作って中身は gitignore してます.

env ディレクトリ

gci/env

├── env
│   └── example.env

設定ファイルを入れる場所です. 一つの GitHub リポジトリに対して一つの設定ファイルを置く感じになります. リポジトリにチェックインされている example.env は設定のサンプルなので、これをコピーして README.md を見ながら自身の設定ファイルを作るのが楽チンです. このディレクトリも example.env 以外は gitignore されます.

ちなみに設定値は v0.1.1 時点では以下の通りです. 詳細は上記リンクから README を参照してください :)

S3_SAM_ARTIFACTS_BUCKET_NAME
aws cloudformation package コマンドで CloudFormation テンプレートをアップロードする S3 バケット名

GITHUB_REPOSITORY_URL
GitHub リポジトリの URL

GITHUB_PERSONAL_ACCESS_TOKEN
GitHub パーソナルアクセストークン

GITHUB_TARGET_RESOURCE
push/pull_request どちらのイベントでビルドを蹴るか

GITHUB_IGNORE_BRANCH_REGEX
push イベントで無視したいブランチ名の正規表現

AWS_DEFAULT_REGION
CloudFormation で AWS リソースを作成するリージョン

CODEBUILD_PROJECT_NAME
AWS CodeBuild のビルドプロジェクト名

CODEBUILD_PROJECT_REGION
AWS CodeBuild のビルドプロジェクトが存在するリージョン

scripts ディレクトリ

gci/scripts

├── scripts
│   ├── clean
│   ├── dependency
│   ├── deploy
│   ├── destroy
│   ├── lint
│   ├── package
│   ├── test
│   └── validate

gci は基本的なコマンドをすべて Makefile (後述) に定義することで提供していますが、make はただのインターフェースです. コマンドの実体は一部を除きこの scripts ディレクトリの中に入っています. 各スクリプトの責務はだいたい名前の通りです.

src ディレクトリ

gci/src

├── src
│   └── functions
│       ├── build-dispatcher
│       │   └── ...
│       ├── build-result-exporter
│       │   └── ...
│       ├── build-result-notifier
│       │   └── ...
│       ├── github-webhook-handler
│       │   └── ...
│       └── github-webhook-resource
│           └── ...

gci のソースコードの類が含まれるディレクトリですが、現在は Lambda Functions とその関連物しか入っておらず、functions 以下のディレクトリがそれぞれ別個の Lambda Function としてデプロイされます.

src ディレクトリの下の Lambda Function のディレクトリ

例として github-webhook-handler ディレクトリを眺めます.

gci/src/functions/github-webhook-handler

├── src
│   └── functions
│       ├── github-webhook-handler
│       │   ├── lib
│       │   │   ├── event-types.js
│       │   │   ├── event-types.test.js
│       │   │   ├── should-ignore.js
│       │   │   ├── should-ignore.test.js
│       │   │   └── snsnizer.js
│       │   ├── index.js
│       │   └── package.json

ディレクトリの下に package.json がありますが、これはファンクション別に依存ライブラリを管理していることを意味します.

lib ディレクトリ以下のコードについては簡単なテストを書いていますが、トップレベルに test ディレクトリがあるにも関わらずこれらのテストコードがここに含まれているのはわたしがテスト実行環境の構成に手を抜いた証拠ですごめんなさい.

test ディレクトリ

gci/test

├── test
│   └── fixtures
│       ├── pr-closed.json
│       ├── pr-created.json
│       ├── pr-merged.json
│       ├── pr-reopened.json
│       ├── pr-synchronized.json
│       └── pushed.json

ツールを書き始めた当初はここにテストコードも入る予定だったんですが、上述した悲しい理由により現状ではただのテスト用データ置き場と化しています. 使ってないものまで入っているのはご愛嬌です.

ディレクトリルート

├── Makefile
├── README.md
├── buildspec.yml
├── package.json
└── sam.yml

Makefile
gci が提供するコマンドが定義されており、GNU Make を前提にしています. 内容については後述します.

README.md
長くね?という酷評を受けた README ファイルですが、自分でも長いと思っているのでそのうち短くしたいです.

buildspec.yml
gci 自体も AWS CodeBuild で CI を回しているので、このファイルはその定義ファイルです1. (CircleCI における circle.yml のようなものです}

package.json
src ディレクトリのところで触れたとおり、gci ソースの依存ライブラリは各ファンクション用ディレクトリで個別に管理していますので、ここにある package.json は Linter やユニットテストなどの開発用ツールをインストールするためのものです.

sam.yml
AWS SAM の仕様に沿って書かれた CloudFormation テンプレートです. gci が必要とする AWS リソースが全て定義されています.

デプロイまわりの話

gci では以下のコマンドによってデプロイを実行します.

$ make deploy ENV_FILE_PATH=env/${YOUR-FILE-NAME}.env

上述した通り Makefile はただのインターフェースで、処理の実態は一部を除いて scripts 以下の Bash スクリプトに実装されています. make deploy の中では

を実行しています.

Amazon API Gateway じゃなくて Amazon SNS を選んだ理由

Webhook といえば Amazon API Gateway になりそうですが、gci では Amazon SNS を選択しました.

理由は紹介編の方でも FAQ で触れましたが、不必要に外部に対して API を公開する意味はまったくないので、gci では Webhook 受け取り用途としての Amazon API Gateway 利用は見送った感じです.

AWS SAM と node_modules の関係がエグい

これは AWS SAM というよりも、Lambda Function を使ってアプリケーションを構築していくときにおおむねみんなが悩むポイントとも言えます.

一つのコンテキストから構成されるアプリケーションにおいて複数の Lambda Function が存在する場合に、Lambda Function ソースコードの配置方法には大きく二つの戦術があると思っています.

一つはすべての Lambda Function コードを同一のディレクトリに格納する方法で、もう一つは Lambda Function ごとにディレクトリを切る方法です. gci は後者ですが、それぞれの良し悪しを見ていきます.

1. すべてのコードを同一のディレクトリに格納する方法

もしこの方法で gci を構成すると、例えば以下のような感じのファイルの置き方になります.

├── src
│   └── functions
|       ├── lib
│       │   └── ...
|       ├── node_modules
│       │   └── ...
│       ├── build-dispatcher.js
│       ├── build-result-exporter.js
│       ├── build-result-notifier.js
│       ├── github-webhook-handler.js
│       └── package.json

この方法を用いると一つの package.json ですべての依存ライブラリを管理できるので、いわゆる Node.js でのアプリケーション開発に近い手触りで開発を進められます. 自前のライブラリソースも lib ディレクトリ以下にまとめて置いてしまえば良く、コードの共通化やモジュール化を容易に進められます.

ただし、aws cloudformation package コマンドで zip ファイルを作成する際には、functions 以下のファイルすべてが一つのアーカイブにバンドルされてしまうので、悪く言えば不要なファイルが大量に含まれたアーカイブになってしまうことは否めません. また、CloudFormation の Labmda デプロイ(更新)は S3 に置かれたアーカイブが更新されているかどうかをもとに再デプロイを実施するか(正確にはファイル名っぽい)を判定していると思われるため、アーカイブが一つになるということはすべての Lambda Function が無意味に再デプロイされることになります.

2. 関数ごとにディレクトリを切る方法

gci では以下のようなファイルの置き方になっています.

├── src
│   └── functions
│       ├── build-dispatcher
│       │   └── ...
│       ├── build-result-exporter
│       │   └── ...
│       ├── build-result-notifier
│       │   └── ...
│       ├── github-webhook-handler
│       │   ├── lib
│       │   │   ├── event-types.js
│       │   │   ├── event-types.test.js
│       │   │   ├── should-ignore.js
│       │   │   ├── should-ignore.test.js
│       │   │   └── snsnizer.js
│       │   ├── node_modules
│       │   │   └── ...
│       │   ├── index.js
│       │   └── package.json

この方法は「すべてのコードを同一のディレクトリに格納する方法」のアーカイブに関するデメリットを解消してくれますが、各ファンクション別のディレクトリすべてに package.json を用意して依存を管理することになるので、開発中のワークフローが煩雑になってしまいます.

また、aws cloudformation package コマンドで作成されるアーカイブには各ファンクション用ディレクトリの外のファイルは含まれないので、例えば src/functions/lib/xxx.js のように親ディレクトリに置いたライブラリコードを利用することが非常に難しくなります.

今回はコードベースが小規模なためアーカイブサイズの最小化と健全なデプロイを目指してこちらの方法にしましたが、なんというかまぁ、んあーーーって感じです. (もしかしたら自分が勘違いしてて、ドキュメントの読み込みが足りないだけかもしれないので誰か教えてください)

AWS Step Functions ええやん

気を取り直して楽しい話です.

AWS Step Functions、名前は知っていたものの今まで触ったことがなかったので、これみよがしに導入しました. 使ってみた結論としてはええやんという感じです.

今回は State MachineAmazon States Language という JSON で sam.yml に定義し、GitHub Webhook で起動する Lambda Functionから StartExecution API をコールして動かしています.

< gci 用の State Machine はこんな感じ. マネコンにビジュアライズ機能があって便利です. >

gci 用の State Machine はこんな感じ. マネコンにビジュアライズ機能があって便利です.

Start から End までの各ステップは State と呼ばれ、単に Lambda Function を呼び出す以外にもいろいろとビルトインな機能があります. どんなものがあるかについては AWS のドキュメントが詳しいですが、例えば Lambda Function を呼び出せる Task2、指定した秒数もしくは日時まで待ってくれる Wait などいろいろあります.

gci では

  • Task
  • Wait
  • Choice (後述)

を利用しています.

各 State (ステップ) の内容はざっくり以下のような感じです.

Dispatch Build (Task - Lambda)
AWS CodeBuild の StartBuild API を蹴ってビルドジョブを開始します. Lambda Function として build-dispatcher/index.js に実装されています.

Wait 10 Seconds (Wait)
10 秒待ちます. (ビルド処理というコンテキストの特性上、頻繁にビルドが終わったかどうかをチェックしても意味がないことが多いのでここに待ち処理を挟んでいます)
この待ち処理は Lambda Function ではなく、前述した Wait という AWS Step Functions の State を利用しています.

Export Build Result (Task - Lambda)
AWS CodeBuild の BatchGetBuilds API を蹴り、ビルドジョブの結果を取得します. この Lambda Function の実装は build-result-exporter/index.js ですが、やっていることはビルドジョブ結果を取得してそのまま次の State に渡すだけです. ビルド結果がどうだったかの判定ロジックをここに含めないことで、AWS Step Functions を利用したループを自然な形で組めるようになります.

Test If Build Finished (Choice)
AWS CodeBuild の BatchGetBuilds API の返り値の中には buildComplete というビルドジョブが終了したかどうかを示す値が入っています. このステップでは Choice というビルトインステップを使って buildComplete キーの値によって分岐を形成しています. 値が true だったら次の Notify Build Result State に、そうでなければ待ち処理に戻ることでループを形成します. Lambda Function を書かなくても以下のような簡単な定義を書くだけで分岐を組めたので楽でした.

...
"Test If Build Finished": {
    "Type": "Choice",
    "Choices": [
        {
            "Variable": "$.buildComplete",
            "BooleanEquals": true,
            "Next": "Notify Build Result"
        }
    ],
    "Default": "Wait 10 Seconds"
},
...

Notify Build Result (Task - Lambda)
ビルドが終了したら結果を GitHub に通知します. シンプルに GitHub API を叩くだけの Lambda Function で、実装は build-result-notifier/index.js です.

ちなみにここまで長々と説明しておいてアレなんですが、このビルド完了を待つ仕組みは次バージョンで AWS Step Functions から Amazon CloudWatch Events に置き換え予定です. ガチムチなビルドジョブだと長い時間ループが発生する可能性があること、AWS Step Functions の課金が States 間の遷移回数に対して発生することを考慮すると CloudWatch Events の方がおそらく安上がりです. そもそもループなんかなくて済むならない方がいいですしね.

CloudFormation Lambda-backed カスタム・リソース便利

なにを今更という感じですがやっぱり便利です. 初見の方のために簡単に説明すると、CloudFormation Lambda-backed カスタム・リソース は CloudFormation がネイティブでサポートしていないリソースを作成するために使える便利機能です3. ど定番な使い方だと

  • CloudFormation だと作れない AWS リソースを Lambda Function の中から AWS SDK 使って作る
  • 最新の AMI を Lambda で見つけてきて CloudFormation で作成する EC2 のベースイメージとして使う

のようなものが挙げられます. この機能が面白いのは、Create/Update/Delete という普段 CloudFormation でリソースを作成する際に発生するライフサイクルイベントに合わせて自分の作成したいリソースのライフサイクルを自前の Lambda Function を使って管理できるというところです.

なんでこんな話をしているかというと、gci の中でこの機能を使っているからです.

過去に GitHub の外部サービスインテグレーションを設定した方であれば、gci のセットアップを行なう際に以下のような疑問を持つと思います.

  • セットアップが完了したら自分のリポジトリの Settings > Integrations & services にいつの間にか Amazon SNS が増えてるけどいつ足したの?
  • gci アンインストールしたら Amazon SNS も消えた?

これらへの回答が「CloudFormation カスタム・リソースとして GitHub の Webhook を作成することで対応してます」になります.

GitHub Webhook を CloudFormation のプロビジョニングリソースとして扱う

CloudFormation テンプレート sam.yml#L47-L84 が GitHub Webhook をリソースとして定義したものです.

オブジェクト指向な言語で例えるなら GitHubWebhookCustomResource がクラスで GitHubWebhook がインスタンスです. (GitHubWebhookCustomResourceRole はこのリソースを作成するために必要な IAM Role の定義です)

クラスである GitHubWebhookCustomResource はその定義の中で src/functions/github-webhook-resource/index.js を参照しており、実際に CloudFormation でデプロイすると

  • GitHubWebhook リソースを作成するために
  • GitHubWebhookCustomResource から参照されている
  • Lambda Function の github-webhook-resource/index.js を蹴る

という動きになります.

src/functions/github-webhook-resource/index.js#L67 以降を見ると分かるとおり、CloudFormation リソースのライフサイクル Create/Update/Delete に合わせて GitHub API を蹴って createHook/editHook/deleteHook しています.

GitHub API の editHook/deleteHook は呼び出し時に GitHub Webhook の ID を指定する必要があるので、createHook 時の戻り値を CloudFormation リソースの ID (= physicalResourceId) として保存しています. physicalResourceId は Update/Delete の時に #L70 で利用しているような感じで Lambda Function の引数 event から値を引っ張り出すことができるので、これを利用して updateHook/deleteHook を呼んでいます.

GitHub と Amazon SNS を連携させたことがある人が抱きそうなもう一つの疑問

もう一つ別に浮かびそうな疑問が

「GitHub の Amazon SNS 連携って push イベントしか飛んでこないはずだけどどうやって pull_request イベントを受け取ってるの?」

というものです. 実際には特に難しいことはしておらず、シンプルに GitHub の API を利用しています.

普段 GitHub リポジトリに手作業で Webhook を足す際は、Settings > Webhooks からどのイベントの発生時に Webhook を受け取るかを任意に指定して作成すると思います4.

Amazon SNS は Settings > Integrations & services に追加されますが、実体はこの Webhook です. というわけで GitHub Webhooks API を使ってどのイベントの発生時に Amazon SNS に通知をするかを設定することができます.

src/functions/github-webhook-resource/index.js#L11 で Webhook を受け取りたいイベントをリスト化し、craeteHook 時は #L42 を、editHook 時は #L56 で GitHub API に送るパラメーターを生成しています.

まとめ

ぜひご利用くださいませ 🙏

toricls/github-codebuild-integration・GitHub


  1. buildspec.yml の仕様は AWS ドキュメントをご覧くださいませ. [return]
  2. Task では Lambda Function 以外も呼び出せますが、その際は Activities という別の概念を経由して行うようです. [return]
  3. 詳しくは AWS のドキュメントをご覧くださいませ [return]
  4. イベントの種類は GitHub のドキュメントに一覧があります. [return]