6
4

terraformとgithub actionsで一つのリポジトリで完結するCDを実装

Last updated at Posted at 2024-06-19

お急ぎの方

概要

terraform planとapplyをgithub actionsで実行しようとするとiamロールの管理がめんどくさいと思います
今回は

  • プロジェクトと同一リポジトリでiamロールも管理して
  • ワークフローを勝手に改竄されて実行されないような仕組みで
  • OIDCでsecret keyをリポジトリにおかない

ような構成を実現しようと思います

なので今回は一つのプロジェクトで

  1. terraformを実行するために必要なロールを作成するためのterraformプロジェクト:terraform-iam
  2. 実際にアプリに必要なインフラを適用するためのterraformプロジェクト:terraform-exec

の二つを作ります(名前つけたので以降は↑の名前で表します

準備

基本的にはIaCでほとんどを管理しますが最初に手作業で準備するものが少しあります。

  1. terraformを実行するiamロールとポリシーの作成
    OIDCを使ってgithubの特定のリポジトリからのみassume roleできるものを作ります
  2. terraformのstateを管理するS3リソース
    リソースの状態を保存するため
  3. terraform applyを実行するときにstateをロックするためのdynamodb

terraformを実行するiamロールとポリシーの作成

terraform-iamのためのロールを作ります

まずはAWSコンソールからIAM > ロール へと移動しロールを作成します
カスタム信頼ポリシーを選択して下記の構成のJSONに差し替えて次へ行きます

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::<account_id>:oidc-provider/token.actions.githubusercontent.com"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "token.actions.githubusercontent.com:sub": "repo:<your_account_name>/<your_repository>:event_name:push:base_ref::ref:refs/heads/main",
                    "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
                }
            }
        }
    ]
}

subの構成の説明ですがここでは下記を指定しています

  • repo:your_account_name/your_repository_name
    このロールに切り替えられるリポジトリを指定しています
    私の場合を例にするとsasakitimaru/terraform-learnになります
  • event_name:push
    pushイベントだけを指定します
  • base_ref::ref:refs/heads/main
    宛先がmainブランチの場合だけを指定します

デフォルトではinclude_claimsにevent_name, base_refなどが含まれていません
なので対象のgithub repositoryにGitHub API を使って設定しておきます

gh api \
  --method PUT \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  "/repos/<your-account-name>/<your-repository>/actions/oidc/customization/sub" \
  -F use_default=false \
  -f "include_claim_keys[]=repo" \
  -f "include_claim_keys[]=event_name" \
  -f "include_claim_keys[]=base_ref" \
  -f "include_claim_keys[]=ref"

参考

できたら次に、ポリシーを追加します

IAMFullAccessポリシーとカスタムでTerraformStateManageというロールを作成して付与します

// TerraformStateManage
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "s3:DeleteObject",
                "s3:GetObject",
                "s3:ListBucket",
                "s3:PutObject"
            ],
            "Resource": "*"
        }
    ]
}

本当ならIAMのポリシーも必要な権限だけ与えるのが良いです

terraformのstateを管理するS3リソースの作成

パケットのバージョニングを有効にして全世界で一意な名前をつけて作成してください
terraform-iam, terraform-exec二つのterraformプロジェクトがあるのでこちらも2つ作成します

terraform applyを実行するときにstateをロックするためのdynamodbリソースの作成

tfstateをロックするために必要です
terraform planをPRで実行することを想定しterraform-execにだけdynamodbリソースは当てようと思います
なのでこっちは一つだけ作ります
特に設定は変更せずに名前だけ決めて作成してください

ワークフローの定義

準備が終わったのでmainブランチ宛のPR, pushをトリガーにするワークフローを作成していきます
今回のリポジトリの構成は以下です

├──.github
│   └── workflows
│       ├── apply.yml
│       └── plan.yml

└── deployment
    ├── envs
    │   └── prod
    │       └── main.tf
    ├── init
    │   └── main.tf
    │   
    └── modules
        
        └── network
            └── main.tf

