クロスアカウント共有した Private CA を使って ECS Service Connect のサービス間通信を自動暗号化してみた

クロスアカウント共有した Private CA を使って ECS Service Connect のサービス間通信を自動暗号化してみた

2025.09.28

ECS Service Connect で自動トラフィック暗号化を利用する際、Private CA を用意する必要があります。
この際、Resource Access Manager(RAM) でクロスアカウント共有した Private CA を利用して問題無く設定できるか試してみました。

仕様確認

実際に試してみる前に軽く仕様をまとめます。

Private CA の共有について

ECS Service Connect で利用する場合に限らない話ですが、RAM で共用する際に Organizations 組織内のアカウントにしか共有できないリソースと、Organizations 外のアカウントにも共有できるリソースがあります。
Private CA は非 Organizations 環境でも利用可能です。
Shareable AWS resources

直近対応した CloudMap 名前空間などは、Organizations 組織内のアカウントにのみ共有できるようになっていますね。

https://aws.amazon.com/jp/about-aws/whats-new/2025/08/aws-cloud-map-support-cross-account-service-discovery/

管理アクセス許可設定について

RAM でリソースを共有する際、管理アクセス許可設定によって共有先で行使できる権限を指定可能です。
ECS Service Connect で利用する場合、一般的な Web サーバ証明書として機能する X.509 証明書の発行を許可する AWSRAMDefaultPermissionCertificateAuthority を選択すれば良いと思います。
私も細かい証明書の違いまで理解できているわけでは無いですが、各 AWS 管理アクセス許可設定の違いについて気になった方には下記ブログがおすすめです。

https://dev.classmethod.jp/articles/managed-aws-private-ca-access-on-aws-ram/

Private CA の種類について

Private CA には汎用モードと短期モード (有効期限が短い証明書を扱うモード) が存在します。
ECS Service Connect では両方のモードを利用することができますが、コスト観点から有効期限が短い証明書の利用が推奨されています。

While Amazon ECS supports both modes, we recommend using short-lived certificates. By default, certificates rotate every five days, and running in short-lived mode offers significant cost savings over general purpose.
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect-tls.html#service-connect-tls-certificates

元々 ECS Service Connect は積極的に証明書を更新するので、短期モードで問題無いということですね。
ただ、以前短期モードで試してみたので、今回は汎用モードで試します。

https://dev.classmethod.jp/articles/ecs-service-connect-traffic-encryption/

秘密鍵格納用のシークレットについて

ECS が証明書を管理する際、Secret Manager を作成して作成した秘密鍵を保存します。
こうすることで、TLS 暗号化を効率的に行えるようになるようです。

The automatic creation and management of these secrets by Service Connect streamlines the process of implementing TLS encryption for your services.
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect-tls.html#service-connect-asm

これらのシークレットは ecs-sc! で始まる名前で作成され、RAM で Private CA を共有している場合でも ECS サービスが存在する側の AWS アカウントに作成されました。
シークレット作成には ECS サービス作成時に指定するインフラストラクチャロールを利用しており、特に Private CA がある側の AWS 権限を許可しているわけではないので、まぁ当たり前ではありますね。
ちなみに、今回は AWS マネージドポリシーである AmazonECSInfrastructureRolePolicyForServiceConnectTransportLayerSecurity をインフラストラクチャロールに付与しています。

利用できるキーアルゴリズムについて

256 bit ECDSA と 2048 bit RSA を利用できます。

It supports AWS Private Certificate Authority TLS certificates with 256-bit ECDSA and 2048-bit RSA encryption.
https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect-tls.html#service-connect-asm

試してみる

Private CA の作成と共有はマネジメントコンソールから、その他インフラは CDK で作成します。

Private CA の作成と共有

まず、Private CA の認証局を作成します。
前述の通り、汎用モードでも短期モードでも良いですが、コスト観点から短期モードが推奨されています。
ただ、どちらでも問題無く設定できることを検証するために今回は汎用モードを選択します。

privateca1.png

キーアルゴリズムとしては RSA 2048 を指定します。

privateca2.png

AmazonECSManaged=true のタグを付与して、CA を作成します。

privateca3.png

こちらのタグは AWS マネージドポリシーである AmazonECSInfrastructureRolePolicyForServiceConnectTransportLayerSecurity を利用する場合は付与必須です。

「CA 証明書をインストール」をクリックします。

privateca4.png

「確認してインストール」をクリックします。

privateca5.png

RAM のサービスページに移動して、「リソース共有を作成」をクリックします。

ram1.png

作成した Private CA を選択して「次へ」をクリックします。

ram-pca1.png

アクセス許可は AWSRAMDefaultPermissionCertificateAuthority を選択して「次へ」をクリックします。

ram-pca2.png

今回は AWS アカウントを指定して共有します。
もちろん、組織や OU も指定可能です。

