🪣

ECSからS3をマウントしてみた

2023/08/18に公開2

こんにちは。
ご機嫌いかがでしょうか。
"No human labor is no human error" が大好きな吉井 亮です。

Mountpoint for Amazon S3 が発表されました。

Linux から直接 S3 バケットに対して読み書き機能を提供するソフトウェアです。細かく書くと、読み取りと新規ファイル作成を行うものです。
データレイクからファイルを読み取り何らかの処理をして結果ファイルをデータレイクへ戻す、みたいな処理に適切です。
処理をするアプリケーションによっては S3 API をネイティブにサポートしていないことは珍しくないはずで、その際は前後に S3 Get/Put する別処理が必要でしたが、Mountpoint for Amazon S3 によってファイルシステムとしてマウント可能になれば別処理は要らなくなります。

ただ、S3 はオブジェクトストレージであることには変わりありません。ファイルサーバーのような使い方はできない、というか向かないのでファイルサーバー用途にこの Mountpoint for Amazon S3 を使うことは控えたほうがよいと思います。

やってみた

コンテナアプリケーションでも S3 からデータを Get して処理、結果を Put というパターンは多く存在すると想像しています。
コンテナ内の作業ディレクトリに Get/Put する手間を省くことになれば幸せということで ECS から S3 をマウントしてみます。

今回は ECS on EC2 です。
ECS on Fargate でマウントはまだできないようなので、ECS on Fargate で S3 バケット上のファイルを読みたい場合は API や AWS CLI を検討ください。
Mount failed on ECS Fargate container #450

Amazon ECS-optimized AMI に Mountpoint for Amazon S3 をインストール

データプレーンとなる EC2 は Amazon ECS-optimized AMI から起動します。
ECS クラスターのキャパシティプロパイダーで指定しているオートスケーリンググループの起動テンプレートにてユーザーデータを追加します。

userdata.sh
## 既存のユーザーデータに以下を追加
## mountpoint-3
### Graviton インスタンス はこちら
curl -o /tmp/mount-s3.rpm https://s3.amazonaws.com/mountpoint-s3-release/latest/arm64/mount-s3.rpm
### x86_64 はこちら
curl -o /tmp/mount-s3.rpm https://s3.amazonaws.com/mountpoint-s3-release/latest/x86_64/mount-s3.rpm
yum install -y /tmp/mount-s3.rpm
mount-s3 your_bucket_name /mnt
rm /tmp/mount-s3.rpm

上のユーザーデータが正常に実行されれば EC2 インスタンスの /mnt に S3 バケットがマウントされるようになります。もちろん /mnt は任意に変更して大丈夫です。

インスタンスプロファイルにポリシーを追加

データプレーンとなる EC2 のインスタンスプロファイルにポリシーを追加します。マウント先バケットに対して5つの権限が最低限必要なようです。
Configuring Mountpoint for Amazon S3

{
   "Version": "2012-10-17",
   "Statement": [
        {
            "Sid": "MountpointFullBucketAccess",
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::your_bucket_name"
            ]
        },
        {
            "Sid": "MountpointFullObjectAccess",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:AbortMultipartUpload",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:s3:::your_bucket_name/*"
            ]
        }
   ]
}

ECS タスク定義でバインドマウント

ECS タスク定義に数行追加してマウントします。

containerPath はコンテナ内のマウントポイントです。
sourcePath にはユーザーデータの mount-s3 your_bucket_name /mnt のマウント先で指定したマウントポイントを記述します。

    "containerDefinitions": [
      (省略)
        {
            "mountPoints": [
                {
                    "sourceVolume": "s3root",
                    "containerPath": "/mnt"
                }
        }
      (省略)
    ],
    (省略)
    "volumes": [
        {
            "name": "s3root",
            "host": {
                "sourcePath": "/mnt"
            }
        }
    ],
(省略)

タスク実行

ls /mnt を打つだけのタスクを作って動作確認しました。

クリックして展開
{
    "containerDefinitions": [
        {
            "name": "ryoyoshii_mount_s3",
            "image": "public.ecr.aws/amazonlinux/amazonlinux:2023",
            "cpu": 0,
            "portMappings": [],
            "essential": true,
            "command": [
                "ls",
                "/mnt"
            ],
            "environment": [],
            "mountPoints": [
                {
                    "sourceVolume": "s3root",
                    "containerPath": "/mnt"
                }
            ],
            "volumesFrom": [],
            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-group": "/ecs/cluster_name",
                    "awslogs-region": "us-west-2",
                    "awslogs-stream-prefix": "mount_s3"
                }
            }
        }
    ],
    "family": "ryoyoshii_mount_s3",
    "taskRoleArn": "your_Task_RoleArn",
    "executionRoleArn": "your_Task_Execution_RoleArn",
    "networkMode": "awsvpc",
    "volumes": [
        {
            "name": "s3root",
            "host": {
                "sourcePath": "/mnt"
            }
        }
    ],
    "requiresCompatibilities": [
        "EC2"
    ],
    "cpu": "512",
    "memory": "1024",
}

