AWS CDKでECS Fargate Bastionを一撃で作ってみた

NAT Gatewayがないサブネットにも簡単に踏み台を用意できる
2024.03.07

EC2インスタンスの踏み台を用意したくない

こんにちは、のんピ(@non____97)です。

皆さんはEC2インスタンスの踏み台を用意したくないと思ったことはありますか? 私はあります。

VPC上のRDS DBインスタンスやRedisクラスター、OpenSearch Service ドメインなどのリソースに接続したい場合、Site-to-Site VPNやClient VPN、Direct Connectがなければ踏み台(Bastion)が必要になります。

踏み台へのアクセス方法は以下のようなものがあります。

  • 直接SSH
  • SSMセッションマネージャー
  • EC2 Instance Connect

そして、踏み台となるリソースとして採用される多くがEC2インスタンスだと考えます。EC2インスタンスの場合、OS周りの面倒をみる必要があります。OS内のパッケージのアップデートが面倒であれば「踏み台が欲しいタイミングにEC2インスタンスを一瞬だけ作って、不要になったら削除する」という運用も考えられますが、起動に時間がかかりそうです。また、ワクワクしません。

そんな時に活躍するのがECS Fargateを使った踏み台です。FargateなのでOSの面倒をみる必要がなくなります。コンテナなので起動もEC2インスタンスと比較して早いでしょう。そんなECS FargateのBastionは偉大な先人達が既に紹介しています。

私もAWS CDKを使って一撃でECS Fargate Bastionを作ってみたくなったのでチャレンジしてみました。ロマンを追い求めています。

使用するコードの構成

使用したコードは以下リポジトリに保存しています。

ディレクトリツリーは以下のとおりです。

> tree
.
├── .gitignore
├── .npmignore
├── README.md
├── bin
│   └── ecs-fargate-bastion.ts
├── cdk.context.json
├── cdk.json
├── jest.config.js
├── lib
│   ├── construct
│   │   ├── ecs-fargate-construct.ts
│   │   └── vpc-endpoint-construct.ts
│   ├── ecs-fargate-bastion-stack.ts
│   └── parameter
│       └── index.ts
├── package-lock.json
├── package.json
├── test
│   └── ecs-fargate-bastion.test.ts
└── tsconfig.json

6 directories, 15 files

「デプロイ先のVPCにはNAT Gatewayはない。ECSで使用するVPCエンドポイントもない」という環境もあるでしょう。ということで必要に応じてVPCエンドポイントも作成するような構成にしています。具体的なStackのコードは以下のとおりです。

./lib/ecs-fargate-bastion-stack.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { VpcEndpointParams, EcsFargateParams } from "./parameter";
import { VpcEndpointConstruct } from "./construct/vpc-endpoint-construct";
import { EcsFargateConstruct } from "./construct/ecs-fargate-construct";

export interface EcsFargateBastionStackProps extends cdk.StackProps {
  vpcId: string;
  vpcEndpointParams?: VpcEndpointParams;
  ecsFargateParams: EcsFargateParams;
}

export class EcsFargateBastionStack extends cdk.Stack {
  constructor(
    scope: Construct,
    id: string,
    props: EcsFargateBastionStackProps
  ) {
    super(scope, id, props);

    // VPC Endpoints
    const vpcEndpointConstruct = props.vpcEndpointParams
      ? new VpcEndpointConstruct(this, "VpcEndpoint", {
          vpcId: props.vpcId,
          vpcEndpointParams: props.vpcEndpointParams,
        })
      : undefined;

    // ECS Fargate
    const ecsFargateConstruct = new EcsFargateConstruct(this, "EcsFargate", {
      vpcId: props.vpcId,
      ecsFargateParams: props.ecsFargateParams,
    });

    if (vpcEndpointConstruct) {
      ecsFargateConstruct.node.addDependency(vpcEndpointConstruct);
    }
  }
}

「ECSで使用するVPCエンドポイントはあるけど、SSMセッションマネージャーで使用するVPCエンドポイントはない」といった場合もあると思います。後述する./lib/parameter/index.tsで指定したフラグに応じて、以下のVPCエンドポイントを指定されたサブネット上に作成するようにしています。

  • shouldCreateEcrVpcEndpoint が true
    • com.amazonaws.region.ecr.dkr
    • com.amazonaws.region.ecr.api
  • shouldCreateSsmVpcEndpoint が true
    • com.amazonaws.region.ssm
    • com.amazonaws.region.ssmmessages
  • shouldCreateLogsVpcEndpoint が true
    • com.amazonaws.region.ssm
  • shouldCreateS3VpcEndpoint が true
    • com.amazonaws.region.s3 (Gateway型)

ECSで使用するVPCエンドポイントは以下記事にまとまっています。

具体的なVPCエンドポイントのConstructのコードは以下のとおりです。

./lib/construct/vpc-endpoint-construct.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { VpcEndpointParams } from "../parameter";

