PrivateLinkとPrivateApiGatewayを使ったプライベートApiGatewayをCDKで構築する記事

記事タイトルとURLをコピーする

CS1の石井です。

ApiGatewayでRestAPIを作成しようとすると、REST APIとREST APIプライベートという二通りの作り方がマネジメントコンソール上に出てきます。

役割としては、画像の通りREST APIはパブリック用のサービスであり、REST API プライベートはVPC内のプライベート用のApiGatewayを作成するときに使えばよさそうです。 直近の案件でプライベート用のAPI Gatewayを作成し、PrivateLinkで接続した別アカウントからApiGatewayを実行するというテーマで検証を行っていました。

一連の流れを手順としてまとめたのですが、その手順をCDKで表現したので本記事にて紹介したいと思います。

構成

アカウント名 役割 対応するスタック名 ファイル名
Serviceアカウント Private用のApiGatewayでLambdaを実行させるアカウント ServiceStack lib/service-stack.ts
Customerアカウント PrivateLinkから接続してApiGatewayを実行するアカウント CustomerStack lib/customer-stack.ts

使用するスタックについて

ServiceStack

ServiceStack はVPC-EサービスやPrivateApiGatewayを作成します。 VPC-Eサービスは関連づけるリソースにNLBしか指定ができないため、NLBを作成しています。 なお、EC2も作成していますが、これはVPC内の確認用のリソースです。

注意点としてはコメントにも記載してありますがNLBはVPC-Eを作成した後に、VPC-EのIPアドレスをコード内に記載して再デプロイしてください。

※NLBのターゲットグループはIPしか指定ができません。 また、VPC-EのIPアドレスをCDK上で参照することができません。

そのため、下記のスタックではNLB関連のリソースがコメントアウトがされていますが、VPC-Eのデプロイが終わった後、VPC-EのIPアドレスをelbv2_targets.IpTargetに記載して再デプロイしてください。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as elbv2_targets from 'aws-cdk-lib/aws-elasticloadbalancingv2-targets';

