Terraformを使ってGolang製LambdaのCICDをGitHub Actionsで構築しIaCも体感してみた

はじめに

こんにちは、SHIFT の開発部門に所属しているKatayamaです。今期から転属になり、開発を担当していくことになりました。 ただ、前期はDevOps導入支援等に携わっていた関係で今回はCICD関係の記事を書こうと思います。

前回投稿させて頂いた記事(Cloud Formation を使って Golang 製 Lambda の CICD を GitHub Actions で構築してみた)では、Cloud Formation を使った CICD を構築してみた。ただ IaC と言えば Terafform という感じだと思われるので、今回は Terafform を理解する目的で Terraform を使った Lambda の CICD の構築をやってみた。

Terafform を使うためのLinux環境の用意(WSL2 の Ubuntu)

WLS2 についてはWSL のインストールを参考にして設定しているので、Linux ディストリビューションは Ubuntu。ちなみにバージョンは以下。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.3 LTS
Release:        20.04
Codename:       focal

※Terafform を実装する上で VS Code を使ったが、Extentions のHashiCorp Terraformを使った。また、Ubuntu へはRemote - SSHで SSH 接続した。

※WSL2 の Ubuntu への SSH は少し設定をいじる必要があり、

$ sudo vim /etc/ssh/sshd_config

で、PasswordAuthentication no, PubkeyAuthentication yes にそれぞれ変更する必要があるので注意。また、

$ sudo ssh-keygen -A
$ sudo service ssh start
$ chmod 700 ~/.ssh
$ chmod 600 ~/.ssh/authorized_keys

等の操作もお忘れなく。

・参考:Install Terraform

Terraform で S3Bucket を作成する

まず Lambda 関数を作成するために必要になるソースコードを置く s3 bucket の作成と、ソースコードの upload を行う terraform configratuion を作成した。

※AWS の Credential は Terraform Cloud の Environment Variables から読み込んで実行している。Terafform Cloud についてはAutomate Terraform with GitHub Actionsのチュートリアルで設定しているやり方が参考になる。

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

  required_version = ">= 1.0.8"

  backend "remote" {
    organization = "yuta_katayama"

    workspaces {
      name = "terraform-go-lambda-cicd-s3bucket"
    }
  }
}

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

variable "user_name" {}

resource "aws_s3_bucket" "s3_bucket" {
  bucket = "terraform-build-artifact-go-lambda"
}

resource "aws_s3_bucket_policy" "policy" {
  bucket = aws_s3_bucket.s3_bucket.id
  policy = data.aws_iam_policy_document.policy_doc.json
}

resource "aws_s3_bucket_object" "build_artifact" {
  bucket = aws_s3_bucket.s3_bucket.id
  key    = "main.zip"
  source = "${path.module}/main.zip"
  etag   = filemd5("${path.module}/main.zip")
}

resource "aws_s3_bucket_object" "base64sha256" {
  bucket = aws_s3_bucket.s3_bucket.id
  key    = "main.zip.base64sha256"
  source = "${path.module}/main.zip.base64sha256"
  etag   = filemd5("${path.module}/main.zip.base64sha256")
}

data "aws_caller_identity" "current" {}

data "aws_iam_policy_document" "policy_doc" {
  statement {
    sid = "bucket-policy"

    effect = "Allow"

    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/${var.user_name}"]
    }

    actions = [
      "s3:GetObject"
    ]

    resources = [
      "${aws_s3_bucket.s3_bucket.arn}/*"
    ]
  }
}

中身について少し補足する。

●data "aws_caller_identity" "current" {}

Data Source: aws_caller_identityに書かれている通り、Terraform を実行している自分自身の Account Id を参照する事ができるが、これで Bucket ポリシーの Resource 設定を動的に行っている

●variable "user_name" {}

Terraform の変数で、動的に値を設定するために使っている(今回は特に、外部に晒したくない情報を動的に設定するために使っている)

●data "aws_iam_policy_document" "policy_doc" {...}

頑張って JSON で書く事もできるが、チュートリアルCreate IAM Policiesで扱われているように、Data Source: aws_iam_policy_documentを使うとより IAM のポリシー設定が書きやすくなるのでこれを使っている。

●etag = filemd5("${path.module}/main.zip")

etagに書かれているように、値が変わると(=今回で言えば build して zip 化したアーティファクト)リソースの更新をトリガーさせるために設定している
※もし etag がないと、build してもリソース aws_s3_bucket_object は変更がないと判断されて、s3 にアーティファクトを upload できず CICD にならない

●${path.module}

