How to use the Amazon VPC Lattice Terraform module (multi-Account use case)

Good news! We have published the Amazon VPC Lattice Terraform module. Good to remember that this module is created and maintained by AWS (as all the ones published under the AWS IA organization in the Terraform Registry). Any feedback you have is more than welcomed – we will do our best to give you an answer you as soon as possible.

Why a blog post talking about a Terraform module? I want to show you how you can use the VPC Lattice module to simplify your networking deployment using Terraform – while Amazon VPC Lattice is simplifying your AWS networking configuration. The module itself has been created to be used in a single AWS Account, with the idea to be called as many times as you need (most likely as many times as AWS Accounts you are deploying VPC Lattice resources). The trick here is that in each of those AWS Accounts you will need to perform different actions – and the module allows you to do exactly that!

As I don’t want to lose anybody in the first paragraphs of the blog, first I will do an overview of VPC Lattice, its components, and overall functionality. After that, is when I’ll focus on two reference architectures in multi-AWS account environments, and how you can codify those architectures using the VPC Lattice module. Feel free to jump to the section you are most interested in.

Basics come first: what is Amazon VPC Lattice?

Amazon VPC Lattice is a fully managed application networking service that allows you to connect, secure, and monitor your applications across multiple AWS Accounts and VPCs. As simple as that. You connect your service consumers and producers in VPC Lattice, and it handles all the connectivity for you – having Amazon Route 53 as the best ally for the “service discovery” piece. Let me take some time to describe the different components:

  • Service network: this is a logical collection of services. Think of it as the central point of connectivity between your consumers and producers. And because it’s a logical resource, you can have as many service networks as you want (to allow service consumption segmentation). In addition, you can associate an IAM auth policy to the resource to add extra security to your services.
  • Service: I think the name is clear here. A VPC Lattice service is an indepently deployable unit of software that delivers a specific task or function. In other terms, what you want to expose to the different consumers. With a VPC Lattice service you don’t need to create any connection to the VPC where they are located, and neither a load balancer or API gateway to reach your backend application. You configure your listeners and targets (Instance, IP, AWS Lambda function, or Application Load Balancer) directly from the VPC Lattice configuration. Connectivity is enabled once you associate the service to the service network.
    • When a service is created, a domain name is generated by VPC Lattice (alongside a managed Route 53 public hosted zone). In addition, you can configure a custom domain name, and/or serve HTTPS requests if you associate an Amazon Certificate Manager (ACM) certificate.
    • Same as with the service network, you can associate an IAM auth policy to the resource to add extra security to the service created.
  • VPC association: to consume services associated to a service network, you need to associate the VPC where your consumers are located to that service network. This association is highly available by design (no need to select subnets) and you can attach Security Groups.

Amazon VPC Lattice traffic flow, from the DNS resolution within the consumer VPC, to the service consumption via the VPC association. Only 1 VPC Lattice service (with an Auto-Scaling group as backend) is associated to the service network.
Figure I. Amazon VPC Lattice traffic flow

Okay, components defined. But, how is the communication possible? When your consumer located in a VPC (associated to a service network) wants to consume a service, first thing is the DNS resolution. You can either use directly the VPC Lattice generated domain name in your applications, however you may want to use a custom domain name – so you’ll need to configure a Private Hosted Zone with a CNAME mapping the custom domain name to this generated one.

DNS resolution will provide you a set of link-local addresses in the 169.254.171.x/24 (RFC3927) and fd00:ec2:80::/64 (RFC4291) ranges. These addresses are not routable and are intended for devices that are connected to the same physical (or logical) link. Traffic destined to that address is routed to an ingress endpoint for VPC Lattice within the associated VPC – the inverse happens in the destination VPC (where your service backend is located). If the Security Groups associated to the VPC association allow traffic, the following is checked:

  • Is the VPC Lattice service you want to consume associated to the service network you are connected?
  • Does the auth policy in the service network allow communication from the consumer?
  • Does the auth policy in the service allow communication from the consumer?