export interface VpcEndpointConstructProps {
  vpcId: string;
  vpcEndpointParams: VpcEndpointParams;
}

export class VpcEndpointConstruct extends Construct {
  public readonly ecrRepository: cdk.aws_ecr.IRepository;

  constructor(scope: Construct, id: string, props: VpcEndpointConstructProps) {
    super(scope, id);

    // VPC
    const vpc = cdk.aws_ec2.Vpc.fromLookup(this, "Vpc", {
      vpcId: props.vpcId,
    });

    if (props.vpcEndpointParams.shouldCreateEcrVpcEndpoint) {
      // ECR
      vpc.addInterfaceEndpoint("EcrEndpoint", {
        service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.ECR,
        subnets: vpc.selectSubnets(
          props.vpcEndpointParams.vpcEndpointSubnetSelection
        ),
      });

      // ECR DOCKER
      vpc.addInterfaceEndpoint("EcrDockerEndpoint", {
        service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER,
        subnets: vpc.selectSubnets(
          props.vpcEndpointParams.vpcEndpointSubnetSelection
        ),
      });
    }

    if (props.vpcEndpointParams.shouldCreateSsmVpcEndpoint) {
      // SSM
      vpc.addInterfaceEndpoint("SsmEndpoint", {
        service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.SSM,
        subnets: vpc.selectSubnets(
          props.vpcEndpointParams.vpcEndpointSubnetSelection
        ),
      });

      // SSM MESSAGES
      vpc.addInterfaceEndpoint("SsmMessagesEndpoint", {
        service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.SSM_MESSAGES,
        subnets: vpc.selectSubnets(
          props.vpcEndpointParams.vpcEndpointSubnetSelection
        ),
      });
    }

    if (props.vpcEndpointParams.shouldCreateLogsVpcEndpoint) {
      // LOGS
      vpc.addInterfaceEndpoint("LogsEndpoint", {
        service: cdk.aws_ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
        subnets: vpc.selectSubnets(
          props.vpcEndpointParams.vpcEndpointSubnetSelection
        ),
      });
    }

    if (props.vpcEndpointParams.shouldCreateS3VpcEndpoint) {
      // Gateway S3
      vpc.addGatewayEndpoint(`S3GatewayEndpoint`, {
        service: cdk.aws_ec2.GatewayVpcEndpointAwsService.S3,
      });
    }
  }
}

ECS FargateのConstructは以下のとおりです。./lib/parameter/index.tsで指定されたコンテナイメージを使って起動するというものです。「Pull through cacheを使いたいな」という方向けにも対応しています。

./lib/construct/ecs-fargate-construct.ts

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { EcsFargateParams } from "../parameter";

export interface EcsFargateConstructProps {
  vpcId: string;
  ecsFargateParams: EcsFargateParams;
}

