会社でGitHubをソースコードの管理として、AWSをインフラ基盤としてつかっているのですが、今回ECSを用いて環境を構築する事になり、以前試験的に運用していたサービスで構築していたCodePipelineをつかったデプロイフローを参考に構築していっておりました。
ただ、あるタイミングで、「これってGithub Actionつかったほうがいいよね」って思うタイミングがあり、全面的に構築を変えたので、その経緯と意思決定の理由を記事にします。
CodePipelineにしてた理由:AWS ECSと親和性が高かった。
御存知の通り、会社ではTerraformを用いてIaCをしています。
ECSのアプリケーションを構築する際、デプロイのたびにTaskDefinitionというjsonファイルに記述したimageのタグを最新にしてデプロイする必要があります。
通常であれば
- TaskDefinitionを新たに作り直して、新しいバージョンで保存
- 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を使うことで、下記のような恩恵を受けることができます。
- Stepごとにマーケットプレイスで提供されているものを使えるため、よく使うテンプレートのようなものは、単にそれを呼び出すだけで使える
- if構文が使えるので、細かい条件をもとに実行する/しないをstepごとやjobごとに設定できる
- ソースの提供、トリガーのタイミング、Commit Statusの反映がほぼ何もせずとも実装できる←でかい
ほかにも色々ありますが、細かい仕様は 公式ドキュメント を参考にしてください。
当然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をおとなしくつかったほうがいいかなと思います。
Author
kotamat
LastMod
2020-05-14
(2d9a08a)