If the three conditions above are yes, the request is sent to the backend. No more AWS Networking configuration, directly from consumer to producer (while adding several security controls in the middle). What about consumers outside this VPC? (think on hybrid or public consumption). That’s a good question! You have the answer in this AWS blog post.

At this time you may be asking yourself: “all good, but what about the Terraform module?”. We are almost there, because the trick here is deciding, in a multi-Account environment, where to manage the VPC Lattice components explained above. Let’s try to answer this by codifying several reference architectures – and yes, using the VPC Lattice module. You can find the architectures described here and more in the Amazon VPC Lattice reference architecture series.

Amazon VPC Lattice Reference Architectures: let’s translate them to code!

Note that in this section will be focusing in codifying the VPC Lattice resources using the VPC Lattice module. For a complete architecture configuration that you can deploy and test, check the following aws-samples repository.

1. Centralized Service Networks

The first architecture to discuss is the centralization of the service network(s) in one central AWS Account. Think of this as the same multi-Account pattern you have with hub and spoke architectures with AWS Transit Gateway

Multi-Account VPC Lattice architecture, with a centralized model. Three AWS Accounts are shown: owning one of them the VPCs, other one the Service Network, and the last one the Service.
Figure II. Centralized VPC Lattice service network

This central Account manages the service network(s), and all the spoke VPCs (to consume) or services (to provide) associate themselves to the corresponding service network – this decition is made now by this central Networking/Security team. Note that you can share service networks and services using AWS Resource Access Manager (RAM).

Time to codify the architecture! Let’s suppose we have 3 AWS Accounts: servicecentral, and consumer (I think it’s clear what each of them do). 

Service Account – VPC Lattice service

# VPC Lattice Module
module "vpc_lattice_service" {
  source  = "aws-ia/amazon-vpc-lattice-module/aws"
  version = "0.0.2"

  services = {
    lambdaservice = {
      name        = "lambda-service"
      auth_type   = "AWS_IAM"
      auth_policy = local.auth_policy

      listeners = {
        http_listener = {
          name     = "httplistener"
          port     = 80
          protocol = "HTTP"
          default_action_forward = {
            target_groups = {
              lambdatarget = { weight = 100 }
            }
          }
        }
      }
    }
  }

  target_groups = {
    lambdatarget = {
      type = "LAMBDA"
      targets = {
        lambdafunction = { id = aws_lambda_function.lambda.arn }
      }
    }
  }
}

# VPC Lattice service Auth Policy
locals {
  auth_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action    = "*"
        Effect    = "Allow"
        Principal = "*"
        Resource  = "*"
      }
    ]
  })
}

Two variables are defined in the module: services and target_groups. Only 1 service is created (lambdaservice), defining the auth_type (AWS_IAM), the auth_policy, the listener (HTTPS) and rules. As there’s only 1 target group (Lambda function), I am not doing any complex routing configuration and I simply target this Lambda function using a default forward action. 

Note two things when using the VPC Lattice module:

  • When defining default actions and rules in the VPC Lattice service, the target group reference is the key of the target group created inside the variable target_groups. The module identifies the target group to use by its key, and it references the corresponding ID when defining the listener rule. Of course, that means your target group keys has to be unique.
  • The auth_policy document is referenced outside of the module definition for a better reading (using a local variable). We are using the most open policy (allowing any access) – my recommendation is that you add more control here.

What about the Lambda function to use? I’m not adding here the code that creates this resource for better readability, you can find the full code in the aws-samples repository. 

Last thing to do in the service Account is to share the VPC Lattice service created with the central Account – the one that is managing the service network.

# Resource Share
resource "aws_ram_resource_share" "resource_share" {
  name                      = "Amazon VPC Lattice service"
  allow_external_principals = true
}

# Principal Association
resource "aws_ram_principal_association" "principal_association" {
  principal          = var.central_aws_account
  resource_share_arn = aws_ram_resource_share.resource_share.arn
}

# Resource Association - VPC Lattice service
resource "aws_ram_resource_association" "lattice_service_share" {
  for_each = module.vpc_lattice_service.services

  resource_arn       = each.value.attributes.arn
  resource_share_arn = aws_ram_resource_share.resource_share.arn
}