export class EcsFargateConstruct extends Construct {
  constructor(scope: Construct, id: string, props: EcsFargateConstructProps) {
    super(scope, id);

    // Pull through cache rules
    const pullThroughCacheRule = props.ecsFargateParams.ecrRepositoryPrefix
      ? new cdk.aws_ecr.CfnPullThroughCacheRule(this, "PullThroughCacheRule", {
          ecrRepositoryPrefix: props.ecsFargateParams.ecrRepositoryPrefix,
          upstreamRegistryUrl: "public.ecr.aws",
        })
      : undefined;

    // VPC
    const vpc = cdk.aws_ec2.Vpc.fromLookup(this, "Vpc", {
      vpcId: props.vpcId,
    });

    // Log Group
    const logGroup = new cdk.aws_logs.LogGroup(this, "LogGroup", {
      logGroupName: `/ecs/${props.ecsFargateParams.clusterName}/${props.ecsFargateParams.repositoryName}/ecs-exec`,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      retention: cdk.aws_logs.RetentionDays.TWO_WEEKS,
    });

    // ECS Cluster
    const cluster = new cdk.aws_ecs.Cluster(this, "Cluster", {
      vpc,
      containerInsights: false,
      clusterName: props.ecsFargateParams.clusterName,
      executeCommandConfiguration: {
        logging: cdk.aws_ecs.ExecuteCommandLogging.OVERRIDE,
        logConfiguration: {
          cloudWatchLogGroup: logGroup,
        },
      },
    });

    // Task definition
    const taskDefinition = new cdk.aws_ecs.FargateTaskDefinition(
      this,
      "TaskDefinition",
      {
        cpu: 256,
        memoryLimitMiB: 512,
        runtimePlatform: {
          cpuArchitecture: cdk.aws_ecs.CpuArchitecture.ARM64,
          operatingSystemFamily: cdk.aws_ecs.OperatingSystemFamily.LINUX,
        },
      }
    );

    // Container
    taskDefinition.addContainer("Container", {
      image: pullThroughCacheRule?.ecrRepositoryPrefix
        ? cdk.aws_ecs.ContainerImage.fromEcrRepository(
            cdk.aws_ecr.Repository.fromRepositoryName(
              this,
              pullThroughCacheRule.ecrRepositoryPrefix,
              props.ecsFargateParams.repositoryName
            ),
            props.ecsFargateParams.imagesTag
          )
        : cdk.aws_ecs.ContainerImage.fromRegistry(
            `${props.ecsFargateParams.repositoryName}:${props.ecsFargateParams.imagesTag}`
          ),
      pseudoTerminal: true,
      linuxParameters: new cdk.aws_ecs.LinuxParameters(
        this,
        "LinuxParameters",
        {
          initProcessEnabled: true,
        }
      ),
    });

    // Pull through cache Policy
    if (pullThroughCacheRule?.ecrRepositoryPrefix) {
      taskDefinition.obtainExecutionRole().attachInlinePolicy(
        new cdk.aws_iam.Policy(this, "PullThroughCachePolicy", {
          statements: [
            new cdk.aws_iam.PolicyStatement({
              actions: ["ecr:CreateRepository", "ecr:BatchImportUpstreamImage"],
              resources: [
                `arn:aws:ecr:${cdk.Stack.of(this).region}:${
                  cdk.Stack.of(this).account
                }:repository/${props.ecsFargateParams.ecrRepositoryPrefix}/*`,
              ],
            }),
          ],
        })
      );
    }

    // Attache Security Group
    const securityGroups = props.ecsFargateParams.ecsServiceSecurityGroupIds
      ? props.ecsFargateParams.ecsServiceSecurityGroupIds.map(
          (securityGroupId) => {
            return cdk.aws_ec2.SecurityGroup.fromSecurityGroupId(
              this,
              `EcsServiceSecurityGroupId_${securityGroupId}`,
              securityGroupId
            );
          }
        )
      : undefined;

    // ECS Service
    const ecsService = new cdk.aws_ecs.FargateService(this, "Service", {
      cluster,
      enableExecuteCommand: true,
      taskDefinition,
      desiredCount: props.ecsFargateParams.desiredCount,
      minHealthyPercent: 100,
      maxHealthyPercent: 200,
      deploymentController: {
        type: cdk.aws_ecs.DeploymentControllerType.ECS,
      },
      circuitBreaker: { rollback: true },
      securityGroups,
      vpcSubnets: props.ecsFargateParams.ecsFargateSubnetSelection,
    });

    // Allow dst Security Group from ECS Service Security Group
    props.ecsFargateParams.inboundFromEcsServiceAllowedSecurityGroupId?.forEach(
      (allowRule) => {
        const securityGroup = cdk.aws_ec2.SecurityGroup.fromSecurityGroupId(
          this,
          `InboundFromEcsServiceAllowedSecurityGroupId_${allowRule.securityGroupId}`,
          allowRule.securityGroupId
        );

        allowRule.ports.forEach((port) => {
          ecsService.connections.securityGroups.forEach(
            (ecsServiceSecurityGroup) => {
              securityGroup.addIngressRule(
                ecsServiceSecurityGroup,
                port,
                `Inbound ${props.ecsFargateParams.clusterName} service`
              );
            }
          );
        });
      }
    );
  }
}

どのコンテナイメージを使うのか、どのVPCのどのサブネットにデプロイするかは./lib/parameter/index.tsで指定します。各プロパティの説明はコード内にコメントしています。

./lib/parameter/index.ts

import * as cdk from "aws-cdk-lib";

export interface VpcEndpointParams {
  vpcEndpointSubnetSelection?: cdk.aws_ec2.SubnetSelection;
  shouldCreateEcrVpcEndpoint?: boolean;
  shouldCreateSsmVpcEndpoint?: boolean;
  shouldCreateLogsVpcEndpoint?: boolean;
  shouldCreateS3VpcEndpoint?: boolean;
}

export interface EcsFargateParams {
  ecsFargateSubnetSelection: cdk.aws_ec2.SubnetSelection;
  clusterName: string;
  ecrRepositoryPrefix?: string;
  repositoryName: string;
  imagesTag: string;
  desiredCount: number;
  ecsServiceSecurityGroupIds?: string[];
  inboundFromEcsServiceAllowedSecurityGroupId?: {
    securityGroupId: string;
    ports: cdk.aws_ec2.Port[];
  }[];
}

export interface EcsFargateBastionStackParams {
  env?: cdk.Environment;
  property: {
    vpcId: string;
    vpcEndpointParams?: VpcEndpointParams;
    ecsFargateParams: EcsFargateParams;
  };
}