export class ServiceStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPCの作成
    const vpc = new ec2.Vpc(this, 'ServiceVpc', {
      maxAzs: 2
    });

    const lambdaExecutionRole = new iam.Role(this, 'LambdaExecutionRole', {
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'),
      ],
    });

    // Lambda関数の作成
    const serviceLambda = new lambda.Function(this, 'ServiceLambda', {
      runtime: lambda.Runtime.PYTHON_3_12,
      code: lambda.Code.fromInline(`
def handler(event, context):
    return {
        'statusCode': 200,
        'body': 'Hello from Lambda'
    }
      `),
      handler: 'index.handler',
      vpc: vpc,
      role: lambdaExecutionRole
    });

    // VPCエンドポイントの作成
    const vpce = new ec2.InterfaceVpcEndpoint(this, 'ApiGatewayVpcEndpoint', {
      vpc,
      service: ec2.InterfaceVpcEndpointAwsService.APIGATEWAY,
      subnets: {
        subnetType: ec2.SubnetType.PUBLIC,
      },
      privateDnsEnabled: true,
    });

    const api = new apigateway.RestApi(this, 'ServiceApi', {
      restApiName: 'Service API',
      description: 'PrivateLink PrivateApiGateway',
      endpointConfiguration: {
        types: [apigateway.EndpointType.PRIVATE],
      },
      policy: new iam.PolicyDocument({
        statements: [
          new iam.PolicyStatement({
            effect: iam.Effect.ALLOW,
            principals: [new iam.AnyPrincipal()],
            actions: ['execute-api:Invoke'],
            resources: ['execute-api:/*'],
            conditions: {
              StringEquals: {
                'aws:SourceVpce': vpce.vpcEndpointId // VPCエンドポイントのIDを使用
              }
            }
          })
        ]
      })
    });

    const getIntegration = new apigateway.LambdaIntegration(serviceLambda, {
      requestTemplates: { "application/json": '{ "statusCode": 200 }' }
    });

    api.root.addMethod('GET', getIntegration); // GET /

    // EC2インスタンスロールの作成
    const ec2Role = new iam.Role(this, 'Ec2InstanceRole', {
      assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')],
    });

    // EC2インスタンスの作成
    const ec2Instance = new ec2.Instance(this, 'ServiceEc2Instance', {
      vpc,
      instanceType: new ec2.InstanceType('t3.micro'),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
      role: ec2Role,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
    });

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

    // // NLBターゲットグループの設定
    // const targetGroup = new elbv2.NetworkTargetGroup(this, 'ApiGatewayTargetGroup', {
    //   vpc,
    //   port: 443,
    //   targets: [
    //     new elbv2_targets.IpTarget(''), //NLBはVPC-Eを作成した後にデプロイしてください。
    //     new elbv2_targets.IpTarget('')   //VPC-Eデプロイ後にIPを確認して記述してください
    //   ],
    //   healthCheck: {
    //     enabled: true,
    //     healthyThresholdCount: 2,
    //     interval: cdk.Duration.seconds(30),
    //     protocol: elbv2.Protocol.TCP,
    //     unhealthyThresholdCount: 2,
    //   }
    // });

    // // NLBのリスナーを作成(ポート443)証明書を追加
    // const listener = nlb.addListener('Listener', {
    //   port: 443,
    //   defaultTargetGroups: [targetGroup]
    // });

    // // NLBがVPCエンドポイントにアクセスできるようにセキュリティグループの設定
    // vpce.connections.allowDefaultPortFrom(nlb, 'Allow NLB to access VPC Endpoint');

    // // VPC Endpoint Serviceの作成
    // const endpointService = new ec2.VpcEndpointService(this, 'VpcEndpointService', {
    //   vpcEndpointServiceLoadBalancers: [nlb],
    //   acceptanceRequired: false,
    //   allowedPrincipals: [new iam.AnyPrincipal()] // 全アカウントのプリンシパルを許可
    // });


    // // VPCエンドポイントIDを出力に追加
    // new cdk.CfnOutput(this, 'VpcEndpointServiceId', {
    //   value: endpointService.vpcEndpointServiceId,
    //   description: 'The ID of the VPC Endpoint Service'
    // });



    // VPCエンドポイントIDを出力に追加
    new cdk.CfnOutput(this, 'VpcEndpointId', {
      value: vpce.vpcEndpointId,
      description: 'The ID of the VPC Endpoint'
    });
  }
}

CustomerStack

CustomerStackはServiceStackが作成したVPC-Eサービスに対して通信を行うVPC-Eを作成するスタックです。

事前にServiceStackが作成したVPC-Eサービスの「サービス名」を確認し、「service: new ec2.InterfaceVpcEndpointService('XXXXXXX'),」に入力してデプロイを行ってください。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';

export class CustomerStack extends cdk.Stack {
    constructor(scope: Construct, id: string, props?: cdk.StackProps) {
        super(scope, id, props);

        // VPCの作成
        const vpc = new ec2.Vpc(this, 'ServiceVpc', {
            maxAzs: 2
        });

        // IAMロールの作成
        const role = new iam.Role(this, 'SSMRole', {
            assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
        });

        // SSMポリシーのアタッチ
        role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'));

        // セキュリティグループの作成
        const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', {
            vpc,
            allowAllOutbound: true,
        });
        securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(22), 'Allow SSH access');

        // EC2インスタンスの作成
        const instance = new ec2.Instance(this, 'Instance', {
            vpc,
            instanceType: new ec2.InstanceType('t2.micro'),
            machineImage: ec2.MachineImage.latestAmazonLinux2023(),
            role: role,
            securityGroup: securityGroup,
        });

        // VPCエンドポイントの作成
        const vpcEndpoint = new ec2.InterfaceVpcEndpoint(this, 'VpcEndpoint', {
            vpc,
            service: new ec2.InterfaceVpcEndpointService('XXXXXXX'),
            subnets: vpc.selectSubnets({
                subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
            }),
            securityGroups: [securityGroup], // VPCエンドポイントにセキュリティグループを追加
        });

        // Route 53 Private Hosted Zoneの作成
        const zone = new route53.PrivateHostedZone(this, 'HostedZone', {
            zoneName: 'hoge.co.jp',
            vpc
        });

        // VPCエンドポイントのDNS名を抽出
        const vpcEndpointDnsName = cdk.Fn.select(1, cdk.Fn.split(':', vpcEndpoint.vpcEndpointDnsEntries[0]));

        // Route 53 Aレコードの作成
        new route53.ARecord(this, 'VpcEndpointARecord', {
            zone,
            recordName: 'vpce',
            target: route53.RecordTarget.fromAlias(new targets.InterfaceVpcEndpointTarget(vpcEndpoint)),
        });

    }
}

