Building Serverless APIs with the Serverless Framework

Module 26: Advanced Backend & API Development

Introduction to the Serverless Framework

The Serverless Framework is an open-source tool that simplifies developing, deploying, and operating serverless applications across multiple cloud providers. While it supports various cloud providers, we'll focus on its integration with AWS in this lecture.

Analogy: Serverless Framework as a Universal Remote

Think of the Serverless Framework as a universal remote control for your cloud infrastructure:

  • A universal remote replaces multiple device-specific remotes with a single interface
  • The Serverless Framework replaces provider-specific tools with a unified approach
  • Just as the universal remote has preset configurations for common devices, Serverless Framework offers templates for common architectures
  • Both simplify complex operations into simple commands (e.g., "turn on TV" becomes "deploy")
  • Both can be customized for specific needs while maintaining a consistent user experience
graph TD A[Serverless Framework] --> B[Infrastructure as Code] A --> C[Multi-Provider Support] A --> D[Plugin System] A --> E[Simplified Deployment] B --> B1[AWS CloudFormation] B --> B2[Azure Resource Manager] B --> B3[Google Cloud Deployment Manager] C --> C1[AWS] C --> C2[Azure] C --> C3[Google Cloud] C --> C4[Others] D --> D1[Monitoring Plugins] D --> D2[Deployment Plugins] D --> D3[Workflow Plugins] D --> D4[Custom Plugins] E --> E1[Single Command Deployment] E --> E2[Stage Management] E --> E3[Rollback Capability]

Setting Up the Serverless Framework

Before we start building, let's set up the Serverless Framework and configure it for AWS:

Installation


# Install the Serverless Framework globally
npm install -g serverless

# Verify installation
serverless --version

# Output should show something like:
# Framework Core: X.Y.Z
# Plugin: X.Y.Z
# SDK: X.Y.Z
      

AWS Credentials Setup

The Serverless Framework requires AWS credentials to deploy resources:


# Configure AWS credentials using the serverless config command
serverless config credentials --provider aws --key YOUR_ACCESS_KEY --secret YOUR_SECRET_KEY

# Alternatively, use AWS CLI to configure credentials
aws configure
      

Best Practices for AWS Credentials

  • Use IAM roles: For CI/CD pipelines and production environments
  • Create dedicated IAM users: Don't use your root AWS account
  • Principle of least privilege: Only grant necessary permissions
  • Use different profiles: Separate credentials for different environments
  • Regularly rotate credentials: Change access keys periodically

# Using a specific AWS profile
serverless deploy --aws-profile development

# Or set in serverless.yml
provider:
  name: aws
  profile: development
        

Creating a New Project

The Serverless Framework provides templates to help get started quickly:


# Create a new service with a specific template
serverless create --template aws-nodejs --path my-serverless-api

# Change to the new directory
cd my-serverless-api

# Check the template files
ls -la
      

The template creates a basic structure with a serverless.yml configuration file and a handler.js file containing a sample Lambda function.

Understanding serverless.yml

The serverless.yml file is the core of your Serverless Framework project, defining all the resources and configurations:


# serverless.yml - Basic structure
service: my-serverless-api

frameworkVersion: '3'

provider:
  name: aws
  runtime: nodejs16.x
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'us-east-1'}
  profile: ${opt:profile, 'default'}
  environment:
    STAGE: ${self:provider.stage}
    SERVICE_NAME: ${self:service}

functions:
  hello:
    handler: handler.hello
    events:
      - http:
          path: hello
          method: get

plugins:
  - serverless-offline

custom:
  myVariable: myValue
      

Key Sections of serverless.yml

  • service: Name of your service
  • provider: Cloud provider configuration
  • functions: Lambda functions and their triggers
  • resources: Additional infrastructure resources
  • plugins: Serverless Framework plugins
  • custom: Custom variables and configurations
  • package: Packaging configurations

Variables and References

The Serverless Framework provides a powerful variable system for dynamic configurations:


# serverless.yml - Variables example
service: ${env:SERVICE_NAME, 'default-service-name'}