ram-pca3.png

リソース共有を作成します。

ram-pca4.png

各種インフラの作成

Private CA を共有できたので、各種インフラを作成していきます。
構成は下図のようになり、共有した Private CA を利用して自動 TLS 有効化しつつ ECS Service を構成します。

sc-agent.png

ECS Service は Service Connect を有効化した ECS サービス間の通信のみ TLS 化するので、クライアント側も Service Connect を有効化した ECS タスクとします。
CDK は下記のように作成しました。

bin/iac.ts

			
			#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { EcrStack } from "../lib/ecr-stack";
import { MainStack } from "../lib/main-stack";

const app = new cdk.App();
new EcrStack(app, "EcrStack", {
  repositoryName: "sample-ecs-app",
});
const mainStack = new MainStack(app, "MainStack", {
  repositoryName: "sample-ecs-app",
  imageTag: "v1",
  privateCaArn:
    "arn:aws:acm-pca:ap-northeast-1:xxxxxxxxxxxx:certificate-authority/3954bf18-debf-43b8-8673-37035efba52f",
});

		

lib/ecr.ts

			
			import * as cdk from "aws-cdk-lib";
import { aws_ecr as ecr } from "aws-cdk-lib";
import type { Construct } from "constructs";

export interface EcrStackProps extends cdk.StackProps {
  repositoryName: string;
}

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

    // ECR Repository
    new ecr.Repository(this, props.repositoryName, {
      repositoryName: `${props.repositoryName}`,
      imageScanOnPush: true,
      imageTagMutability: ecr.TagMutability.IMMUTABLE,
    });
  }
}

		

lib/main-stack

共有した Private CA でも tls.awsPcaAuthorityArn で ARN を指定すれば自アカウントのものと変わらない形で設定できます。

			
			import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ecr from "aws-cdk-lib/aws-ecr";
import * as logs from "aws-cdk-lib/aws-logs";
import * as rds from "aws-cdk-lib/aws-rds";
import * as servicediscovery from "aws-cdk-lib/aws-servicediscovery";