Note that in the aws_ram_principal_association resource I’m using a variable containing the Principal ID of the central AWS Account. In this example, I’m using a terraform.tfvars file to share this information. Alternatively, you can share the resource with your whole AWS Organization.

Central Account – VPC Lattice service network

# VPC Lattice Module
module "vpclattice_service_network" {
  source  = "aws-ia/amazon-vpc-lattice-module/aws"
  version = "0.0.2"

  service_network = {
    name        = "centralized-service-network"
    auth_type   = "AWS_IAM"
    auth_policy = local.auth_policy
  }

  services = { for k, v in var.lattice_services: k => { identifier = v } }

  depends_on = [aws_ram_resource_share_accepter.share_accepter]
}

# VPC Lattice service network Auth Policy
locals {
  auth_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action    = "*"
        Effect    = "Allow"
        Principal = "*"
        Resource  = "*"
      }
    ]
  })
}

# Accepting VPC Lattice services from Service AWS Account
resource "aws_ram_resource_share_accepter" "share_accepter" {
  share_arn = local.lattice_services.ram_share
}

Same Terraform module used, but a very different structure: now we are using the service_network and services variables. This Account is the one creating the VPC Lattice service network, with AWS_IAM auth type (you can see the policy used outside the module definition using a local variable). 

You will notice that the services variable is now used in a different way. Instead of defining a name and attributes of a new service to create, we are “importing” an existing one by using its ID. Using the module, any VPC Lattice service created or “imported” while a service network is created will be associated to it. 

  • I have a depends_on because first the resource share accepter is needed – as we are sharing resources between external Accounts.
  • The VPC Lattice service ID is passed to the central Account using a variable. To be honest, not the most scalable and automated way. You can use several solutions to share information between AWS Accounts, one that I really like is using AWS Secrets Manager (the aws-samples repository uses this pattern).

Anything else to do? Yes! We need to share the service network with our consumer Account.

# Resource Share
resource "aws_ram_resource_share" "resource_share" {
  name                      = "Amazon VPC Lattice service network"
  allow_external_principals = true
}

# Principal Association
resource "aws_ram_principal_association" "principal_association" {
  principal          = var.consumer_aws_account
  resource_share_arn = aws_ram_resource_share.resource_share.arn
}

# Resource Association - VPC Lattice service network
resource "aws_ram_resource_association" "lattice_service_network_share" {
  resource_arn       = module.vpclattice_service_network.service_network.arn
  resource_share_arn = aws_ram_resource_share.resource_share.arn
}

Same as before, the consumer Account ID is passed using a variable defined in a terraform.tfvars file.

Consumer Account – VPC Lattice VPC association

Last (but not less important) is to associate our consumer’s VPC to the service network to consume our amazing Lambda function. 

module "vpc_lattice_vpc_association" {
  source  = "aws-ia/amazon-vpc-lattice-module/aws"
  version = "0.0.2"

  service_network = { identifier = var.service_network }

  vpc_associations = {
    vpc1 = {
      vpc_id             = module.vpc1.vpc_attributes.id
      security_group_ids = [aws_security_group.vpc1_lattice_sg.id]
    }
  }

  depends_on = [
    aws_ram_resource_share_accepter.share_accepter
  ]
}

module "vpc1" {
  source  = "aws-ia/vpc/aws"
  version = "4.3.0"

  name       = "vpc1"
  cidr_block = "10.0.0.0/24"
  az_count   = 2

  subnets = {
    workload  = { netmask = 28 }
    endpoints = { netmask = 28 }
  }
}

resource "aws_security_group" "vpc1_lattice_sg" {
  name        = "lattice-sg-vpc1"
  description = "VPC Lattice SG - VPC1"
  vpc_id      = module.vpc1.vpc_attributes.id

  ingress {
    description = "HTTPS access"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/24"] 
  }

  egress {
    description = "Any traffic"
    from_port   = 0
    to_port     = 0
    protocol    = egress.value.protocol
    cidr_blocks = egress.value.cidr_blocks
  }
}

