One Cloud Please

Exploring Amazon VPC Lattice

01 April 2023

(yes, that is a picture of my breakfast)

Today, AWS has released Amazon VPC Lattice to General Availability. This post walks through creating a simple VPC Lattice service using CloudFormation, and takes a look at the service overall.

VPC Lattice was my #1 favourite announcement of AWS re:Invent 2022, so I’m excited to see it released today. As of the time of writing, it’s available in US East (Ohio), US East (N. Virginia), US West (Oregon), Asia Pacific (Singapore), Asia Pacific (Sydney), Asia Pacific (Tokyo), and Europe (Ireland).

How it works

VPC Lattice is a service that enables you to connect clients to services within a VPC. It is very similar to AWS PrivateLink (also known as private VPC Endpoints), but with a key difference.

Whilst PrivateLink works by placing Elastic Network Interfaces within your subnet, which your clients can hit to tunnel network traffic through to the destination service, VPC Lattice works by exposing endpoints as link-local addresses. Link-local addresses are (generally) only accessible by software that runs on the client instance itself.

AWS has carved out the range 169.254.171.0/24 for VPC Lattice’s use, typically routing directly to 169.254.171.0 (there’s also an IPv6 equivalent). This is not the first network that AWS exposes via link-local addresses. You may know of:

  • EC2’s Instance Metadata Service, which is located at 169.254.169.254
  • Route 53’s DNS Resolver, which is located at 169.254.169.253
  • ECS’s Task Metadata Endpoint, which is located at 169.254.170.2
  • Amazon Time Sync Service (NTP), which is located at 169.254.169.123

Generally, these endpoints are automatically available to clients within the VPC network without any special routing or security rules. VPC Lattice differs from this slightly, as it requires Security Groups and NACLs to allow traffic to and from the VPC Lattice data plane at 169.254.171.0/24 on whichever port the destination service exposes. I was pretty surprised by this requirement when I saw it as it’s the first link-local address to need this, but it does give network administrators some basic control. Generally, it’s advised to use a managed prefix list instead of the exact range above, as it’s subject to change.

Targets which VPC Lattice connects to closely match that of load balancing target groups, including EC2 instances, VPC IP addresses (both IPv4 and IPv6), Lambda functions, and ALBs. An EKS-specific target type is in private beta as of the time of writing.

A walkthrough

For this walkthrough, we’ll discuss the various components needed for a VPC Lattice setup. For simplicity, we’ll be creating a Lambda function as a client (initiates a HTTPS request), and another Lambda function as a server (responds to the HTTPS request). If you want to skip ahead, here’s the completed template.

Let’s begin by creating a basic VPC. The VPC will have two private subnets, but we won’t add any direct routing between them. For simplicity, we’ll also skip adding Network ACLs.

Resources:

  # Basic VPC

  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.0.0/24
      MapPublicIpOnLaunch: false
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: Private Subnet (Source Subnet)
      AvailabilityZone: !Select
        - 0
        - Fn::GetAZs: !Ref AWS::Region

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.1.0/24
      MapPublicIpOnLaunch: false
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: Private Subnet (Destination Subnet)
      AvailabilityZone: !Select
        - 1
        - Fn::GetAZs: !Ref AWS::Region

  RouteTablePrivate1:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: Private Route Table (Source Subnet)

  RouteTablePrivate1Association1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref RouteTablePrivate1
      SubnetId: !Ref PrivateSubnet1

  RouteTablePrivate2:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: Private Route Table (Destination Subnet)

  RouteTablePrivate2Association1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref RouteTablePrivate2
      SubnetId: !Ref PrivateSubnet2

Next, we’ll create the service itself. The service will be a Lambda function which performs a basic successful response to any requests, whilst including it’s own event payload in its response body. The function will be within the second private subnet within the VPC, and its security group will only have a single inbound rule from the VPC Lattice service on the port in which it serves.

  # Inbound Lambda (Service)

  InboundLambdaFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
      Policies:
        - PolicyName: root
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - xray:PutTraceSegments
                  - xray:PutTelemetryRecords
                  - ec2:CreateNetworkInterface
                  - ec2:DescribeNetworkInterfaces
                  - ec2:DeleteNetworkInterface
                Resource: '*'

  InboundLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt InboundLambdaFunctionRole.Arn
      TracingConfig:
        Mode: Active
      Runtime: python3.9
      Timeout: 10
      Code:
        ZipFile: |
          import os
          import json
          import http.client

          def handler(event, context):
            print(event)
            return {
              "statusCode": 200,
              "body": json.dumps({
                "success": "true",
                "capturedEvent": event
              }),
              "headers": {
                "Content-Type": "application/json"
              }
            }
      VpcConfig:
        SecurityGroupIds:
          - !Ref InboundLambdaFunctionSecurityGroup
        SubnetIds:
          - !Ref PrivateSubnet2

  InboundLambdaFunctionSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for InboundLambdaFunction
      VpcId: !Ref VPC
      SecurityGroupEgress: []
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 169.254.171.0/24 # should be the prefix list instead, this'll work though
      GroupName: demo-inboundsg