terraform

一連の動作確認ができる tf ファイルを記述しておきます。よしなに編集してお使いください。

クリックして展開
locals {
  // General
  sysname = "ryoyoshii_test"
  region  = "us-west-2"

  // Network
  vpcid = "vpc-"
  subnet = {
    public_a = "subnet-"
    public_b = "subnet-"
    public_c = "subnet-"
  }

  work_bucket_name = "ryoyoshii-tfstate"
}

// ECS Cluster
resource "aws_ecs_cluster" "this" {
  name = local.sysname

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = {
    Name = "${local.sysname}"
  }
}
// End of ECS Cluster

// Task definition
//// mount-s3
resource "aws_ecs_task_definition" "mount_s3" {
  family = "${local.sysname}_mount_s3"
  container_definitions = jsonencode([
    {
      "logConfiguration" : {
        "logDriver" : "awslogs",
        "options" : {
          "awslogs-group" : aws_cloudwatch_log_group.ecs.name,
          "awslogs-region" : "${local.region}",
          "awslogs-stream-prefix" : "mount_s3"
        }
      },
      "command" : [
        "ls",
        "/mnt"
      ],
      "essential" : true,
      "volumesFrom" : [],
      "mountPoints" : [
        {
          "sourceVolume" : "s3root",
          "containerPath" : "/mnt"
        }
      ],
      "image" : "public.ecr.aws/amazonlinux/amazonlinux:2023",
      "name" : "${local.sysname}_mount_s3"
    }
  ])

  volume {
    name      = "s3root"
    host_path = "/mnt"
  }

  task_role_arn            = aws_iam_role.ecs_Task.arn
  execution_role_arn       = aws_iam_role.ecs_TaskExecution.arn
  requires_compatibilities = ["EC2"]
  cpu                      = 512
  memory                   = 1024
  network_mode             = "awsvpc"
}
// End of Task definition

// Security Group ECS
resource "aws_security_group" "ecs" {
  name        = "${local.sysname}_ecs_sg"
  description = "ECS ${local.sysname}"
  vpc_id      = local.vpcid

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${local.sysname}_ecs_sg"
  }
}
// End Of Security Group ECS

// IAM Role TaskExecution
resource "aws_iam_role" "ecs_TaskExecution" {
  name = "CustomRoleTaskExecution_${local.sysname}"

  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "ecs-tasks.amazonaws.com"
        },
        "Action" : "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "ecs_TaskExecution" {
  role       = aws_iam_role.ecs_TaskExecution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
// End Of IAM Role TaskExecution

// IAM Role ECS Task
resource "aws_iam_role" "ecs_Task" {
  name = "CustomRoleTask_${local.sysname}"

  assume_role_policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "ecs-tasks.amazonaws.com"
        },
        "Action" : "sts:AssumeRole"
      }
    ]
  })


  inline_policy {
    name = "CustomRoleTask_${local.sysname}"

    policy = jsonencode({
      Version = "2012-10-17"
      Statement = [
        {
          "Effect" : "Allow",
          "Action" : "s3:ListAllMyBuckets",
          "Resource" : "arn:aws:s3:::*"
        },
        {
          "Effect" : "Allow",
          "Action" : [
            "s3:ListBucket",
            "s3:ListBucketVersions"
          ]
          "Resource" : "arn:aws:s3:::${local.work_bucket_name}"
        },
        {
          "Effect" : "Allow",
          "Action" : [
            "s3:GetObject",
            "s3:PutObject"
          ],
          "Resource" : "arn:aws:s3:::${local.work_bucket_name}/*"
        }
      ]
    })
  }
}

