Private Serverless REST APIs With AWS Lambda Using SAM

Quickly create serverless microservices using Lambdas

Paulo Carvalho
Oct 1 · 6 min read
A scenic mountainside view
A scenic mountainside view
Photo by Philipp Lehmann on Unsplash

By using Amazon API Gateway, it’s possible to quickly create serverless microservices backed by Lambda. We’ll review one such deployment and use the AWS Serverless Application Model (SAM) to deploy it.

Introduction

With ever-growing cloud systems, many companies have opted to move away from monoliths into a distributed architecture composed of microservices. AWS allows the development of such services without the need to manage the underlying server infrastructure.

We’ll go over an infrastructure-as-code (IaC) approach to deploying one such service using SAM. The deployment will assume that a virtual private cluster (VPC) is already in place and that only VMs on specific subnets within the cluster should be able to access the private service. We’ll also assume that the developer already has the necessary AWS SAM tools installed locally.

1. Files and Folders

my-microservice/
├── cmd/
│ ├── debug.sh
│ └── deploy.sh
├── src/
│ ├── index.js
│ └── package.js
├── .gitignore
├── test-event.json
└── template.yaml

The cmd folder will contain our helper scripts for the deploying and local debugging of our function, as shown below:

debug.sh: Runs the function on the local computer using test-event.json as input.

#!/bin/bashscript_path=$(cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P)sam local invoke \  
--event ${script_path}/../test-event.json \
--template-file ${script_path}/../template.yaml

deploy.sh: Deploys the service to AWS.

#!/bin/bash# exit when any command fails
set -e
script_path=$(cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P)sam deploy \
--template-file ${script_path}/../template.yaml \
--stack-name my-microservice \
--capabilities CAPABILITY_IAM \
--guided

The src folder will contain Lambda’s source code. In this case, it consists of Node index.js and package.json files. However, the same technique that will be shown here would work with any of the other supported Lambda languages as well.

{  
"name": "my-lambda-code",
"version": "1.0.0",
"description": "AWS Lambda function for microservice",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {},
"devDependencies": {}
}

A simple function that expects a callerName key in the body of the request. The function prepends “Hello, ” to the received callerName string and returns it as the response.

exports.handler = async (eventObject, context, callback) => {

const name = JSON.parse(eventObject.body).callerName;

var response = {
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"isBase64Encoded": false,
"body": JSON.stringify({message: "Hello, " + name})
}

return response
};

Contains a sample of the request object that’ll be proxied by API Gateway to the function. This is used by our debug.sh when testing the function locally and can be a huge time-saver by removing the requirement of deploying after each change.

{
"resource": "/",
"path": "/",
"httpMethod": "POST",
"requestContext": {
"resourcePath": "/",
"httpMethod": "POST",
"path": "/"
},
"headers": {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"accept-encoding": "gzip, deflate, br",
"Host": "70ixmpl4fl.execute-api.us-east-2.amazonaws.com",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36",
"X-Amzn-Trace-Id": "Root=1-5e66d96f-7491f09xmpl79d18acf3d050"
},
"multiValueHeaders": {
"accept": [
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"
],
"accept-encoding": [
"gzip, deflate, br"
]
},
"queryStringParameters": null,
"multiValueQueryStringParameters": null,
"pathParameters": null,
"stageVariables": null,
"body": "{\"callerName\":\"Luis Hamilton\"}",
"isBase64Encoded": false
}

The template.yaml file describes our deployment and will be presented in detail in part 2.

2. Infrastructure as Code

Without further ado, the template.yaml file can be found below, outlining the deployment of our Lambda function that’s invoked by an API gateway accessible only from within an already existent VPC. The following sections will go over the different components of the deployment.

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: My serverless hello world API

Resources:
MyServelessLambdaFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: index.handler
Runtime: nodejs12.x
MemorySize: 1024
Timeout: 20
Policies:
- AWSLambdaBasicExecutionRole
- AWSLambdaVPCAccessExecutionRole
Events:
APIRoot:
Type: Api
Properties:
Path: /
Method: ANY
RestApiId: !Ref MyPrivateApi

MyApiSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
VpcId: MyVpcIdShouldBeHardcodedHere
GroupDescription: Allows access over 443
SecurityGroupIngress:
-
IpProtocol: "tcp"
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0

MyApiAccessEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: MyVpcIdShouldBeHardcodedHere
ServiceName: !Sub "com.amazonaws.${AWS::Region}.execute-api"
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds:
- MySubnetId1ShouldBeHardcodedHere
- MySubnetId2ShouldBeHardcodedHere
SecurityGroupIds:
- !Ref MyApiSecurityGroup

MyPrivateApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
MethodSettings:
- HttpMethod: POST
ResourcePath: /
EndpointConfiguration: PRIVATE
DefinitionBody:
swagger: 2.0
info:
title: MyPrivateApi
basePath: /Prod
schemes:
- https
x-amazon-apigateway-policy:
Version: "2012-10-17"
Statement:
-
Effect: "Allow"
Principal: "*"
Action:
- "execute-api:Invoke"
Resource: "execute-api:/*"
Condition:
StringEquals:
aws:sourceVpce: !Ref MyApiAccessEndpoint
paths:
/:
x-amazon-apigateway-any-method:
produces:
- application/json
x-amazon-apigateway-integration:
responses:
default:
statusCode: 200
uri: !Join [ "", [ !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:", !Ref MyServelessLambdaFunction, "/invocations"] ]
passthroughBehavior: when_no_match
httpMethod: POST
type: AWS_PROXY

Outputs:
MyPrivateApi:
Description: "API Gateway endpoint URL for Prod stage"
Value: !Sub "https://${MyPrivateApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
MyServelessLambdaFunction:
Description: "Lambda Function ARN"
Value: !GetAtt MyServelessLambdaFunction.Arn

The following code block specifies the Lambda function. Some important attributes include:

  1. Runtime: Specifies the language of our script.
  2. Events: An object that describes resources that trigger this Lambda. In this case, the Lambda is triggered by the API gateway MyPrivateApi. The resulting gateway can be seen in the image below taken from the Lambda console.
A screenshot of the author’s Lambda console, showing the API Gateway resulting from the Lambda function.
A screenshot of the author’s Lambda console, showing the API Gateway resulting from the Lambda function.
  MyServelessLambdaFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: index.handler
Runtime: nodejs12.x
MemorySize: 1024
Timeout: 20
Policies:
- AWSLambdaBasicExecutionRole
- AWSLambdaVPCAccessExecutionRole
Events:
APIRoot:
Type: Api
Properties:
Path: /
Method: ANY
RestApiId: !Ref MyPrivateApi

The code block below creates a security block we’ll assign to our API which permits HTTP access (port 443) from any origin IP address. Note that since the API will be of a private type, it won’t be accessible from outside the VPC.

  MyApiSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
VpcId: MyVpcIdShouldBeHardcodedHere
GroupDescription: Allows access over 443
SecurityGroupIngress:
-
IpProtocol: "tcp"
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0

Below, we create a VPC endpoint, which is an AWS construct that allows connecting resources such as an API gateway to a VPC without exposing traffic to the internet. A few key attributes to consider:

  1. ServiceName: The AWS resource type to which the endpoint will connect. The ${AWS:Region} will be substituted by the region the script is used to deploy to.
  2. VpcEndpointType: We should set this to interface, which is an AWS private link–powered endpoint that connects to internal resources.
  3. PrivateDnsEnabled: Should be set to true to create the DNS record that’ll allow instances from within the VPC to access the API.
MyApiAccessEndpoint:
Type: AWS::EC2::VPCEndpoint
Properties:
VpcId: MyVpcIdShouldBeHardcodedHere
ServiceName: !Sub "com.amazonaws.${AWS::Region}.execute-api"
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds:
- MySubnetId1ShouldBeHardcodedHere
- MySubnetId2ShouldBeHardcodedHere
SecurityGroupIds:
- !Ref MyApiSecurityGroup

Finally, the code block below creates the API gateway using the resources outlined in the previous blocks. Some important attributes to keep in mind:

  1. EndpointConfiguration: Defines the type of endpoint. In our case, we have a private endpoint that can only be accessed from a VPC.
  2. x-amazon-apigateway-policy: Defines a resource policy for the API that grants the previously created endpoint access to it.
  3. paths: A list of paths that should be created and how they should behave. In our case, we create a single root path, /.
  4. x-amazon-apigateway-integration: This attribute defines the integration between the API gateway and the Lambda function. Information on other attributes can be found here.
  5. uri: This verbose expression associates the Lambda function to the integration.
  6. httpMethod: The httpMethod of the x-amazon-apigateway-integration to a Lambda function should always be set to POST; otherwise, the integration won’t work. Note that this is different from the HTTP method of the exposed API, which can be of any type.
MyPrivateApi:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
MethodSettings:
- HttpMethod: POST
ResourcePath: /
EndpointConfiguration: PRIVATE
DefinitionBody:
swagger: 2.0
info:
title: MyPrivateApi
basePath: /Prod
schemes:
- https
x-amazon-apigateway-policy:
Version: "2012-10-17"
Statement:
-
Effect: "Allow"
Principal: "*"
Action:
- "execute-api:Invoke"
Resource: "execute-api:/*"
Condition:
StringEquals:
aws:sourceVpce: !Ref MyApiAccessEndpoint
paths:
/:
x-amazon-apigateway-any-method:
produces:
- application/json
x-amazon-apigateway-integration:
responses:
default:
statusCode: 200
uri: !Join [ "", [ !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:", !Ref MyServelessLambdaFunction, "/invocations"] ]
passthroughBehavior: when_no_match
httpMethod: POST
type: AWS_PROXY

3. Deploying

To deploy our service stack, just run the deploy.sh script; likewise, you can run ./cmd/deploy.sh.

During the deployment, you’ll be prompted with the message: “MyServelessLambdaFunction may not have authorization defined. Is this okay?” This is expected since we didn’t implement authorization.

Better Programming

Advice for programmers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store