AWS PrivateLink のプロバイダー側とコンシューマー側の両方を AWS CDK で実装して接続してみた

AWS PrivateLink のプロバイダー側とコンシューマー側の両方を AWS CDK で実装して接続してみた

2025.08.23

こんにちは、製造ビジネステクノロジー部の若槻です。

AWS PrivateLink を利用すると、VPC 内のリソースをインターネットに公開することなく、他の AWS アカウントや VPC からアクセス可能とすることができます。

アーキテクチャイメージは次のようになります。左が接続される側(サービスプロバイダー)、右が接続する側(サービスコンシューマー)です。


アーキテクチャ 1: AWS PrivateLink - AWS 規範ガイダンス より

AWS PrivateLink では、AWS アカウント間の接続の場合は以下のような対応が不要となり、これにより接続される側/する側ともにセキュリティリスクやコストの軽減が臨めます。

  • 接続される側(サービスプロバイダー)
    • パブリックサブネットへのリソース(ELB など)の配置
  • 接続する側(サービスコンシューマー)
    • パブリックサブネットへのリソース(NAT Gateway など)の配置
    • サービスプロバイダー側と重複しない CIDR ブロックの確保

今回は、AWS CDK を利用して、PrivateLink のプロバイダー側とコンシューマー側の両方を実装し、接続できることを確認してみました。

また参考資料としては下記の公式提供ドキュメントが分かりやすいので併せてご覧ください。

https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/integrate-third-party-services/architecture-1.html
https://aws.amazon.com/jp/blogs/news/aws-privatelink-cross-account-connection/

実装

プロバイダー側とコンシューマー側の両方の実装を行うのですが、実装をする際に双方間で次のようなやり取りが必要となります。

  1. プロバイダー側はコンシューマー側の AWS アカウント ID を受領して、VPC エンドポイントサービス許可プリンシパル一覧に設定する
  2. コンシューマー側はプロバイダー側の VPC エンドポイントサービス名を受領して、VPC エンドポイントを作成する
  3. プロバイダー側はコンシューマー側の VPC エンドポイント接続リクエストを承諾する(自動承諾とすることも可能)
  4. コンシューマー側はプロバイダー側のリソースへのアクセスを行う

また注意点として今回は以下の設定は行なっていませんが、実際の運用時は設定を検討してください。

  • VPC エンドポイントサービスへの接続の HTTPS 化(証明書の設定)
  • VPC エンドポイント接続リクエストの手動承諾

前者は実質必須でしょう。後者は運用ポリシー次第です。接続可能とする AWS アカウントのプロバイダー側への登録はそもそも必須ですが、それに加えて VPC エンドポイントを手動承諾と自動承諾のいずれにするかどうかです。手動承諾としたら、プロバイダー側で承諾作業が発生し、作業待ち時間が発生しますが、よりセキュアになります。一方で自動承諾としたら、承諾作業は不要ですが、接続可能な AWS アカウントに対しては即座に接続可能となります。

今回は、HTTPS 化はなし、VPC エンドポイント接続リクエストは自動承諾として実装しています。

プロバイダー側実装

プロバイダー側はあらかじめコンシューマー側のアカウント ID を受領しておきます。

プロバイダー側の CDK 実装は以下となります。

lib/constructs/privatelink-provider.ts
			
			import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as elbv2_targets from "aws-cdk-lib/aws-elasticloadbalancingv2-targets";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";

const CONSUMER_ACCOUNT_ID = process.env.CONSUMER_ACCOUNT_ID;