provider:
  name: aws
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'us-east-1'}
  environment:
    TABLE_NAME: ${self:service}-${self:provider.stage}-table
    S3_BUCKET: ${cf:another-stack-${self:provider.stage}.BucketName}
    SECRET_KEY: ${ssm:/my-app/${self:provider.stage}/secret-key}

custom:
  domains:
    dev: dev.api.example.com
    prod: api.example.com
  domain: ${self:custom.domains.${self:provider.stage}}
      

Variable Types

  • ${self:xxx} - Reference a property of the serverless.yml
  • ${opt:xxx, 'default'} - Command line options with default
  • ${env:xxx} - Environment variables
  • ${file(./path/to/file.json):propertyName} - External file
  • ${cf:stackName.outputName} - CloudFormation stack outputs
  • ${ssm:/path/to/param} - AWS Systems Manager Parameters

Building a RESTful API

Let's build a complete RESTful API for a todo application using the Serverless Framework:

Project Structure


# Create a folder structure
mkdir -p todo-api/src/{functions,models,libs}
cd todo-api

# Initialize npm
npm init -y

# Install dependencies
npm install --save aws-sdk uuid jsonschema
npm install --save-dev serverless-offline
      

Recommended Folder Structure


todo-api/
├── serverless.yml       # Service configuration
├── package.json         # Dependencies
├── .gitignore           # Git ignore file
├── README.md            # Documentation
├── src/
│   ├── functions/       # Lambda function handlers
│   │   ├── create.js
│   │   ├── list.js
│   │   ├── get.js
│   │   ├── update.js
│   │   └── delete.js
│   ├── models/          # Data models
│   │   └── todo.js
│   └── libs/            # Shared code
│       ├── dynamodb.js
│       ├── response.js
│       └── validator.js
└── tests/               # Test files
    └── ...
        

Configuring the API in serverless.yml


# serverless.yml for the Todo API
service: todo-api

frameworkVersion: '3'

provider:
  name: aws
  runtime: nodejs16.x
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'us-east-1'}
  environment:
    TODOS_TABLE: ${self:service}-${self:provider.stage}-todos
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:Query
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:PutItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: !GetAtt TodosTable.Arn

functions:
  create:
    handler: src/functions/create.handler
    events:
      - http:
          path: todos
          method: post
          cors: true
  
  list:
    handler: src/functions/list.handler
    events:
      - http:
          path: todos
          method: get
          cors: true
  
  get:
    handler: src/functions/get.handler
    events:
      - http:
          path: todos/{id}
          method: get
          cors: true
  
  update:
    handler: src/functions/update.handler
    events:
      - http:
          path: todos/{id}
          method: put
          cors: true
  
  delete:
    handler: src/functions/delete.handler
    events:
      - http:
          path: todos/{id}
          method: delete
          cors: true

resources:
  Resources:
    TodosTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:provider.environment.TODOS_TABLE}
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH

plugins:
  - serverless-offline

custom:
  serverless-offline:
    httpPort: 3000
      

Creating Shared Libraries

Let's create some shared libraries for our API:


// src/libs/dynamodb.js
const AWS = require('aws-sdk');

let options = {};

// If running offline, use local DynamoDB
if (process.env.IS_OFFLINE) {
  options = {
    region: 'localhost',
    endpoint: 'http://localhost:8000'
  };
}

const client = new AWS.DynamoDB.DocumentClient(options);

module.exports = client;
      

// src/libs/response.js
/**
 * Create a standardized API response
 */
function response(statusCode, body) {
  return {
    statusCode,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Credentials': true,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(body)
  };
}

/**
 * Success response helper
 */
function success(body) {
  return response(200, body);
}

/**
 * Error response helper
 */
function error(statusCode, message, details = null) {
  const body = { 
    error: message
  };
  
  if (details) {
    body.details = details;
  }
  
  return response(statusCode, body);
}

module.exports = {
  response,
  success,
  error
};
      

// src/libs/validator.js
const { Validator } = require('jsonschema');

