AWS PrivateLink でプライベート DNS 名を使用して接続する構成を AWS CDK で実装してみた
こんにちは、製造ビジネステクノロジー部の若槻です。
AWS PrivateLink を利用すると、VPC 内のリソースをインターネットに公開することなく、他の AWS アカウントや VPC からアクセス可能とすることができます。
アーキテクチャ 1: AWS PrivateLink - AWS 規範ガイダンス より
前回のブログでは、その AWS PrivateLink の接続される側(サービスプロバイダー)と接続する側(サービスコンシューマー)の両方を AWS CDK で実装する方法を紹介しました。
さて、デフォルトではコンシューマー側のリソースは自動生成された DNS 名(<エンドポイントID>-<ランダム値>.<エンドポイントサービスID>.ap-northeast-1.vpce.amazonaws.com
)を使用して VPC エンドポイントに接続しますが、プロバイダー側で用意したカスタムドメインでプライベート DNS 名を設定し、コンシューマー側でそのカスタムドメインを使用して VPC エンドポイントに接続させることも可能です。これにより、証明書を使った接続となるので、接続の HTTPS 化にも対応できます。
今回は、AWS PrivateLink でプライベート DNS 名を使用して接続する構成を AWS CDK で実装してみました。
実装
ここでは下記の実装手順を行っていきます。すべてを AWS CDK で完結できず、一部手動の操作が混在している点に注意してください。
- プロバイダー側
- VPC エンドポイントサービスを作成する(AWS CDK)
- プライベート DNS 名を設定する(手動)
- Route 53 で TXT レコードを作成し、ドメイン検証を実施する
- コンシューマー側
- VPC エンドポイントを作成する(AWS CDK)
privateDnsEnabled
を有効化する- VPC エンドポイントサービスの接続リクエストを送信する
- VPC エンドポイントを作成する(AWS CDK)
- プロバイダー側
- VPC エンドポイントサービスの接続リクエストを承諾する(手動)
- コンシューマー側
- プライベート DNS 名で VPC エンドポイントに接続できることを確認する
プロバイダー側(VPC エンドポイントサービスを作成)
プロバイダー側では予め次の対応を実施済みである前提とします。
- カスタムドメインのホストゾーンを Route 53 に設定済みであること
- コンシューマー側の AWS アカウント ID を受領済みであること
プロバイダー側の CDK 実装は以下となります。
import * as cdk from "aws-cdk-lib";
import * as acm from "aws-cdk-lib/aws-certificatemanager";
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 * as route53 from "aws-cdk-lib/aws-route53";
import { Construct } from "constructs";
const CONSUMER_ACCOUNT_ID = process.env.CONSUMER_ACCOUNT_ID;
const CUSTOM_DOMAIN_NAME = process.env.CUSTOM_DOMAIN_NAME || "";
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, // アクセス権限最小化のため、デフォルトのセキュリティグループを制限
});
// -----------------------------------------------
// ↓ DNS および SSL 証明書の設定
// -----------------------------------------------
/**
* 既存の Route 53 ホストゾーンを参照
*/
const hostedZone = route53.HostedZone.fromLookup(this, "HostedZone", {
domainName: CUSTOM_DOMAIN_NAME,
});
/**
* PrivateLink 用のサブドメイン設定
*/
const privateLinkDomainName = `privatelink.${CUSTOM_DOMAIN_NAME}`;
/**
* SSL 証明書の作成(ACM)
*/
const certificate = new acm.Certificate(this, "Certificate", {
domainName: privateLinkDomainName,
subjectAlternativeNames: [`*.${privateLinkDomainName}`],
validation: acm.CertificateValidation.fromDns(hostedZone),
});
// -----------------------------------------------
// ↓ 外部提供したいサービスの実装(ALB)
// -----------------------------------------------
/**
* ALB の作成
*/
const alb = new elbv2.ApplicationLoadBalancer(
this,
"ApplicationLoadBalancer",
{
vpc,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
}
);
/**
* ALB 用リスナーの作成
*/
alb.addListener("AlbListener", {
port: 443,
defaultAction: elbv2.ListenerAction.fixedResponse(200, {
messageBody: "Hello from ALB!",
}),
certificates: [certificate],
});
// -----------------------------------------------
// ↓ 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: 443,
vpc,
}
);
/**
* NLB 用のターゲットとして ALB を追加
*/
nlbTargetGroup.addTarget(
new elbv2_targets.AlbListenerTarget(alb.listeners[0])
);
/**
* NLB リスナーの作成
*/
nlb.addListener("NlbListener", {
port: 443,
defaultTargetGroups: [nlbTargetGroup],
});
/**
* VPC Endpoint Service の作成(PrivateLink プロバイダー)
*/
const vpcEndpointService = new ec2.VpcEndpointService(
this,
"VpcEndpointService",
{
vpcEndpointServiceLoadBalancers: [nlb],
acceptanceRequired: true, // 手動承認を必須に設定
allowedPrincipals: [
new iam.ArnPrincipal(`arn:aws:iam::${CONSUMER_ACCOUNT_ID}:root`),
],
}
);
/**
* VPC エンドポイントサービス名の出力
*/
new cdk.CfnOutput(this, "ProviderVpcEndpointServiceName", {
value: vpcEndpointService.vpcEndpointServiceName,
});
}
}
上記では privatelink.<カスタムドメイン>
というサブドメインを PrivateLink 用に設定し、ACM で SSL 証明書を作成しています。また同証明書を利用してリスナーを HTTPS(ポート 443)で作成するようにしています。
環境変数 CONSUMER_ACCOUNT_ID
にコンシューマー側の AWS アカウント ID、CUSTOM_DOMAIN_NAME
にカスタムドメイン設定して CDK デプロイをします。
プロバイダー側(プライベート DNS 名を設定)
デプロイにより作成された VPC エンドポイントサービスの詳細画面を開き、プライベート DNS 名を変更をクリックします。
プライベート DNS 名をサービスに関連付ける をチェックし、使用するプライベート DNS 名を指定して、保存します。
詳細画面に戻ると、ドメインの検証ステータスが Pending verification
となっており、またドメインの検証名および検証値が表示されます。
ドメインの検証名および検証値を Route 53 のホストゾーンに TXT レコードとして追加します。TTL 値は 1800
とするようにドキュメントにあったので従っています。
VPC エンドポイントサービスの詳細画面に戻り、プライベート DNS 名のドメイン所有権を確認をクリックします。
所有権の確認を行います。(ここで検証
と入力する必要性がよく分からない)
少し時間を要する場合もありますが、ドメインの検証が完了するとステータスが Verified
に変わります。
これでプライベート DNS 名の設定は完了です。先ほど登録した TXT レコードは削除して構いません。
コンシューマー側に VPC エンドポイントサービス名(com.amazonaws.vpce.ap-northeast-1.<エンドポイントサービス ID>
)とプライベート DNS 名(privatelink.<カスタムドメイン名>
)を伝えます。
コンシューマー側(VPC エンドポイントを作成)
コンシューマー側は、プロバイダー側の VPC エンドポイントサービス名およびプライベート DNS 名を受け取ったら、AWS CDK で次のように実装します。
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 || "";
const PRIVATE_DNS_NAME = process.env.PRIVATE_DNS_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, // 検証のため、AZを1つに制限
natGateways: 0, // 検証のため、NAT Gatewayを使用しない
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)
// -----------------------------------------------
/**
* Lambda用のセキュリティグループ
*
* MEMO: 接続先を VPC Endpoint のみに制限するために明示的に作成している。(明示的に作成しない場合は allowAllOutbound が既定で有効化される)
*/
const lambdaSecurityGroup = new ec2.SecurityGroup(
this,
"LambdaSecurityGroup",
{
vpc,
allowAllOutbound: false,
}
);
/**
* 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: {
// Lambda 関数から VPC エンドポイントにアクセスする際に使用する DNS 名を環境変数で設定する
VPC_ENDPOINT_DNS_NAME: `https://${PRIVATE_DNS_NAME}`,
},
});
/**
* lambdaFunction から VPC Endpoint への 443 ポートでのアクセスを許可
*/
vpcEndpoint.connections.allowFrom(lambdaSecurityGroup, ec2.Port.tcp(443));
}
}
疎通確認用の Lambda 関数のハンドラー実装
import axios from "axios";
const VPC_ENDPOINT_DNS_NAME = process.env.VPC_ENDPOINT_DNS_NAME || "";
export const handler = async () => {
console.log(VPC_ENDPOINT_DNS_NAME);
try {
const response = await axios.get(VPC_ENDPOINT_DNS_NAME);
console.log("Response:", response.data);
return response.data;
} catch (error) {
console.error("Error:", error);
return { error: "Failed to fetch data" };
}
};
環境変数 PROVIDER_VPC_ENDPOINT_SERVICE_NAME
にプロバイダー側の VPC エンドポイントサービス名、PRIVATE_DNS_NAME
にプライベート DNS 名を設定して CDK デプロイをします。このデプロイにより、VPC エンドポイントが作成され、プロバイダー側の VPC エンドポイントサービスに対して接続リクエストが送信されます。
注意点として、コンシューマー側では VPC エンドポイントの プライベート DNS 名有効化はプロバイダー側で接続リクエストを承諾した後に有効化する必要があるため、この時点では無効化しておきます。
プロバイダー側(接続リクエストを承諾)
プロバイダー側の VPC エンドポイントサービスのエンドポイント接続一覧を見ると、作成されたコンシューマー側の VPC エンドポイントが未承諾の状態で追加されています。
接続リクエストを承諾します。
接続リクエストを却下した場合
プロバイダー側で接続リクエストを却下した場合。
コンシューマー側の VPC エンドポイントの状態がRejectedとなり、使用できなくなります。
プロバイダー側の運用者が誤って却下した場合などは、コンシューマー側で再度 VPC エンドポイントを作成し、接続リクエストを送信する必要があります。
コンシューマー側(プライベート DNS 名で接続できることを確認)
接続リクエストが承諾されてから10秒ほど待つと、コンシューマー側の VPC エンドポイントの状態がAvailableになりました。
さて早速疎通確認と行きたいところですが、この時点では Lambda 関数を実行すると、エラーになってしまいます。
$ aws lambda invoke \
--function-name ${FUNCTION_NAME} response.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
$ cat response.json
{"error":"Failed to fetch data"}
VPC エンドポイントでプライベート DNS 名を忘れずに有効化する必要があるためです。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, // 有効化に変更
});
デプロイ後に VPC エンドポイントの詳細画面を見ると、プロバイダー側で指定されたプライベート DNS 名が利用可能となっていることが確認できます。
DNS の伝搬を待ってから、再度 Lambda 関数を実行すると、正常にレスポンスが返ってきました。(伝搬前だと接続エラーが継続します)
$ aws lambda invoke \
--function-name ${FUNCTION_NAME} response.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
$ cat response.json
"Hello from ALB!"
これで、コンシューマー側でプライベート DNS 名を使用して VPC エンドポイントに接続できることが確認できました。
トラブルシュート
Private DNS can only be enabled after the endpoint connection is accepted by the owner of com.amazonaws.vpce.<リージョン>.<VPC エンドポイント ID>.
エラーになる
プロバイダー側で承諾前に プライベート DNS を有効化すると、 前述のコンシューマー側の初回の VPC エンドポイントが承諾される前にプライベート DNS を有効化してしまうと、次のようなエラーでデプロイが失敗します。
MainStack | 2 | 11:50:32 PM | UPDATE_FAILED | AWS::EC2::VPCEndpoint | PrivateLinkConsumer/PrivateLinkEndpoint (PrivateLinkConsumerPrivateLinkEndpoint8010210F) Resource handler returned message: "Private DNS can only be enabled after the endpoint connection is accepted by the owner of com.amazonaws.vpce.ap-northeast-1.vpce-svc-002a3e872c7846a9c. (Service: Ec2, Status Code: 400, Request ID: 8070ca16-b033-4b0a-95c9-019133f8ed9a) (SDK Attempt Count: 1)" (RequestToken: 5cb452ad-975f-0c6a-af71-0108687f55e4, HandlerErrorCode: GeneralServiceException)
privateDnsEnabled
を無効化した状態でデプロイし、プロバイダー側で承諾された後に、privateDnsEnabled
を有効化して再度デプロイするようにしましょう。
参考
以上