Filesystem and Workspace Infoに書かれているように、 ${path.module} という構文を書いているファイルのディレクトリパスが自動的に補完される。

※Terraform の変数は、Assigning Values to Root Module Variablesに書かれている通り 3 通りの設定方法がある。一番簡単そうなので terraform apply -var "user_name=xxxxxxxx" のようにして設定するかもだが、Assign values with a terraform.tfvars fileに書かれている通り、terraform.tfvars というファイルに変数を定義して設定するのがいいらしい

 ただし、CICD 等でやる場合は terraform.tfvars は GitHub 等に上げないのがルールなので、-var オプションで設定する or 環境変数に TF_VAR*{変数名} を登録する、という事になる気がする(Terafform Cloud を使っている場合は -var オプションは使えず、Terafform Cloud の Variables で設定する。詳細はTerafform によるリソースの構築(GitHub Actions の CICD パイプライン)の項を参照。

●source = "${path.module}/main.zip"

How to upload a non-terraform file to Terraform cloudに書かれている通り、Terraform では terraform configure(.tf)が存在するディレクトリを root ディレクトリとして扱い、そのディレクトリだけを Terafform Cloud にアップロードして apply 等の作業を行う事になる。そのため、source = "${path.module}/../../main.zip" のようにした場合、Terafform Cloud に親・祖父母ディレクトリが upload されてないのでエラー(Error opening S3 bucket object source (./../../main.zip): open ./../../main.zip: no such file or directory)になるので注意。

※GitHub Actions の pipeline.yaml のTerafform によるリソースの構築(GitHub Actions の CICD パイプライン)の『cp main.zip terraform/s3bucket/main.zip, cp main.zip.base64sha256 terraform/s3bucket/main.zip.base64sha256』の方も参照。

$ tree
.
├── README.md
├── config.yaml
├── go.mod
├── go.sum
├── lambda.go
├── main
├── main.zip.base64sha256
└── terraform
    ├── lambda
    │   ├── main.tf
    │   └── terraform.tfstate.backup
    └── s3bucket
        ├── main.tf
        ├── main # <-ここに移動(コピー)する必要がある
        ├── main.zip.base64sha256 # <-ここに移動(コピー)する必要がある
        ├── terraform.tfstate.backup
        └── terraform.tfvars

・参考:Authentication
・参考:Resource: aws_s3_bucket
・参考:Resource: aws_s3_bucket_policy
・参考:Resource: aws_s3_bucket_object
・参考:AWS で Terraform に入門
・参考:Define Input Variables
・参考:Terraform Working Directory

Terraform で Lambda を作成する

本題の Lambda 関数の作成だが、以下のような .tf ファイルになる。

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

  required_version = ">= 0.14.9"

  backend "remote" {
    organization = "yuta_katayama"

    workspaces {
      name = "terraform-go-lambda-cicd-lambda"
    }
  }
}

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

variable "s3_bucket_name" {
  type    = string
  default = "terraform-build-artifact-go-lambda"
}

variable "s3_key_name" {
  type    = string
  default = "main.zip"
}

variable "s3_key_name_base64sha256" {
  type    = string
  default = "main.zip.base64sha256"
}

variable "backlog_api_key" {
  type = string
}

variable "backlog_domain" {
  type = string
}

variable "slack_api_token" {
  type = string
}

resource "aws_lambda_function" "go-lambda" {
  function_name    = "terraform-go-lambda"
  runtime          = "go1.x"
  handler          = "main"
  description      = "Lambda function create by aws terraform."
  s3_bucket        = var.s3_bucket_name
  s3_key           = var.s3_key_name
  source_code_hash = data.aws_s3_bucket_object.build_artifact.body
  role             = aws_iam_role.iam_for_lambda.arn

  environment {
    variables = {
      "BACKLOG_API_KEY"      = var.backlog_api_key
      "BACKLOG_DOMEIN"       = var.backlog_domain
      "BACKLOG_ISSUE_STATUS" = "未対応"
      "BACKLOG_STATUS_ID"    = "3"
      "SLACK_API_TOKEN"      = var.slack_api_token
      "SLACK_CHANNEL"        = "#lambda実行"
    }
  }
}

resource "aws_iam_role" "iam_for_lambda" {
  name               = "service-role-for-terraform-go-lambda"
  assume_role_policy = data.aws_iam_policy_document.assume_role_doc.json
  path               = "/service-role/"
}

resource "aws_iam_role_policy_attachment" "lambda_policy" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy" "lambda_execute_role_policy" {
  name   = "lambda_execute"
  role   = aws_iam_role.iam_for_lambda.id
  policy = data.aws_iam_policy_document.codecommit_policy.json
}

