I was first exposed to Lambda functions at work when the team needed to implement an API service that will receive requests from other services, parse the data, and then sends this data to Kafka. Now this service is handling around 1M request each month ππ
I was very confused about where to start or how to get this done to work in development, testing, and production environments. I saw Serverless Framework, Knative, and Kubeless on the internet and took much time to decide what to choose since we are using K8s in our environments.
At last, I found Serverless the most suitable one to use in all our environments so I started learning how to build a serverless function using it. It's pretty easy to learn, use, and even read your configuration.
The directory structure
It's better to abstract each function in a separate directory with its components. You will also see Dockerfile and .docker directory which are used in the development environment. You will also see we are using Jenkins in our CI/CD pipeline. The programming language we used is NodeJS. We will talk about each environment setup next.
The function handler's code is always inside handler.js. We create as many files inside lib/ as necessary. The corresponding tests to all this code are under tests/.
The serverless.yml
Let me start by sharing our "simple" serverless.yml π
, and then I'll start commenting on pieces of it and explain how we are using this file for all of our environments:
service: api-service
provider:
name: aws
runtime: nodejs12.x
memorySize: 512
stage: ${opt:stage, 'dev'}
logRetentionInDays: ${self:custom.logRetention.${self:provider.stage}, self:custom.logRetention.other}
vpc:
subnetIds:
- ${self:custom.subnetIds.${self:provider.stage}, self:custom.subnetIds.other}
securityGroupIds:
- ${self:custom.securityGroupIds.${self:provider.stage}, self:custom.securityGroupIds.other}
deploymentBucket:
name: api-service
First, you will have to specify the service name to which you want to name the service. In the provider part, you will need to name your cloud provider you are going to use. I am AWS as shown above but if you are using GCP it will be google. You can see an example for GCP here. Then, the runtime your serverless application will use. Don't forget to set the version like the one you are using in the developent environment.
One of the important options in the provider block is adding a memorySize value. The default value is 1024 but sometimes your function is not going to use this much of memory so it's good to save some money here. Next the stage option, you can set it with passing an sls argument during the deployment or use a value to fallback to as above, dev in our case.
Next is setting a retention policy to your function (logRetentionInDays). Some people forget to use this option but it's a good way to get rid of old logs and save money from decreasing the tons of logs stored on Cloudwatch. We are setting a different value in testing other than the production as we need the production logs to be kept longer. The values here are set by values in the custom section. We are setting a value depending on the stage we are deploying to. We are using this approach in most of our fields. It's good to write a generic serverless.yml file that could handle multiple stages/environments. So if you are using prod stage, it will get the value from custom.logRetention.prod. It will go and look if we have custom.logRetention.prod already defined. If not, it will set the value with custom.logRetention.other which we are using for all the testing environments.
For the subnetIDs and securityGroupIds options, you will use them if you need to connect the application to an internal resource in your cluster, so skip it if not. You will have to grab the subnets and security-groups that your other resources is hosted in.
Be carefull, as soon as you are connecting your function with a VPC, the function is no longer able to access the Internet, even if you are choosing a public subnet with a route to the Internet gateway for your function (see Internet Access for Lambda Functions to learn more). So you canβt have both: access to resources within your VPC and through the Internet.
To be able to access your VPC as well as the Internet, you need to spin up a NAT Gateway. Or in some cases, you might get away with a VPC Endpoint.
Last part here, is adding your deploymentBucket. It's important to put all of your deployments in one bucket. You will have each stage, function, and deployment in an organized directories with timestamps. If your going to skip this one, it will create a random bucket for each deployment.
package:
include:
- functions/api-service/.env.defaults
- functions/api-service/app.js
- functions/api-service/handler.js
- functions/api-service/lib/**
- functions/api-service/node_modules/**
The package option is used to include and also exclude files and directories from your functions and can also help you in decreasing your application size that will be uploaded every deployment to S3.
functions:
api-service:
handler: functions/api-service/handler.api-service
description: this function will receive data from multiple services
environment:
KAFKA_TOPIC: api.service
KAFKA_BROKERS: ${self:custom.KAFKA_BROKERS.${self:provider.stage}, self:custom.KAFKA_BROKERS.other}
events:
- http:
path: api-service
method: post
cors:
origins:
- https://*.example.com
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: true
The most interseting part is defining your functions array. In our case we are using just one function but you can add as many as you need depending on the pipeline you are working on or you are kinda nerd and using step functions π€. I recommend looking here if you are interested to play with step functions.
Name your function and add it alongside with its description and handler. Make sure you are writing the right handler function not the file name. Then, you can use environment option to export some environment variables to use while your function is running. You will realize we are using the same approach in handling the same environment variable with multiple stages like in ${self:custom.KAFKA_BROKERS.${self:provider.stage}, self:custom.KAFKA_BROKERS.other}
In the events section, it depends on what is triggering your function: api gateway, websocket, kinesis & dynamodb ,s3 , etc. Look here for more details.
We are using an API gateway with http endpoint handling only POST requests on /api-service. You can also add some CORS configurations to your function as we you see above.
plugins:
- serverless-domain-manager
- serverless-offline
A good thing serverless framework provide you with, is plugins. We use serverless-offline for running our setup locally in the development environment and serverless-domain-manager plugin to handle the API gateway configuration.
custom:
customDomain:
domainName: ${self:custom.subDomain.${self:provider.stage}, self:custom.subDomain.other}.${self:custom.domain.${self:provider.stage}, self:custom.domain.other}
certificateName: "*.${self:custom.domain.${self:provider.stage}, self:custom.domain.other}"
basePath: "${self:custom.basePath.${self:provider.stage}, self:custom.basePath.other}"
stage: ${self:provider.stage}
createRoute53Record: true
domain:
prod: example-prod.com
other: example.com
subDomain:
prod: api-service
other: api-service
basePath:
prod:
other: ${self:provider.stage}
subnetIds:
prod: subnet-xxxxxxx
other: subnet-yyyyyyy
securityGroupIds:
prod: 'sg-xxxxxxx'
other: 'sg-yyyyyyy'
KAFKA_BROKERS:
prod: kafka-prod.example.com
other: kafka-${self:provider.stage}.example.com
logRetention:
prod: 14
other: 7
In the custom section, it's like declaring variables. Variables allow you to dynamically replace config values in serverless.yml config. We declare one for the prod and other to fallback to when the stage is not prod. Like when we used ${self:custom.subnetIds.${self:provider.stage}, self:custom.subnetIds.other}.
The customDomain variable is used here to define the API gateway config. So, we are setting domainName, certificateName, basePath which is very helpful if the certificate you are using is not handling alternate domain names (CNAMEs) look here for more info,and the stage field. You can also set createRoute53Record with true to have the framework create for you Route53Records to use in your other external services.
Setting up the development environment
For the development environment, I preferred to run the serverless function using Docker to better handle the dependencies. So this is how the Dockerfile and the entrypoint look like:
FROM node:12.15.0-stretch
COPY ./.docker/startup.sh /etc/
RUN chmod +x /etc/startup.sh
CMD /etc/startup.sh
#!/bin/bash
cd /var/www/app
npm install -g serverless
npm install
sls offline -o api-service
You will see above how simple the setup is in the development environment. We are just running the sls command with the serverless-offline plugin. This will let a local API gateway to run on port 3000 inside the container and triggers the function once we hit it.
Our parameters are stored in functions/api-service/.env.defaults. We will handle our parameters in a different way in the other environments.
To run this setup locally, you can either use only docker command or you can also use docker-compose instead.
Docker command:
$ docker build -t api-service .
$ docker run --name api-service -d -p 80:3000 -v ${PWD}:/var/www/app api-service
docker-compose:
version: '3.7'
services:
# api service
api:
build:
context: .
ports:
- "80:3000"
volumes:
- .:/var/www/app
$ docker-compose up -d
Setting up testing and production environments
Luckily, because we have written a very cool and generic serverless.yml file. We don't have much of steps to do in these environments. You will just need to run:
sls deploy -s [stage] -v
This step will create a .serverless directory that will have the cloudformation template generated, serverless-state and the zip file that will be uploaded to the S3 bucket. I also like the -v option to see every resource created and every step serverless is making π
Automating production deployments
If you want to step higher with your pipeline and add some automation, you can use Jenkins to automate your CI/CD pipeline or autodeploy your application when merging your changes to master for example.
An example of a jenkinsfile is as below:
pipeline {
agent {
kubernetes {
label 'api-service'
defaultContainer 'jnlp'
yaml """
apiVersion: v1
kind: Pod
metadata:
labels:
component: ci
spec:
# Use service account that can deploy to all namespaces
containers:
- name: node
image: node:12.15.0-stretch
command:
- cat
tty: true
- name: awscli
image: organs/awscli
command:
- cat
tty: true
"""
}
}
stages {
stage('build') {
environment {
BRANCH_NAME = sh ( script: "echo ${env.GIT_BRANCH}| rev | cut -f1 -d '/' | rev | sed 's/ *//g'", returnStdout: true ).trim()
}
steps {
container('node') {
sh """
npm install
"""
}
container('awscli') {
sh """
# deploy to prod stage if branch is master
if [ "${env.BRANCH_NAME}" == "master" ]; then
sls deploy -s prod -v
fi
"""
}
}
}
}
}
Closing
Serverless is an amazing technology, or a mindset if you will. It's a major step towards delegating infrastructure problems to companies that are much better positioned to deal with them. No matter how good you become at DevOps, you sometimes need those companies to handle and manage your applications you don't want to bother yourself with. And the main point here is building your architecture without spending ages building it yourself and saving tons of money hosting it.
I will continue writing about various serverless topics and case scenarios that I have faced that I also see it interesting and challenging.
Appreciate your comments and feedback π

Top comments (1)
Really awesome and fruitful article, I have 2 suggestions though:
Set the runtime to by dynamically defined and fetch it from an endpoint which DevOps and Development team could access so that you won't have a missing dependency issue.
Kudos on being frugal regarding the memory, but you never know what traffic could cause so I suggest to either setup a SNS notification to alert you when the memory is about to cross a certain threshold or migrate to EKS which could autoscale.
But seriously bravo and really proud <3