Article:

この記事ではGithubActionsでTerraformを使って、AWS環境をコードで管理するチュートリアルです。 GithubActionsに実装されたOIDC認証を用いて、トークンなしにロールを割り当て、AWS Lambda + API Gatewayのエンドポイントを作成します。

Terraformとは

TerraformはいわゆるIaC(Infrastucture as Code)を実現するツールの1つです。HashiCorp社(Vagrantとか作ってる)が開発しているOSSで、無料で使えます。 Terraformは様々なサービスを「Provider」という形で抽象化し、プラグインのように追加できるようにしていることで、同じノリで様々なサービスに対して開発を行う事ができます。 今現在存在するProviderの一覧はここから見ることができます。

いいこと

  • ドキュメントの品質が高い
  • TerraformはAWSやGCPなどのクラウドサービスだけでなく、KubernetesやActiveDirectoryなど様々なシステムをいい感じにモデル化してくれている
  • AWSとGCPでもちろんコードの共通化はできないものの、CloudFormationとCloudDeploymentManagerを書き分けるのに比べたらTerraformを使ったほうが割りと同じノリで書くことができる

あまりよくないこと

  • サービスにアップデートがあった場合、抽象化レイヤーを挟んでいるためそれが使えるようになるのにワンテンポ遅れる
  • 万が一バグった場合に問題の特定がより困難になる

ロールの作成

基本的にクラウドのリソースにアクセスするためには、アクセスキーIDとシークレットアクセスキーなどのクレデンシャルが必要になります。 クラウドの構築を自動化するためには任意のリソースの作成・削除・変更など非常に高い権限が必要になりますが、そのような高い権限を持ったアカウントのクレデンシャルを どのように管理するのかというのは非常に難しい問題でした。

しかし半年前くらいにGithubActionsがOIDCに対応したことで、GithubActions側が自身の正当性をAWSやGCPに証明してくれるようになったことで、キーを自分で管理しなくて良くなりました!

ので、この記事でもそのようなOIDCに紐づいたロールを作成してみます。

ちなみにOIDCについての基本事項は@TakahikoKawasakiさんのIDトークンが分かれば OpenID Connect が分かるが非常によくまとまっていてわかりやすかったです。素敵な記事をありがとうございます!

では実際に作成していきましょう。

まずはIAM>IDプロパイダのページから、「プロバイダを追加」を選びます。

プロバイダのタイプをOpenID Connectに、ProviderURLをhttps://token.actions.githubusercontent.com に、対象者をsts.amazonaws.comにします。 stsに関してはこのページに記載があります。

作成したら作成したプロバイダ’token.actions.githubusercontent.com’を選択して、右上のロールの割当を選び、新しいロールを作成します。

ロールの作成では先程作成したIDプロバイダとAudienceを指定して、一度空の権限でロールを作成してしまいます。これはこの画面では(なぜか)細かい設定ができないからです。

作成したら、作成したロールの「信頼関係」タブを開きます。

ここで、「信頼されたエンティティ」の部分が次のようになっていると思います。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Principal": {
                "Federated": "arn:aws:iam::************:oidc-provider/token.actions.githubusercontent.com"
            },
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:aud": [
                        "sts.amazonaws.com"
                    ]
                }
            }
        }
    ]
}

注目してほしいのが、Conditionの部分です。 ここを見ると、「tokenのaud(audience: IDの発行の依頼者)がsts.amazonaws.comであること」が指定されています。 これはつまり、sts.amazonaws.com向けに発行されたgithubからのトークンはすべてオッケー!ということになるので、自分以外のユーザーのレポジトリからでもarnさえあれば認証できることを意味します。 とてもヤバイです。ので、sub(subject: ユーザーの一意識別子)を限定します。(最初に空権限で先にロールを作成したのはこの為です。)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::************:oidc-provider/token.actions.githubusercontent.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                },
                "StringLike": {
                    "token.actions.githubusercontent.com:sub": "repo:<レポジトリオーナー名>/<レポジトリ名>:*"
                }
            }
        }
    ]
}

ちなみに、複数のレポジトリに渡って認可をしたいときはStringLike句の中に複数記述するとORになります。

1
2
3
4
"StringLike": {
	"token.actions.githubusercontent.com:sub": "repo:myname/myrepo1:*",
	"token.actions.githubusercontent.com:sub": "repo:myname/myrepo2:*"
}

StringLike句自体を複数記述するとAndになってしまうので気をつけてください。

複数のキーまたは値による条件の作成

あとはロールに必要そうなポリシーを付与してください。今回の例ではIAM, S3, Lambda, APIGateway, CloudWatchへのフルアクセスを付与します。

そしたら完成です!。 ロールのARNを後で使用します。

Terraformのコードを作成(あとs3も)

それではLambda+APIGateway構成を作成するTerraformのコードを作成します。 今回は簡単のために1ファイルにします。main.tfという名前にしておきます。