resource "aws_lambda_permission" "allow_cloudwatchevents" {
  statement_id  = "terraform-go-lambda-GoLambdaFunctionCodeCommitPushPermission"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.go-lambda.function_name
  principal     = "events.amazonaws.com"
  source_arn    = aws_cloudwatch_event_rule.codecommit_push.arn
}

resource "aws_cloudwatch_event_rule" "codecommit_push" {
  name          = "terraform-go-lambda-GoLambdaFunctionCodeCommitPush"
  description   = "when developer push to codecommit, trigger lambda function."
  event_pattern = <<EOF
{
        "source" : [
                "aws.codecommit"
        ],
        "detail" : {
            "eventSource" : [
                "codecommit.amazonaws.com"
            ]
        },
        "detail-type" : [
                "CodeCommit Repository State Change"
        ]
}
EOF
}

resource "aws_cloudwatch_event_target" "go_lambda_function_codecommit_push" {
  rule = aws_cloudwatch_event_rule.codecommit_push.name
  arn  = aws_lambda_function.go-lambda.arn
}

data "aws_iam_policy_document" "assume_role_doc" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

data "aws_iam_policy_document" "codecommit_policy" {
  statement {
    sid    = "CodeCommit"
    effect = "Allow"

    actions = [
      "codecommit:GetCommit"
    ]

    resources = [
      "arn:aws:codecommit:ap-northeast-1:${data.aws_caller_identity.current.account_id}:*",
    ]
  }
}

data "aws_s3_bucket_object" "build_artifact" {
  bucket = var.s3_bucket_name
  key    = var.s3_key_name_base64sha256
}

data "aws_caller_identity" "current" {}

output "go_lambda_function_arn" {
  value = aws_lambda_function.go-lambda.arn
}

output "implicit_ima_role_created_for_go_function" {
  value = aws_iam_role.iam_for_lambda.arn
}

output "cloud_watch_events_for_go_function" {
  value = aws_cloudwatch_event_rule.codecommit_push.arn
}

中身について少し補足すると、

●source_code_hash = data.aws_s3_bucket_object.build_artifact.body, data "aws_s3_bucket_object" "build_artifact"{}

リソースではなくデータソースData Source: aws_s3_bucket_objectなので注意。S3 Bucket にあるビルドアーティファクトの情報(Attributes Reference)を取得できるので、これでコードが更新された際にもリソース= Lambda 関数の更新をトリガーさせるようにしている。
実際には一工夫が必要で、source_code_hash は "Must be set to a base64-encoded SHA256 hash of the package file" と書かれているように、base64sha256 である必要があるので、S3 にそのファイルを content-type text/plain で配置しておきそれを参照するようにする必要がある。そのため、source_code_hash = data.aws_s3_bucket_object.build_artifact.body のようになっている。
※Lambda のコード変更だけでも deploy を実行させるために上記のようにしているが、S3 の vesioning を有効にして、s3_object_version を指定するようにすればコードの変更(= build artifact の更新で zip が新規バージョンになる) のでコードの更新で deploy が実行させる事も可能。

●aws_iam_policy_document

若干好みの問題ではあるが、JSON を inline で書く部分を data として別に切り出してかけるので個人的にはすっきりかけるのでこれで IAM のポリシーは記述した。

●"BACKLOG_API_KEY" = var.backlog_api_key など

これも variable "user_name" {} と同じで、外部に晒したくない情報を動的に設定している

●output "go_lambda_function_arn" {}など

Terafform で設定したリソースの情報が分からなくなるので、これでどのようなリソースを作成したのか?を terraform apply の際に log に出力させるようにしている(output の情報は terraform.tfstate にも記録される)

・参考:Resource: aws_lambda_function
・参考:Data Source: aws_iam_role
・参考: Data Source: aws_iam_policy_document
・参考:archive_file
・参考:aws_iam_role_policy_attachment
・参考:Resource: aws_lambda_permission
・参考:Resource: aws_cloudwatch_event_rule
・参考:Resource: aws_cloudwatch_event_target
・参考:Deploying AWS Lambda functions with Terraform: Just Don't

Terafform によるリソースの構築(GitHub Actions の CICD パイプライン)

AWS SAM・Cloud Formation による Build・Deploy と同様に、GitHub Actions 上で CICD を実行させるようにした。 GitHub Actions の pipeline.yaml は以下の通り。

name: lambda-build-deploy-with-terraform

on:
  push:
    branches: [terraform]

