はじめに
需要があるのかわからなかったので、しばらく記事を途中まで書いて放置していたのですが、せっかく下書きにあるのと、誰かの役に立つかもしれないと思い、投稿します。
以前の記事FastAPI + nginx on ECS Fargateでは、ECS Fargateにデプロイするところを説明しましたが、本記事では、Github ActionsでRailsプロダクトをECS Fargateにデプロイしたので紹介します。アプリケーションがFastAPIだったり、Railsだったりしていますが、本質的には変わらないです。(仕事で両方並行して触る機会があり・・。)また、CodePipelineなどを用いてもいいと思いますが、今回は以下の「実現したいこと」の理由によりGithub Actionsでやってみました。
実現したいこと
- Github Actionsを用いて、ECS Fargateにデプロイしたい
- トリガーとして、developにmergeされたときなどという制御ではなく、任意のタイミングでデプロイしたい場合など、Github Actionsによる操作でデプロイできるのは便利。
- Docker Imageのタグは任意で設定できるようにしたい
- 例えばコミットのハッシュ値でタグを打つ例もありますが、自分たちでコントロールしたいなど。
- 任意のタイミングで(AWSアカウントを保持していない人でも)デプロイできるようにしたい
- 例えば業務委託の方にはAWSアカウントは付与していない場合、productionはともかくstagingはデプロイしてもらいたい場合など。
- タスク定義をAWSのマネジメントコンソールやterraformなどで変更した場合、その変更は取り込みたい(リポジトリにtask_definition.jsonファイルを保持しているとこちらも変更する必要があり、変更忘れのリスクがある)。
- 例えばインスタンスのスペックなどはアプリケーションエンジニア側ではなく、インフラ側や適切な権限を持つ人のみが管理したい。
- したがって、環境に反映されているタスク定義をベースに(DockerのImageタグだけつけかえて)デプロイしたい。
前提
いくつか前提条件を共有します。
- 環境はstgとprodの2環境ある(あるいは省略せずにstaging、production)
- stgとprodの2環境とも
RAILS_ENV
はproduction
で動かしたい-
config/environments/production.rb
の設定をstg
とprod
で同じにしたいため。
-
- 一方で、外部サービスとの接続情報といった秘匿値などは
stg
とprod
で異なるようにしたい - jsのビルドをDockerfile内で行なっているが、jsビルド時に環境変数を渡しており、
stg
とprod
で別のDocker Imageをビルドしている - 「実現したいこと」の4に関連するが、Github Actionsでのデプロイは、アプリケーション側の更新だけできればよく、インスタンスのスペック変更などはできる必要はない
2,3に関しては、秘匿値はRailsのcredentialsを利用して管理しますが、ECS Fargateの環境変数にTIER_ENV
というものを導入し、この変数でcredentialsの制御をしています。具体的には、config/environments/production.rb
にて
Rails.application.configure do
# 中略
config.credentials.content_path = Rails.root.join("config/credentials/#{ENV['TIER_ENV']}.yml.enc")
config.credentials.key_path = Rails.root.join("config/credentials/#{ENV['TIER_ENV']}.key")
end
のように設定しています。
環境ごとの制御とDocker Imageタグの任意設定
environmentに関しては、Githubの機能を利用します。Settings
→Code and automation
のEnvorionments
にて、production
とstaging
を定義しました。GithubActions内のビルドで用いる秘匿値に関しては、GithubのEnvironments内のEnvironment secretsで定義するようにします。例えば、
- AWS_IAM_ROLE_ARN: DockerのImageビルドやデプロイで利用するIAM ROLEのARN
- AWS_ACCOUNT_ID: AWSのACCOUNT_ID(そこまで秘匿する値ではないですが念のため)
- RAILS_MASTER_KEY: Railsのcredentialsでmaster.key
などの値をsecretsに定義しました。
Github Actionsでのデプロイ時には、環境(productionかstaging)を選択できるようにし、Docker Imageのタグも入力できるようにしたいので、.github/workflows/deploy.yml
には次のように定義しました。ここで、vertagというのがDockerのImageにつけるタグ名を格納する変数になります。
on:
workflow_dispatch:
inputs:
environment:
type: environment
default: staging
vertag:
required: true
そうすると、Github Actions上では、下図のようにenvironmentを選択できたりvertagを入力できるようになります。
また、設定したsecretsは以下のようにして環境変数にセットできます。(AWS_REGIONは秘匿値とも言えないのレベルなのでベタ書きしています)
env:
AWS_REGION: ap-northeast-1
AWS_IAM_ROLE_ARN: ${{ secrets.AWS_IAM_ROLE_ARN }}
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
タスク定義の自動登録とECS Fargateへのデプロイ
ここから先は、主に公式ドキュメント通りなのですが、少し異なる部分もあるので、載せていきます。
jobsの冒頭のConfigure AWS credentials
ですが、AWS_ACCESS_KEY_IDやAWS_SECRET_ACCESS_KEYを利用してもいいですが、自分の場合、AWS_IAM_ROLE_ARNを利用しています。
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_IAM_ROLE_ARN }}
次はECRへのログインですが、こちらは公式と同じですが、一部変数を用いています。
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@v1
env:
ECR_REGISTRY: ${{ env.ENV_PREFIX }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
run
の中では、Githubで設定したEnvironmentはproduction
やstaging
なのですが、AWS上では省略してprod
やstg
と使っている箇所もあるため、ENV_PREFIX
という変数を用意しています。run
の中の大まかな流れは、
- 環境に応じて環境変数を適宜定義
- Docker Imageのビルド
- 新しいタスク定義の登録
- CodeDeployを用いてデプロイ
となっています。ECSのデプロイの流れとしては、Docker Imageのビルド、タスク定義の登録、デプロイという流れになると思いますので、その流れを実施しているだけになります。
工夫点としては、新しいタスク定義の登録のところかと思います。
- name: my-app Build Image And Deploy To ECS
env:
VERTAG: ${{ github.event.inputs.vertag }}
TIER_ENV: ${{ github.event.inputs.environment }}
run: |
# 1. 環境変数を適宜定義
if [ "$TIER_ENV" = "production" ]; then
ENV_PREFIX=prod
# credential用のkeyをの準備
echo $RAILS_MASTER_KEY > config/credentials/production.key
else
ENV_PREFIX=stg
echo $RAILS_MASTER_KEY > config/credentials/staging.key
fi
## タスク定義を環境ごとに用意している
TASK_DEFINITION=my-app-$ENV_PREFIX
## CodeDeployのアプリケーション名とデプロイグループを変数に格納
CODE_DEPLOY_APPLICATION_NAME=$ENV_PREFIX-my-app_web
CODE_DEPLOY_DEPLOY_GROUP=$ENV_PREFIX-my-app_web
# 2. Docker Imageのビルド
## vertagで指定されたImageがある場合は、Docker Imageのビルドをスキップする
IMAGE_META=$(bash -c "aws ecr describe-images --repository-name=my-app_rails --image-ids=imageTag=$VERTAG 2> /dev/null" || true)
pwd
if [ "$IMAGE_META" = "" ]; then
TIER_ENV=$TIER_ENV docker compose -f docker-compose.ecs_fargate.yml build
docker tag my-app_rails:latest $AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:$VERTAG
docker push $AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:$VERTAG
fi
# 3. 新しいタスク定義の登録
# task-definition.jsonなどをリポジトリに保持していてもいいが、AWSコンソール上から更新された場合に戻ってしまうので、現在適用されている最新のタスク定義を持ってきて、さらに、DockerのImageタグのところをGithubAction実行時のvertagで指定されたものに変更して、input_json変数に格納している
input_json=$(aws ecs describe-task-definition --task-definition $TASK_DEFINITION | jq '.taskDefinition | del(.taskDefinitionArn,.status,.compatibilities,.requiresAttributes,.revision,.registeredAt,.registeredBy)' | jq --arg VERTAG $VERTAG '(.containerDefinitions[] | select(.name == "rails") | .image) |= "$AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:" + $VERTAG')
# 上記で格納したinput_jsonを利用して新しいタスク定義を登録
result_json=$(aws ecs register-task-definition --cli-input-json "$input_json")
# 4. CodeDeployを用いてデプロイ
# タスク定義のarnをresult_jsonから取得しtask_definition_arn変数に格納
task_definition_arn=$(echo $result_json | jq -r .taskDefinition.taskDefinitionArn)
aws deploy create-deployment \
--application-name $CODE_DEPLOY_APPLICATION_NAME \
--deployment-group-name $CODE_DEPLOY_DEPLOY_GROUP \
--region ap-northeast-1 \
--revision '{"revisionType": "AppSpecContent", "appSpecContent": {"content": "{\"version\": 1, \"Resources\": [{\"TargetService\": {\"Type\": \"AWS::ECS::Service\", \"Properties\": {\"TaskDefinition\": \"'$task_definition_arn'\", \"LoadBalancerInfo\": {\"ContainerName\": \"nginx\", \"ContainerPort\": 80}}}}]}"}}'
「3. 新しいタスク定義の登録」のところをもう少し詳細に
この処理の中で大切なのは、「3. 新しいタスク定義の登録」になりますので、ここの部分を取り出して説明します。
aws ecs describe-task-definition --task-definition $TASK_DEFINITION
を実行すると、具体的には、
{
"taskDefinition": {
"taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:xxxxxxxxxxxx:task-definition/my-app:123",
"containerDefinitions": [
{
"name": "rails",
"image": "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:1.0.0.1",
"cpu": 0,
"portMappings": [],
"essential": true,
"environment": [
{
"name": "TIER_ENV",
"value": "staging"
},
# その他の環境変数
],
"mountPoints": [],
"volumesFrom": [],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/aws/ecs/stg-my-app",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "container-stdout"
}
}
},
{
"name": "nginx",
"image": "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_nginx:0.0.1",
"cpu": 0,
"portMappings": [
{
"containerPort": 80,
"hostPort": 80,
"protocol": "tcp"
}
],
"essential": true,
"environment": [],
"mountPoints": [],
"volumesFrom": [
{
"sourceContainer": "rails"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/aws/ecs/stg-my-app",
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "container-stdout"
}
}
}
],
"family": "my-app-stg",
"taskRoleArn": "arn:aws:iam::xxxxxxxxxxxx:role/stg-my-app-task",
"executionRoleArn": "arn:aws:iam::xxxxxxxxxxxx:role/stg-my-app-task-execution",
"networkMode": "awsvpc",
"revision": 463,
"volumes": [],
"status": "ACTIVE",
"requiresAttributes": [
{
"name": "com.amazonaws.ecs.capability.logging-driver.awslogs"
},
{
"name": "ecs.capability.execution-role-awslogs"
},
{
"name": "com.amazonaws.ecs.capability.ecr-auth"
},
{
"name": "com.amazonaws.ecs.capability.docker-remote-api.1.19"
},
{
"name": "com.amazonaws.ecs.capability.task-iam-role"
},
{
"name": "ecs.capability.execution-role-ecr-pull"
},
{
"name": "com.amazonaws.ecs.capability.docker-remote-api.1.18"
},
{
"name": "ecs.capability.task-eni"
}
],
"placementConstraints": [],
"compatibilities": [
"EC2",
"FARGATE"
],
"requiresCompatibilities": [
"FARGATE"
],
"cpu": "1024",
"memory": "2048",
"registeredAt": "2023-04-07T18:08:30.362000+09:00",
"registeredBy": "arn:aws:sts::xxxxxxxxxxxx:assumed-role/my_app_from_github_actions/GitHubActions"
},
"tags": []
}
の様なJSONが帰ってきます。ここでデプロイ時に必要なのは、containerDefinitions
, family
, taskRoleArn
, executionRoleArn
, networkMode
, volumes
, placementConstraints
, requiresCompatibilities
, cpu
, memory
なので、
aws ecs describe-task-definition --task-definition $TASK_DEFINITION
| jq '.taskDefinition | del(.taskDefinitionArn,.status,.compatibilities,.requiresAttributes,.revision,.registeredAt,.registeredBy)'
のようにjq
を利用することで不要な属性を取り除き、以下の様なJSONを得ることができます。
{
"containerDefinitions": [
{
"name": "rails",
"image": "xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:stg-1.3.1.2"
// 略
}
],
"family": "my-app-stg",
"taskRoleArn": "arn:aws:iam:: xxxxxxxxxxxx:role/stg-my-app-task",
"executionRoleArn": "arn:aws:iam::570924330089:role/stg-my-app-task-execution",
"networkMode": "awsvpc",
"volumes": [],
"placementConstraints": [],
"requiresCompatibilities": [
"FARGATE"
],
"cpu": "1024",
"memory": "2048"
}
さらに、DockerImageのタグを変更してあげる必要があるので、再びjq
を利用し、Imageタグを変更しています。
| jq --arg VERTAG $VERTAG '(.containerDefinitions[] | select(.name == "rails") | .image) |= "$AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:" + $VERTAG')
ファイルサンプル
参考までに、docker compose
のymlとGithub Actionsで利用したWorkflowのymlを提示しておきます。docker compose
はRailsだけでなくnginxのイメージもビルドしたいため、利用していますが、単一イメージで問題なければdocker build
で良いと思います。
docker-compose.ecs_fargate.yml
version: '3.4'
services:
rails:
build:
context: .
dockerfile: Dockerfile-ecs-fargate
args:
TIER_ENV: $TIER_ENV
environment:
RAILS_ENV: production
RAILS_LOG_TO_STDOUT: 'true'
nginx:
build:
context: .
dockerfile: ./ecs_fargate/nginx/Dockerfile
.github/workflows/deploy.yml
name: my-app Deploy To ECS
on:
workflow_dispatch:
inputs:
environment:
type: environment
default: staging
vertag:
required: true
env:
AWS_REGION: ap-northeast-1
AWS_IAM_ROLE_ARN: ${{ secrets.AWS_IAM_ROLE_ARN }}
AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.AWS_IAM_ROLE_ARN }}
- name: Login to Amazon ECR
uses: aws-actions/amazon-ecr-login@v1
env:
ECR_REGISTRY: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
- name: my-app Build Image And Deploy To ECS
env:
VERTAG: ${{ github.event.inputs.vertag }}
TIER_ENV: ${{ github.event.inputs.environment }}
run: |
if [ "$TIER_ENV" = "production" ]; then
ENV_PREFIX=prod
echo $RAILS_MASTER_KEY > config/credentials/production.key
else
ENV_PREFIX=stg
echo $RAILS_MASTER_KEY > config/credentials/staging.key
fi
TASK_DEFINITION=my-app-${ENV_PREFIX}
CODE_DEPLOY_APPLICATION_NAME=$ENV_PREFIX-my-app_web
CODE_DEPLOY_DEPLOY_GROUP=$ENV_PREFIX-my-app_web
IMAGE_META=$(bash -c "aws ecr describe-images --repository-name=my-app_rails --image-ids=imageTag=$VERTAG 2> /dev/null" || true)
pwd
if [ "$IMAGE_META" = "" ]; then
TIER_ENV=$TIER_ENV docker compose -f docker-compose.ecs_fargate.yml build
docker tag my-app_rails:latest $AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:$VERTAG
docker push $AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:$VERTAG
fi
input_json=$(aws ecs describe-task-definition --task-definition $TASK_DEFINITION | jq '.taskDefinition | del(.taskDefinitionArn,.status,.compatibilities,.requiresAttributes,.revision,.registeredAt,.registeredBy)' | jq --arg VERTAG $VERTAG '(.containerDefinitions[] | select(.name == "rails") | .image) |= "$AWS_ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/my-app_rails:" + $VERTAG')
result_json=$(aws ecs register-task-definition --cli-input-json "$input_json")
task_definition_arn=$(echo $result_json | jq -r .taskDefinition.taskDefinitionArn)
echo $task_definition_arn
aws deploy create-deployment \
--application-name $CODE_DEPLOY_APPLICATION_NAME \
--deployment-group-name $CODE_DEPLOY_DEPLOY_GROUP \
--region ap-northeast-1 \
--revision '{"revisionType": "AppSpecContent", "appSpecContent": {"content": "{\"version\": 1, \"Resources\": [{\"TargetService\": {\"Type\": \"AWS::ECS::Service\", \"Properties\": {\"TaskDefinition\": \"'$task_definition_arn'\", \"LoadBalancerInfo\": {\"ContainerName\": \"nginx\", \"ContainerPort\": 80}}}}]}"}}'