export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  property: {
    vpcId: "vpc-0c923cc42e5fb2cbf",                             // デプロイ先のVPCのID
    vpcEndpointParams: {                                        // VPCエンドポイントを作成する時に使用するパラメーター
      vpcEndpointSubnetSelection: {                             // VPCエンドポイントをどのサブネットにデプロイするか指定
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
        availabilityZones: ["us-east-1a"],
      },
      shouldCreateEcrVpcEndpoint: true,                         // ECS関連のVPCエンドポイントを作成するか 
      shouldCreateSsmVpcEndpoint: true,                         // SSM関連のVPCエンドポイントを作成するか
      shouldCreateLogsVpcEndpoint: true,                        // CloudWatch LogsのVPCエンドポイントを作成するか
      shouldCreateS3VpcEndpoint: true,                          // Gateway型のS3 VPCエンドポイントを作成するか
    },
    ecsFargateParams: {                                         // ECS Fargateを作成する時に使用するパラメーター
      ecsFargateSubnetSelection: {                              // ECS Fargateをどのサブネットにデプロイするか
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
        availabilityZones: ["us-east-1a"],
      },
      clusterName: "ecs-fargate-bastion",                       // ECSクラスターの名前
      ecrRepositoryPrefix: "ecr-public-pull-through",           // Pull through cache ruleに指定するリポジトリのプレフィックス
      repositoryName: "ecr-public-pull-through/ubuntu/ubuntu",  // リポジトリ名
      imagesTag: "22.04",                                       // 使用するコンテナイメージのタグ
      desiredCount: 1,                                          // デプロイするタスクの数
      ecsServiceSecurityGroupIds: [                             // ECSサービスのSecurity GroupのID
        "sg-0e5bce3c653793012",
        "sg-0a15755f2fb642698",
      ],
      inboundFromEcsServiceAllowedSecurityGroupId: [            // ECSサービスからのインバウンド通信を許可するSecurity GroupのID
        {
          securityGroupId: "sg-0a15755f2fb642698",
          ports: [cdk.aws_ec2.Port.allTcp(), cdk.aws_ec2.Port.allIcmp()],
        },
      ],
    },
  },
};

やってみた

検証環境

検証環境は以下のとおりです。

AWS CDKでECS Fargate Bastionを一撃で作ってみた検証環境構成図

こちらの環境は以下リポジトリのコードをベースに作成しました。

この環境上に用意したAWS CDKのコードを用いてECS Fargate Bastionをデプロイします。

Egress Subnetにデプロイ

まず、Egress Subnetにデプロイします。./lib/parameter/index.tsは以下のとおりです。

./lib/parameter/index.ts

export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  property: {
    vpcId: "vpc-0c923cc42e5fb2cbf",
    ecsFargateParams: {
      ecsFargateSubnetSelection: {
        subnetFilters: [
          cdk.aws_ec2.SubnetFilter.byIds(["subnet-02ea7423910b506f4"]),
        ],
      },
      clusterName: "ecs-fargate-bastion",
      repositoryName: "public.ecr.aws/ubuntu/ubuntu",
      imagesTag: "22.04",
      desiredCount: 1,
      ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
    },
  },
};

構成図にすると以下のとおりです。

AWS CDKでECS Fargate Bastionを一撃で作ってみた検証環境構成図_Egress Subnet

起動したECSのタスクを確認します。

ECSのタスクの確認

試しにECS Execでコンテナに接続してみます。

$ cluster_name="ecs-fargate-bastion"

$ task_id=$(
  aws ecs list-tasks \
    --cluster "${cluster_name}" \
    --query 'taskArns[0]' \
    --output text \
  |  sed 's/.*'"${cluster_name}"'\///'
)

$ aws ecs execute-command \
  --cluster "${cluster_name}" \
  --task "${task_id}" \
  --container Container \
  --interactive \
  --command "/bin/sh"

The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.


Starting session with SessionId: ecs-execute-command-03e53b704c35dfdb5
# ls -l
total 56
lrwxrwxrwx   1 root root    7 Feb 27 16:01 bin -> usr/bin
drwxr-xr-x   2 root root 4096 Apr 18  2022 boot
drwxr-xr-x   5 root root  380 Mar  6 10:12 dev
drwxr-xr-x   1 root root 4096 Mar  6 10:12 etc
drwxr-xr-x   2 root root 4096 Apr 18  2022 home
lrwxrwxrwx   1 root root    7 Feb 27 16:01 lib -> usr/lib
drwxr-xr-x   3 root root 4096 Mar  6 10:12 managed-agents
drwxr-xr-x   2 root root 4096 Feb 27 16:01 media
drwxr-xr-x   2 root root 4096 Feb 27 16:01 mnt
drwxr-xr-x   2 root root 4096 Feb 27 16:01 opt
dr-xr-xr-x 188 root root    0 Mar  6 10:12 proc
drwx------   2 root root 4096 Feb 27 16:08 root
drwxr-xr-x   5 root root 4096 Feb 27 16:08 run
lrwxrwxrwx   1 root root    8 Feb 27 16:01 sbin -> usr/sbin
drwxr-xr-x   2 root root 4096 Feb 27 16:01 srv
dr-xr-xr-x  12 root root    0 Mar  6 10:12 sys
drwxrwxrwt   2 root root 4096 Feb 27 16:08 tmp
drwxr-xr-x  11 root root 4096 Feb 27 16:01 usr
drwxr-xr-x   1 root root 4096 Feb 27 16:08 var

