Over the past few days, I've spent some time re-assessing the Serverless Framework to see if it can help bootstrap new ventures in a faster way.
The Previous Venture
In my last venture for a major Australian bank, we used Auth0 for authentication and a series of Golang microservices on a Kubernetes cluster. This workflow was extremely fast and very pleasureable to work in. But then I thought, we keep re-doing these integrations and spinning up a lot of infrastructure; We used Rancher, Terraform + Helm Charts on AWS. This took a lot of maintenance such as:
- Managing AMIs and security updates
- Managing Rancher which was supposed to manage the cluster
- Writing and maintaining Helm Charts as we added things in like Cron pods or adding Vault instead of using Kubernetes secrets
- Restarting nodes when EC2 instances just decided to hang
From this though, I built a Golang Microservice Yeoman Generator to help bring up microservices faster. It would use protocol buffers and hook up GRPC endpoints.
Experimenting With Serverless
So now I've gone back to look at less management again for the next venture. Enter Serverless a second time. The last time I looked at Serverless(framework), it wasn't very mature. Now, I can see a lot of potential, especially when it comes prototyping and reducing costs.
The problem I wanted to tackle was authentication and logging. Additionally, I wanted to do it in Golang so that I had a fallback. Basically if Serverless didn't work out, I can at least I can move the code from a decomposed state, back into microservices onto Kubernetes. This is much easier to do than starting with a monolith and decomposing it into functions. The other optional fallback was to install Kubeless which will give Kubernetes the ability to run functions. But that's for another day.
I chose AWS Lambda over Google Cloud Functions because AWS seemed more mature in this space. Lambda had more event triggers, it had other authorization functions, it can be written in more languages including Go. GCP only had Node and the authorization had to be done in the function itself. You also get all the benefits of Golang. Small, compiled binaries that run fast.
Logging First
Part of the learnings was that log aggregation should be tackled first. The general process is this:
- create a Serverless project with a single function (the log forwarder/aggregator) to your integration of choice e.g. Splunk, Logz.io, Sumo Logic etc.
- Deploy the function without API Gateway as a standalone function
- Grab the ARN of the function
Then using the ARN of that function, we put it into another Serverless project using the serverless-log-forwarding
plugin.
The reason behind this is that every function gets a Cloudwatch Log Group created so we can see the logs for the individual function calls. What we want to do is subscribe future functions to this log forwarder log group, so that it ships the content onto our integration of choice. That way we get richer searches, reporting, and alerts. Keep in mind though that if you want to keep the correlation ID across all the functions, you need to pass on the X-Amzn-Trace-Id
header.
I've put together a Golang Log forwarder boilerplate on GitHub with instructions so you can see how it works. Keep in mind though that people like Logz.io already have a log forwarder function implemented for you. But you can wrap it in a Serverless framework for easier deployment.
The Auth Application
Update July 23, 2018: Read below for using User Pools and Identity Pools. However, I found that to keep flexibility of auth providers and to have fine grained permissions without too much lock-in to how AWS does things, I skipped Identity pools and used a custom authorizer function with just the User Pools. You can see an example here: https://github.com/serinth/serverless-cognito-auth
Okay so that was the forwarder, now let's look at an actual Go Serverless application. Start by creating a boilerplate app with Dep as the dependency manager. We can do that with:
serverless create -t aws-go-dep
We also need the plugin above so that our function logs will go to our forwarder lambda function.
npm init
Fill out the details. Then:
npm install --save-dev serverless-log-forwarding
Now let's take a look at the main serverless.yml
file:
service: myService
plugins:
- serverless-log-forwarding
custom:
logForwarding:
destinationARN: <ARN OF FORWARDER>
filterPattern: "-\"RequestId: \""
stage: ${opt:stage, self:provider.stage}
appName: myAppName
provider:
name: aws
runtime: go1.x
stage: dev
region: ap-southeast-2
memorySize: 128
tags:
appName: ${self:custom.appName}
stage: ${self:custom.stage}
owner: tony.truong
package:
exclude:
- ./**
include:
- ./bin/**
functions:
hello:
handler: bin/hello
events:
- http:
path: hello
method: get
cors: true
authorizer: aws_iam
world:
handler: bin/world
events:
- http:
path: world
method: get
cors: true
postConfirmation:
handler: bin/postConfirmation
# Create our resources with separate CloudFormation templates
resources:
# Cognito
- ${file(resources/cognito-user-pool.yml)}
- ${file(resources/cognito-identity-pool.yml)}
There's a few things to note here:
- The plugin reference. When we deploy, it will look at that plugin on the dev machine and know to attach to the log forwarder with the ARN from earlier
- The
custom
field can be filled out with anything. We take optional command line arguments so we can override the default dev environment Provider
options will apply those configurations to all of our functions such as tagging so that we know where our resources came fromauthorizer: aws_iam
will use the identity pool (more later) so that only logged in users can invoke thehello
function${file...
allows us to have Cloudformation-like yaml files external to the main serverless.yml file so that it is more readable
Cognito User Pool and Identity Pool Resource
These are the yaml files as an example of how to create the two different pools.
Cognito User Pool - Contains user information. Logs users in with JWTs that have claims attached and has Group management (which we won't use here). Users signing up will have an entry into the User Pool on the AWS Console. The resources/cognito-user-pool.yml
is an example of provisioning us a user pool if one doesn't exist already.
cognito-user-pool.yml
:
Resources:
CognitoUserPoolMyPool:
Type: AWS::Cognito::UserPool
Properties:
# Generate a name based on the stage
UserPoolName: ${self:custom.appName}-${self:custom.stage}-user-pool
# Set email as an alias
UsernameAttributes:
- email
AutoVerifiedAttributes:
- email
CognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
# Generate an app client name based on the stage
ClientName: ${self:custom.appName}-${self:custom.stage}-user-pool-client
UserPoolId:
Ref: CognitoUserPoolMyPool
ExplicitAuthFlows:
- ADMIN_NO_SRP_AUTH
GenerateSecret: false
# Print out the Id of the User Pool that is created
Outputs:
UserPoolId:
Value:
Ref: CognitoUserPoolMyPool
UserPoolClientId:
Value:
Ref: CognitoUserPoolClient
Cognito Identity Pool - Is what we use to allow the client to invoke AWS resources (our secure lambda functions, S3 etc).
cognito-identity-pool.yml
Resources:
# The federated identity for our user pool to auth with
CognitoIdentityPool:
Type: AWS::Cognito::IdentityPool
Properties:
# Generate a name based on the stage
IdentityPoolName: GoAuth${self:custom.stage}IdentityPool
# Don't allow unathenticated users
AllowUnauthenticatedIdentities: false
# Link to our User Pool
CognitoIdentityProviders:
- ClientId:
Ref: CognitoUserPoolClient
ProviderName:
Fn::GetAtt: [ "CognitoUserPoolMyPool", "ProviderName" ]
# IAM roles
CognitoIdentityPoolRoles:
Type: AWS::Cognito::IdentityPoolRoleAttachment
Properties:
IdentityPoolId:
Ref: CognitoIdentityPool
Roles:
authenticated:
Fn::GetAtt: [CognitoAuthRole, Arn]
# IAM role used for authenticated users
CognitoAuthRole:
Type: AWS::IAM::Role
Properties:
Path: /
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Principal:
Federated: 'cognito-identity.amazonaws.com'
Action:
- 'sts:AssumeRoleWithWebIdentity'
Condition:
StringEquals:
'cognito-identity.amazonaws.com:aud':
Ref: CognitoIdentityPool
'ForAnyValue:StringLike':
'cognito-identity.amazonaws.com:amr': authenticated
Policies:
- PolicyName: 'CognitoAuthorizedPolicy'
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Action:
- 'mobileanalytics:PutEvents'
- 'cognito-sync:*'
- 'cognito-identity:*'
Resource: '*'
# Allow users to invoke our API
- Effect: 'Allow'
Action:
- 'execute-api:Invoke'
Resource:
Fn::Join:
- ''
-
- 'arn:aws:execute-api:'
- Ref: AWS::Region
- ':'
- Ref: AWS::AccountId
- ':'
- Ref: ApiGatewayRestApi
- '/*'
# Print out the Id of the Identity Pool that is created
Outputs:
IdentityPoolId:
Value:
Ref: CognitoIdentityPool
Once a user has authorized with the User Pool, the User Pool will go ask the Identity pool for temporary credentials and pass that back to the front end. The front end will use that session token for invoking our secured lambda functions. That session token has taken the AWS role: CognitoAuthRole
in the identity pool file. Which has permissions to invoke our lambda functions.
I could never get Serverless framework to automatically hook into the post confirmation event to the User Pool. There's a yaml syntax to add it but it never worked for me. Even when re-creating the pool, trying an ARN instead of the pool name, referencing the pool using Ref:
. I think this will be worked out in the future on the framework. So in order to tie that event with the lambda function, you have to do it manually in the console for now.
One big thing to understand about this though is that it does not give you fine grained permissions in your application. If you wanted to deal with roles and teams that can access each other's resources, you need to manage that yourself.
In order to do that, you'll need to store the details of the user in a custom table and update it later. We can use Cognito to verify emails and mobile numbers for us. When that happens, we can hook into the PostConfirmation
event to retrieve the user details. That is the postConfirmation
function. Which is why you don't see an http endpoint for it when deploying.
I initially went down the path of using the Identity pool and tried to get the user details in the lambda function. That's not necessary, we only need the unique identifier of the user after they've logged in. Keep track of who the user is on post confirmation and maintain permissions / relationships that way. Identity pool is not meant for group and team management at a fine level. If you do know of a better way of managing this, please email me and let me know. I'll update this post.
Lambda Functions in Go
An example of a lambda function in Go that hooks off of API Gateway looks like this:
package main
import (
"fmt"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
func Handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
fmt.Printf("events.APIGatewayProxyRequestContext.Identity: %#v \n", request.RequestContext)
fmt.Printf("Headers: %#v \n", request.Headers)
return events.APIGatewayProxyResponse{
Body: "Hello called, authenticated",
StatusCode: 200,
Headers: map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Credentials": "true",
},
}, nil
}
func main() {
lambda.Start(Handler)
}
Yet the one for the the post confirmation hook is a bit inconsistent and looks more like this:
package main
import (
"fmt"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
func Handler(event events.CognitoEventUserPoolsPostConfirmation) (events.CognitoEventUserPoolsPostConfirmation, error) {
fmt.Printf("User Attributes: %#v \n", event)
return event, nil
}
func main() {
lambda.Start(Handler)
}
Lambda pitches reduced costs and I think this is true -- at least initially. At scale though, I actually think it's easier to run a cluster where the functions are being invoked constantly and use very little memory. You also don't have the notorious warm up time to deal with.
To tie it all together and start testing everything, you can use the AWS provided lib: https://github.com/aws-amplify/amplify-js for the front end.
Overall, I think it's worth a try for things like personal projects where you don't know if your ideas are going to take off so you don't have to pay for much invocation. Then pay for nothing if people aren't using it. I'm willing to jump through a little bit more hoops and have a bit of vendor lock in even if it means I can penny pinch =].