AWS CDK を用いてサーバーレスな GitHub App を作る
こんにちは
株式会社 POL プロダクト部の山田です。
今回、AWS CDK を使って、API Gateway + Lambda な GitHub App を作成したので内容を共有してみたいと思います。
下記で紹介するコードは下記 GitHub 上でも公開していますので、必要に応じて参照してください。
https://github.com/tyrwzl/aws-cdk-github-app-sample
AWS CDK で API Gateway + Lambda のデプロイ
まずはさくっと CDK のプロ時を初期化して、AWS CDK モジュールを依存関係に追加しておきます
$ mkdir aws-cdk-github-app-sample; cd aws-cdk-github-app-sample
$ cdk init --language typescript
$ yarn add "@aws-cdk/aws-lambda-nodejs" "@aws-cdk/aws-apigateway"
そして、lib ディレクトリ配下にある拡張子が ts であるファイルを編集していき、API Gateway, Lambda のリソースを定義していきます。
自分の場合、"aws-cdk-github-app-sample" というディレクトリで "cdk init" コマンドを実行したので "lib/aws-cdk-github-app-sample.ts" が作成されました。"lib/aws-cdk-github-app-sample.ts" を編集した結果は下記のようになります。
// lib/aws-cdk-github-app-sample.ts
import * as cdk from "@aws-cdk/core";
import * as iam from "@aws-cdk/aws-iam";
import * as lambda from "@aws-cdk/aws-lambda";
import { NodejsFunction } from "@aws-cdk/aws-lambda-nodejs";
import * as apigateway from "@aws-cdk/aws-apigateway";
export class AwsCdkGithubAppSampleStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// Lambda に紐付ける IAM ロール
const serviceRole = new iam.Role(this, `IAMRoleForLambdaFunction`, {
roleName: `IAMRoleForLambdaFunction`,
assumedBy: new iam.CompositePrincipal(
new iam.ServicePrincipal("lambda.amazonaws.com")
),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
),
],
inlinePolicies: {
inlinePolicies: iam.PolicyDocument.fromJson({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: "secretsmanager:GetSecretValue",
Resource: ["*"],
},
],
}),
},
});
// Lambda 関数
const lambdaFunction = new NodejsFunction(this, `LambdaFunction`, {
runtime: lambda.Runtime.NODEJS_12_X,
handler: "handler",
entry: "src/handler.ts",
role: serviceRole,
timeout: cdk.Duration.seconds(10),
});
// API Gateway
const api = new apigateway.RestApi(this, "APIGateway", {
restApiName: "APIGateway",
description: "APIGateway",
});
// API Gateway と Lambda 関数の関連付け
const eventHandlerWidgetIntegration = new apigateway.LambdaIntegration(
lambdaFunction
);
api.root.addMethod("POST", eventHandlerWidgetIntegration);
}
}
上記コードを補足すると
・GitHub App の機密情報を Secrets Manager に保存するため、Lambda に付与する IAM ロールに Secrets Manager に対する権限を付与している (※ 全ての Secrets Manager リソースに対して権限を与えているのでよしなに権限を絞ってください)
・GitHub イベントは POST リクエストなので、API Gateway では POST で Lambda と紐付ける
となっております。
GitHub App の作成
今回は Issue に任意のラベルが付与されたときにそのラベルを削除して、末尾に "ラベル名 attached" というコメントをつけるという GitHub App を作りたいと思います。
ソースコード
まずは Lambda のソースコードファイルの作成と必要な依存関係を追加しておきます。
$ mkdir src; cd src
$ touch handler.ts app.ts secret.ts
$ yarn add "aws-lambda" "@aws-sdk/client-secrets-manager" "probot"
ソースコードは下記のとおりです。
src/handler.ts (Lambda が起動したときに呼ばれる関数)
// src/handler.ts
import { Handler } from "aws-lambda";
import { createProbot } from "probot";
import * as secrets from "./secret";
import { app } from "./app";
export const handler: Handler = async (event, context) => {
try {
// Node.js イベントループが空になるまで待機せずに、コールバックが実行されるとすぐにレスポンスが送信されます。
context.callbackWaitsForEmptyEventLoop = false;
// Probot の初期化
const secret = await secrets.getSecretJSON("githubapp-secret-strings");
const cert = await secrets.getSecretString("githubapp-private-key");
const probot = createProbot({
overrides: {
appId: secret["APP_ID"],
secret: secret["WEBHOOK_SECRET"],
privateKey: cert,
},
});
await probot.load(app);
await probot.webhooks.verifyAndReceive({
id:
event.headers["X-GitHub-Delivery"] ||
event.headers["x-github-delivery"],
name: event.headers["X-GitHub-Event"] || event.headers["x-github-event"],
signature:
event.headers["X-Hub-Signature-256"] ||
event.headers["x-hub-signature-256"],
payload: JSON.parse(event.body),
});
return {
statusCode: 200,
body: '{"ok":true}',
};
} catch (error) {
console.log(error);
return {
statusCode: error.status || 500,
error: "ooops",
};
}
};
src/app.ts (GitHub App の中身、GitHub のイベントに応じた GitHub アクションを定義する)
// src/app.ts
import { Probot, Context } from "probot";
const removeLabel = (context: Context, labelName: string) => {
return context.octokit.issues.removeLabel({
issue_number: context.payload.issue.number,
name: labelName,
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
});
};
const addComment = (context: Context, labelName: string) => {
return context.octokit.issues.createComment(
context.issue({ body: labelName + " attached !" })
);
};
const labelAction = (context: Context) => {
const attachedLabelName = context.payload.label.name;
return Promise.all([
removeLabel(context, attachedLabelName),
addComment(context, attachedLabelName),
]);
};
export const app = (app: Probot) => {
app.on("issues.labeled", labelAction);
};
src/handler.ts (AWS Secrets Manager を利用するための関数)
// src/secret.ts
import * as secretsManager from "@aws-sdk/client-secrets-manager";
const client = new secretsManager.SecretsManager({ region: "us-east-1" });
export const getSecretString = async (secretId: string) => {
const getSecretValueCommandInput: secretsManager.GetSecretValueCommandInput = {
SecretId: secretId,
};
const secretString = (await client.getSecretValue(getSecretValueCommandInput))
.SecretString;
if (secretString) return secretString;
else throw new Error("Couldn't get secret binary" + secretId);
};
export const getSecretJSON = async (secretId: string) => {
const getSecretValueCommandInput: secretsManager.GetSecretValueCommandInput = {
SecretId: secretId,
};
const secretString = (await client.getSecretValue(getSecretValueCommandInput))
.SecretString;
if (secretString) return JSON.parse(secretString);
else throw new Error("Couldn't get secret binary" + secretId);
};
GitHub App の設定
次に、GitHub 上で GitHub App の設定を行います。
1. https://github.com/settings/apps に飛ぶ
2. "New GitHub App" をクリック
3. 下記項目に適当な値を埋め込む
・"GitHub App name": お好きなものを
・"Homepage URL": お好きなものを
・"Webhook URL": 後で API Gateway の URL を入れるので一旦適当なものを
・"Webhook secret (optional)": GitHub からの POST リクエストの検証に利用するので適切な値を ("openssl rand -base64 32" の結果で良いと思います)
4. "Repository permissions" > "Issues" を "Access: Read & Write" にする
5. "Subscribe to events" で "Issues" と "Label" にチェックをつける
6. "Create GitHub App" をクリック
7. 作成に成功すると下記のメッセージが出るので "generate a private key" をクリック
8. 遷移した先の "Generate a private key" をクリックすると、pem ファイルがダウンロードされるので大切に保管しておく
9. "About" 配下の "App ID" をメモしておく
10. 左側から "Install App" をクリック
11. "Install" をクリック
12. "Install" をクリック
13. 遷移した先のページの URL (https://github.com/settings/installations/<数字> という形式) の末尾の数字をメモしておく
次に、GitHub App の機密情報を保持した Secrets Manager リソースを作成しておきます。
まず、「8. 遷移した先の "Generate a private key" をクリックすると、pem ファイルがダウンロードされるので大切に保管しておく」で保存した pem ファイルを "private-key.pem" と名前を変えておいてください。
その後、下記情報を記載した "secrets.json" というファイルを作成します。
{
"WEBHOOK_SECRET": "「3. 下記項目に適当な値を埋め込む」で設定した Webhook secret (optional) の値",
"APP_ID": "「9. "About" 配下の "App ID" をメモしておく」でメモした値",
"INSTALLATION_ID": "「13. 遷移した先のページの URL (https://github.com/settings/installations/<数字> という形式) の末尾の数字をメモしておく」でメモした値"
}
最後に下記のような AWS CLI コマンドを実行してSecrets Manager リソースを作成します。
#!/bin/bash
## パラメータ
PRIVATE_KEY_KEYPAIR_NAME="githubapp-private-key"
PRIVATE_KEY_VALUE_FILE_NAME="private-key.pem"
SECRET_STRINGS_NAME="githubapp-secret-strings"
SECRET_STRINGS_FILE_NAME="secrets.json"
###
## PRIVATE_KEY を作成する
## binary として保存する
###
aws secretsmanager create-secret \
--name ${PRIVATE_KEY_KEYPAIR_NAME} \
--secret-string file://${PRIVATE_KEY_VALUE_FILE_NAME} \
--region us-east-1
###
## WEBHOOK_SECRET などを json としてまとめて保存する
###
aws secretsmanager create-secret \
--name ${SECRET_STRINGS_NAME} \
--secret-string file://${SECRET_STRINGS_FILE_NAME} \
--region us-east-1
これで前準備は完了です。
いざ、デプロイ!
cdk コマンドを使って AWS リソースをデプロイします。
$ cdk deploy
※ "@aws-cdk/aws-lambda-nodejs" でコンテナを利用するため docker デスクトップなどが起動している必要があります。
下記のような出力がされるので "y" キーを入力して "Enter" を押すとデプロイが開始されます。
IAM Statement Changes
┌───┬──────────────────────────────────┬────────┬───────────────────────────────┬──────────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ │ Resource │ Effect │ Action │ Principal │ Condition │
├───┼──────────────────────────────────┼────────┼───────────────────────────────┼──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${APIGateway/CloudWatchRole.Arn} │ Allow │ sts:AssumeRole │ Service:apigateway.amazonaws.com │ │
├───┼──────────────────────────────────┼────────┼───────────────────────────────┼──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${IAMRoleForLambdaFunction.Arn} │ Allow │ sts:AssumeRole │ Service:lambda.amazonaws.com │ │
├───┼──────────────────────────────────┼────────┼───────────────────────────────┼──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${LambdaFunction.Arn} │ Allow │ lambda:InvokeFunction │ Service:apigateway.amazonaws.com │ "ArnLike": { │
│ │ │ │ │ │ "AWS:SourceArn": "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${APIGateway71B6BBE0}/${APIGateway/DeploymentStage.prod}/POST/" │
│ │ │ │ │ │ } │
│ + │ ${LambdaFunction.Arn} │ Allow │ lambda:InvokeFunction │ Service:apigateway.amazonaws.com │ "ArnLike": { │
│ │ │ │ │ │ "AWS:SourceArn": "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${APIGateway71B6BBE0}/test-invoke-stage/POST/" │
│ │ │ │ │ │ } │
├───┼──────────────────────────────────┼────────┼───────────────────────────────┼──────────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ + │ * │ Allow │ secretsmanager:GetSecretValue │ AWS:${IAMRoleForLambdaFunction} │ │
└───┴──────────────────────────────────┴────────┴───────────────────────────────┴──────────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
IAM Policy Changes
┌───┬──────────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────┐
│ │ Resource │ Managed Policy ARN │
├───┼──────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${APIGateway/CloudWatchRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs │
├───┼──────────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${IAMRoleForLambdaFunction} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole │
└───┴──────────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Do you wish to deploy these changes (y/n)?
しばらくして、下記のような出力がされればデプロイ成功です。
"Outputs" にある URL (API Gateway の URL) をメモしてください。
✅ AwsCdkGithubAppSampleStack
Outputs:
AwsCdkGithubAppSampleStack.APIGatewayEndpoint038C57B1 = https://aaaaaaaaaa.execute-api.ap-northeast-1.amazonaws.com/prod/
もう一度 GitHub 上で作成した GitHub App の設定画面を開きます (https://github.com/settings/apps から "Edit" を押す)。
"Webhook URL" に先程メモした "API Gateway の URL" を設定して "Save changes" をクリックします。
設定が完了したら Issue を作成して適当なラベルを付与してみましょう。
すると下記のように付与したラベルが削除されてコメントが追加されることが確認できます。
デバッグ方法
うまく動かないときは、GitHub イベントが正常に処理されたか確認しましょう。
GitHub App の設定ページから "Advanced" をクリックします。
すると下記のように直近の配信された GitHub Event の結果が確認できます (赤い三角印になっているのは失敗したもの)
上記のように赤い三角が確認できたら Lambda のコンソールから CloudWatch ログを確認してエラー内容を確認しましょう。
まとめ
GitHub App は開発補助ツールとしての用途が多いのではないかと思います。こういったツールはツールの作成者に属人化しがちですが、AWS CDK を利用してインフラ構成までコード管理することで、「 デプロイ方法がわからない」などといった問題に対策できるのではないかと思います。
最後に
POL プロダクト部について
株式会社 POL ではエンジニアを募集しております!
自分の Twitter でも良いのでお気軽にご相談ください!