# Accepting VPC Lattice service network from Central AWS Account
resource "aws_ram_resource_share_accepter" "share_accepter" {
  share_arn = local.service_network.ram_share
}

This time, I use the service_network and vpc_associations variables. I don’t create a new service network, what we do is reference the one created in the central Account, so now we can do associations. In the vpc_associations definition is where I define the VPC ID and Security Groups (only 1 in this example).

That’d be all, however… notice that we use our Amazon VPC module to create the VPC (vpc1). And if you check its documentation, you will see that you can also create VPC Lattice VPC associations using it! For simplicity, if already using the VPC module, you can manage the association directly within the module itself without using the VPC Lattice module.

module "vpc2" {
  source  = "aws-ia/vpc/aws"
  version = "4.3.0"

  name       = "vpc2"
  cidr_block = "10.0.0.0/24"
  az_count   = 2

  vpc_lattice = {
    service_network_identifier = var.service_network_id
    security_group_ids         = [aws_security_group.vpc2_lattice_sg.id]
  }

  subnets = {
    workload  = { netmask = 28 }
    endpoints = { netmask = 28 }
  }
  
  depends_on = [
    aws_ram_resource_share_accepter.share_accepter
  ]
}

2. Decentralized Service Networks

Let’s move now to a decentralized model. I need to start calling out a limitation in the number of service networks a VPC can associate with – which is 1. However, I don’t feel this is a real limitation, given you can associate a VPC Lattice service with N service networks. 

Figure III. Distributed VPC Lattice service networks

Meaning, the owner of a VPC can create its own VPC Lattice service network and associate to it. Then, VPC Lattice service owners can share them with all the potential consumer AWS Accounts (using RAM), and now the consumers can decide which services to associate to its service network! This builds a distributed environment, where both consumer and service owner don’t depend to each other (well, resource share needs to happen at some point), and they both can create their own security controls.

Time to codify the architecture! Let’s suppose we have 2 AWS Accounts: service and consumer (same as before, it’s clear what each of them do).

Service Account – VPC Lattice service

The Service AWS Account in this model acts exactly the same as in the centralized model. The only difference is that the AWS Account to share the VPC Lattice service resource(s) is directly the consumer one – as opposed as before, which the resource share was with the Central Account.

# VPC Lattice Module
module "vpc_lattice_service" {
  source  = "aws-ia/amazon-vpc-lattice-module/aws"
  version = "0.0.2"

  services = {
    lambdaservice = {
      name        = "lambda-service"
      auth_type   = "AWS_IAM"
      auth_policy = local.auth_policy

      listeners = {
        http_listener = {
          name     = "httplistener"
          port     = 80
          protocol = "HTTP"
          default_action_forward = {
            target_groups = {
              lambdatarget = { weight = 100 }
            }
          }
        }
      }
    }
  }

  target_groups = {
    lambdatarget = {
      type = "LAMBDA"
      targets = {
        lambdafunction = { id = aws_lambda_function.lambda.arn }
      }
    }
  }
}

# VPC Lattice service Auth Policy
locals {
  auth_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action    = "*"
        Effect    = "Allow"
        Principal = "*"
        Resource  = "*"
      }
    ]
  })
}

# Resource Share
resource "aws_ram_resource_share" "resource_share" {
  name                      = "Amazon VPC Lattice service"
  allow_external_principals = true
}

# Principal Association
resource "aws_ram_principal_association" "principal_association" {
  principal          = var.consumer_aws_account
  resource_share_arn = aws_ram_resource_share.resource_share.arn
}

# Resource Association - VPC Lattice service
resource "aws_ram_resource_association" "lattice_service_share" {
  for_each = module.vpc_lattice_service.services

  resource_arn       = each.value.attributes.arn
  resource_share_arn = aws_ram_resource_share.resource_share.arn
}

Same as before, in the aws_ram_principal_association resource I’m using a variable containing the Principal ID of the consumer AWS Account (value provided using a terraform.tfvars file). Alternatively, you can share the resource with your whole AWS Organization.

