こちらは下記のブログの転載です。
会社でGitHubをソースコードの管理として、AWSをインフラ基盤としてつかっているのですが、今回ECSを用いて環境を構築する事になり、以前試験的に運用していたサービスで構築していたCodePipelineをつかったデプロイフローを参考に構築していっておりました。 ただ、あるタイミングで、「これってGithub Actionつかったほうがいいよね」って思うタイミングがあり、全面的に構築を変えたので、その経緯と意思決定の理由を記事にします。
CodePipelineにしてた理由:AWS ECSと親和性が高かった。
御存知の通り、会社ではTerraformを用いてIaCをしています。 ECSのアプリケーションを構築する際、デプロイのたびにTaskDefinitionというjsonファイルに記述したimageのタグを最新にしてデプロイする必要があります。 通常であれば
- TaskDefinitionを新たに作り直して、新しいバージョンで保存
- ECSのサービスの中のTaskDefinitionのバージョンを変更
というプロセスが必要となるのですが、CodePipelineでは、imagedefinitions.jsonという、コンテナ名とimageのタグだけが記述されたjsonファイルを入力とし、下記のように設定を書くだけで、デプロイ環境を構築することができます。
resource "aws_codepipeline" "main" { stage { ... // imagedefinitions.jsonを出力するなにか。大抵はCodeBuildで出力する } stage { name = "Deploy" action { category = "Deploy" configuration = { ClusterName = aws_ecs_cluster.main.name ServiceName = aws_ecs_service.main.name } input_artifacts = [ "BuildArtifact" // imagedefinitions.jsonが入っている想定 ] name = "Deploy" output_artifacts = [] owner = "AWS" provider = "ECS" run_order = 1 version = "1" } } }
当然ほかのリソースもTerraformで記述できるため、単一のTerraformのソースコード配下だけで全てが完結してかけるようになります。
CodePipelineの壁:GitHubと接続する3つの方法
ただ、CodePipeline単体だけでは意味がありません。GitHubを使用してソースコードを管理しているので、GitHubとはうまく連携させたいですね。 そうなった場合に、CodePipelineとは3つの方法で連携する必要があります
1. GitHubからソースを持ってくる
当然ソースをGitHubから持ってこないとビルドもできません。
下記のようにCodePipelineにステージを追加し、category Sourceとして、GitHubを指定する必要があります。
resource "aws_codepipeline" "main" { stage { name = "Source" action { category = "Source" configuration = { Branch = "master" Owner = "kotamat" PollForSourceChanges = false Repo = var.repo_name OAuthToken = var.github_token } name = "Source" owner = "ThirdParty" output_artifacts = [ "SourceArtifact" ] provider = "GitHub" version = "1" } } ... }
OAuthTokenには、GitHubからリソースをとってこれる権限を持ったPrivate Access Tokenを発行し付与する必要があります。 こちらではvarで指定していますが、必要に応じてSSMのParameterに入れてもいいかもしれません。
2. GitHubからソースをとってくるタイミングをトリガーする
1だけでは、手動でCodePipelineを起動しない限りソースをとってくることはできません PollForSourceChangesをtrueにすればポーリングすることはできますが、パッシブにトリガーすることはできません。
GitHubの方に存在するWebhookの機能をつかって、Githubで発火した任意のタイミングを補足し、CodePipelineがそれを扱えるようにします
resource "aws_codepipeline_webhook" "main" { authentication = "GITHUB_HMAC" name = local.base_name target_action = "Source" target_pipeline = aws_codepipeline.main.name authentication_configuration { secret_token = local.secret } filter { json_path = "$.ref" match_equals = "refs/heads/{Branch}" } } resource "github_repository_webhook" "main" { configuration { url = aws_codepipeline_webhook.main.url content_type = "json" insecure_ssl = true secret = local.secret } events = ["push"] repository = var.repo_name }
この2つを追加するだけでWebhookの処理を作ることができるので、Terraformで扱うのは簡単ですね。 GitHubのwebhookを使うためにはgithubのproviderを設定する必要があります。
3. CodePipelineの結果をCommit Statusに反映する
一応上記でデプロイはできるようになるのですが、デプロイが正常に完了したかどうかをGitHubで確認したくなると思います。 こちらは、CodePipelineの完了を、CloudWatchのEvent Targetで補足し、その結果をLambdaに流してLambdaからGithubのCommitStatusを更新するということをする必要があります。
機能として完全に独立しているのでmodule化して実装するのがいいかと思います。(以下はmodule化している前提)
data "archive_file" "lambda" { source_file = "${path.module}/lambda/${var.function_name}.js" output_path = "${path.module}/lambda/${var.function_name}.zip" type = "zip" } resource "aws_cloudwatch_event_target" "main" { arn = aws_lambda_function.main.arn rule = aws_cloudwatch_event_rule.main.name } resource "aws_lambda_permission" "main" { action = "lambda:InvokeFunction" function_name = aws_lambda_function.main.function_name principal = "events.amazonaws.com" } data "template_file" "event_pattern" { template = file("${path.module}/rule/event_pattern.json") vars = { codepipeline_arn = var.codepipeline_arn } } resource "aws_cloudwatch_event_rule" "main" { event_pattern = data.template_file.event_pattern.rendered } resource "aws_iam_role" "main" { assume_role_policy = file("${path.module}/policies/assume_lambda.json") } resource "aws_iam_role_policy_attachment" "codepipeline_readonly" { policy_arn = "arn:aws:iam::aws:policy/AWSCodePipelineReadOnlyAccess" role = aws_iam_role.main.name } resource "aws_lambda_function" "main" { function_name = var.function_name handler = "${var.function_name}.handler" role = aws_iam_role.main.arn runtime = "nodejs12.x" filename = data.archive_file.lambda.output_path source_code_hash = base64sha256(filesha256(data.archive_file.lambda.output_path)) timeout = 300 environment { variables = { ACCESS_TOKEN = var.github_token } } }
上記のlambdaのfilepathに、下記のファイルを設置しておきます。
const aws = require('aws-sdk'); const axios = require('axios'); const BaseURL = 'https://api.github.com/repos'; const codepipeline = new aws.CodePipeline(); const Password = process.env.ACCESS_TOKEN; exports.handler = async event => { console.log(event); const { region } = event; const pipelineName = event.detail.pipeline; const executionId = event.detail['execution-id']; const state = transformState(event.detail.state); if (state === null) { return null; } const result = await this.getPipelineExecution(pipelineName, executionId); const payload = createPayload(pipelineName, region, state); try { return await this.postStatusToGitHub(result.owner, result.repository, result.sha, payload); } catch (error) { console.log(error); return error; } }; function transformState(state) { if (state === 'STARTED') { return 'pending'; } if (state === 'SUCCEEDED') { return 'success'; } if (state === 'FAILED') { return 'failure'; } return null; } function createPayload(pipelineName, region, status) { console.log('status', status); let description; if (status === 'started') { description = 'Build started'; } else if (status === 'success') { description = 'Build succeeded'; } else if (status === 'failure') { description = 'Build failed!'; } return { state: status, target_url: buildCodePipelineUrl(pipelineName, region), description, context: 'continuous-integration/codepipeline', }; } function buildCodePipelineUrl(pipelineName, region) { return `https://${region}.console.aws.amazon.com/codepipeline/home?region=${region}#/view/${pipelineName}`; } exports.getPipelineExecution = async (pipelineName, executionId) => { const params = { pipelineName, pipelineExecutionId: executionId, }; const result = await codepipeline.getPipelineExecution(params).promise(); const artifactRevision = result.pipelineExecution.artifactRevisions[0]; const revisionURL = artifactRevision.revisionUrl; const sha = artifactRevision.revisionId; const pattern = /github.com\/(.+)\/(.+)\/commit\//; const matches = pattern.exec(revisionURL); return { owner: matches[1], repository: matches[2], sha, }; }; exports.postStatusToGitHub = async (owner, repository, sha, payload) => { const url = `/${owner}/${repository}/statuses/${sha}`; const config = { baseURL: BaseURL, headers: { 'Content-Type': 'application/json', }, auth: { password: Password, }, }; try { const res = await axios.post(url, payload, config); console.log(res); return { statusCode: 200, body: JSON.stringify(res), }; } catch (e) { console.log(e); return { statusCode: 400, body: JSON.stringify(e), }; } };
あとはポリシーとか諸々をいい感じに設定しておきます。 はい、以上です。
結論、めんどい
はい、めんどいです。(特に最後のCommit Statusへの反映のあたりとか特に) 更に、CodePipelineはTerraformで記述していくのですが、stepの中で条件を達成したら発動するものみたいなのがあった場合にそれをいい感じに表現することはできないです。 また、CodePipelineだけではビルドはできないので、ほかのサービスと連携することが求められます。
そこでGitHub Actions
GitHub Actionsとは、GitHubが提供している、CI/CDの仕組みです。ソースコードの .github/workflows/**.yml
に適切なymlを書くだけで、簡単にCI/CDを実現することができます。
GitHub Actionsを使うことで、下記のような恩恵を受けることができます。
- Stepごとにマーケットプレイスで提供されているものを使えるため、よく使うテンプレートのようなものは、単にそれを呼び出すだけで使える
- if構文が使えるので、細かい条件をもとに実行する/しないをstepごとやjobごとに設定できる
- ソースの提供、トリガーのタイミング、Commit Statusの反映がほぼ何もせずとも実装できる←でかい
ほかにも色々ありますが、細かい仕様は 公式ドキュメント を参考にしてください。
AWS、Terraformとの連携方法
当然Github Action側ではAWSのIAM Roleを直接アタッチしたりはできません。Github Action用に適度な権限を付与されたユーザを作成し、それのアクセスキーをつかって諸々のリソースをいじることになります。 Terraformではそのアクセスキーを生成するところまでを責務とするのが良さそうです。
また、ECSで用いるTaskDefinitionは、Terraform側でベースを作成したほうが何かと都合がいいので、S3にベースとなるTaskDefinitionを設置するまでを行い、Github Actionではそれをつかって更新するようにします。
ECRに対しての更新処理の権限
resource "aws_iam_user_policy_attachment" "can_handle_ecr" { policy_arn = aws_iam_policy.actions_for_ecr.arn user = var.iam_user_name } resource "aws_iam_policy" "actions_for_ecr" { name = "${var.base_name}-github-actions-ecr" policy = data.template_file.actions_for_ecr.rendered } data template_file "actions_for_ecr" { template = file("${path.module}/policies/actions-for-ecr.json") vars = { ecr_arn = var.ecr_arn } }
{ "Version":"2012-10-17", "Statement":[ { "Sid":"GetAuthorizationToken", "Effect":"Allow", "Action":[ "ecr:GetAuthorizationToken" ], "Resource":"*" }, { "Sid":"AllowPull", "Effect":"Allow", "Action":[ "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability" ], "Resource":"${ecr_arn}" }, { "Sid":"AllowPush", "Effect":"Allow", "Action":[ "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability", "ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload" ], "Resource":"${ecr_arn}" } ] }
ECRからimageをとってきてTaskDefinitionを更新する
resource "aws_iam_user_policy_attachment" "deploy_task_definition" { policy_arn = aws_iam_policy.actions_for_deploy_task_definition.arn user = var.iam_user_name } resource "aws_iam_policy" "actions_for_deploy_task_definition" { policy = data.template_file.actions_for_ecs_deploy_task_definition.rendered name = "${var.base_name}-github-actions-task-definition" } data template_file "actions_for_ecs_deploy_task_definition" { template = file("${path.module}/policies/ecs-deploy-task-definition.json") vars = { ecs_service_arn = var.ecs_service_arn task_execution_role_arn = var.task_execution_role_arn task_role_arn = var.task_role_arn } }
{ "Version":"2012-10-17", "Statement":[ { "Sid":"RegisterTaskDefinition", "Effect":"Allow", "Action":[ "ecs:RegisterTaskDefinition" ], "Resource":"*" }, { "Sid":"PassRolesInTaskDefinition", "Effect":"Allow", "Action":[ "iam:PassRole" ], "Resource":[ "${task_role_arn}", "${task_execution_role_arn}" ] }, { "Sid":"DeployService", "Effect":"Allow", "Action":[ "ecs:UpdateService", "ecs:DescribeServices" ], "Resource":[ "${ecs_service_arn}" ] } ] }
TaskDefinitionのjsonファイルの生成
s3 bucket objectに指定しているcontentがだいぶカオスな感じになっていますが、
これは aws_ecs_task_definition
がjson出力をサポートしていないため、container_definitionsから無理やり生成している感じになっています。
resource "aws_s3_bucket_object" "task_definition" { bucket = var.task_definition_bucket key = var.task_definition_filename content = jsonencode({ containerDefinitions = jsondecode(aws_ecs_task_definition.main.container_definitions) networkMode : aws_ecs_task_definition.main.network_mode requiresCompatibilities : aws_ecs_task_definition.main.requires_compatibilities taskRoleArn : aws_ecs_task_definition.main.task_role_arn executionRoleArn : aws_ecs_task_definition.main.execution_role_arn family : aws_ecs_task_definition.main.family cpu : aws_ecs_task_definition.main.cpu memory : aws_ecs_task_definition.main.memory }) acl = "private" } data "template_file" "download_task_definition_policy" { template = file("${path.module}/policies/download-task-definition.json") vars = { bucket = var.task_definition_bucket } } resource "aws_iam_policy" "download_task_definition" { policy = data.template_file.download_task_definition_policy.rendered } resource "aws_iam_user_policy_attachment" "task_definition" { policy_arn = aws_iam_policy.download_task_definition.arn user = var.iam_user_name }
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:GetObject", "s3:ListBucket" ], "Resource": "arn:aws:s3:::${bucket}/*" } ] }
GitHub Actions側の設定
あとは下記のようなymlを .github/actions/
に追加するだけです。
上記で生成されたACCESS_KEY_IDとSECRETをgithubのリポジトリの設定に追加してmasterブランチでpushすれば自動でデプロイされます。
name: Deploy to ECS on: push: branches: - master jobs: stg: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - 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 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 # build - name: Build, tag, and push image to Amazon ECR id: build env: ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: my-repository IMAGE_TAG: ${{ github.sha }} run: | docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" # deploy - name: Download base of task definition file run: | aws s3 cp s3://bucket-of-task-definition/task-definition.json task-definition.json - name: Render Amazon ECS task definition id: render-container uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: task-definition.json container-name: my-container image: ${{ steps.build.outputs.image }} - name: Deploy to Amazon ECS service uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: task-definition: ${{ steps.render-container.outputs.task-definition }} service: ecs-service cluster: ecs-cluster - name: Logout of Amazon ECR if: always() run: docker logout ${{ steps.login-ecr.outputs.registry }}
まとめ
なかなか長くなってしまいましたが、GitHubをつかっているのであれば、CI/CDはGitHub Actionsをおとなしくつかったほうがいいかなと思います。