export interface MainStackProps extends cdk.StackProps {
  repositoryName: string;
  imageTag: string;
  privateCaArn: string;
}

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

    // VPC
    const vpc = new ec2.Vpc(this, "Vpc", {
      maxAzs: 2,
      natGateways: 1,
    });

    // Namespace
    const namespace = new servicediscovery.HttpNamespace(
      this,
      "ServiceConnectNamespace",
      {
        name: "sample-namespace",
      }
    );

    // Secret for DB credentials
    const dbSecret = new secretsmanager.Secret(this, "AuroraSecret", {
      secretName: `aurora-root-secret`,
      generateSecretString: {
        excludePunctuation: true,
        includeSpace: false,
        generateStringKey: "password",
        secretStringTemplate: JSON.stringify({
          username: "postgres",
        }),
      },
    });

    // ECS Cluster
    const cluster = new ecs.Cluster(this, "EcsCluster", {
      vpc: vpc,
      containerInsightsV2: ecs.ContainerInsights.ENABLED,
    });

    // ECS Task Execution Role
    const taskExecutionRole = new iam.Role(this, "TaskExecutionRole", {
      assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonECSTaskExecutionRolePolicy"
        ),
      ],
    });

    // ECS Task Definition
    const taskDefinition = new ecs.FargateTaskDefinition(
      this,
      "TaskDefinition",
      {
        cpu: 256,
        memoryLimitMiB: 512,
        executionRole: taskExecutionRole,
      }
    );
    const ecsContainer = taskDefinition.addContainer("App", {
      image: ecs.ContainerImage.fromEcrRepository(
        ecr.Repository.fromRepositoryName(
          this,
          "AppRepository",
          props.repositoryName
        ),
        props.imageTag
      ),
      essential: true,
      secrets: {
        DATABASE_USER: ecs.Secret.fromSecretsManager(dbSecret, "username"),
        DATABASE_PASSWORD: ecs.Secret.fromSecretsManager(dbSecret, "password"),
        DATABASE_HOST: ecs.Secret.fromSecretsManager(dbSecret, "host"),
        DATABASE_NAME: ecs.Secret.fromSecretsManager(dbSecret, "dbname"),
      },
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: "ecs",
        logGroup: new logs.LogGroup(this, "AppLogGroup", {
          logGroupName: `/ecs/${props.repositoryName}`,
          retention: logs.RetentionDays.ONE_WEEK,
          removalPolicy: cdk.RemovalPolicy.DESTROY,
        }),
      }),
    });
    ecsContainer.addPortMappings({
      name: "app",
      containerPort: 80,
      protocol: ecs.Protocol.TCP,
      appProtocol: ecs.AppProtocol.http,
    });

    // Security Group for App
    const appSg = new ec2.SecurityGroup(this, "AppSg", {
      vpc: vpc,
      allowAllOutbound: true,
    });

    // Role for ECS Service Connect TLS
    const ecsServiceConnectTlsRole = new iam.Role(
      this,
      "EcsServiceConnectTlsRole",
      {
        assumedBy: new iam.ServicePrincipal("ecs.amazonaws.com"),
        managedPolicies: [
          iam.ManagedPolicy.fromAwsManagedPolicyName(
            "service-role/AmazonECSInfrastructureRolePolicyForServiceConnectTransportLayerSecurity"
          ),
        ],
      }
    );

    // ECS Service
    const service = new ecs.FargateService(this, "Service", {
      cluster: cluster,
      serviceName: "sample-ecs-app",
      taskDefinition: taskDefinition,
      platformVersion: ecs.FargatePlatformVersion.VERSION1_4,
      desiredCount: 1,
      assignPublicIp: false,
      securityGroups: [appSg],
      vpcSubnets: vpc.selectSubnets({
        subnetGroupName: "Private",
      }),
      healthCheckGracePeriod: cdk.Duration.seconds(240),
      serviceConnectConfiguration: {
        namespace: namespace.namespaceArn,
        services: [
          {
            portMappingName: "app",
            port: 80,
            tls: {
              awsPcaAuthorityArn: props.privateCaArn,
              role: ecsServiceConnectTlsRole,
            },
          },
        ],
      },
    });

    // Client ECS Task Role
    const clientTaskRole = new iam.Role(this, "ClientTaskRole", {
      assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
      inlinePolicies: {
        SSMMessagesPolicy: new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: [
                "ssmmessages:CreateControlChannel",
                "ssmmessages:CreateDataChannel",
                "ssmmessages:OpenControlChannel",
                "ssmmessages:OpenDataChannel",
              ],
              resources: ["*"],
            }),
          ],
        }),
      },
    });

    // Security Group for Client
    const clientSg = new ec2.SecurityGroup(this, "ClientSg", {
      vpc: vpc,
      allowAllOutbound: true,
    });

    // Client ECS Task Definition
    const clientTaskDefinition = new ecs.FargateTaskDefinition(
      this,
      "ClientTaskDefinition",
      {
        cpu: 256,
        memoryLimitMiB: 512,
        taskRole: clientTaskRole,
        executionRole: taskExecutionRole,
      }
    );
    clientTaskDefinition.addContainer("client", {
      image: ecs.ContainerImage.fromRegistry("amazonlinux:latest"),
      essential: true,
      command: ["/bin/bash", "-c", "while true; do sleep 30; done"],
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: "client",
        logGroup: new logs.LogGroup(this, "ClientLogGroup", {
          logGroupName: `/ecs/client`,
          retention: logs.RetentionDays.ONE_WEEK,
          removalPolicy: cdk.RemovalPolicy.DESTROY,
        }),
      }),
    });

    // Client ECS Service
    const clientService = new ecs.FargateService(this, "ClientService", {
      cluster: cluster,
      serviceName: "client-service",
      taskDefinition: clientTaskDefinition,
      desiredCount: 1,
      assignPublicIp: false,
      securityGroups: [clientSg],
      vpcSubnets: vpc.selectSubnets({
        subnetGroupName: "Private",
      }),
      enableExecuteCommand: true,
      serviceConnectConfiguration: {
        namespace: namespace.namespaceArn,
        services: [],
      },
    });

    // Allow Access to App Service
    service.connections.allowFrom(clientService, ec2.Port.tcp(80));

    // Aurora Serverless Cluster
    const auroraCluster = new rds.DatabaseCluster(
      this,
      "AuroraServerlessCluster",
      {
        engine: cdk.aws_rds.DatabaseClusterEngine.auroraPostgres({
          version: cdk.aws_rds.AuroraPostgresEngineVersion.VER_16_4,
        }),
        defaultDatabaseName: "postgres",
        credentials: rds.Credentials.fromSecret(dbSecret),
        removalPolicy: cdk.RemovalPolicy.DESTROY,
        writer: rds.ClusterInstance.serverlessV2("WriterInstance", {
          publiclyAccessible: false,
        }),
        vpc: vpc,
        vpcSubnets: vpc.selectSubnets({
          subnetGroupName: "Private",
        }),
        serverlessV2MaxCapacity: 1.0,
        serverlessV2MinCapacity: 0.0,
        cloudwatchLogsExports: ["postgresql"],
        cloudwatchLogsRetention: logs.RetentionDays.ONE_WEEK,
        storageEncrypted: true,
      }
    );
    auroraCluster.connections.allowFrom(appSg, ec2.Port.tcp(5432));
  }
}

		

※ 今回の検証自体には RDS は不要ですが、せっかくなので DB アクセスのあるアプリケーションとしてみました。

動作確認

