会社でGitHubをソースコードの管理として、AWSをインフラ基盤としてつかっているのですが、今回ECSを用いて環境を構築する事になり、以前試験的に運用していたサービスで構築していたCodePipelineをつかったデプロイフローを参考に構築していっておりました。 ただ、あるタイミングで、「これってGithub Actionつかったほうがいいよね」って思うタイミングがあり、全面的に構築を変えたので、その経緯と意思決定の理由を記事にします。

CodePipelineにしてた理由:AWS ECSと親和性が高かった。

御存知の通り、会社ではTerraformを用いてIaCをしています。 ECSのアプリケーションを構築する際、デプロイのたびにTaskDefinitionというjsonファイルに記述したimageのタグを最新にしてデプロイする必要があります。 通常であれば

  1. TaskDefinitionを新たに作り直して、新しいバージョンで保存
  2. ECSのサービスの中のTaskDefinitionのバージョンを変更

というプロセスが必要となるのですが、CodePipelineでは、imagedefinitions.jsonという、コンテナ名とimageのタグだけが記述されたjsonファイルを入力とし、下記のように設定を書くだけで、デプロイ環境を構築することができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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を指定する必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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がそれを扱えるようにします

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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化している前提)

 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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に、下記のファイルを設置しておきます。

  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
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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を使うことで、下記のような恩恵を受けることができます。

  1. Stepごとにマーケットプレイスで提供されているものを使えるため、よく使うテンプレートのようなものは、単にそれを呼び出すだけで使える
  2. if構文が使えるので、細かい条件をもとに実行する/しないをstepごとやjobごとに設定できる
  3. ソースの提供、トリガーのタイミング、Commit Statusの反映がほぼ何もせずとも実装できる←でかい

ほかにも色々ありますが、細かい仕様は 公式ドキュメント を参考にしてください。

AWS、Terraformとの連携方法

当然Github Action側ではAWSのIAM Roleを直接アタッチしたりはできません。Github Action用に適度な権限を付与されたユーザを作成し、それのアクセスキーをつかって諸々のリソースをいじることになります。 Terraformではそのアクセスキーを生成するところまでを責務とするのが良さそうです。

また、ECSで用いるTaskDefinitionは、Terraform側でベースを作成したほうが何かと都合がいいので、S3にベースとなるTaskDefinitionを設置するまでを行い、Github Actionではそれをつかって更新するようにします。

ECRに対しての更新処理の権限

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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
  }
}
 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
36
37
{
  "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を更新する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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
  }
}
 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
{
  "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から無理やり生成している感じになっています。

 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
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
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "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すれば自動でデプロイされます。

 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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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をおとなしくつかったほうがいいかなと思います。