んで、基本的に設定部分以外は公式のチュートリアルのまんまです。公式にこんな丁寧なチュートリアルがあるなんて素敵すぎる…。

私が使ったコード全体はこちらになります。

設定部分

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
terraform {
  backend "s3" {
    bucket = "<適当なバケツ名>"
    key    = "terraform.tfstate"
    region = "ap-northeast-1"
  }
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.27"
    }
  }
  required_version = ">= 0.14.9"
}

provider "aws" {
  region = "ap-northeast-1"
}

terraformは動作に"tfstate"と呼ばれる要するに「今どういう状態なのか」という状態を記録したファイルが必要です。 要するに、変更されたコードとこのtfstateを見比べて、リソースが削除されたなら削除し、追加されたなら追加してくれる感じです。 で、これをgitに入れておくのはちょっと微妙なので、s3とかで管理したいんですよね。

ここで嬉しいニュースなのですがこの設定、backendブロックの3行でできます!適当にterraform用にバケツを(手動で)作ってあげて、bucket=でその名前を指定してあげてください。

それ以外(lambda + APIGateway)

基本的にはこの章の頭で述べた通りチュートリアル部同じなのですが、チュートリアルではコードをs3を経由して配布していたのを、別に私はひとまず直接で別にいいかな~と思ったのでそこだけ変更しました

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
data "archive_file" "lambda_payload" {
	type = "zip"
	source_dir = "${path.module}/src"
	output_path = "${path.module}/payload.zip"
}

resource "aws_lambda_function" "test_lambda" {

  filename      = data.archive_file.lambda_payload.output_path
  function_name = "myTestFunction"
  runtime = "nodejs16.x"
  role          = aws_iam_role.iam_for_lambda.arn
  handler       = "index.handler"

  source_code_hash = data.archive_file.lambda_payload.output_base64sha256
}

普通だとactionsとかでzipコマンドを叩くんでしょうけど、terraformはその辺までちゃんと用意されてるのが偉いですね。

ついでに、LambdaとAPIGatewatyのそれぞれのドキュメントは以下になります。

Lambdaにデプロイするコードの作成

ひとまず適当です。 前節のわたしの例ではコードのソースをsrcディレクトリにしたので、そこに、index.jsという名前でプログラムを作成しました。

1
2
3
4
5
6
7
exports.handler = async (event) => {
    const response = {
        statusCode: 200,
        body: JSON.stringify('Hello from Lambda!'),
    };
    return response;
};

手動でLambdaを生成したときに自動でできるやつです

Actions Workflowの作成

今現在、GithubActionsのテンプレート選択でTerraformを選ぶと、TerraformCloudを利用するやつがでてくるんですよね…。

今回はActionsだけで完結させたいのでそこを変更すると次のようになります。(ブランチ名がmasterなのでmainの人は書き換えてください)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
name: 'Terraform'
on:
  push:
    branches:
      - master
  workflow_dispatch:

jobs:
  Deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read

    steps:
    - uses: actions/checkout@v3

    - name: Configure AWS credentials from IAM Role
      uses: aws-actions/configure-aws-credentials@v1
      with:
        role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
        aws-region: ${{ secrets.AWS_REGION }}

    - name: Terraform Init
      run: terraform init

    - name: Terraform Validate
      run: terraform validate

    - name: Terraform Plan
      run: terraform plan
      continue-on-error: true

    - name: Terraform Apply
      run: terraform apply -auto-approve

このActionsではAWS_ROLE_ARNAWS_REGIONをシークレットとして参照しています。 お好みのリージョンと、前の章で作ったロールのARNを入れてあげてください。

(ここでAccountKeyIDとかSecretAccessKeyとかを入力しなくていいのが冒頭の下りのおかげですね!)

おわり

これでActionsをデプロイしたらちゃんとモリモリ動いてインフラを作ってくれるはずです! 無事成功したらWebConsoleからAPI Gatewayの存在が確認できるはず…

追加で考えること

ここは個人的なメモです(ご意見あったら教えて欲しい…)

CI

今回はデリバリー中心の話題でしたが、Actionsをちゃんと書いて「PullReqに対してterraform validate&planを行う」などは 普通にやったほうが良いですね。

ステージング

masterとdevelopブランチでデプロイ先環境を分けたいですよね!terraformならその辺もめっちゃいい感じにできるので使うべきです。

レポジトリ構成のベストプラクティス

APIがデカくなってくると、パスごとにレポジトリ分けたいな~とかもしかしたらでてくるかもしれない… そういったときに、routeとかも全部上位のレポジトリに入れてコードだけsubmoduleにしちゃうのか、 それともlambdaとかの設定は個別でしたいしrouteの部分+lambdaのリソースを定義したterraformファイルも 下位のレポジトリに配置するのか… 色々やり方がありそうですが、どれもメリット・デメリットあるのですぐに「これが良さそう!」って方法があまり思いつかない…。