const validator = new Validator();

// Todo schema for validation
const todoSchema = {
  type: 'object',
  properties: {
    title: { type: 'string', minLength: 1, maxLength: 100 },
    description: { type: 'string', maxLength: 500 },
    completed: { type: 'boolean' },
    dueDate: { type: 'string', format: 'date-time' }
  },
  required: ['title'],
  additionalProperties: false
};

/**
 * Validate a todo item against the schema
 */
function validateTodo(todo) {
  return validator.validate(todo, todoSchema);
}

module.exports = {
  validateTodo
};
      

Implementing CRUD Functions

Let's implement the CRUD (Create, Read, Update, Delete) functions for our Todo API:

Create Function


// src/functions/create.js
const { v4: uuidv4 } = require('uuid');
const dynamoDB = require('../libs/dynamodb');
const { success, error } = require('../libs/response');
const { validateTodo } = require('../libs/validator');

module.exports.handler = async (event) => {
  try {
    // Parse request body
    const body = JSON.parse(event.body);
    
    // Validate todo
    const validation = validateTodo(body);
    if (!validation.valid) {
      return error(400, 'Invalid todo data', validation.errors);
    }
    
    // Create todo item
    const now = new Date().toISOString();
    const todo = {
      id: uuidv4(),
      title: body.title,
      description: body.description || '',
      completed: body.completed || false,
      dueDate: body.dueDate || null,
      createdAt: now,
      updatedAt: now
    };
    
    // Save to DynamoDB
    await dynamoDB.put({
      TableName: process.env.TODOS_TABLE,
      Item: todo
    }).promise();
    
    return success(todo);
  } catch (err) {
    console.error('Error creating todo:', err);
    return error(500, 'Could not create todo');
  }
};
      

List Function


// src/functions/list.js
const dynamoDB = require('../libs/dynamodb');
const { success, error } = require('../libs/response');

module.exports.handler = async (event) => {
  try {
    // Extract query string parameters
    const params = event.queryStringParameters || {};
    
    // Basic scan operation
    const result = await dynamoDB.scan({
      TableName: process.env.TODOS_TABLE
    }).promise();
    
    // Apply filtering if needed
    let todos = result.Items;
    
    // Filter by completion status if specified
    if (params.completed !== undefined) {
      const isCompleted = params.completed === 'true';
      todos = todos.filter(todo => todo.completed === isCompleted);
    }
    
    // Sort by due date or creation date
    if (params.sort === 'dueDate') {
      todos.sort((a, b) => {
        if (!a.dueDate) return 1;
        if (!b.dueDate) return -1;
        return new Date(a.dueDate) - new Date(b.dueDate);
      });
    } else {
      todos.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
    }
    
    return success({
      count: todos.length,
      todos: todos
    });
  } catch (err) {
    console.error('Error listing todos:', err);
    return error(500, 'Could not list todos');
  }
};
      

Get Function


// src/functions/get.js
const dynamoDB = require('../libs/dynamodb');
const { success, error } = require('../libs/response');

module.exports.handler = async (event) => {
  try {
    // Get todo ID from path parameters
    const id = event.pathParameters.id;
    
    // Fetch from DynamoDB
    const result = await dynamoDB.get({
      TableName: process.env.TODOS_TABLE,
      Key: { id }
    }).promise();
    
    // Check if todo exists
    if (!result.Item) {
      return error(404, 'Todo not found');
    }
    
    return success(result.Item);
  } catch (err) {
    console.error('Error getting todo:', err);
    return error(500, 'Could not get todo');
  }
};
      

Update Function


// src/functions/update.js
const dynamoDB = require('../libs/dynamodb');
const { success, error } = require('../libs/response');
const { validateTodo } = require('../libs/validator');

