お急ぎの方
概要
terraform planとapplyをgithub actionsで実行しようとするとiamロールの管理がめんどくさいと思います
今回は
- プロジェクトと同一リポジトリでiamロールも管理して
- ワークフローを勝手に改竄されて実行されないような仕組みで
- OIDCでsecret keyをリポジトリにおかない
ような構成を実現しようと思います
なので今回は一つのプロジェクトで
- terraformを実行するために必要なロールを作成するためのterraformプロジェクト:terraform-iam
- 実際にアプリに必要なインフラを適用するためのterraformプロジェクト:terraform-exec
の二つを作ります(名前つけたので以降は↑の名前で表します
準備
基本的にはIaCでほとんどを管理しますが最初に手作業で準備するものが少しあります。
- terraformを実行するiamロールとポリシーの作成
OIDCを使ってgithubの特定のリポジトリからのみassume roleできるものを作ります - terraformのstateを管理するS3リソース
リソースの状態を保存するため - 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
こうすることで一つのリポジトリでインフラ管理を完結できるようになりました
おしまい