# df -h
Filesystem      Size  Used Avail Use% Mounted on
overlay          30G  9.5G   19G  34% /
tmpfs            64M     0   64M   0% /dev
shm             461M     0  461M   0% /dev/shm
tmpfs           461M     0  461M   0% /sys/fs/cgroup
/dev/nvme0n1p1  4.9G  2.0G  2.9G  41% /dev/init
/dev/nvme1n1     30G  9.5G   19G  34% /etc/hosts
tmpfs           461M     0  461M   0% /proc/acpi
tmpfs           461M     0  461M   0% /sys/firmware
tmpfs           461M     0  461M   0% /proc/scsi

# hostname
ip-10-1-16-203.ec2.internal

# hostname -a

# exit


Exiting session with sessionId: ecs-execute-command-03e53b704c35dfdb5.

問題なく操作できましたね。

ECS ExecのログはCloudwatch Logsに出力するようにしています。確認すると、確かにログが出力されていました。

ECS Execのログ

ログを出力するにはECSクラスター側でログの出力設定しているのはもちろん、使用するコンテナイメージにはscriptcatがインストールされている必要があります。注意しましょう。

また、コマンドログを Amazon S3 または CloudWatch Logs に正しくアップロードするには、コンテナイメージに scriptと catをインストールする必要があることを知っておくことも重要です。

デバッグ用にAmazon ECS Exec を使用 - Amazon Elastic Container Service

続けて、SSMセッションマネージャーでも接続してみます。

$ runtime_id=$(aws ecs describe-tasks \
  --cluster "${cluster_name}" \
  --task "${task_id}" \
  --query 'tasks[].containers[].runtimeId' \
  --output text
)

$ aws ssm start-session \
  --target "ecs:${cluster_name}_${task_id}_${runtime_id}"

Starting session with SessionId: botocore-session-1709724330-0d1f19c11d18fd7cc
/bin/bash
cd /home/$(whoami)
# root@ip-10-1-16-203:/# cd /home/$(whoami)
bash: cd: /home/root: No such file or directory

root@ip-10-1-16-203:/# ps aufe
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root       135  0.0  0.0   2304   828 pts/1    Ss   11:29   0:00 sh PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344 ECS_CO
root       136  0.0  0.3   4552  3584 pts/1    S    11:29   0:00  \_ /bin/bash HOSTNAME=ip-10-1-16-203.ec2.internal HOME=/root AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae AWS_EXECUTION_EN
root       141  0.0  0.1   6828  1652 pts/1    R+   11:30   0:00      \_ ps aufe AWS_EXECUTION_ENV=AWS_ECS_FARGATE AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae HOSTNAME=ip-10-1-16-203.ec2.
root         1  0.0  0.0    828     4 pts/0    Ss   10:12   0:00 /dev/init -- /bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0
root         6  0.0  0.3   4132  3144 pts/0    S+   10:12   0:00 /bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344

root@ip-10-1-16-203:/# printenv
AWS_EXECUTION_ENV=AWS_ECS_FARGATE
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/b58dd647-9af2-418e-865a-68482af6eaae
HOSTNAME=ip-10-1-16-203.ec2.internal
AWS_DEFAULT_REGION=us-east-1
AWS_REGION=us-east-1
PWD=/
ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/0ba05e109d2246bfa4213045b0b3b0c7-2990360344
HOME=/root
LANG=C.UTF-8
LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:mi=00:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.zst=01;31:*.tzst=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.wim=01;31:*.swm=01;31:*.dwm=01;31:*.esd=01;31:*.jpg=01;35:*.jpeg=01;35:*.mjpg=01;35:*.mjpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.webp=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=00;36:*.au=00;36:*.flac=00;36:*.m4a=00;36:*.mid=00;36:*.midi=00;36:*.mka=00;36:*.mp3=00;36:*.mpc=00;36:*.ogg=00;36:*.ra=00;36:*.wav=00;36:*.oga=00;36:*.opus=00;36:*.spx=00;36:*.xspf=00;36:
ECS_AGENT_URI=http://169.254.170.2/api/0ba05e109d2246bfa4213045b0b3b0c7-2990360344
TERM=xterm-256color
ECS_CONTAINER_METADATA_URI=http://169.254.170.2/v3/0ba05e109d2246bfa4213045b0b3b0c7-2990360344
SHLVL=1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
_=/usr/bin/printenv

問題なく操作できました。なお、SSMセッションマネージャーのセッションログをS3バケットやCloudWatch Logsに出力するようにしている場合は権限不足で接続できないと思われます。コードを変更して適切なアクセス許可をしてあげましょう。