module.exports.handler = async (event) => {
  try {
    // Get todo ID from path parameters
    const id = event.pathParameters.id;
    
    // Parse request body
    const body = JSON.parse(event.body);
    
    // Validate todo
    const validation = validateTodo(body);
    if (!validation.valid) {
      return error(400, 'Invalid todo data', validation.errors);
    }
    
    // Check if todo exists
    const existingTodo = await dynamoDB.get({
      TableName: process.env.TODOS_TABLE,
      Key: { id }
    }).promise();
    
    if (!existingTodo.Item) {
      return error(404, 'Todo not found');
    }
    
    // Update todo
    const updatedTodo = {
      ...existingTodo.Item,
      title: body.title,
      description: body.description || existingTodo.Item.description,
      completed: body.completed !== undefined ? body.completed : existingTodo.Item.completed,
      dueDate: body.dueDate !== undefined ? body.dueDate : existingTodo.Item.dueDate,
      updatedAt: new Date().toISOString()
    };
    
    // Save to DynamoDB
    await dynamoDB.put({
      TableName: process.env.TODOS_TABLE,
      Item: updatedTodo
    }).promise();
    
    return success(updatedTodo);
  } catch (err) {
    console.error('Error updating todo:', err);
    return error(500, 'Could not update todo');
  }
};
      

Delete Function


// src/functions/delete.js
const dynamoDB = require('../libs/dynamodb');
const { success, error } = require('../libs/response');

module.exports.handler = async (event) => {
  try {
    // Get todo ID from path parameters
    const id = event.pathParameters.id;
    
    // Check if todo exists
    const existingTodo = await dynamoDB.get({
      TableName: process.env.TODOS_TABLE,
      Key: { id }
    }).promise();
    
    if (!existingTodo.Item) {
      return error(404, 'Todo not found');
    }
    
    // Delete from DynamoDB
    await dynamoDB.delete({
      TableName: process.env.TODOS_TABLE,
      Key: { id }
    }).promise();
    
    return success({
      message: 'Todo deleted successfully',
      id
    });
  } catch (err) {
    console.error('Error deleting todo:', err);
    return error(500, 'Could not delete todo');
  }
};
      

Local Development and Testing

The Serverless Framework provides tools for local development and testing:

serverless-offline Plugin

This plugin emulates AWS Lambda and API Gateway locally:


# Start the local development server
serverless offline start

# You should see output like:
# Starting Offline: dev/us-east-1.
# Server ready: http://localhost:3000
      

Testing with curl


# Create a todo
curl -X POST \
  http://localhost:3000/todos \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Learn Serverless Framework",
    "description": "Complete the tutorial and build a Todo API",
    "dueDate": "2025-06-01T00:00:00Z"
  }'

# List todos
curl http://localhost:3000/todos

# Get a specific todo
curl http://localhost:3000/todos/{id}

# Update a todo
curl -X PUT \
  http://localhost:3000/todos/{id} \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Learn Serverless Framework",
    "description": "Tutorial completed!",
    "completed": true
  }'

# Delete a todo
curl -X DELETE http://localhost:3000/todos/{id}
        

Unit Testing

We can use Jest for unit testing our Lambda functions:


# Install Jest and testing libraries
npm install --save-dev jest aws-sdk-mock

# Create a jest.config.js file
echo '{
  "testEnvironment": "node"
}' > jest.config.js

# Add test script to package.json
# "scripts": {
#   "test": "jest"
# }
      

// tests/functions/create.test.js
const AWS = require('aws-sdk-mock');
const { v4: uuidv4 } = require('uuid');
const createHandler = require('../../src/functions/create').handler;

// Mock UUID generation to return a predictable value
jest.mock('uuid');
uuidv4.mockImplementation(() => 'test-uuid');