jobs:
  build-and-package:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Setup Go environment
        uses: actions/setup-go@v2.1.3
        with:
          go-version: "1.17.1"

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}

      - name: Go get aws lambda library
        run: go get github.com/aws/aws-lambda-go/lambda

      - name: Go build
        run: GOOS=linux go build -o main lambda.go

      - name: Create zip and cp to terraform configure directory(terraform working directory)
        run: |
          zip main.zip main config.yaml
          cp main.zip terraform/s3bucket/main.zip

      - name: Create base64sha256 and cp to terraform configure directory(terraform working directory)
        run: |
          openssl dgst -sha256 -binary main.zip | openssl enc -base64 | tr -d "\n" > main.zip.base64sha256
          cp main.zip.base64sha256 terraform/s3bucket/main.zip.base64sha256

      - name: Upload artifacts to artifact buckets
        run: |
          cd terraform/s3bucket
          terraform fmt -check
          terraform init
          terraform validate -no-color
          terraform apply -auto-approve > /dev/null

  deploy:
    needs: [build-and-package]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}

      - name: Terraform deploy(apply)
        run: |
          cd terraform/lambda
          terraform fmt -check
          terraform init
          terraform validate -no-color
          terraform apply -auto-approve > /dev/null

中身について少し補足すると、

●cp main.zip terraform/s3bucket/main.zip, cp main.zip.base64sha256 terraform/s3bucket/main.zip.base64sha256

まず、Terraform Cloud の working directory の仕組みで、terraform configure(.tf ファイル)があるディレクトリが root ディレクトリになり、そのディレクトリが Terafform Cloud に upload されて Terafform Cloud 上で apply 等の作業が実行されるという前提がある。そのため、今回で言えば、terraform configure の方で "で書いているpath.module/main.zip"で書いている{path.module} 以下は terraform/s3bucket/ になり、そこにファイルがないと Terafform Cloud 上に main.zip や main.zip.base64sha256 が upload されずエラーになる。
※この話があるので、Terraform で S3Bucket を作成するの『source = "${path.module}/main.zip"』の補足内容のようにしている。

●cd terraform/s3bucket, cd terraform/lambda

Switching working directory with -chdirを用いて、カレントディレクトリを変えずに terraform -chdir=terraform/lambda apply -auto-approve のようにする事も可能

●terraform apply -auto-approve > /dev/null

何もしないで terreaform apply をしてしまうと、リソースの state の差分が log 上に出てしまい、その中に aws の account id などが見えてほしくないものが含まれている事あるので、コマンドの出力を捨てている

※Terafform Cloud を用いている時、terraform apply -auto-approve -var user_name=xxxxx のように -var オプションで variable を上書きする方法は以下のようにエラーになるので、Terafform Cloud 上の Variables に設定をする。

╷
│ Error: Run variables are currently not supported
│
│ The "remote" backend does not support setting run variables at this time.
│ Currently the only to way to pass variables to the remote backend is by
│ creating a '*.auto.tfvars' variables file. This file will automatically be
│ loaded by the "remote" backend when the workspace is configured to use
│ Terraform v0.10.0 or later.

※今回はhashicorp/setup-terraform@v1を使って GitHub Actions 上で Terafform CLI を実行できるようにしたが、以下のように AWS の Credential を設定してそれを読み込ませる形でも同じ事は実現可能だが、Terafform Cloud の仕組みを使うのがいいと思われる。

- name: configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v1
  with:
    aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
    aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    aws-region: ap-northeast-1

※今回もまた、Golang 製 Lambda のための CICD を GitHub Actions で構築してみたと同じような考え方で Build・Deploy を区切った。

・参考:Automate Terraform with GitHub Actions
・参考:-auto-approve

参考文献

Providers
AWS Provider
Deploy Serverless Applications with AWS Lambda and API Gateway

まとめとして

Terraform を使って Lambda の CICD を構築する事ができた。Terafform は Terafform という言語なので少し学習が必要だが、ドキュメントも充実しており思ったよりは簡単に configratuion をかけた気がする。

IaC(CICD) の仕組みとして AWS Cloud Formation を使うか、Terafform を使うかは議論がありそうだが、個人的には Terafform でいいかなと思った(が、Terafform を使いこなすには AWS のサービスに関する知識が前提な気がするのでそれを考えると Cloud Formation で AWS のサービスの仕組みの設定を覚えてからの方がいいかもしれない・・・)。

__________________________________

執筆者プロフィール:Katayama Yuta
SaaS ERPパッケージベンダーにて開発を2年経験。 SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。 最近開発部門へ異動し、再び開発エンジニアに。座学で読み物を読むより、色々手を動かして試したり学んだりするのが好きなタイプ。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/