I implemented and connected both the provider side and consumer side of AWS PrivateLink with AWS CDK
Hello, I'm Wakatsuki from the Manufacturing Business Technology Department.
AWS PrivateLink allows resources within a VPC to be accessed from other AWS accounts or VPCs without exposing them to the internet.
The architecture image looks like this. The left side is the service provider (being connected to), and the right side is the service consumer (connecting to).
From Architecture 1: AWS PrivateLink - AWS Prescriptive Guidance
When using AWS PrivateLink for connections between AWS accounts, the following measures are unnecessary, which helps reduce security risks and costs for both the provider and consumer sides:
- Service Provider side:
- Placing resources (such as ELB) in public subnets
- Service Consumer side:
- Placing resources (such as NAT Gateway) in public subnets
- Securing CIDR blocks that don't overlap with the service provider side
In this article, I've implemented both the PrivateLink provider and consumer sides using AWS CDK and confirmed that they can connect.
For reference, the following official documentation is very helpful:
Implementation
When implementing both the provider side and consumer side, the following interactions between the two are required:
- The provider side receives the consumer side's AWS account ID and adds it to the VPC endpoint service allowed principals list
- The consumer side receives the provider side's VPC endpoint service name and creates a VPC endpoint
- The provider side accepts the consumer side's VPC endpoint connection request (can be set to auto-accept)
- The consumer side accesses the provider side's resources
As a note, I did not implement the following settings in this case, but you should consider them for actual operation:
- HTTPS configuration for VPC endpoint service connections (certificate settings)
- Manual acceptance of VPC endpoint connection requests
The former is practically essential. The latter depends on operational policy. Registering AWS accounts that are allowed to connect is mandatory on the provider side, but beyond that, you need to decide whether to use manual or automatic acceptance for VPC endpoints. With manual acceptance, the provider side must perform acceptance work, which creates waiting time, but it's more secure. With automatic acceptance, no approval work is needed, but connections are immediately available to all allowed AWS accounts.
For this implementation, I've set up without HTTPS and with automatic acceptance of VPC endpoint connection requests.### Provider-side Implementation
The provider side should receive the consumer's account ID in advance.
The CDK implementation for the provider side is as follows.
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);
/**
* Creating a VPC
*/
const vpc = new ec2.Vpc(this, "VPC", {
subnetConfiguration: [
// Create only private subnets assuming connection to ALB will be allowed only via PrivateLink
{
name: "PrivateIsolated",
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
],
maxAzs: 2, // Specifying minimum configuration for testing. ALB requires 2 AZs
natGateways: 0, // Set NAT Gateway to 0 as it's not used in this test
restrictDefaultSecurityGroup: true, // Restrict default security group to minimize access permissions
});
// -----------------------------------------------
// ↓ Implementation for access verification from consumer side
// -----------------------------------------------
/**
* Creating an ALB
*/
const alb = new elbv2.ApplicationLoadBalancer(
this,
"ApplicationLoadBalancer",
{
vpc,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
}
);
/**
* Creating a listener for ALB
*
* TODO: Recommended to configure certificates for HTTPS
*/
alb.addListener("AlbListener", {
port: 80,
defaultAction: elbv2.ListenerAction.fixedResponse(200, {
messageBody: "Hello from ALB!",
}),
});
// -----------------------------------------------
// ↓ Implementation required for PrivateLink provider side
// -----------------------------------------------
/**
* Creating an NLB
*/
const nlb = new elbv2.NetworkLoadBalancer(this, "NetworkLoadBalancer", {
vpc,
internetFacing: false,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
});
/**
* Creating a target group for NLB
*/
const nlbTargetGroup = new elbv2.NetworkTargetGroup(
this,
"NlbTargetGroup",
{
port: 80,
vpc,
}
);
/**
* Adding ALB as a target for NLB
*/
nlbTargetGroup.addTarget(
new elbv2_targets.AlbListenerTarget(alb.listeners[0])
);
/**
* Creating an NLB listener
*/
nlb.addListener("NlbListener", {
port: 80,
defaultTargetGroups: [nlbTargetGroup],
});
/**
* Creating a VPC Endpoint Service (PrivateLink provider)
*/
const vpcEndpointService = new ec2.VpcEndpointService(
this,
"VpcEndpointService",
{
vpcEndpointServiceLoadBalancers: [nlb],
acceptanceRequired: false, // Disable automatic acceptance of connection requests
allowedPrincipals: [
new iam.ArnPrincipal(`arn:aws:iam::${CONSUMER_ACCOUNT_ID}:root`),
],
}
);
/**
* Output the VPC endpoint service name
*/
new cdk.CfnOutput(this, "ProviderVpcEndpointServiceName", {
value: vpcEndpointService.vpcEndpointServiceName,
});
/**
* TODO: Configure private DNS name for VPC endpoint service using certificates and HTTPS
* @see https://docs.aws.amazon.com/ja_jp/vpc/latest/privatelink/manage-dns-names.html
*/
}
}
```The implementation required on the PrivateLink provider side is creating an NLB and creating a VPC endpoint service. The connection flow is as follows:
```shell
NLB → VPC endpoint service → ALB → ALB listener → ALB target group → ALB target (EC2, etc.)
Set the consumer's AWS account ID in the environment variable CONSUMER_ACCOUNT_ID
and deploy with CDK.
This creates a VPC endpoint service with the consumer's AWS account ID set in the allowed principals list.
Also, the VPC endpoint service name is displayed in the CloudFormation output, which you should communicate to the consumer side.### Consumer-side implementation
After the consumer side receives the VPC endpoint service name from the provider side, implement it as follows.
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);
/**
* Creating VPC
*/
const vpc = new ec2.Vpc(this, "VPC", {
subnetConfiguration: [
// Create only private subnets assuming communication will only be via VPC endpoints
{
name: "PrivateIsolated",
subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
},
],
maxAzs: 1, // Specifying minimum configuration for testing. ALB creation requires 2 AZs
natGateways: 0, // Set NAT Gateway to 0 as it's not used in this test
restrictDefaultSecurityGroup: true, // Restrict default security group to minimize access permissions
});
// -----------------------------------------------
// ↓ Implementation required on the PrivateLink consumer side
// -----------------------------------------------
/**
* Security group for VPC Endpoint
*
* MEMO: Explicitly created to limit connections only to the VPC Endpoint. (If not explicitly created, allowAllOutbound is enabled by default)
*/
const vpcEndpointSecurityGroup = new ec2.SecurityGroup(
this,
"VpcEndpointSecurityGroup",
{
vpc,
allowAllOutbound: false,
}
);
/**
* Creating 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, // Disable the setting that allows all traffic from within the VPC to this endpoint by default
/**
* Enable this if the provider has set up a private DNS name for the VPC endpoint service
* @see https://docs.aws.amazon.com/ja_jp/vpc/latest/privatelink/manage-dns-names.html
*/
// privateDnsEnabled: true,
}
);
// -----------------------------------------------
// ↓ Implementation for verifying access to provider-side resources
// -----------------------------------------------
/**
* Security group for Lambda
*
* MEMO: Explicitly created to limit connections only to the VPC Endpoint. (If not explicitly created, allowAllOutbound is enabled by default)
*/
const lambdaSecurityGroup = new ec2.SecurityGroup(
this,
"LambdaSecurityGroup",
{
vpc,
allowAllOutbound: false,
}
);
/**
* Get VPC endpoint DNS name
*/
const firstDnsEntry = cdk.Fn.select(0, vpcEndpoint.vpcEndpointDnsEntries);
const consumerVpcDnsName = cdk.Fn.select(
1,
cdk.Fn.split(":", firstDnsEntry)
);
/**
* Lambda function for testing PrivateLink connection
*/
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,
},
});
/**
* Allow access from lambdaFunction to VPC Endpoint on port 80
*/
vpcEndpoint.connections.allowFrom(lambdaSecurityGroup, ec2.Port.tcp(80));
}
}
```The implementation required as a consumer of PrivateLink is creating a VPC endpoint. The connection flow is as follows:
```shell
Connection source (in this case, Lambda function) → VPC endpoint → PrivateLink → Provider-side resource
Lambda function handler implementation
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);
}
};
Set the environment variable PROVIDER_VPC_ENDPOINT_SERVICE_NAME
to the provider's VPC endpoint service name (com.amazonaws.vpce.<region>.<ID>
) and deploy using CDK.
Then, the VPC endpoint created on the consumer side will be added to the endpoint connection list of the provider's VPC endpoint service and automatically accepted. (To repeat, if automatic acceptance is disabled, manual acceptance on the provider side is necessary)
Access Confirmation
Let's confirm that the consumer-side VPC resources can access the provider-side VPC endpoint service.
When executing the Lambda function, we confirmed that it can access the provider-side resource and retrieve responses from the ALB.
$ aws lambda invoke \
--function-name ${FUNCTION_NAME} response.json
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
$ cat response.json
"Hello from ALB!"
Troubleshooting### Error "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." when deploying on the consumer side
When deploying consumer-side resources with CDK, the following error occurred during VPC endpoint creation:
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.
The cause was that on the consumer side, privateDnsEnabled: true
was specified when creating the VPC endpoint, despite the provider side not having a private DNS name configured for the VPC endpoint service.
/**
* VPC Endpoint creation
*/
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, // ← This needs to be false or omitted
});
After removing the privateDnsEnabled
specification or changing it to false
and redeploying with CDK, the VPC endpoint was created successfully.
Cannot delete provider-side resources while VPC endpoints are connected to PrivateLink
As the title says, though this is to be expected. When trying to deploy deletion of NLB or VPC endpoint service on the provider side, the following error occurred and deletion failed:
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
After removing all accepted VPC endpoints and redeploying with CDK, deletion was successful.
Conclusion
I implemented both the provider and consumer sides of PrivateLink using AWS CDK and confirmed that the connection works.
In the next article, I want to set up a private DNS name for the VPC endpoint service on the PrivateLink provider side and implement HTTPS, which I couldn't do this time.