describe('Create Todo Function', () => {
  beforeAll(() => {
    // Mock process.env
    process.env.TODOS_TABLE = 'test-todos-table';
  });
  
  beforeEach(() => {
    // Reset all mocks before each test
    AWS.restore();
  });
  
  test('should create a todo successfully', async () => {
    // Mock DynamoDB put operation
    AWS.mock('DynamoDB.DocumentClient', 'put', (params, callback) => {
      callback(null, { });
    });
    
    // Mock event with valid todo data
    const event = {
      body: JSON.stringify({
        title: 'Test Todo',
        description: 'This is a test todo'
      })
    };
    
    // Call the handler
    const response = await createHandler(event);
    
    // Verify response
    expect(response.statusCode).toBe(200);
    
    const body = JSON.parse(response.body);
    expect(body.id).toBe('test-uuid');
    expect(body.title).toBe('Test Todo');
    expect(body.description).toBe('This is a test todo');
    expect(body.completed).toBe(false);
  });
  
  test('should return error for invalid todo data', async () => {
    // Mock event with invalid todo data (missing title)
    const event = {
      body: JSON.stringify({
        description: 'This is an invalid todo'
      })
    };
    
    // Call the handler
    const response = await createHandler(event);
    
    // Verify response
    expect(response.statusCode).toBe(400);
    
    const body = JSON.parse(response.body);
    expect(body.error).toBe('Invalid todo data');
  });
  
  test('should handle DynamoDB errors', async () => {
    // Mock DynamoDB put operation to return an error
    AWS.mock('DynamoDB.DocumentClient', 'put', (params, callback) => {
      callback(new Error('DynamoDB error'));
    });
    
    // Mock event with valid todo data
    const event = {
      body: JSON.stringify({
        title: 'Test Todo',
        description: 'This is a test todo'
      })
    };
    
    // Call the handler
    const response = await createHandler(event);
    
    // Verify response
    expect(response.statusCode).toBe(500);
    
    const body = JSON.parse(response.body);
    expect(body.error).toBe('Could not create todo');
  });
});
      

Deployment and CI/CD

The Serverless Framework makes deployment straightforward:

Deploying to AWS


# Deploy to the default stage (dev)
serverless deploy

# Deploy to a specific stage
serverless deploy --stage production

# Deploy a single function
serverless deploy function --function create
      

Deployment Process

graph TD A[serverless deploy] --> B[Package Service] B --> C[Upload Artifacts to S3] C --> D[Deploy CloudFormation Stack] D --> E[Create/Update Resources] E --> F[Stack Outputs] G[serverless deploy function] --> H[Package Function] H --> I[Upload Function Code] I --> J[Update Lambda Function]

CI/CD Integration

Integrating the Serverless Framework into CI/CD pipelines:


# GitHub Actions Workflow Example (.github/workflows/deploy.yml)
name: Deploy Serverless API

on:
  push:
    branches:
      - main
      - develop

jobs:
  deploy:
    name: deploy
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [16.x]
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test
      - name: Set deployment stage
        id: set-stage
        run: |
          if [[ ${{ github.ref }} == 'refs/heads/main' ]]; then
            echo "STAGE=prod" >> $GITHUB_ENV
          else
            echo "STAGE=dev" >> $GITHUB_ENV
          fi
      - name: Serverless Deploy
        uses: serverless/github-action@v3
        with:
          args: deploy --stage ${{ env.STAGE }}
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      

CI/CD Best Practices

  • Environment-specific deployments: Use different stages for dev, staging, production
  • Automated testing: Run tests before deployment
  • Infrastructure validation: Use the serverless package command to validate the generated CloudFormation template
  • Rollback strategy: Implement automatic rollback on failure
  • Security scanning: Integrate security scanning tools

Advanced Serverless Features

Let's explore some advanced features of the Serverless Framework:

Custom Domains

Using the serverless-domain-manager plugin to configure custom domains:


# Install the plugin
npm install --save-dev serverless-domain-manager

# Update serverless.yml
plugins:
  - serverless-domain-manager

custom:
  customDomain:
    domainName: api.example.com
    basePath: ''
    stage: ${self:provider.stage}
    createRoute53Record: true
    endpointType: 'regional'
    securityPolicy: tls_1_2
    apiType: rest
    autoDomain: true

# Create the domain
serverless create_domain

# Deploy the service
serverless deploy
      

API Authorization

Adding authorization to your API endpoints:


