ECS 前編: Fargateでコンテナ実行基盤を整える
前章で、Tasuku の ALB は HTTPS の入口を持ちました。ただし 443 番のリスナーは固定文字列を返すだけで、その奥にはまだ何も実体がありません。この章では、リクエストを実際に処理するコンテナの実行基盤を作ります。ECS のクラスタとタスク定義、イメージの置き場となる ECR リポジトリ、コンテナに権限を与える 2 種類の IAM ロール、ログの送り先となる CloudWatch Logs です。ALB との接続や常時稼働するサービス化は次の章に譲り、この章では単発のタスクを起動して基盤が機能することを確かめるところまでを扱います。
8.1. ECSの基本概念: クラスタ・タスク定義・タスク・サービスの整理
ECS (Elastic Container Service) は、コンテナの実行基盤です。4 つの概念の関係を先に整理します。
- タスク定義: コンテナの構成を記述する JSON 形式の設計図です。使うイメージ、CPU・メモリ、ログの送り先などを指定します
- タスク: タスク定義をクラスタ内で実際にインスタンス化したものです1
- サービス: 指定した数のタスクを維持し続ける仕組みです。タスクが落ちれば、サービスが自動的に代わりのタスクを起動します1
- クラスタ: サービスや単独のタスクをまとめる論理的なグループです1
この章で扱うのは、クラスタとタスク定義、そして動作確認のための単発のタスクまでです。サービスとしての常時稼働は第10章に譲ります。 前編でタスク定義とその実行に必要な権限・ログの基盤を固め、後編で ALB と接続した常駐サービスに仕立てるという順序です。
8.2. なぜFargateか
ECS には、コンテナをどこで動かすかを決める起動タイプが複数あります。この連載ではFargateを使います。第1章で予告した通りです。2
もう 1 つの主要な選択肢が EC2 起動タイプです。EC2 起動タイプでは、コンテナを乗せる EC2 インスタンス自体を自分で用意し、OS のパッチ適用やインスタンスサイズの調整を継続的に行う必要があります。Fargate はこの管理を AWS 側に任せるサーバーレスの実行基盤で、タスクごとに専用の Linux カーネル・CPU・メモリ・ネットワークインターフェースを持ち、他のタスクと共有しません。3 Tasuku の規模 (数十テナント、同時アクセス数百ユーザー程度) では、インスタンスの管理コストをかける理由がないため、Fargate を選びます。
8.3. ECR: イメージの置き場
ECR (Elastic Container Registry) は、コンテナイメージを保管する AWS のレジストリです。タスク定義が参照するイメージは、このリポジトリに置きます。
第1章1.3 節で述べた通り、この連載では Tasuku のアプリケーションコードを書きません。そのため、この章の ECR リポジトリには実際の web/api イメージではなく、動作確認用のプレースホルダーイメージを置きます。使うイメージは 8.9 節で説明します。
ECR へイメージを push するには、まず Docker クライアントをレジストリに対して認証する必要があります。認証トークンはaws ecr get-login-passwordコマンドで取得し、docker loginにパイプで渡します。4
aws ecr get-login-password --region ap-northeast-1 \
| docker login --username AWS --password-stdin <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com
このトークンは 12 時間で失効します。4 長時間の作業では再認証が必要になる点に注意してください。
リポジトリのimage_tag_mutability(タグの上書き可否) は、省略した場合MUTABLE(上書き可能) が既定値です。5 本番運用ではタグの固定 (IMMUTABLE) を検討する余地がありますが、この章では基盤を学ぶことに主眼を置き、既定値のまま進めます。
8.4. 実行ロールとタスクロール
第3章3.4 節で、ECS 向けに 2 種類の IAM ロールを見込んでいると予告しました。ここでその具体的な値を見ます。
タスク実行ロール (execution role) は、ECS のコンテナエージェントと Fargate エージェントが、ユーザーに代わって AWS API を呼び出すためのロールです。ECR からのイメージ取得や、awslogsドライバによる CloudWatch Logs へのログ送信に使われます。コンテナの中からこのロールの権限に直接アクセスはできません。6
タスクロール (task role) は、コンテナの中で動くアプリケーションコード自身が使う権限です。実行ロールとは別物で、タスク定義ごとに個別のロールを用意し、必要最小限の権限だけを与えることが推奨されています。7 この章のタスクロールには、まだ何のポリシーもアタッチしません。第3章3.4 節の予告表で、タスクロールへの権限追加は第 10 章 (S3 添付バケットへのアクセス) と明記した通りです。この章ではロールの入れ物と信頼ポリシーだけを作ります。
どちらのロールも、信頼ポリシーの Principal はecs-tasks.amazonaws.comです。8 このサービスプリンシパルが、ロールを引き受けられる相手を「ECS タスク」に限定します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "ecs-tasks.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
8.5. 実行ロールの権限設計: マネージドポリシーかカスタムポリシーか
タスク実行ロールに与える権限には、AWS が用意するAmazonECSTaskExecutionRolePolicyという管理ポリシーを使う方法があります。このポリシーの中身は、ECR の認証・イメージ取得と、CloudWatch Logs へのログ書き込みに絞られた 6 つのアクションです。9
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "*"
}
]
}
ただし、この管理ポリシーをそのままアタッチする方法は、Tasuku の terraform 実行者では選べません。第3章で、terraform 実行者が AWS 管理ポリシーを含む任意のポリシーを自由にアタッチできないよう、iam:AttachRolePolicyを/tasuku/配下の自作ポリシーだけに制限したためです。この制限を緩めるには、terraform_iam_managementポリシー自体の内容を書き換える必要がありますが、それにはiam:CreatePolicyVersionという権限が要ります。10 この権限は、terraform 実行者が自分自身の権限を書き換えられる経路を塞ぐという第3章3.6 節の設計判断により、意図的に与えていません。つまり、確立済みの terraform 実行者のままでは、管理ポリシーへの切り替え自体が apply 不可能です。
そこで、この章ではAmazonECSTaskExecutionRolePolicy相当の権限を、/tasuku/配下の自前のaws_iam_policyとして新規に定義し、実行ロールにアタッチします。この方法なら第3章のポリシーには一切触れず、iam:CreatePolicyとiam:AttachRolePolicyという既に許可済みの権限の範囲内で完結します。加えて、自前で定義する分、権限を ECR リポジトリ・ロググループの単位まで絞り込めます。ecr:GetAuthorizationTokenはリソースレベル権限に対応しないアクションのため、Resource "*"が必須です。11 一方、ecr:BatchCheckLayerAvailability/ecr:GetDownloadUrlForLayer/ecr:BatchGetImageの pull 系アクションはリポジトリの ARN にスコープできます。12 logs:CreateLogStream/logs:PutLogEventsも、ロググループ ARN の末尾に:*を付けた形にスコープできます。13
自前ポリシーの内容が AWS 管理ポリシーの権限に追従しない (AWS が将来ポリシーの中身を拡張しても自動的には反映されない) 点は、この方式の対称的なトレードオフです。第 10 章で Secrets Manager からの読み取り権限を実行ロールに追加する際も、このポリシー自体を書き換えるのではなく、別の新規ポリシーとして追加する方針にします。1 つのロールに複数のポリシーをアタッチすることは、IAM の正規の使い方です。
8.6. ログ設計: CloudWatch Logsとawslogs
コンテナの標準出力を CloudWatch Logs へ送るには、タスク定義のログ設定でawslogsドライバを指定します。必要なオプションは、送信先リージョンを示すawslogs-region(必須)14、送信先ロググループを示すawslogs-group、ログストリームの識別しやすさのために付けるawslogs-stream-prefixです。14
ロググループは、この章の Terraform コードで明示的に作成します。CloudWatch Logs のロググループは、保持期間を指定しなければ「失効しない」が既定の挙動です。15 ログを無期限に貯め続けるとコストが積み上がるため、保持期間を明示的に設定します。
8.7. ECSタスク用セキュリティグループ
第4章4.7 節のセキュリティグループチェーンの図では、ECS タスク用の SG が「ALB 用 SG のみ」からの ingress を許可する形で予告されていました。ここでその通りに作ります。第6章の ALB と同じく、VPC の既定セキュリティグループには依存せず、明示的な SG を新設します。
ingress は ALB 用 SG からの 3000 番ポートのみ許可します。第1章1.3 節の「web」コンテナが待ち受けるポート番号です。この章で動くタスクはあとで説明するプレースホルダーイメージで、実際には何のポートも待ち受けません。この ingress ルールは、第 10 章で web 相当のサービスが ALB からの転送を受け取れるようにするための、先取りした準備です。
egress は 443 番のみを許可します。Fargate のタスクが private-app サブネットから ECR のイメージを取得し、CloudWatch Logs へログを put するための HTTPS 通信です。16 第4章で用意した NAT Gateway 経由の経路をそのまま使うため、この章で VPC エンドポイントの追加は行いません。ドメイン名の解決 (DNS) にはポート 53 番の通信が要りますが、VPC が提供する Amazon DNS サーバーとの通信はセキュリティグループやネットワーク ACL でフィルタできないため、この一覧に DNS 向けの egress ルールは含みません。17
8.8. Terraform実装
ここまでの内容をecs.tfという新しいファイルに書きます。
resource "aws_ecr_repository" "app" {
name = "${local.name_prefix}-app"
tags = {
Name = "${local.name_prefix}-app"
}
}
resource "aws_ecs_cluster" "main" {
name = "${local.name_prefix}-cluster"
tags = {
Name = "${local.name_prefix}-cluster"
}
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${local.name_prefix}-app"
retention_in_days = 30
tags = {
Name = "${local.name_prefix}-app-logs"
}
}
信頼ポリシーは、実行ロール・タスクロール共通のためデータソースとして 1 つ定義し、両方のロールから参照します。
data "aws_iam_policy_document" "ecs_tasks_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ecs_task_execution" {
name = "${local.name_prefix}-ecs-task-execution-role"
path = "/${var.project}/"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json
tags = {
Name = "${local.name_prefix}-ecs-task-execution-role"
}
}
resource "aws_iam_role" "ecs_task" {
name = "${local.name_prefix}-ecs-task-role"
path = "/${var.project}/"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume_role.json
tags = {
Name = "${local.name_prefix}-ecs-task-role"
}
}
8.5 節で決めたカスタムポリシーは、次のように定義してアタッチします。
resource "aws_iam_policy" "ecs_task_execution" {
name = "${local.name_prefix}-ecs-task-execution-policy"
path = "/${var.project}/"
description = "AmazonECSTaskExecutionRolePolicy相当の権限を、ECRリポジトリ・ロググループ単位に絞って自前定義したもの"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "EcrAuth"
Effect = "Allow"
Action = ["ecr:GetAuthorizationToken"]
Resource = "*"
},
{
Sid = "EcrPull"
Effect = "Allow"
Action = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
]
Resource = aws_ecr_repository.app.arn
},
{
Sid = "LogsWrite"
Effect = "Allow"
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "${aws_cloudwatch_log_group.app.arn}:*"
},
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
role = aws_iam_role.ecs_task_execution.name
policy_arn = aws_iam_policy.ecs_task_execution.arn
}
セキュリティグループは、8.7 節の方針通りに作ります。
resource "aws_security_group" "ecs_tasks" {
name = "${local.name_prefix}-ecs-tasks-sg"
description = "ECSタスク用SG。ALB用SGからのingressのみ許可"
vpc_id = aws_vpc.main.id
tags = {
Name = "${local.name_prefix}-ecs-tasks-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "ecs_tasks_from_alb" {
security_group_id = aws_security_group.ecs_tasks.id
referenced_security_group_id = aws_security_group.alb.id
from_port = 3000
to_port = 3000
ip_protocol = "tcp"
description = "ALBからのみ許可 (第10章のweb service化・forward切替で実際に使う)"
}
resource "aws_vpc_security_group_egress_rule" "ecs_tasks_https" {
security_group_id = aws_security_group.ecs_tasks.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "ECR API・CloudWatch Logs APIへのHTTPS"
}
最後にタスク定義です。Fargate ではnetwork_modeにawsvpcが必須で、Terraform のスキーマ上もrequires_compatibilitiesがFARGATEの場合はcpu・memory・runtime_platform.operating_system_familyが必須になります。18 cpu/memoryは、Fargate で有効な組み合わせのうち最小の 256(0.25 vCPU)/512MiB を使います。19
resource "aws_ecs_task_definition" "app" {
family = "${local.name_prefix}-app"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
runtime_platform {
operating_system_family = "LINUX"
cpu_architecture = "X86_64"
}
container_definitions = jsonencode([
{
name = "app"
image = "${aws_ecr_repository.app.repository_url}:latest"
essential = true
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "app"
}
}
}
])
tags = {
Name = "${local.name_prefix}-app"
}
}
コンテナ定義にportMappingsがないのは、8.9 節で使うイメージがポートを一切待ち受けないためです。
最後に、後続の章が参照する値をoutputs.tfに追加します。
output "ecs_cluster_id" {
description = "ECSクラスタのID (第9/10章でも同じクラスタを使う)"
value = aws_ecs_cluster.main.id
}
output "ecr_repository_url" {
description = "ECRリポジトリのURL (push手順、タスク定義のimage参照で使う)"
value = aws_ecr_repository.app.repository_url
}
output "ecs_task_execution_role_arn" {
description = "ECSタスク実行ロールのARN (第10章のweb/apiサービスのタスク定義でも再利用)"
value = aws_iam_role.ecs_task_execution.arn
}
output "ecs_task_role_arn" {
description = "ECSタスクロールのARN (第10章でS3添付バケットへのアクセス権限を追加)"
value = aws_iam_role.ecs_task.arn
}
output "ecs_tasks_security_group_id" {
description = "ECSタスク用SGのID (第9章のRDS用SGのingress許可元として参照)"
value = aws_security_group.ecs_tasks.id
}
output "ecs_task_definition_arn" {
description = "タスク定義のARN (run-task・第10章のサービス定義で参照)"
value = aws_ecs_task_definition.app.arn
}
8.9. 動作確認
terraform fmt -check -recursive -diffとterraform validateを実行し、構文とプロバイダスキーマの整合を確認します。この章の検証も、これまでと同じく validate までで、planやapplyは対象に含めません。
実際に apply して確認する場合の手順です。まず、Tasuku のアプリイメージはまだ存在しないため、Docker 公式のhello-worldイメージで代用します。このイメージは単一の静的バイナリで、実行するとテキストを標準出力に出してすぐに終了します。20 ポートを待ち受けず常駐もしないという性質が、この章で確認したい一連の流れにちょうど合います。タスクが起動し、ログが届き、正常に終了するという流れです。hello-worldはマルチアーキテクチャ対応イメージのため、--platformを指定しないとホストマシンの CPU アーキテクチャに一致するものが自動選択されます。タスク定義はruntime_platform.cpu_architectureをX86_64に固定しているため(後述)、ARM64 ホスト(Apple Silicon など)から push する場合は--platformで明示的に x86_64 を指定してください。
aws ecr get-login-password --region ap-northeast-1 \
| docker login --username AWS --password-stdin <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com
docker pull --platform linux/amd64 hello-world
docker tag hello-world:latest <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/tasuku-dev-app:latest
docker push <account-id>.dkr.ecr.ap-northeast-1.amazonaws.com/tasuku-dev-app:latest
イメージを push したら、run-taskで単発のタスクを起動します。awsvpcConfigurationにはsubnetsが必須で、securityGroupsを省略すると VPC の既定セキュリティグループが使われてしまうため、この章で作った SG を明示します。21 サブネット ID と SG ID はterraform outputで取得した値を使います。
aws ecs run-task \
--cluster tasuku-dev-cluster \
--task-definition tasuku-dev-app \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[<private-appサブネットID>],securityGroups=[<ecs_tasks SGのID>],assignPublicIp=DISABLED}"
タスクは、PROVISIONING→PENDING→ACTIVATING→RUNNING→DEACTIVATING→STOPPING→DEPROVISIONING→STOPPEDという状態を遷移します。22 aws ecs describe-tasksでlastStatusを確認し、STOPPEDになった時点でcontainers[].exitCodeが0であることを確認します。あわせてaws logs tail /ecs/tasuku-dev-appを実行すると、hello-worldの標準出力がログストリームに記録されているのが確認できます。
この章のホストゾーンや ACM と同様、実際に AWS へ apply してこの手順を実行した結果は保証しません。第7章までと同じ検証水準です。
本章のまとめ
- ECS のクラスタ・タスク定義・タスク・サービスの関係を整理し、サービスとしての常時稼働は第 10 章に送りました
- Fargate はタスクごとに専用のカーネル・CPU・メモリ・ネットワークインターフェースを持つサーバーレスの実行基盤で、EC2 起動タイプのようなインスタンス管理が要りません
- タスク実行ロールの権限は、AWS 管理ポリシーではなく
/tasuku/配下の自前ポリシーとして定義しました。第3章の権限制約と矛盾せず、権限をリポジトリ・ロググループ単位まで絞れます - タスクロールは信頼ポリシーのみで作り、S3 添付バケットへのアクセス権限は第 10 章で追加します
- ECS タスク用 SG は、ALB 用 SG からの ingress のみを許可する形で新設し、default SG には依存しません
- 動作確認は
hello-worldイメージの単発run-taskで行い、タスクの起動からログ到達までの一連の流れを確かめました
次に読む
- 第 9 章: RDS: サブネットグループと Multi-AZ、マスターパスワードの Secrets Manager 統合、セキュリティグループチェーンの完成
- 第 7 章: Route 53 / ACM: DNS 委任と ACM の DNS 検証、443 番リスナーの追加による HTTPS 化
- 連載の目次: 全 12 章の構成と読み進め方