SessionId: botocore-session-1709692953-074d742931e439454 : Couldn't start the session because we are unable to validate encryption on Amazon S3 bucket. Error: AccessDenied: Access Denied
	status code: 403, request id: M69254NJ2XKEX1KD, host id: aOYdS3DE1fH6rf8TOXvZpmSNmTBWx20+TAo+6n85VHoUAi2um0jDSwwWU9ESUxZzjwvexLbdVn0=

SSMセッションマネージャーのリモートホストのポートフォワーディングも試してみましょう。SSMセッションマネージャーのリモートホストのポートフォワーディングの紹介は以下記事をご覧ください。

Aurora PostgreSQLへのポートフォワーディングをしてあげます。

$ db_endopoint="db-cluster.cluster-cicjym7lykmq.us-east-1.rds.amazonaws.com"

$ aws ssm start-session \
  --target "ecs:${cluster_name}_${task_id}_${runtime_id}" \
    --document-name AWS-StartPortForwardingSessionToRemoteHost \
    --parameters '{"host":["'${db_endopoint}'"],"portNumber":["5432"], "localPortNumber":["15432"]}'

Starting session with SessionId: botocore-session-1709724330-0eb982afb8a0e4f31
Port 15432 opened for sessionId botocore-session-1709724330-0eb982afb8a0e4f31.
Waiting for connections...

別セッションでポートフォワーディングしているポートに対して接続します。

# Autora PostgreSQLの認証情報取得
$ get_secrets_value=$(aws secretsmanager get-secret-value \
    --secret-id AuroraSecret7ACECA7F-jZsiEVe2jrDs \
    --region us-east-1 \
    | jq -r .SecretString)

$ export PGHOST=localhost
$ export PGPORT=15432
$ export PGDATABASE=$(echo "${get_secrets_value}" | jq -r .dbname)
$ export PGUSER=$(echo "${get_secrets_value}" | jq -r .username)
$ export PGPASSWORD=$(echo "${get_secrets_value}" | jq -r .password)

$ psql
psql (16.2, server 15.5)
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
Type "help" for help.

testDB=> SELECT version();
                                                   version
-------------------------------------------------------------------------------------------------------------
 PostgreSQL 15.5 on aarch64-unknown-linux-gnu, compiled by aarch64-unknown-linux-gnu-gcc (GCC) 9.5.0, 64-bit
(1 row)

testDB=> SELECT aurora_db_instance_identifier();
 aurora_db_instance_identifier
-------------------------------
 db-instance-writer
(1 row)

testDB=> SELECT * FROM aurora_global_db_instance_status();
     server_id      |    session_id     | aws_region | durable_lsn | highest_lsn_rcvd | feedback_epoch | feedback_xmin | oldest_read_view_lsn | visibility_lag_in_msec
--------------------+-------------------+------------+-------------+------------------+----------------+---------------+----------------------+------------------------
 db-instance-writer | MASTER_SESSION_ID | us-east-1  |   179417812 |                  |                |               |                      |
(1 row)

testDB=> SELECT inet_client_addr();
 inet_client_addr
------------------
 10.1.16.211
(1 row)

問題なく接続でき、クエリを叩くこともできました。接続元のIPアドレスはECSタスクのIPアドレスになっています。

Egress Subnetにデプロイ × Pull through cache(初回)

続いて、Pull through cacheを試してみます。./lib/parameter/index.tsは以下のとおりです。

./lib/parameter/index.ts

export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  property: {
    vpcId: "vpc-0c923cc42e5fb2cbf",
    ecsFargateParams: {
      ecsFargateSubnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_WITH_EGRESS,
        availabilityZones: ["us-east-1a"],
      },
      clusterName: "ecs-fargate-bastion",
      ecrRepositoryPrefix: "ecr-public-pull-through",
      repositoryName: "ecr-public-pull-through/ubuntu/ubuntu",
      imagesTag: "22.04",
      desiredCount: 1,
      ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
    },
  },
};

デプロイ後、ECRのプライベートリポジトリを確認すると、指定したコンテナイメージがpushされていました。

Pull through cacheの確認

起動してきたECSタスクを確認すると、Pull through cacheによるものと思われるコンテナイメージを使っていました。

Pull_through_cacheのイメージを使っていることを確認

起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。

Pull through cache ECS Execのログ

Isolated Subnetにデプロイ × Pull through cache(2回目)

次に、Isolated Subnetにデプロイします。

続いて、Pull through cacheを試してみます。./lib/parameter/index.tsは以下のとおりです。

./lib/parameter/index.ts