initにはterraform-execのためのiam権限を定義するためのterraformプロジェクトを置きます(もうちょいマシな名前にすればよかった

envsに各環境のterraformプロジェクトを定義します
今回は簡単のためprod環境だけにしています

PRでterraform planを実行する

plan.yml
name: terraform plan

on:
  pull_request_target:
    branches:
      - main
      - develop

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write

    env:
      ENVIRONMENT: null
      AWS_ACCOUNT_ID: null

    steps:
      - uses: actions/github-script@v6
        id: pr
        with:
          script: |
            const { data: pullRequest } = await github.rest.pulls.get({
              ...context.repo,
              pull_number: context.payload.pull_request.number,
            });
            return pullRequest

      - uses: actions/checkout@v4
        with:
          ref: ${{fromJSON(steps.pr.outputs.result).merge_commit_sha}}

      - name: Set environment based on branch
        run: |
          if [[ "${GITHUB_REF##*/}" == "main" ]]; then
            echo "ENVIRONMENT=prod" >> $GITHUB_ENV
            echo "AWS_ACCOUNT_ID=${{ secrets.AWS_PROD_ACCOUNT_ID }}" >> $GITHUB_ENV
          elif [[ "${GITHUB_REF##*/}" == "develop" ]]; then
            echo "ENVIRONMENT=dev" >> $GITHUB_ENV
            echo "AWS_ACCOUNT_ID=${{ secrets.AWS_DEV_ACCOUNT_ID }}" >> $GITHUB_ENV
          fi

      - name: Configure AWS credentials for IAM manager
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-exec
          aws-region: ${{ vars.REGION }}

      - uses: hashicorp/setup-terraform@v3

      - name: Terraform Format
        id: fmt
        run: |
          cd ${{ github.workspace }}/deployment/envs/${{ env.ENVIRONMENT }}
          terraform fmt -check

      - name: Terraform Init
        id: init
        run: |
          cd ${{ github.workspace }}/deployment/envs/${{ env.ENVIRONMENT }}
          terraform init

      - name: Terraform Plan
        id: plan
        env:
          TF_VAR_account_id: ${{ env.AWS_ACCOUNT_ID }}
          TF_VAR_region: ${{ vars.REGION }}
          TF_VAR_name_prefix: ${{ vars.NAME_PREFIX }}
        run: |
          cd ${{ github.workspace }}/deployment/envs/${{ env.ENVIRONMENT }}
          terraform plan -no-color

      - name: Comment PR
        uses: actions/github-script@v3
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `### Terraform Plan 📖\n${{ steps.plan.outputs.stdout }}`;
            github.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Terraform checks for '${{ env.ENVIRONMENT }}'\n` + output
            })

要所だけ説明します

on:
  pull_request_target:
    branches:
      - main
      - develop

ここではmain, developブランチへのPRをトリガーに指定しています
また、pull_requestではなくpull_request_targetを指定することでPRのターゲットのブランチのワークフローが使用されます
これにより誰かがワークフローを改竄してAWSリソースを操作しようとしても、改竄前のmain, developブランチのワークフローが使用されます
怪しいPRが来たらレビューでブロックできます

またpull_request_targetではbase_refがmainブランチとなるのでPRを出しているブランチにチェックアウトするには↓のようなひと工夫が入ります

      # pullRequestを取得	
      - uses: actions/github-script@v6
        id: pr
        with:
          script: |
            const { data: pullRequest } = await github.rest.pulls.get({
              ...context.repo,
              pull_number: context.payload.pull_request.number,
            });
            return pullRequest

      # PRを出しているブランチのコミットハッシュを使用してcheckout
      - uses: actions/checkout@v4
        with:
          ref: ${{fromJSON(steps.pr.outputs.result).merge_commit_sha}}

次にterraform-execを使用するためにロールスイッチします

      - name: Configure AWS credentials for IAM manager
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-exec
          aws-region: ${{ vars.REGION }}

このterraform-execのロールはこのリポジトリ内で定義して作成、更新していきます(後述
あとは普通にterraform initしてplanしてその結果をコメントに出力しているだけです

mainブランチへのPushでterraform applyする

apply.yml
name: Deploy Infrastructure

on:
  push:
    branches:
      - main
      - develop

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

    env:
      ENVIRONMENT: null
      AWS_ACCOUNT_ID: null
      changes_in_deployment_init: false

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          fetch-depth: 2

      - name: Set environment based on branch
        run: |
          if [[ "${GITHUB_REF##*/}" == "main" ]]; then
            echo "ENVIRONMENT=prod" >> $GITHUB_ENV
            echo "AWS_ACCOUNT_ID=${{ secrets.AWS_PROD_ACCOUNT_ID }}" >> $GITHUB_ENV
          elif [[ "${GITHUB_REF##*/}" == "develop" ]]; then
            echo "ENVIRONMENT=dev" >> $GITHUB_ENV
            echo "AWS_ACCOUNT_ID=${{ secrets.AWS_DEV_ACCOUNT_ID }}" >> $GITHUB_ENV
          fi

      - name: Configure AWS credentials for IAM manager
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-iam-manager
          aws-region: ${{ vars.REGION }}

      - uses: hashicorp/setup-terraform@v3

      - name: Check if there are changes in deployment/init
        id: check-changes-in-deployment-init
        run: |
          cd ${{ github.workspace }}/deployment/init
          if git diff --name-only HEAD^1 HEAD | grep -q '^deployment/init/'; then
            echo "changes_in_deployment_init=true" >> $GITHUB_ENV
          fi

      - name: Create role
        if: env.changes_in_deployment_init == 'true'
        env:
          TF_VAR_account_id: ${{ env.AWS_ACCOUNT_ID }}
        run: |
          cd ${{ github.workspace }}/deployment/init
          terraform init
          terraform apply -auto-approve

      - name: Configure AWS credentials for deploy
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-exec
          aws-region: ${{ vars.REGION }}

      - name: Deploy infrastructure
        env:
          TF_VAR_account_id: ${{ env.AWS_ACCOUNT_ID }}
          TF_VAR_region: ${{ vars.REGION }}
          TF_VAR_name_prefix: ${{ vars.NAME_PREFIX }}
        run: |
          cd ${{ github.workspace }}/deployment/envs/${{ env.ENVIRONMENT }}
          terraform init
          terraform apply -auto-approve

こちらではまずはterraform-iam-managerというterraform-iam用のロールに切り替えます

      - name: Configure AWS credentials for IAM manager
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-iam-manager
          aws-region: ${{ vars.REGION }}

このロールを使って、terraform applyするためのiamロールを作成します

      - name: Create role
        if: env.changes_in_deployment_init == 'true'
        env:
          TF_VAR_account_id: ${{ env.AWS_ACCOUNT_ID }}
        run: |
          cd ${{ github.workspace }}/deployment/init
          terraform init
          terraform apply -auto-approve

ここで作成されるロールの定義を作ります
/deployement/initにmain.tfを作成します

main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }

  backend "s3" {
    bucket  = "terraform-iam-manager"
    key     = "terraform.tfstate"
    region  = "ap-northeast-1"
    encrypt = true
  }
}

resource "aws_iam_role" "terraform-exec" {
  name = "terraform-exec"
  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [{
      "Sid" : "",
      "Effect" : "Allow",
      "Principal" : {
        "Federated" : "arn:aws:iam::${var.account_id}: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:<your-account-name>:<your-repository>:event_name:pull_request_target:base_ref:main:*",
            "repo:<your-account-name>:<your-repository>:event_name:push:base_ref::ref:refs/heads/main"
          ]
        }
      }
    }]
  })
}

resource "aws_iam_policy" "terraform-exec" {
  name        = "terraform-exec"
  description = "Policy for various AWS services"
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "VisualEditor0",
        "Effect" : "Allow",
        "Action" : [
          "dynamodb:DeleteItem",
          "dynamodb:DescribeTable",
          "dynamodb:GetItem",
          "dynamodb:PutItem"
        ],
        "Resource" : "*"
      },
      {
        "Sid" : "VisualEditor1",
        "Effect" : "Allow",
        "Action" : [
          "ec2:AssociateRouteTable",
          "ec2:AttachInternetGateway",
          "ec2:CreateInternetGateway",
          "ec2:CreateRoute",
          "ec2:CreateRouteTable",
          "ec2:CreateSubnet",
          "ec2:CreateTags",
          "ec2:CreateVPC",
          "ec2:DeleteInternetGateway",
          "ec2:DeleteRoute",
          "ec2:DeleteRouteTable",
          "ec2:DeleteSubnet",
          "ec2:DeleteTags",
          "ec2:DeleteVPC",
          "ec2:DescribeAccountAttributes",
          "ec2:DescribeInternetGateways",
          "ec2:DescribeNetworkInterfaces",
          "ec2:DescribeRouteTables",
          "ec2:DescribeSubnets",
          "ec2:DescribeVpcAttribute",
          "ec2:DescribeVpcs",
          "ec2:DescribeVpcClassicLink",
          "ec2:DescribeVpcClassicLinkDnsSupport",
          "ec2:DescribeSecurityGroups",
          "ec2:DescribeSecurityGroupReferences",
          "ec2:DetachInternetGateway",
          "ec2:DisassociateRouteTable",
        ],
        "Resource" : "*"
      },
      {
        "Sid" : "VisualEditor4",
        "Effect" : "Allow",
        "Action" : [
          "s3:DeleteObject",
          "s3:GetObject",
          "s3:ListBucket",
          "s3:PutObject"
        ],
        "Resource" : "*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "attach_policy" {
  role       = aws_iam_role.terraform-exec.name
  policy_arn = aws_iam_policy.terraform-exec.arn
}

variable "account_id" {}

先ほどのようにOIDCの信頼ポリシーの設定をしています

      "Condition" : {
        "StringEquals" : {
          "token.actions.githubusercontent.com:aud" : "sts.amazonaws.com"
        },
        "StringLike" : {
          "token.actions.githubusercontent.com:sub" : [
            "repo:<your-account-name>:<your-repository>:event_name:pull_request_target:base_ref:main:*",
            "repo:<your-account-name>:<your-repository>:event_name:push:base_ref::ref:refs/heads/main"
          ]
        }

複数条件を入れるとOR条件として判定されます
ここではmainブランチに対するpull_request_targetとpushのみがこのロールを使えます

backendには先ほど作成したterraform-iam用のS3のバケット名を入れます

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }

  backend "s3" {
    bucket  = "terraform-iam-manager" # ここに作成したバケット名
    key     = "terraform.tfstate"
    region  = "ap-northeast-1"
    encrypt = true
  }
}

現状はvpcの作成に必要な権限を与えています
今後リソースを足す場合はこのmain.tfの権限を編集してmainブランチにプッシュすると新しい権限がCreate Roleのステップで付与されます

terraform-execのためのロールが作成されました
次のステップで早速このロールに切り替えています

      - name: Configure AWS credentials for deploy
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/terraform-exec
          aws-region: ${{ vars.REGION }}

あとはterraform applyするだけです
applyするリソース例として今回はVPCを作成したいと思います

# deployment/envs/prod/main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }
  backend "s3" {
    bucket         = "terraform-example-bucket" # 作成したバケット名
    key            = "terraform.tfstate"
    region         = "ap-northeast-1"
    encrypt        = true
    dynamodb_table = "terraform-example-terraform" # 作成したdynamoの名前
  }
}

module "network" {
  source = "../../modules/network"

  name_prefix = var.name_prefix
  region      = var.region
}

# Global
variable "name_prefix" {}
variable "region" {}
# deployment/modules/network/main.tf

# ig
resource "aws_internet_gateway" "default" {
  vpc_id = aws_vpc.default.id
}

# rt
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.default.id
}

resource "aws_route" "public" {
  route_table_id         = aws_route_table.public.id
  gateway_id             = aws_internet_gateway.default.id
  destination_cidr_block = "0.0.0.0/0"
}

resource "aws_route_table_association" "public_a" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_a.id
}

resource "aws_route_table_association" "public_c" {
  route_table_id = aws_route_table.public.id
  subnet_id      = aws_subnet.public_c.id
}

# subnet
resource "aws_subnet" "public_a" {
  cidr_block        = "10.0.1.0/24"
  vpc_id            = aws_vpc.default.id
  availability_zone = "ap-northeast-1a"
}

resource "aws_subnet" "public_c" {
  cidr_block        = "10.0.2.0/24"
  vpc_id            = aws_vpc.default.id
  availability_zone = "ap-northeast-1c"
}

# vpc
resource "aws_vpc" "default" {
  cidr_block = local.vpc_cidr
  tags = {
    Name = local.tag_name
  }
}

variable "region" {}
variable "name_prefix" {}

locals {
  vpc_cidr = "10.0.0.0/16"
  tag_name = "${var.name_prefix}-vpc"
}

どうやって運用するか

このリポジトリではterraform applyする権限も管理します
なので例えば新規リソースを追加する場合はiam権限を追加するPRとリソースを追加するPRに分ける必要があります

例:ECRを追加したい場合

Step1

iam権限の追加のPR

# deployment/init/main.tf

...

      {
        "Sid" : "VisualEditor2",
        "Effect" : "Allow",
        "Action" : [
          "ecr:CreateRepository",
          "ecr:DeleteRepository",
          "ecr:DescribeRepositories",
          "ecr:ListTagsForResource",
          "ecr:PutImageScanningConfiguration"
        ],
        "Resource" : "*"
      }

...

Step2

ECRリソースの追加のPR

├──.github
│   └── workflows
│       ├── apply.yml
│       └── plan.yml
│
└── deployment
    ├── envs
    │   └── prod
    │       └── main.tf
    ├── init
    │   └── main.tf
    │   
    └── modules
        ├── ecr # 追加
        │   └── main.tf # 追加
        │
        └── network
            └── main.tf

こうすることで一つのリポジトリでインフラ管理を完結できるようになりました
おしまい

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4