Consumer Account – VPC Lattice service network and VPC association

Now it’s when things change a bit from the centralized model. The Consumer AWS Account is the one owning both the VPC and the service network. As we saw before, the VPC association can be created also from the VPC module, so we have two ways to create the VPC Lattice resources.

First one, using the VPC Lattice module for the service network and VPC association. Although we are still using the VPC module for the VPC creation, this option makes sense if you don’t use the VPC module (and we may need to talk about this): 

# VPC Lattice module (Service Network and VPC association)
module "vpc_lattice_all" {
  source  = "aws-ia/amazon-vpc-lattice-module/aws"
  version = "0.0.2"

  service_network = {
    name        = "vpc1-service-network"
    auth_type   = "AWS_IAM"
    auth_policy = local.auth_policy
  }

  vpc_associations = {
    vpc1 = {
      vpc_id             = module.vpc1.vpc_attributes.id
      security_group_ids = [aws_security_group.vpc1_lattice_sg.id]
    }
  }

  services = { for k, v in var.lattice_services: k => { identifier = v } }

  depends_on = [
    aws_ram_resource_share_accepter.share_accepter
  ]
}

# VPC - VPC module
module "vpc1" {
  source  = "aws-ia/vpc/aws"
  version = "4.3.0"

  name       = "vpc1"
  cidr_block = var.vpcs.vpc1.cidr_block
  az_count   = var.vpcs.vpc1.number_azs

  subnets = {
    workload  = { netmask = var.vpcs.vpc1.workload_subnet_netmask }
    endpoints = { netmask = var.vpcs.vpc1.endpoints_subnet_netmask }
  }
}

# VPC Lattice service network Auth Policy
locals {
  auth_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action    = "*"
        Effect    = "Allow"
        Principal = "*"
        Resource  = "*"
      }
    ]
  })
}

# Accepting VPC Lattice service from Service AWS Account
resource "aws_ram_resource_share_accepter" "share_accepter" {
  share_arn = local.lattice_services.ram_share
}

Same as before, we first wait for the AWS RAM share accepter before creating the VPC Lattice resources – so we make sure the services shared are already available in the Account. As you can see, now the VPC Lattice module is used both to create a new service network, create the VPC association, and associate all the services shared with the Account.

Alternatively you can leverage the VPC association to the VPC module, and use the VPC Lattice module for the rest of resources (service network, and service associations):

# VPC Lattice module (service network and service association)
module "vpc_lattice_sn_service" {
  source  = "aws-ia/amazon-vpc-lattice-module/aws"
  version = "0.0.2"

  service_network = {
    name        = "vpc2-service-network"
    auth_type   = "AWS_IAM"
    auth_policy = local.auth_policy
  }

  services = { for k, v in local.lattice_services: k => { identifier = v } }

  depends_on = [
    aws_ram_resource_share_accepter.share_accepter
  ]
}

module "vpc2" {
  source  = "aws-ia/vpc/aws"
  version = "4.3.0"

  name       = "vpc2"
  cidr_block = var.vpcs.vpc2.cidr_block
  az_count   = var.vpcs.vpc2.number_azs

  vpc_lattice = {
    service_network_identifier = module.vpc_lattice_sn_service.service_network.id
    security_group_ids         = [aws_security_group.vpc2_lattice_sg.id]
  }

  subnets = {
    workload  = { netmask = var.vpcs.vpc2.workload_subnet_netmask }
    endpoints = { netmask = var.vpcs.vpc2.endpoints_subnet_netmask }
  }
}

Conclusion

Hopefully, this blog can help you to achieve two things: understand how the VPC Lattice Terraform module works, and give you an idea on how you can architect your service-to-service consumption in multi-AWS Account environments.

You can to build and play? Check the aws-samples repository where you can find the full code for both the centralized and decentralized model, and dive deep in the AWS services used (and why). Questions? You know where to find me!! (spoiler: LinkedIn is a good place)

Have fun building in AWS!


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.