NAT with Serverless
In a previous guide, we looked at how to manually create a NAT instance on AWS to allow outgoing traffic from a resource that sits inside a private subnet.
In this guide we’ll automate the process with the help of Serverless. The resources and the code outlined in this guide can be found here.
Practical example
We’ll start by building a Serverless stack that prevents an outgoing request before fixing it by adding a NAT instance.
Creating our Lambda function
The Lambda function is the same as we used in the previous guide, simply reaching out to the internet to see if our setup is working.
js
var fetch = require("isomorphic-unfetch");exports.handler = async () => {var response = await fetch("https://jsonplaceholder.typicode.com/todos/1");var json = await response.json();return {statusCode: 200,body: JSON.stringify(json),};};
Adding serverless.yml
The first step in moving to Serverless is to add a basic setup through the .yml
file.
The service name is arbitrary, but will be the name of the stack created for us in AWS CloudFormation. In the provider
section, we select AWS and provide a region and stage name. The memory size, role and runtime options relate to the Lambda itself.
LambdaRole
comes from our resource
directory and is an AWS role that will be assigned to the Lambda so it can access logs and EC2 instances. Finally, we define our only function that will call the JSONPlaceholder API.
yml
service: nat-slsprovider:name: awsregion: eu-west-1stage: devmemorySize: 256runtime: nodejs12.xrole: LambdaRoleresources:Resources:LambdaRole: ${file(./resource/LambdaRole.yml)}functions:nat:handler: src/index.handlerevents:- http:path: /natmethod: getcors: true
Let’s take a look at the LambdaRole
. This is two separate inline policies grouped together: log
and ec2
. The first allows the function to write to CloudWatch and the second allows communication with EC2 instance.
yml
Type: AWS::IAM::RoleProperties:AssumeRolePolicyDocument:Statement:- Effect: AllowAction: sts:AssumeRolePrincipal:Service: lambda.amazonaws.comVersion: "2012-10-17"Policies:- PolicyName: logPolicyDocument:Version: "2012-10-17"Statement:- Effect: AllowAction:- logs:CreateLogStream- logs:CreateLogGroup- logs:PutLogEventsResource: arn:aws:logs:*:*:*- PolicyName: ec2PolicyDocument:Version: "2012-10-17"Statement:- Effect: AllowAction:- ec2:CreateNetworkInterface- ec2:DescribeNetworkInterfaces- ec2:DeleteNetworkInterfaceResource: "*"
VPC and subnets
Our goal is to create all the required resources: VPC, Internet Gateway and private and public subnets. Our Lambda function will then sit in the private subnet, with the Internet Gateway and NAT sat in the public subnets.
This requires a fair deal of Serverless boilerplate:
- A VPC defined in
ServerlessVPC.yml
- An Internet Gateway defined in
ServerlessInternetGateway.yml
attached to the VPC throughServerlessVPCGA.yml
. - Three private subnets described in
ServerlessSubnet*.yml
and one public subnet inPublicSubnet .yml
.
The public subnet is different because in the route table (OutgoingRouteRable.yml
) they are associated with (PublicSubnetAssociation.yml
) they point to the Internet Gateway (Outgoing Route.yml
).
Lambda inside our VPC
The important part is putting our Lambda function inside our VPC. For that we need the security group and three subnets.
yml
functions:nat:vpc:securityGroupIds:- !GetAtt LambdaSecurityGroup.GroupIdsubnetIds:- Ref: ServerlessSubnetA- Ref: ServerlessSubnetB- Ref: ServerlessSubnetChandler: src/index.handlerevents:- http:path: /natmethod: getcors: true
With this configuration our function throws an internal server error on calling, which is expected.
Reaching the internet
We are ready to create the resources that will allow our Lambda to reach out to the internet, even inside a VPC.
NAT instance
First, we create a private key through the AWS CLI so we can ssh
into our EC2 VM later. To do so, we run the following command:
sh
aws ec2 create-key-pair --key-name bastion-key
Next, we create our EC2 instance as a Serverless CloudFormation template file.
The ImageId
attribute specifies which blueprint to use for the EC2 instance. We can check these here. We stick with a micro
instance type for cost savings, but we can increase the size of the instance for improved performance later. KeyName
should match the key name created via the CLI above.
This instance will have its own dedicated security group called NatInstanceSG
, allowing traffic (all ports all protocols) from the Lambda function. SubnetId
describes where to place the EC2 instance. Since our NAT instance will communicate with the outside world it needs to be in the public subnet. Finally, AWS blocks traffic that does not come from the the EC2 VM itself by default, but our traffic will originate from a Lambda function so we disable SourceDestCheck
.
yml
Type: AWS::EC2::InstanceProperties:ImageId: ami-06ce3edf0cff21f07InstanceType: t2.microKeyName: bastion-keySecurityGroupIds:- !GetAtt NatInstanceSG.GroupIdSubnetId: !Ref PublicSubnetSourceDestCheck: false
NAT instance security group
The security group attached to our VM is an important piece of the puzzle, so let’s examine that.
The security group has two jobs: first it will allow all traffic (all protocol and all ports) from the Lambda (by specifying the SourceSecurityGroupId
of the security group that will be attached to our Lambda function). Second, it will allow port 22 (ssh
). We will need to ssh
into our machine to change some configuration to enable NAT translation.
yml
DependsOn: ServerlessVPCType: AWS::EC2::SecurityGroupProperties:GroupDescription: natVpcId:Ref: ServerlessVPCSecurityGroupIngress:- IpProtocol: -1FromPort: -1ToPort: -1SourceSecurityGroupId: !GetAtt LambdaSecurityGroup.GroupId- IpProtocol: tcpFromPort: "22"ToPort: "22"CidrIp: 0.0.0.0/0Tags:- Key: "Name"Value: "nat SSH"
SSH into our VM
Next, we need to access our VM. First, we change the access permissions on the key file by running the following command from the directory where we saved our .pem
.
sh
chmod 600 bastion-key.pem
Next we head to our AWS console or EC2 dashboard and find and then copy the IP address of our NAT VM. With that information, we can ssh
into our machine from the terminal with the following command:
sh
ssh -i PRIVATE_KEY_NAME.pem ec2-user@IP_ADDRESS
Once inside, we run the following commands:
sh
sudo sysctl -w net.ipv4.ip_forward=1sudo /sbin/iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
Route private traffic to the NAT instance
The final step involves routing. We modified the routing table of the public subnet before. It was called OutgoingRouteTable.yml
. It routed all unmatched traffic to the internet gateway. This time, we need to associate the NAT instance as the target for every outgoing traffic from the private subnets.
As illustrated below, unmatched traffic will go from the private subnet to the NAT instance, and unmatched traffic from the public subnet will go to the Internet Gateway.
The way to achieve this is to create a route table. Here we match every destination that did not match anything else with our NatInstance
. In other words, if nothing else is specified, just forward it to the EC2 instance.
yml
Type: AWS::EC2::RouteProperties:DestinationCidrBlock: 0.0.0.0/0InstanceId:Ref: NatInstanceRouteTableId:Ref: PrivateRouteTable
Then we just associate it with the subnets. Not just with ServerlessSubnetA
but with ServerlessSubnetB
and ServerlessSubnetC
.
yml
Type: AWS::EC2::SubnetRouteTableAssociationProperties:RouteTableId:Ref: PrivateRouteTableSubnetId:Ref: ServerlessSubnetA
Conclusion
We ended up with the same setup as we did in the manual guide, but now Serverless handles our resources. There are still bits we need to take care of, like creating a private key and using ssh
to access and configure our VM. Apart from this, we can now enjoy the declarative nature of Serverless and CloudFormation.