# serverless.yml - API with Cognito authorizer
resources:
  Resources:
    CognitoUserPool:
      Type: AWS::Cognito::UserPool
      Properties:
        UserPoolName: ${self:service}-${self:provider.stage}-user-pool
        AutoVerifiedAttributes:
          - email
        UsernameAttributes:
          - email

    CognitoUserPoolClient:
      Type: AWS::Cognito::UserPoolClient
      Properties:
        ClientName: ${self:service}-${self:provider.stage}-user-pool-client
        UserPoolId:
          Ref: CognitoUserPool
        ExplicitAuthFlows:
          - ALLOW_USER_SRP_AUTH
          - ALLOW_REFRESH_TOKEN_AUTH
        GenerateSecret: false

functions:
  create:
    handler: src/functions/create.handler
    events:
      - http:
          path: todos
          method: post
          cors: true
          authorizer:
            type: COGNITO_USER_POOLS
            authorizerId:
              Ref: ApiGatewayAuthorizer

  # Other functions...

  ApiGatewayAuthorizer:
    Type: AWS::ApiGateway::Authorizer
    Properties:
      Name: cognito-authorizer
      IdentitySource: method.request.header.Authorization
      RestApiId:
        Ref: ApiGatewayRestApi
      Type: COGNITO_USER_POOLS
      ProviderARNs:
        - !GetAtt CognitoUserPool.Arn
      

Environment Secrets Management

Using AWS Parameter Store for secure parameter management:


# serverless.yml - Using SSM Parameters
provider:
  name: aws
  environment:
    # Reference a parameter directly
    API_KEY: ${ssm:/my-app/${self:provider.stage}/api-key}
    
    # Reference a secure string parameter (decrypted)
    DATABASE_PASSWORD: ${ssm:/my-app/${self:provider.stage}/db-password~true}
    
    # Reference with a default value
    API_TIMEOUT: ${ssm:/my-app/${self:provider.stage}/api-timeout, '30'}

# Creating parameters with AWS CLI
aws ssm put-parameter \
  --name "/my-app/dev/api-key" \
  --value "my-api-key" \
  --type String

aws ssm put-parameter \
  --name "/my-app/dev/db-password" \
  --value "my-secure-password" \
  --type SecureString
      

Handling Scheduled Tasks

Creating Lambda functions triggered by scheduled events:


# serverless.yml - Scheduled events
functions:
  dailyCleanup:
    handler: src/functions/cleanup.handler
    events:
      - schedule:
          rate: cron(0 1 * * ? *)  # Run at 1 AM UTC every day
          enabled: true
          input:
            maxAge: 30  # Delete todos older than 30 days
  
  remindDueTodos:
    handler: src/functions/remind.handler
    events:
      - schedule:
          rate: rate(1 hour)  # Run every hour
          enabled: true

# Scheduled function implementation
// src/functions/cleanup.handler
module.exports.handler = async (event) => {
  console.log('Cleanup event:', event);
  
  // Get the max age from the event input or use default
  const maxAge = event.maxAge || 30;
  
  // Calculate the cutoff date
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - maxAge);
  
  // Find and delete old todos
  // Implementation...
};
      

Performance Optimization and Monitoring

Optimizing and monitoring your serverless applications is crucial for production deployments:

Cold Start Optimization


# serverless.yml - Lambda provisioned concurrency
functions:
  create:
    handler: src/functions/create.handler
    events:
      - http:
          path: todos
          method: post
          cors: true
    # Keep 5 instances warm for critical functions
    provisionedConcurrency: 5
      

Cold Start Reduction Techniques

  • Choose the right runtime: Node.js and Python have faster startup times
  • Minimize deployment package size: Include only necessary dependencies
  • Separate critical and non-critical functions: Apply provisioned concurrency only where needed
  • Implement connection reuse: Initialize clients outside the handler
  • Implement periodic warming: Use CloudWatch Events to ping critical functions

Monitoring and Debugging

Using the serverless-plugin-aws-alerts plugin for CloudWatch Alarms:


# Install the plugin
npm install --save-dev serverless-plugin-aws-alerts