main.tsの作成

上記のServiceStackとCustomerStackを呼び出すため、cdk initした時のbinファイルに以下の内容を記載します。 本記事ではbin/main.tsとしています。

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { ServiceStack, } from '../lib/service-stack';
import { CustomerStack } from '../lib/customer-stack';
const app = new cdk.App();
new ServiceStack(app, 'ServiceStack', {

});


new CustomerStack(app, 'CustomerStack', {

});

デプロイ

CDKのコードは上記のスタックを作成するコードですべてなので、デプロイを行ってみます。

ますはServiceStackをデプロイを行います。

nob@inspiron16:~/work/CDKWORK/sotest$ npx cdk deploy ServiceStack

✨  Synthesis time: 3.16s

ServiceStack: deploying... [1/1]
ServiceStack: creating CloudFormation changeset...

中略

Outputs:
ServiceStack.ServiceApiEndpoint55B38F80 = https://**********.execute-api.ap-northeast-1.amazonaws.com/prod/
ServiceStack.VpcEndpointId = vpce-*****************
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:************:stack/ServiceStack/********-4324-11ef-8ccc-************

✨  Total time: 4.24s

VPC-Eをデプロイした後にNLBのターゲットグループにVPC-EのIPをターゲットグループに登録する必要があります。

マネジメントコンソール上からVPC-EのENIの画面からIPを確認するか、コンソール上にVPC-EのIDが表示されるので以下のAWS CLIコマンドでIPを取得します。

nob@inspiron16:~/work/CDKWORK/sotest$ VPC_ENDPOINT_ID="vpce-****************"
nob@inspiron16:~/work/CDKWORK/sotest$ NETWORK_INTERFACE_IDS=$(aws ec2 describe-vpc-endpoints --vpc-endpoint-ids $VPC_ENDPOINT_ID --query "VpcEndpoints[0].NetworkInterfaceIds" --output json | jq -r '.[]')
nob@inspiron16:~/work/CDKWORK/sotest$ for ENI_ID in $NETWORK_INTERFACE_IDS
do
  IP_ADDRESSES=$(aws ec2 describe-network-interfaces --network-interface-ids $ENI_ID --query "NetworkInterfaces[0].PrivateIpAddresses[*].PrivateIpAddress" --output json | jq -r '.[]')
  echo "Network Interface ID: $ENI_ID"
  echo "IP Addresses: $IP_ADDRESSES"
done
Network Interface ID: eni-****************
IP Addresses: 10.0.111.246
Network Interface ID: eni-****************
IP Addresses: 10.0.56.102
nob@inspiron16:~/work/CDKWORK/sotest$

上記のようにIPが取得できたら elbv2_targets.IpTarget に正しいIPを記載して再度 npx cdk deploy ServiceStack を行います。

問題なくデプロイできたら、確認用のEC2にSSMでログインしてLambdaが正しく実行できるか確認してみます。

[root@ip-10-0-142-110 ~]# curl -k https://1tou1e7kxj.execute-api.ap-northeast-1.amazonaws.com/prod
Hello from Lambda[root@ip-10-0-142-110 ~]#
[root@ip-10-0-142-110 ~]#

ApiGateway向けに実行したら成功しました。 逆に自端末から実行すると以下のように名前解決できずにエラーになります。正常にPrivate用で作成できていることがわかります。

nob@inspiron16:~/work/CDKWORK/sotest$ curl -k https://1tou1e7kxj.execute-api.ap-northeast-1.amazonaws.com/prod
curl: (6) Could not resolve host: 1tou1e7kxj.execute-api.ap-northeast-1.amazonaws.com
nob@inspiron16:~/work/CDKWORK/sotest$ 

ではNLB経由で実行を試してみます。

[root@ip-10-0-142-110 ~]# curl -k https://Servic-NlbBC-6DNFkWlbVoOw-80223f182be0f997.elb.ap-northeast-1.amazonaws.com/prod
{"message":"Forbidden"}[root@ip-10-0-142-110 ~]#
[root@ip-10-0-142-110 ~]#

{"message":"Forbidden"} と表示されてしまい、ApiGatewayのログにエラーログがありませんでした。なぜでしょうか・・・?

repost.aws 公式ドキュメントの記載によると、ヘッダーにAPIGatewayのIDを含める必要があるようです。

※VPC-EのIPだけでは、どのAPIGatewayに行けばいいのかわからないから、到達ができなかったのだと理解しています。

改めてヘッダーを含めて再度コマンドを実行し問題なく実行できました。

# ※NLBを指定
[root@ip-10-0-142-110 ~]# curl -k https://Servic-NlbBC-6DNFkWlbVoOw-80223f182be0f997.elb.ap-northeast-1.amazonaws.com/prod  -H 'x-apigw-api-id:1tou1e7kxj'
Hello from Lambda[root@ip-10-0-142-110 ~]#
[root@ip-10-0-142-110 ~]#

# ※VPC-Eを直接指定
[root@ip-10-0-142-110 ~]# curl -k https://vpce-060a40aa1862ca090-3ibkoaas.execute-api.ap-northeast-1.vpce.amazonaws.com/prod  -H 'x-apigw-api-id:1tou1e7kxj'
Hello from Lambda[root@ip-10-0-142-110 ~]#

CustomerStackのデプロイ

次にCustomerStack側をデプロイします。

VPC-Eサービスも2回目のデプロイで作成されているので、VPC-Eサービスの「サービス名」をマネジメントコンソールから確認し、下記のコードを修正します。

        const vpcEndpoint = new ec2.InterfaceVpcEndpoint(this, 'VpcEndpoint', {
            vpc,
            service: new ec2.InterfaceVpcEndpointService('com.amazonaws.vpce.ap-northeast-1.vpce-svc-XXXXXXXXXXXXXXXXX'),
            subnets: vpc.selectSubnets({
                subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
            }),
            securityGroups: [securityGroup], // VPCエンドポイントにセキュリティグループを追加
        });

修正が完了したらdeployを行います。

nob@inspiron16:~/work/CDKWORK/sotest$ npx cdk ls
ServiceStack
CustomerStack

nob@inspiron16:~/work/CDKWORK/sotest$ npx cdk deploy CustomerStack --profile XXXXXX

✨  Synthesis time: 5.5s

中略

Do you wish to deploy these changes (y/n)? y
CustomerStack: deploying... [1/1]
CustomerStack: creating CloudFormation changeset...

 ✅  CustomerStack

✨  Deployment time: 198.92s

✨  Total time: 204.42s


nob@inspiron16:~/work/CDKWORK/sotest$ 

デプロイが完了したらCustomerアカウント側のEC2にSSMでログインして確認してみます。

CustomerStackの中にはRoute53のプライベートホストゾーンで「vpce.hoge.co.jp」が登録がされているので、このドメインで接続してみましょう。

# プライベートホストゾーンでの接続
[root@ip-10-0-191-219 ~]# curl -k https://vpce.hoge.co.jp/prod -H 'x-apigw-api-id:1tou1e7kxj'
Hello from Lambda[root@ip-10-0-191-219 ~]#
[root@ip-10-0-191-219 ~]#

# 念のためVPC-Eを直接指定での接続確認

[root@ip-10-0-191-219 ~]# curl -k https://vpce-07d47a495d3f203bd-ealuchv1.vpce-svc-006e3464aef85af9e.ap-northeast-1.vpce.amazonaws.com/prod  -H 'x-apigw-api-id:1tou1e7kxj'
Hello from Lambda[root@ip-10-0-191-219 ~]#
[root@ip-10-0-191-219 ~]#

正常にApiGatewayに到達できたようです。

まとめ

VPC-EからApiGatewayに接続する際ホストヘッダーにAPIGatewayIDを指定しなければならない、という事実に気づくまで1時間ぐらい無駄に時間を費やしてしまったので自戒を込めて手順化しました。

同じようにプライベート環境でApiGatewayからLambdaを実行したいと考える人の参考になればと思います。