export class PrivateLinkProviderConstruct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    /**
     * VPC の作成
     */
    const vpc = new ec2.Vpc(this, "VPC", {
      subnetConfiguration: [
        // ALB への接続を PrivateLink 経由のみ許可する想定で、プライベートサブネットのみを作成
        {
          name: "PrivateIsolated",
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
      maxAzs: 2, // 検証用のため最小構成を指定。ALB 作成には 2つの AZ が必要
      natGateways: 0, // 本検証では使用しないため、NAT Gateway を 0 にする
      restrictDefaultSecurityGroup: true, // アクセス権限最小化のため、デフォルトのセキュリティグループを制限
    });

    // -----------------------------------------------
    // ↓ コンシューマー側からのアクセス確認用の実装
    // -----------------------------------------------

    /**
     * ALB の作成
     */
    const alb = new elbv2.ApplicationLoadBalancer(
      this,
      "ApplicationLoadBalancer",
      {
        vpc,
        vpcSubnets: {
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      }
    );

    /**
     * ALB 用リスナーの作成
     *
     * TODO: 証明書を設定して HTTPS 化することを推奨
     */
    alb.addListener("AlbListener", {
      port: 80,
      defaultAction: elbv2.ListenerAction.fixedResponse(200, {
        messageBody: "Hello from ALB!",
      }),
    });

    // -----------------------------------------------
    // ↓ PrivateLink のプロバイダー側に必要な実装
    // -----------------------------------------------

    /**
     * NLB の作成
     */
    const nlb = new elbv2.NetworkLoadBalancer(this, "NetworkLoadBalancer", {
      vpc,
      internetFacing: false,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      },
    });

    /**
     * NLB 用のターゲットグループ作成
     */
    const nlbTargetGroup = new elbv2.NetworkTargetGroup(
      this,
      "NlbTargetGroup",
      {
        port: 80,
        vpc,
      }
    );

    /**
     * NLB 用のターゲットとして ALB を追加
     */
    nlbTargetGroup.addTarget(
      new elbv2_targets.AlbListenerTarget(alb.listeners[0])
    );

    /**
     * NLB リスナーの作成
     */
    nlb.addListener("NlbListener", {
      port: 80,
      defaultTargetGroups: [nlbTargetGroup],
    });

    /**
     * VPC Endpoint Service の作成(PrivateLink プロバイダー)
     */
    const vpcEndpointService = new ec2.VpcEndpointService(
      this,
      "VpcEndpointService",
      {
        vpcEndpointServiceLoadBalancers: [nlb],
        acceptanceRequired: false, // 接続リクエストの自動承諾の無効化
        allowedPrincipals: [
          new iam.ArnPrincipal(`arn:aws:iam::${CONSUMER_ACCOUNT_ID}:root`),
        ],
      }
    );

    /**
     * VPC エンドポイントサービス名の出力
     */
    new cdk.CfnOutput(this, "ProviderVpcEndpointServiceName", {
      value: vpcEndpointService.vpcEndpointServiceName,
    });

    /**
     * TODO: 証明書を利用した VPC エンドポイントサービスのプライベート DNS 名の設定および HTTPS 化
     * @see https://docs.aws.amazon.com/ja_jp/vpc/latest/privatelink/manage-dns-names.html
     */
  }
}

		

PrivateLink のプロバイダー側として必要な実装は、NLB の作成と VPC エンドポイントサービスの作成です。接続のフローは以下のようになります。

			
			NLB → VPC エンドポイントサービス → ALB → ALB リスナー → ALB ターゲットグループ → ALB ターゲット(EC2 など)

		

環境変数 CONSUMER_ACCOUNT_ID にコンシューマー側の AWS アカウント ID を設定して CDK デプロイをします。

すると VPC エンドポイントサービスが作成され、許可プリンシパル一覧にコンシューマー側の AWS アカウント ID が設定されます。

また、VPC エンドポイントサービス名が CloudFormation の出力に表示されるので、これをコンシューマー側に伝えます。

コンシューマー側実装

コンシューマー側は、プロバイダー側の VPC エンドポイントサービス名を受け取ったら、以下のように実装します。

lib/constructs/privatelink-consumer.ts
			
			import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as logs from "aws-cdk-lib/aws-logs";
import * as lambda_nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";

const PROVIDER_VPC_ENDPOINT_SERVICE_NAME =
  process.env.PROVIDER_VPC_ENDPOINT_SERVICE_NAME || "";