# Update serverless.yml
plugins:
  - serverless-plugin-aws-alerts

custom:
  alerts:
    stages:
      - production
    topics:
      alarm:
        topic: ${self:service}-${self:provider.stage}-alerts
        notifications:
          - protocol: email
            endpoint: alerts@example.com
    alarms:
      - functionErrors
      - functionThrottles
      - functionInvocations
      - functionDuration
      
functions:
  create:
    handler: src/functions/create.handler
    events:
      - http:
          path: todos
          method: post
    alarms:
      - name: functionErrors
        threshold: 1
        evaluationPeriods: 1
        period: 60
      - name: functionDuration
        threshold: 1000
        evaluationPeriods: 1
        period: 60
      

X-Ray Integration

Enabling AWS X-Ray for distributed tracing:


# serverless.yml - X-Ray tracing
provider:
  name: aws
  tracing:
    apiGateway: true
    lambda: true

# Lambda function with X-Ray tracing
// src/functions/create.js with X-Ray
const AWSXRay = require('aws-xray-sdk');
const AWS = AWSXRay.captureAWS(require('aws-sdk'));

// The rest of your function code...
      
sequenceDiagram participant Client participant API Gateway participant Lambda participant DynamoDB Client->>API Gateway: HTTP Request note over API Gateway: X-Ray Segment API Gateway->>Lambda: Invoke Function note over Lambda: X-Ray Subsegment Lambda->>DynamoDB: Query Database note over DynamoDB: X-Ray Subsegment DynamoDB-->>Lambda: Response Lambda-->>API Gateway: Response API Gateway-->>Client: HTTP Response

Multi-Region and Disaster Recovery

Building resilient serverless applications with multi-region deployment:

Multi-Region Strategy


# Deploy to multiple regions
serverless deploy --region us-east-1
serverless deploy --region eu-west-1
serverless deploy --region ap-southeast-1

# Create a package for multi-region deployment
serverless package --region us-east-1 --package us-east-1-package
serverless package --region eu-west-1 --package eu-west-1-package
serverless package --region ap-southeast-1 --package ap-southeast-1-package

# Deploy packages
serverless deploy --package us-east-1-package --region us-east-1
serverless deploy --package eu-west-1-package --region eu-west-1
serverless deploy --package ap-southeast-1-package --region ap-southeast-1
      

Global DynamoDB Replication


# serverless.yml - Global DynamoDB table
resources:
  Resources:
    TodosTable:
      Type: AWS::DynamoDB::GlobalTable
      Properties:
        TableName: ${self:provider.environment.TODOS_TABLE}
        BillingMode: PAY_PER_REQUEST
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        Replicas:
          - Region: us-east-1
          - Region: eu-west-1
          - Region: ap-southeast-1
        

Route 53 Failover Routing

Using Route 53 for failover between regions:


# Setup AWS CLI health checks and failover records
aws route53 create-health-check \
  --caller-reference $(date +%s) \
  --health-check-config '{
    "FullyQualifiedDomainName": "api-us-east-1.example.com",
    "Port": 443,
    "Type": "HTTPS",
    "ResourcePath": "/health",
    "RequestInterval": 30,
    "FailureThreshold": 3
  }'

# Note the returned HealthCheckId

aws route53 change-resource-record-sets \
  --hosted-zone-id YOUR_HOSTED_ZONE_ID \
  --change-batch '{
    "Changes": [
      {
        "Action": "CREATE",
        "ResourceRecordSet": {
          "Name": "api.example.com",
          "Type": "A",
          "SetIdentifier": "Primary",
          "Failover": "PRIMARY",
          "AliasTarget": {
            "HostedZoneId": "Z2FDTNDATAQYW2",
            "DNSName": "d-1234567890.execute-api.us-east-1.amazonaws.com",
            "EvaluateTargetHealth": true
          },
          "HealthCheckId": "YOUR_HEALTH_CHECK_ID"
        }
      },
      {
        "Action": "CREATE",
        "ResourceRecordSet": {
          "Name": "api.example.com",
          "Type": "A",
          "SetIdentifier": "Secondary",
          "Failover": "SECONDARY",
          "AliasTarget": {
            "HostedZoneId": "Z2FDTNDATAQYW2",
            "DNSName": "d-0987654321.execute-api.eu-west-1.amazonaws.com",
            "EvaluateTargetHealth": true
          }
        }
      }
    ]
  }'
      