マネジメントコンソールから ECS Exec でクライアント側のコンテナに接続してリクエストを送ってみます。

https://dev.classmethod.jp/articles/ecs-exec-aws-management-console/

CloudShell が起動されてコンテナ内にアクセス可能になったら、 HTTP リクエストを送ります。

			
			sh-5.2# curl -I http://app.sample-namespace
HTTP/1.1 200 OK
x-powered-by: Express
content-type: text/html; charset=utf-8
content-length: 11
etag: W/"b-Ck1VqNd45QIvq3AZd8XYQLvEhtA"
date: Sun, 28 Sep 2025 10:26:05 GMT
x-envoy-upstream-service-time: 23
server: envoy

		

良い感じに通信できており、server: envoy から ECS Service Connect 経由で通信していることも確認できます。
次に TLS 通信が有効なことを確認します。
ただし、サイドカーとして挿入された Service Connect エージェントが TLS 通信を開始して宛先のエージェントが TLS 通信を終端する都合上、クライアント側で単にリクエストを送るだけでは TLS で通信していることを確認できません。
そのため、Verifying TLS is enabled for Amazon ECS Service Connect に従って openssl でタスクに添付された証明書を検証および表示します。

			
			sh-5.2# openssl s_client -connect 10.0.174.252:80 < /dev/null 2> /dev/null | openssl x509 -noout -text
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number:
            ce:dd:29:64:a6:ba:60:41:11:4e:ed:65:74:9e:b3:6c
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: C=JP, O=Org, OU=devision, ST=Tokyo, CN=sample, L=Minato-ku
        Validity
            Not Before: Sep 28 05:39:08 2025 GMT
            Not After : Oct  5 06:39:08 2025 GMT
        Subject: L=Minato-ku, CN=sample, ST=Tokyo, OU=devision, O=Org, C=JP
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (256 bit)
                pub:
                    04:8c:dc:ac:9a:ff:eb:0c:af:1c:f2:bb:e2:1a:a7:
                    02:c2:28:9e:50:cd:5b:f7:86:9c:64:c2:5d:71:0c:
                    26:c2:15:a5:62:0f:41:95:49:19:4e:f0:f5:5f:4c:
                    07:80:01:ce:c9:08:08:e9:e5:1e:4f:0d:6f:09:a9:
                    fe:f6:ad:e0:66
                ASN1 OID: prime256v1
                NIST CURVE: P-256
        X509v3 extensions:
            X509v3 Subject Alternative Name: critical
                DNS:app.sample-namespace
            X509v3 Basic Constraints:
                CA:FALSE
            X509v3 Authority Key Identifier:
                E7:44:A0:4D:55:89:C7:D1:B9:3A:CF:4D:2B:AD:B4:7E:26:12:96:80
            X509v3 Subject Key Identifier:
                02:49:0F:E7:49:7E:19:7B:BA:1D:1C:58:AA:D0:9A:07:09:5C:C0:23
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
    Signature Algorithm: sha256WithRSAEncryption
    Signature Value:
        5e:22:14:71:5f:f5:ef:a7:47:a1:5e:d0:25:16:4d:da:76:36:
        6c:ee:3a:3b:72:f3:a3:c2:03:0c:8a:bf:94:32:98:10:80:d3:
        7d:05:53:9d:19:b4:5f:fe:5d:40:83:ac:f2:7f:56:57:de:3a:
        ea:b2:18:5e:02:c2:4d:60:36:85:78:3d:8b:a0:14:57:6e:06:
        3f:34:c1:c3:c2:77:4e:6e:58:9a:79:ad:92:6d:fa:2c:db:4a:
        5e:84:0b:15:61:a4:4e:b1:55:ee:48:66:47:71:e2:97:00:64:
        cc:de:94:6a:9d:24:d1:c8:f5:d5:82:c7:bb:4d:b3:17:22:48:
        f1:aa:ea:c8:de:77:2a:d7:18:9f:d6:dd:42:3f:a2:94:95:99:
        28:1a:5b:7e:f5:83:05:51:9c:e3:ef:68:c6:74:89:39:52:c0:
        4d:7c:ef:4d:47:ec:86:7f:01:41:7e:7a:4b:d0:24:4f:c5:f4:
        98:95:63:93:12:a2:77:05:94:05:6d:f5:f5:61:d1:30:a1:c1:
        4b:f1:60:06:71:85:4e:9b:61:a3:bc:be:81:6b:6b:ad:85:1d:
        9f:8b:1d:a2:1c:20:fc:9b:f4:e0:a1:26:3f:12:85:99:e1:d4:
        62:d4:f5:6f:99:8e:53:9d:b1:08:95:8b:ab:cf:8f:c8:6a:8e:
        62:72:ad:a7

		

無事表示できました!

この記事をシェアする

FacebookHatena blogX

関連記事