resource "aws_iam_role_policy_attachment" "ecs_Task" {
  role       = aws_iam_role.ecs_Task.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
// End Of IAM Role ECS Task

// CloudWatch Logs Group
resource "aws_cloudwatch_log_group" "ecs" {
  name              = "/ecs/${local.sysname}"
  retention_in_days = 7
}
// End Of CloudWatch Logs Group

// ECS on EC2 s3 mounted
data "aws_ssm_parameter" "ecs_ami_id" {
  name = "/aws/service/ecs/optimized-ami/amazon-linux-2023/arm64/recommended/image_id"
}

// ecs capacity provider
resource "aws_ecs_cluster_capacity_providers" "this" {
  cluster_name = aws_ecs_cluster.this.name

  capacity_providers = [
    aws_ecs_capacity_provider.this.name,
  ]

  default_capacity_provider_strategy {
    base              = 0
    weight            = 1
    capacity_provider = aws_ecs_capacity_provider.this.name
  }
}

resource "aws_ecs_capacity_provider" "this" {
  name = local.sysname

  auto_scaling_group_provider {
    auto_scaling_group_arn = aws_autoscaling_group.this.arn
    managed_scaling {
      maximum_scaling_step_size = 10000
      minimum_scaling_step_size = 1
      status                    = "ENABLED"
      target_capacity           = 100
    }
  }
}

resource "aws_autoscaling_group" "this" {
  name                      = local.sysname
  max_size                  = 1
  min_size                  = 0
  health_check_grace_period = 0
  health_check_type         = "EC2"
  desired_capacity          = 0
  vpc_zone_identifier = [
    local.subnet.public_a,
    local.subnet.public_b,
    local.subnet.public_c
  ]

  mixed_instances_policy {
    instances_distribution {
      on_demand_base_capacity                  = 0
      on_demand_percentage_above_base_capacity = 0
      spot_allocation_strategy                 = "capacity-optimized"
    }

    launch_template {
      launch_template_specification {
        launch_template_id = aws_launch_template.this.id
      }
    }
  }

  tag {
    key                 = "AmazonECSManaged"
    value               = ""
    propagate_at_launch = true
  }
}

resource "aws_launch_template" "this" {
  name     = local.sysname
  image_id = data.aws_ssm_parameter.ecs_ami_id.value
  iam_instance_profile {
    name = aws_iam_instance_profile.this.name
  }
  user_data              = filebase64("userdata.sh")
  update_default_version = true
  vpc_security_group_ids = [aws_security_group.this.id]

  instance_requirements {
    vcpu_count {
      min = 1
      max = 4
    }
    memory_mib {
      min = 1024
      max = 8096
    }
    instance_generations = ["current"]
  }

  block_device_mappings {
    device_name = "/dev/xvda"

    ebs {
      delete_on_termination = "true"
      encrypted             = "true"
      volume_size           = 30
      volume_type           = "gp3"
    }
  }

  tag_specifications {
    resource_type = "instance"

    tags = {
      Name = local.sysname
    }
  }
}
// End of ecs capacity provider


// Security Group EC2
resource "aws_security_group" "this" {
  name        = "${local.sysname}-ec2-sg"
  description = "ECS on EC2"
  vpc_id      = local.vpcid

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${local.sysname}-ec2-sg"
  }
}
// End of Security Group EC2


// Instance Profile
data "aws_iam_policy_document" "this" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type = "Service"
      identifiers = [
        "ec2.amazonaws.com"
      ]
    }
  }
}

data "aws_iam_policy_document" "inline_policy" {
  statement {
    actions   = ["s3:ListBucket"]
    effect    = "Allow"
    resources = ["arn:aws:s3:::${local.work_bucket_name}"]
  }

  statement {
    actions = [
      "s3:GetObject",
      "s3:PutObject",
      "s3:AbortMultipartUpload",
      "s3:DeleteObject"
    ]
    effect    = "Allow"
    resources = ["arn:aws:s3:::${local.work_bucket_name}/*"]
  }
}

resource "aws_iam_role" "this" {
  name               = "CustomRole-${local.sysname}-ECSonEC2"
  assume_role_policy = data.aws_iam_policy_document.this.json

  inline_policy {
    name   = "my_inline_policy"
    policy = data.aws_iam_policy_document.inline_policy.json
  }
}

resource "aws_iam_instance_profile" "this" {
  name = "CustomRole-${local.sysname}-ECSonEC2"
  role = aws_iam_role.this.name
}

resource "aws_iam_role_policy_attachment" "this" {
  role       = aws_iam_role.this.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_role_policy_attachment" "ecs_ec2_role" {
  role       = aws_iam_role.this.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role"
}
// End Of Instance Profile

Dockerfileに書くパターン

ホストでマウントせずとも Dockerfile に Mountpoint for Amazon S3 をインストールするパターンも可能です。単純な優劣はないと思うので要件に合わせて使い分けると便利だと思います。
Running Mountpoint for Amazon S3 in a Docker container

参考

ECS ではない普通の EC2 インスタンスでマウントしたい場合は AWS製のS3マウントツール「Mountpoint for Amazon S3」を試してみた を参照ください。

Discussion

DogFortuneDogFortune

ホストでS3バケットをマウントしてそこにコンテナからバインドマウントするか、コンテナの中でMountpoint for Amazon S3使って直接S3バケットとマウントするか検証時に迷ったのですが、ECS on EC2、ECS on Fargate(現状マウントできない)、macOS、WindowsでそれぞれS3バケットとマウントする方式が異なると思ったので、

  • ホストとS3バケットのマウントはホスト側でMountpoint for AmazonS3を使ってマウント(UserData使うとか)
  • コンテナはS3バケットとマウントしたディレクトリをバインドマウント

という感じにすればコンテナ側であまりホストとS3バケットのマウント方式の違いを意識しなくて良くなるのでは?と考えたのですがどうでしょう?