export class PrivateLinkConsumerConstruct extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    /**
     * VPC の作成
     */
    const vpc = new ec2.Vpc(this, "VPC", {
      subnetConfiguration: [
        // VPC エンドポイントを経由した通信のみを行う想定で、プライベートサブネットのみを作成
        {
          name: "PrivateIsolated",
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
      maxAzs: 1, // 検証用のため最小構成を指定。ALB 作成には 2つの AZ が必要
      natGateways: 0, // 本検証では使用しないため、NAT Gateway を 0 にする
      restrictDefaultSecurityGroup: true, // アクセス権限最小化のため、デフォルトのセキュリティグループを制限
    });

    // -----------------------------------------------
    // ↓ PrivateLink のコンシューマー側に必要な実装
    // -----------------------------------------------

    /**
     * VPC Endpoint 用のセキュリティグループ
     *
     * MEMO: 接続先を VPC Endpoint のみに制限するために明示的に作成している。(明示的に作成しない場合は allowAllOutbound が既定で有効化される)
     */
    const vpcEndpointSecurityGroup = new ec2.SecurityGroup(
      this,
      "VpcEndpointSecurityGroup",
      {
        vpc,
        allowAllOutbound: false,
      }
    );

    /**
     * VPC Endpoint の作成
     */
    const vpcEndpoint = new ec2.InterfaceVpcEndpoint(
      this,
      "PrivateLinkEndpoint",
      {
        vpc,
        service: new ec2.InterfaceVpcEndpointService(
          PROVIDER_VPC_ENDPOINT_SERVICE_NAME
        ),
        subnets: {
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
        securityGroups: [vpcEndpointSecurityGroup],
        open: false, // VPC 内からこのエンドポイントへの全てのトラフィックが既定で許可される設定の無効化
        /**
         * プロバイダー側で VPC エンドポイントサービスのプライベート DNS 名を設定した場合に有効化する
         * @see https://docs.aws.amazon.com/ja_jp/vpc/latest/privatelink/manage-dns-names.html
         */
        // privateDnsEnabled: true,
      }
    );

    // -----------------------------------------------
    // ↓ プロバイダー側リソースへのアクセス確認用の実装
    // -----------------------------------------------

    /**
     * Lambda 用のセキュリティグループ
     *
     * MEMO: 接続先を VPC Endpoint のみに制限するために明示的に作成している。(明示的に作成しない場合は allowAllOutbound が既定で有効化される)
     */
    const lambdaSecurityGroup = new ec2.SecurityGroup(
      this,
      "LambdaSecurityGroup",
      {
        vpc,
        allowAllOutbound: false,
      }
    );

    /**
     * VPC エンドポイントの DNS 名取得
     */
    const firstDnsEntry = cdk.Fn.select(0, vpcEndpoint.vpcEndpointDnsEntries);
    const consumerVpcDnsName = cdk.Fn.select(
      1,
      cdk.Fn.split(":", firstDnsEntry)
    );

    /**
     * PrivateLink 接続テスト用 Lambda 関数
     */
    new lambda_nodejs.NodejsFunction(this, "PrivateLinkTestLambda", {
      entry: "handler.ts",
      logGroup: new logs.LogGroup(this, "LogGroup", {
        removalPolicy: cdk.RemovalPolicy.DESTROY,
      }),
      vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      },
      securityGroups: [lambdaSecurityGroup],
      environment: {
        CONSUMER_VPC_ENDPOINT_DNS_NAME: consumerVpcDnsName,
      },
    });

    /**
     * lambdaFunction から VPC Endpoint への 80 ポートでのアクセスを許可
     */
    vpcEndpoint.connections.allowFrom(lambdaSecurityGroup, ec2.Port.tcp(80));
  }
}

		

PrivateLink のコンシューマーとして必要な実装は、VPC エンドポイントの作成です。接続のフローは以下のようになります。

			
			接続元(今回は Lambda 関数) → VPC エンドポイント → PrivateLink → プロバイダー側リソース

		