Cost Optimization

Strategies to optimize costs for serverless applications:

Right-Sizing Lambda Functions

Memory vs. Performance Analysis

Memory (MB) Avg. Duration (ms) Cost per 1M Invocations Notes
128 350 $0.73 Lowest memory, slowest performance
256 180 $0.75 Better cost-performance ratio
512 120 $1.00 Good balance for most functions
1024 80 $1.35 Higher cost but better user experience
2048 60 $2.00 Diminishing returns on performance

API Gateway Caching


# serverless.yml - API Gateway caching
provider:
  name: aws
  apiGateway:
    apiKeys:
      - ${self:service}-${self:provider.stage}-key
    usagePlan:
      quota:
        limit: 5000
        period: DAY
      throttle:
        burstLimit: 200
        rateLimit: 100
    # Enable caching
    cacheEnabled: true
    cacheSize: '0.5'  # Cache size in GB
      

Lambda Reservation for Predictable Workloads


# Using Lambda provisioned concurrency for consistent pricing
functions:
  highTrafficFunction:
    handler: src/functions/highTraffic.handler
    provisionedConcurrency: 10
      

Cost Optimization Best Practices

  • Set appropriate timeouts: Avoid unnecessary execution time
  • Optimize third-party dependencies: Use lightweight alternatives or bundle only what you need
  • Share resources across functions: Use singleton patterns for database connections
  • Implement caching: Cache external API responses and database queries
  • Set up cost alarms: Get notified when costs exceed thresholds

Practical Exercise

Enhancing the Todo API

In this exercise, you'll enhance the Todo API with additional features:

  1. Add user authentication using Amazon Cognito
  2. Implement due date notifications using SQS and SNS
  3. Add a search endpoint using DynamoDB query operations
  4. Implement basic analytics for todo completion rates
  5. Add an export function to generate a CSV of todos

Task 1: User Authentication

Update the serverless.yml to include Cognito:


resources:
  Resources:
    CognitoUserPool:
      Type: AWS::Cognito::UserPool
      Properties:
        UserPoolName: ${self:service}-${self:provider.stage}-user-pool
        AutoVerifiedAttributes:
          - email
        
    CognitoUserPoolClient:
      Type: AWS::Cognito::UserPoolClient
      Properties:
        ClientName: ${self:service}-${self:provider.stage}-user-pool-client
        UserPoolId:
          Ref: CognitoUserPool
        GenerateSecret: false
        
    ApiGatewayAuthorizer:
      Type: AWS::ApiGateway::Authorizer
      Properties:
        Name: cognito-authorizer
        RestApiId:
          Ref: ApiGatewayRestApi
        Type: COGNITO_USER_POOLS
        IdentitySource: method.request.header.Authorization
        ProviderARNs:
          - !GetAtt CognitoUserPool.Arn
        

Update the functions to use the authorizer:


functions:
  create:
    handler: src/functions/create.handler
    events:
      - http:
          path: todos
          method: post
          cors: true
          authorizer:
            type: COGNITO_USER_POOLS
            authorizerId:
              Ref: ApiGatewayAuthorizer
        

Update the create function to store user ID:


// Extract user ID from the Cognito JWT
const userId = event.requestContext.authorizer.claims.sub;

// Create todo with user ownership
const todo = {
  id: uuidv4(),
  userId: userId,  // Store the Cognito user ID
  title: body.title,
  // ... other fields
};
        

Conclusion and Key Takeaways

By leveraging the Serverless Framework, you can focus on writing business logic while the framework handles the infrastructure, enabling rapid development and deployment of scalable, cost-effective serverless applications.

Additional Resources