export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  property: {
    vpcId: "vpc-0c923cc42e5fb2cbf",
    vpcEndpointParams: {
      vpcEndpointSubnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
        availabilityZones: ["us-east-1a"],
      },
      shouldCreateEcrVpcEndpoint: true,
      shouldCreateSsmVpcEndpoint: true,
      shouldCreateLogsVpcEndpoint: true,
      shouldCreateS3VpcEndpoint: true,
    },
    ecsFargateParams: {
      ecsFargateSubnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
        availabilityZones: ["us-east-1a"],
      },
      clusterName: "ecs-fargate-bastion",
      ecrRepositoryPrefix: "ecr-public-pull-through",
      repositoryName: "ecr-public-pull-through/ubuntu/ubuntu",
      imagesTag: "22.04",
      desiredCount: 1,
      ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
    },
  },
};

構成図にすると以下のとおりです。

AWS CDKでECS Fargate Bastionを一撃で作ってみた検証環境構成図_Isolated Subnet

デプロイ後、起動してきたECSタスクを確認すると、Pull through cacheによるものと思われるコンテナイメージを使っていました。

Pull_through_cacheのイメージを使っていることを確認_Isolated

Isolated Subnetなのでamazon-ecs-exec-checkerでも叩いてみます。

$ ./check-ecs-exec.sh ecs-fargate-bastion be519c666386432289d355afb155162b
-------------------------------------------------------------
Prerequisites for check-ecs-exec.sh v0.7
-------------------------------------------------------------
  jq      | OK (/opt/homebrew/bin/jq)
  AWS CLI | OK (/opt/homebrew/bin/aws)

-------------------------------------------------------------
Prerequisites for the AWS CLI to use ECS Exec
-------------------------------------------------------------
  AWS CLI Version        | OK (aws-cli/2.15.15 Python/3.11.7 Darwin/23.2.0 source/arm64 prompt/off)
  Session Manager Plugin | OK (1.2.553.0)

-------------------------------------------------------------
Checks on ECS task and other resources
-------------------------------------------------------------
Region : us-east-1
Cluster: ecs-fargate-bastion
Task   : be519c666386432289d355afb155162b
-------------------------------------------------------------
  Cluster Configuration  |
     KMS Key       : Not Configured
     Audit Logging : OVERRIDE
     S3 Bucket Name: Not Configured
     CW Log Group  : /ecs/ecs-fargate-bastion/ecr-public-pull-through/ubuntu/ubuntu/ecs-exec, Encryption Enabled: false
  Can I ExecuteCommand?  | arn:aws:iam::<AWSアカウントID>:role/<IAMロール名>
     ecs:ExecuteCommand: allowed
     ssm:StartSession denied?: allowed
  Task Status            | RUNNING
  Launch Type            | Fargate
  Platform Version       | 1.4.0
  Exec Enabled for Task  | OK
  Container-Level Checks |
    ----------
      Managed Agent Status
    ----------
         1. RUNNING for "Container"
    ----------
      Init Process Enabled (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11)
    ----------
         1. Enabled - "Container"
    ----------
      Read-Only Root Filesystem (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11)
    ----------
         1. Disabled - "Container"
  Task Role Permissions  | arn:aws:iam::<AWSアカウントID>:role/EcsFargateBastionStack-EcsFargateTaskDefinitionTask-CirFF1nrXFna
     ssmmessages:CreateControlChannel: allowed
     ssmmessages:CreateDataChannel: allowed
     ssmmessages:OpenControlChannel: allowed
     ssmmessages:OpenDataChannel: allowed
     -----
     logs:DescribeLogGroups: allowed
     logs:CreateLogStream: allowed
     logs:DescribeLogStreams: allowed
     logs:PutLogEvents: allowed
  VPC Endpoints          |
    Found existing endpoints for vpc-0c923cc42e5fb2cbf:
      - com.amazonaws.us-east-1.s3
      - com.amazonaws.us-east-1.logs
      - com.amazonaws.us-east-1.ecr.api
      - com.amazonaws.us-east-1.ecr.dkr
      - com.amazonaws.us-east-1.ssmmessages
      - com.amazonaws.us-east-1.ssm
  Environment Variables  | (EcsFargateBastionStackEcsFargateTaskDefinition6FF60031:11)
       1. container "Container"
       - AWS_ACCESS_KEY: not defined
       - AWS_ACCESS_KEY_ID: not defined
       - AWS_SECRET_ACCESS_KEY: not defined

特に問題なさそうです。必要なVPCエンドポイントを正しく認識しています。

起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。

Pull through cache ECS Execのログ_Isolated

Isolated Subnetにデプロイ × Pull through cache(初回)

次に、初めてPull through cacheを使用してイメージをpullする際にインターネットへの接続がない場合の挙動を確認してみます。

AWS公式ドキュメントには以下のように「初めてpullする場合はNAT Gatewayが必要」との記載があります。