Lambda 関数のハンドラー実装
			
			import axios from "axios";

const CONSUMER_VPC_ENDPOINT_DNS_NAME =
  process.env.CONSUMER_VPC_ENDPOINT_DNS_NAME || "";

export const handler = async () => {
  console.log(CONSUMER_VPC_ENDPOINT_DNS_NAME);
  try {
    const response = await axios.get(
      `http://${CONSUMER_VPC_ENDPOINT_DNS_NAME}`
    );
    console.log("Response:", response.data);
  } catch (error) {
    console.error("Error:", error);
  }
};

		

環境変数 PROVIDER_VPC_ENDPOINT_SERVICE_NAME にプロバイダー側の VPC エンドポイントサービス名(com.amazonaws.vpce.<リージョン>.<ID>)を設定して CDK デプロイをします。

するとプロバイダー側の VPC エンドポイントサービスのエンドポイント接続一覧に、作成されたコンシューマー側の VPC エンドポイントが追加され、自動承諾されます。(繰り返しになるが、自動承諾を無効にしている場合はプロバイダー側での手動承諾作業が必要)

アクセス確認

コンシューマー側の VPC リソースから、プロバイダー側の VPC エンドポイントサービスにアクセスできることを確認してみます。

Lambda 関数を実行すると、プロバイダー側のリソースにアクセスができ、ALB からのレスポンスが取得できることが確認できました。

			
			$ aws lambda invoke \
  --function-name ${FUNCTION_NAME} response.json
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
$ cat response.json
"Hello from ALB!"

		

トラブルシュート

コンシューマー側のデプロイ時に "Private DNS can't be enabled because the service com.amazonaws.vpce.ap-northeast-1.vpce-svc-<id> does not provide a private DNS name." エラーが発生

コンシューマー側のリソースを CDK デプロイした際に、VPC エンドポイントの作成で以下のようなエラーが発生しました。

Resource handler returned message: "Private DNS can't be enabled because the service com.amazonaws.vpce.ap-northeast-1.vpce-svc-0f5996ed3b6bdc4e0 does not provide a private DNS name.

原因としては、プロバイダー側で VPC エンドポイントサービスのプライベート DNS 名を設定していないにも関わらず、コンシューマー側で VPC エンドポイントの作成時に privateDnsEnabled: true を指定していたためでした。

			
			/**
 * VPC Endpoint の作成
 */
const vpcEndpoint = new ec2.InterfaceVpcEndpoint(this, "PrivateLinkEndpoint", {
  vpc,
  service: new ec2.InterfaceVpcEndpointService(
    PROVIDER_VPC_ENDPOINT_SERVICE_NAME
  ),
  subnets: {
    subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
  },
  securityGroups: [vpcEndpointSecurityGroup],
  open: false,
  privateDnsEnabled: true, // ← false もしくは指定しない必要がある
});

		

privateDnsEnabled の指定を削除するか、false に変更して再度 CDK デプロイを行うと、正常に VPC エンドポイントが作成されました。

タイトルの通りですが、当たり前ですね。プロバイダー側で NLB や VPC エンドポイントサービスの削除デプロイを行おうとすると、以下のようなエラーが発生して削除できませんでした。

Resource handler returned message: "Load balancer 'arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXXXXXXX:loadbalancer/net/Main-Private-4EqEn8yqNnZd/7c2c5a78351836ab' cannot be deleted because it is currently associated with another service

承諾済みの VPC エンドポイントを全て削除してから、再度 CDK デプロイを行うと、正常に削除できました。

おわりに

AWS CDK を利用して、PrivateLink のプロバイダー側とコンシューマー側の両方を実装し、接続できることを確認してみました。

次回は、今回できなかった部分として、PrivateLink のプロバイダー側で VPC エンドポイントサービスのプライベート DNS 名を設定し、HTTPS 化まで行なってみたいと思います。

以上

この記事をシェアする

FacebookHatena blogX

関連記事