Next up, we’ll create the components of the VPC Lattice service itself. This includes:

  • The service network
  • A security group which controls which clients may access the service network
  • The service we are creating
  • A listener for the service (HTTPS on port 443)
  • A target group for the listener to point to, with an initial target of the previously created Lambda function

To keep things simple, we’re not adding an auth policy for the service network or the service itself.

  # VPC Lattice

  VPCLatticeServiceNetwork:
    Type: AWS::VpcLattice::ServiceNetwork
    Properties:
      Name: demo-servicenetwork
      AuthType: NONE

  VPCLatticeServiceNetworkSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for service network access
      VpcId: !Ref VPC
      SecurityGroupEgress: []
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: !GetAtt VPC.CidrBlock
      GroupName: demo-servicenetworksg

  VPCLatticeServiceNetworkVPCAssociation:
    Type: AWS::VpcLattice::ServiceNetworkVpcAssociation
    Properties:
      SecurityGroupIds:
        - !Ref VPCLatticeServiceNetworkSecurityGroup
      ServiceNetworkIdentifier: !Ref VPCLatticeServiceNetwork
      VpcIdentifier: !Ref VPC

  VPCLatticeService:
    Type: AWS::VpcLattice::Service
    Properties:
      Name: demo-service
      AuthType: NONE

  VPCLatticeServiceNetworkServiceAssociation:
    Type: AWS::VpcLattice::ServiceNetworkServiceAssociation
    Properties:
      ServiceNetworkIdentifier: !Ref VPCLatticeServiceNetwork
      ServiceIdentifier: !Ref VPCLatticeService

  VPCLatticeListener:
    Type: AWS::VpcLattice::Listener
    Properties:
      Name: demo-listener
      Port: 443
      Protocol: HTTPS
      ServiceIdentifier: !Ref VPCLatticeService
      DefaultAction:
        Forward:
          TargetGroups:
            - TargetGroupIdentifier: !Ref VPCLatticeTargetGroup
              Weight: 100

  VPCLatticeTargetGroup:
    Type: AWS::VpcLattice::TargetGroup
    Properties:
      Name: demo-targetgroup
      Type: LAMBDA
      Targets:
        - Id: !GetAtt InboundLambdaFunction.Arn

It’s important to note that by associating the service network to the VPC, there are routes created within the VPCs route table to correctly send traffic destined towards 169.254.171.0/24 to the VPC Lattice service.

The target group also automatically adds a resource-based policy statement to the Lambda function for you (some other services require you to explicitly add an AWS::Lambda::Permission).

Finally, we’ll create the client which will send requests to the VPC Lattice service. Again, this will be driven via a basic Lambda function. Note that this time, the security group requires an outbound rule towards the VPC Lattice service.

  # Outbound Lambda (Client)

  OutboundLambdaFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
      Policies:
        - PolicyName: root
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - xray:PutTraceSegments
                  - xray:PutTelemetryRecords
                  - ec2:CreateNetworkInterface
                  - ec2:DescribeNetworkInterfaces
                  - ec2:DeleteNetworkInterface
                Resource: '*'

  OutboundLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Role: !GetAtt OutboundLambdaFunctionRole.Arn
      TracingConfig:
        Mode: Active
      Runtime: python3.9
      Environment:
        Variables:
          ENDPOINT: !GetAtt VPCLatticeServiceNetworkServiceAssociation.DnsEntry.DomainName
      Timeout: 10
      Code:
        ZipFile: |
          import os
          import json
          import http.client

          def handler(event, context):
            conn = http.client.HTTPSConnection(os.environ["ENDPOINT"])

            conn.request("POST", "/", json.dumps(event), {
              "Content-Type": 'application/json'
            })
            res = conn.getresponse()
            data = res.read()

            print(data.decode("utf-8"))
      VpcConfig:
        SecurityGroupIds:
          - !Ref OutboundLambdaFunctionSecurityGroup
        SubnetIds:
          - !Ref PrivateSubnet1

  OutboundLambdaFunctionSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for OutboundLambdaFunction
      VpcId: !Ref VPC
      SecurityGroupEgress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 169.254.171.0/24 # should be the prefix list instead, this'll work though
      SecurityGroupIngress: []
      GroupName: demo-outboundsg

Now that our template is done, we can deploy it via CloudFormation. If you got stuck anywhere, try the pre-made version here.

Once deployed, navigate to the Lambda console and find the function named something similar to “OutboundLambdaFunction”. Create a test event using any JSON object and invoke it. You should see the results from the service come back to you by observing the logs.

A note on pricing

It’s worth noting that the pricing model for VPC Lattice is different to that of PrivateLink and will probably end up costing you more overall. For N. Virginia, a PrivateLink service costs $0.01/hour per availability zone, plus $0.01/GB with volume discounts. For the same region, a VPC Lattice service costs $0.025/hour regardless of AZs, plus $0.025/GB with no volume discounts, plus $0.10 per million requests (with the first 300k requests per hour free).

Wrapping up

I’m interested to see how architectures will evolve with this new technology. Whilst PrivateLink remains more affordable and already widespread, I can see architects reaching for this new technology to improve their security posture and reduce the load on networking engineers.

If you liked what I’ve written, or want to hear more on this topic, reach out to me on Twitter at @iann0036.