初めてプルスルーキャッシュルールを使用してイメージをプルするとき、AWS PrivateLink を使って、インターフェイス VPC エンドポイントを使用するように Amazon ECR を設定した場合、NAT ゲートウェイを使用して、同じ VPC 内にパブリックサブネットを作成し、プルが機能するように、プライベートサブネットから NAT ゲートウェイへのすべてのアウトバウンドトラフィックをインターネットにルーティングする必要があります。その後のイメージプルでは、これは必要ありません。詳細については、Amazon Virtual Private Cloud ユーザーガイドの「シナリオ: プライベートサブネットからインターネットにアクセスする」を参照してください。

Amazon ECR インターフェイス VPC エンドポイント (AWS PrivateLink) - Amazon ECR

しかし、以下記事ではNAT Gatewayへのルートが存在しない場合でも初回のpullができたと紹介されています。

実際に私も試してみました。

Pull through cache ruleで使用するリポジトリプレフィックスを変更してみます。./lib/parameter/index.tsは以下のとおりです。

./lib/parameter/index.ts

export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  property: {
    vpcId: "vpc-0c923cc42e5fb2cbf",
    vpcEndpointParams: {
      vpcEndpointSubnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
        availabilityZones: ["us-east-1a"],
      },
      shouldCreateEcrVpcEndpoint: true,
      shouldCreateSsmVpcEndpoint: true,
      shouldCreateLogsVpcEndpoint: true,
      shouldCreateS3VpcEndpoint: true,
    },
    ecsFargateParams: {
      ecsFargateSubnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
        availabilityZones: ["us-east-1a"],
      },
      clusterName: "ecs-fargate-bastion",
      ecrRepositoryPrefix: "ecr-public-pull-through2",
      repositoryName: "ecr-public-pull-through2/ubuntu/ubuntu",
      imagesTag: "22.04",
      desiredCount: 1,
      ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
    },
  },
};

デプロイ後、起動してきたECSタスクを確認すると、変更後のPull through cache ruleで指定したリポジトリプレフィックスのコンテナイメージを使っていることがわかりました。

Pull_through_cacheのイメージを使っていることを確認_Isolated_初回

先述の記事で紹介しているとおり、NAT Gatewayへのルートが存在しない場合でも初回のpullができるのでしょうか。

今度はコンテナイメージを変更してみます。Ubuntu 22.04からbusyboxに変更します。./lib/parameter/index.tsは以下のとおりです。

./lib/parameter/index.ts

export const ecsFargateBastionStackParams: EcsFargateBastionStackParams = {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
  property: {
    vpcId: "vpc-0c923cc42e5fb2cbf",
    vpcEndpointParams: {
      vpcEndpointSubnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
        availabilityZones: ["us-east-1a"],
      },
      shouldCreateEcrVpcEndpoint: true,
      shouldCreateSsmVpcEndpoint: true,
      shouldCreateLogsVpcEndpoint: true,
      shouldCreateS3VpcEndpoint: true,
    },
    ecsFargateParams: {
      ecsFargateSubnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PRIVATE_ISOLATED,
        availabilityZones: ["us-east-1a"],
      },
      clusterName: "ecs-fargate-bastion",
      ecrRepositoryPrefix: "ecr-public-pull-through3",
      repositoryName: "ecr-public-pull-through3/docker/library/busybox",
      imagesTag: "stable-musl",
      imagesTag: "22.04",
      desiredCount: 1,
      ecsServiceSecurityGroupIds: ["sg-05744485862b195ae"],
    },
  },
};

デプロイ後に起動してきたECSタスクを確認すると、確かにbusyboxのイメージを使用しています。

busyboxで起動できたことを確認

Pull through cacheで作成されたリポジトリにもイメージがpushされています。

ecr-public-pull-through3:docker:library:busybox

この後もスタックを一から作り直して再チャレンジしましたが、結果は同じでした。NAT Gatewayへのルートが存在しない場合でも初回のpullができるようです。

ちなみに、コンテナイメージをUbuntu 22.04からbusyboxに変更すると、スタックを更新してからタスクの起動が完了するまでの時間が4分から1分と3分短くなりました。

また、タスクを停止させて、新しいタスクが実行中になるまでにかかった時間は30秒ほどでした(停止をしてから新しいタスクを起動し始めるまで15秒 /起動したタスクが実行中になるまで15秒)。このぐらいの速度であれば通常はタスクの数を0にしておいて、必要になったタイミングで1に変更するといった運用も耐えられそうです。

起動したECSタスクへのECS Exec、SSMセッションマネージャー、SSMセッションマネージャーのリモートホストのポートフォワーディングのいずれも問題なく行えました。参考までにECS Execのログは以下のとおりです。

busyboxのECS Execログ

NAT Gatewayがないサブネットにも簡単に踏み台を用意できる

AWS CDKでECS Fargate Bastionを一撃で作ってみました。

NAT Gatewayがないサブネットにも簡単に踏み台を用意できましたね。

なお、コンテナにECS ExecやSSMセッションマネージャーして、そこからコマンドを色々叩きたい方はコンテナイメージのビルドが必要です。ぜひカスタマイズしてみてください。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!