Skip to content
Ayhan Sipahi Ayhan Sipahi

Migrating from Serverless Framework to AWS CDK: Part 6 - Migration Strategies and Best Practices

Plan the Serverless Framework to AWS CDK cut-over: blue-green deployment, rollback procedures, infrastructure testing, and performance tuning.

A CDK migration’s final phase is not about the correctness of the generated infrastructure. It is about operational readiness during cut-over: a plan for taking traffic, a plan for reverting if the migration degrades production, and a plan for reconciling drift that appears under live load. For a migration covering dozens of Lambda functions and multiple DynamoDB tables, the cut-over plan is larger than the migration code itself.

Blue-green deployment with an alarm-driven rollback trigger is the default worth reaching for. It costs a duplicate stack for the length of the cut-over, and it buys a switch that reverses in seconds without waiting on a CloudFormation rollback. The rest of the work is unglamorous: health checks that can decide whether a stack is safe, tests that exercise service limits rather than business logic, and a rollback path fired at least once before production traffic depends on it.

Series Navigation:

Choosing a Cut-Over Pattern

Three patterns dominate serverless migrations, and each one fails in a different place.

Approach #1: Big Bang Migration

Implementation: Deploy all CDK infrastructure inside a single maintenance window.

Where it breaks: CloudFormation deployment time for a large stack is hard to predict, so the window is the first thing to overrun. A failed API Gateway stage deployment or a partial DynamoDB import leaves you rolling back while the service is already down, and the rollback path is as long as the deployment path.

Verdict: Workable for a demo application or an internal tool with a genuine maintenance window. Not workable for a production system with cross-service dependencies.

Approach #2: Strangler Fig Pattern

Implementation: Migrate functions in batches, splitting traffic between the old and new APIs until nothing is left on the old stack.

Where it breaks: Both systems have to share authentication and session state for the whole transition. Cross-stack calls add a network hop the single-stack version did not have. The transition is measured in weeks, and each of those weeks means running two deployment pipelines side by side.

Verdict: The right choice when the application is too large to move at once, provided the dependency map is drawn first and the auth layer is shared rather than duplicated.

Approach #3: Blue-Green Deployment

Implementation: Stand up the full CDK stack in parallel, verify it against production-shaped traffic, then switch DNS or the API Gateway stage.

Where it breaks: It costs a second copy of every stateless resource, and it says nothing about state. Shared tables have to stay readable by both stacks, which constrains schema changes during the cut-over.

Verdict: The default. Rollback is a second DNS or stage change rather than a CloudFormation operation, and that is what makes the switch reversible under pressure.

Production-Ready Migration Strategies

1. Blue-Green Deployment Stack

The blue-green stack carries its own health check, its own alarms, and the function that performs the switch:

// lib/stacks/production-blue-green-stack.ts
import { Stack, StackProps, Tags, CfnOutput, Duration, TreatMissingData } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { RestApi, MethodLoggingLevel, LambdaIntegration } from 'aws-cdk-lib/aws-apigateway';
import { Alarm, ComparisonOperator } from 'aws-cdk-lib/aws-cloudwatch';
import { LambdaAction } from 'aws-cdk-lib/aws-cloudwatch-actions';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

export interface BlueGreenStackProps extends StackProps {
  stage: string;
  environment: 'blue' | 'green';
  monitoringConfig: {
    errorThreshold: number;
    latencyThreshold: number;
    rollbackFunction: NodejsFunction;
  };
}

export class ProductionBlueGreenStack extends Stack {
  public readonly api: RestApi;
  public readonly healthCheckEndpoint: string;
  public readonly switchOverFunction: NodejsFunction;

  constructor(scope: Construct, id: string, props: BlueGreenStackProps) {
    super(scope, id, props);

    // Create the complete CDK infrastructure
    this.api = new RestApi(this, 'Api', {
      restApiName: `my-service-${props.stage}-${props.environment}`,
      description: `Production API - ${props.environment.toUpperCase()} environment`,
      deployOptions: {
        stageName: props.environment,
        // Aggressive throttling during migration for safety
        throttlingRateLimit: props.environment === 'green' ? 500 : 1000,
        throttlingBurstLimit: props.environment === 'green' ? 1000 : 2000,
        // Enhanced monitoring during migration
        metricsEnabled: true,
        loggingLevel: MethodLoggingLevel.INFO,
        dataTraceEnabled: true,
        tracingEnabled: true,
      },
    });

    // Deploy all Lambda functions and wire the routes.
    // createLambdaFunctions and setupApiRoutes are private helpers on this
    // class, omitted here for brevity.
    const functions = this.createLambdaFunctions(props);
    this.setupApiRoutes(functions);

    // Create health check endpoint for monitoring
    const healthCheckFn = new NodejsFunction(this, 'HealthCheckFunction', {
      entry: 'src/health/health-check.ts',
      handler: 'handler',
      environment: {
        ENVIRONMENT: props.environment,
        API_VERSION: process.env.API_VERSION || 'v1',
        DEPLOYMENT_TIME: new Date().toISOString(),
      },
    });

    const healthResource = this.api.root.addResource('health');
    healthResource.addMethod('GET', new LambdaIntegration(healthCheckFn));

    this.healthCheckEndpoint = `${this.api.url}health`;

    // Create production monitoring alarms
    this.createProductionAlarms(props);

    // Traffic switching function
    this.switchOverFunction = this.createSwitchOverFunction(props);

    // Tag all resources for identification
    Tags.of(this).add('Environment', props.environment);
    Tags.of(this).add('MigrationPhase', 'cdk-migration');
    Tags.of(this).add('DeploymentTime', new Date().toISOString());
    Tags.of(this).add('Version', process.env.COMMIT_SHA || 'latest');

    // Export critical information
    new CfnOutput(this, 'ApiEndpoint', {
      value: this.api.url,
      exportName: `${this.stackName}-api-endpoint`,
      description: `API endpoint for ${props.environment} environment`,
    });

    new CfnOutput(this, 'HealthCheckUrl', {
      value: this.healthCheckEndpoint,
      exportName: `${this.stackName}-health-check`,
      description: 'Health check endpoint for monitoring',
    });
  }

  private createProductionAlarms(props: BlueGreenStackProps) {
    // Error rate alarm - triggers rollback
    const errorAlarm = new Alarm(this, 'HighErrorRateAlarm', {
      metric: this.api.metricServerError({
        period: Duration.minutes(2),
        statistic: 'Sum',
      }),
      threshold: props.monitoringConfig.errorThreshold,
      evaluationPeriods: 2,
      comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
      alarmDescription: `High error rate detected in ${props.environment} environment`,
      treatMissingData: TreatMissingData.NOT_BREACHING,
    });

    // Latency alarm - triggers investigation
    const latencyAlarm = new Alarm(this, 'HighLatencyAlarm', {
      metric: this.api.metricLatency({
        period: Duration.minutes(5),
        statistic: 'Average',
      }),
      threshold: props.monitoringConfig.latencyThreshold,
      evaluationPeriods: 3,
      alarmDescription: `High latency detected in ${props.environment} environment`,
    });

    // Connect alarms to automated rollback
    errorAlarm.addAlarmAction(
      new LambdaAction(props.monitoringConfig.rollbackFunction)
    );

    // Export alarm ARNs for external monitoring
    new CfnOutput(this, 'ErrorAlarmArn', {
      value: errorAlarm.alarmArn,
      exportName: `${this.stackName}-error-alarm`,
    });
  }

  private createSwitchOverFunction(props: BlueGreenStackProps) {
    return new NodejsFunction(this, 'TrafficSwitchFunction', {
      entry: 'src/deployment/traffic-switch.ts',
      handler: 'handler',
      timeout: Duration.minutes(5),
      environment: {
        CURRENT_ENVIRONMENT: props.environment,
        TARGET_ENVIRONMENT: props.environment === 'blue' ? 'green' : 'blue',
        HOSTED_ZONE_ID: process.env.HOSTED_ZONE_ID!,
        DOMAIN_NAME: process.env.API_DOMAIN!,
        SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK_URL!,
      },
      initialPolicy: [
        new PolicyStatement({
          actions: ['route53:ChangeResourceRecordSets', 'route53:GetChange'],
          resources: ['*'],
        }),
      ],
    });
  }
}

// src/health/health-check.ts - Comprehensive health validation
import { APIGatewayProxyHandler } from 'aws-lambda';
import { DynamoDBClient, DescribeTableCommand } from '@aws-sdk/client-dynamodb';

const dynamoDB = new DynamoDBClient({});

export const handler: APIGatewayProxyHandler = async () => {
  const startTime = Date.now();
  const checks = [];

  try {
    // Database connectivity check
    const tableCheck = await dynamoDB.send(new DescribeTableCommand({
      TableName: process.env.USERS_TABLE!,
    }));
    checks.push({
      name: 'database',
      status: tableCheck.Table?.TableStatus === 'ACTIVE' ? 'healthy' : 'unhealthy',
      responseTime: Date.now() - startTime,
    });

    // Memory usage check
    const memoryUsed = process.memoryUsage();
    checks.push({
      name: 'memory',
      status: memoryUsed.heapUsed < 100 * 1024 * 1024 ? 'healthy' : 'warning', // 100MB threshold
      details: {
        heapUsed: Math.round(memoryUsed.heapUsed / 1024 / 1024) + 'MB',
        heapTotal: Math.round(memoryUsed.heapTotal / 1024 / 1024) + 'MB',
      },
    });

    const overallStatus = checks.every(check => check.status === 'healthy') ? 'healthy' : 'degraded';

    return {
      statusCode: overallStatus === 'healthy' ? 200 : 503,
      headers: {
        'Content-Type': 'application/json',
        'Cache-Control': 'no-cache',
      },
      body: JSON.stringify({
        status: overallStatus,
        environment: process.env.ENVIRONMENT,
        version: process.env.API_VERSION,
        deploymentTime: process.env.DEPLOYMENT_TIME,
        timestamp: new Date().toISOString(),
        responseTime: Date.now() - startTime,
        checks,
      }),
    };
  } catch (error) {
    return {
      statusCode: 503,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        status: 'unhealthy',
        error: (error as Error).message,
        timestamp: new Date().toISOString(),
      }),
    };
  }
};

2. Strangler Fig Pattern

When to use: Large applications requiring zero-downtime migration.

// lib/constructs/migration/traffic-splitter.ts
import { Construct } from 'constructs';
import { RestApi, Deployment, Stage } from 'aws-cdk-lib/aws-apigateway';
import { Alarm } from 'aws-cdk-lib/aws-cloudwatch';

export class TrafficSplitter extends Construct {
  constructor(scope: Construct, id: string, props: {
    legacyApiId: string;
    newApi: RestApi;
    trafficPercentageToNew: number;
  }) {
    super(scope, id);

    // Create canary deployment
    const deployment = new Deployment(this, 'CanaryDeployment', {
      api: props.newApi,
      description: `Canary deployment ${new Date().toISOString()}`,
    });

    const stage = new Stage(this, 'CanaryStage', {
      deployment,
      stageName: 'canary',
      canarySettings: {
        percentTraffic: props.trafficPercentageToNew,
        useStageCache: false,
      },
    });

    // CloudWatch alarms for monitoring.
    // Scope the metric to the canary stage, not the whole API.
    new Alarm(this, 'CanaryErrorAlarm', {
      metric: stage.metricServerError(),
      threshold: 5,
      evaluationPeriods: 2,
    });
  }
}

3. DNS-Level Traffic Switching

When to use: When the switch has to happen outside CloudFormation, so that a rollback is one record change rather than a stack update.

// lib/stacks/blue-green-stack.ts
export class BlueGreenStack extends Stack {
  constructor(scope: Construct, id: string, props: {
    stage: string;
    version: 'blue' | 'green';
  }) {
    super(scope, id);

    const api = new RestApi(this, 'Api', {
      restApiName: `my-service-${props.stage}-${props.version}`,
      deployOptions: {
        stageName: props.version,
      },
    });

    // Tag resources for easy identification
    Tags.of(this).add('Deployment', props.version);
    Tags.of(this).add('Version', process.env.COMMIT_SHA || 'latest');

    // Export API endpoint
    new CfnOutput(this, 'ApiEndpoint', {
      value: api.url,
      exportName: `${this.stackName}-endpoint`,
    });
  }
}

// deployment-scripts/blue-green-switch.ts
import { Route53Client, ChangeResourceRecordSetsCommand } from '@aws-sdk/client-route53';

export async function switchTraffic(targetVersion: 'blue' | 'green') {
  const route53 = new Route53Client({});

  await route53.send(new ChangeResourceRecordSetsCommand({
    HostedZoneId: process.env.HOSTED_ZONE_ID,
    ChangeBatch: {
      Changes: [{
        Action: 'UPSERT',
        ResourceRecordSet: {
          Name: 'api.example.com',
          Type: 'CNAME',
          TTL: 60,
          ResourceRecords: [{
            Value: `api-${targetVersion}.execute-api.region.amazonaws.com`,
          }],
        },
      }],
    },
  }));
}

Effective Testing Strategy for Production

A suite that passes every unit and integration test can still miss the failures that surface at cut-over. Those failures are usually about limits, not logic.

What Standard Tests Miss

What the usual suite covers: Unit tests, integration tests, and a load test, all green.

What it leaves uncovered:

  • CloudFormation template size passing the per-stack limit as constructs accumulate
  • API Gateway integration timeouts conflicting with Lambda timeout settings
  • DynamoDB throttling under peak traffic
  • JWT validation cost growing with request volume

Production-Oriented Testing Strategy

Assertions against the synthesized template catch limit and configuration problems before a deploy:

// test/infrastructure/api-stack.test.ts
import { Template, Match } from 'aws-cdk-lib/assertions';
import { App } from 'aws-cdk-lib';
import { ApiStack } from '../../lib/stacks/api-stack';

describe('ApiStack', () => {
  let template: Template;

  beforeAll(() => {
    const app = new App();
    const stack = new ApiStack(app, 'TestStack', {
      config: testConfig,
    });
    template = Template.fromStack(stack);
  });

  test('Lambda functions have correct runtime', () => {
    template.allResourcesProperties('AWS::Lambda::Function', {
      Runtime: 'nodejs20.x',
    });
  });

  test('API Gateway has throttling enabled', () => {
    template.hasResourceProperties('AWS::ApiGateway::Stage', {
      ThrottlingRateLimit: Match.anyValue(),
      ThrottlingBurstLimit: Match.anyValue(),
    });
  });

  test('DynamoDB tables have point-in-time recovery', () => {
    template.allResourcesProperties('AWS::DynamoDB::Table', {
      PointInTimeRecoverySpecification: {
        PointInTimeRecoveryEnabled: true,
      },
    });
  });
});

Integration Testing

// test/integration/api.test.ts
import { CloudFormationClient, ListExportsCommand } from '@aws-sdk/client-cloudformation';
import axios from 'axios';

describe('API Integration Tests', () => {
  let apiEndpoint: string;
  let authToken: string;

  beforeAll(async () => {
    // Get deployed API endpoint
    const cf = new CloudFormationClient({});
    const exports = await cf.send(new ListExportsCommand({}));
    apiEndpoint = exports.Exports?.find(
      e => e.Name === 'ApiStack-endpoint'
    )?.Value!;

    // Get auth token
    authToken = await getTestAuthToken();
  });

  test('Health check endpoint', async () => {
    const response = await axios.get(`${apiEndpoint}/health`);
    expect(response.status).toBe(200);
    expect(response.data).toEqual({ status: 'healthy' });
  });

  test('Create and retrieve user', async () => {
    // Create user
    const createResponse = await axios.post(
      `${apiEndpoint}/users`,
      { name: 'Test User', email: '[email protected]' },
      { headers: { Authorization: `Bearer ${authToken}` } }
    );
    expect(createResponse.status).toBe(201);

    // Retrieve user
    const userId = createResponse.data.userId;
    const getResponse = await axios.get(
      `${apiEndpoint}/users/${userId}`,
      { headers: { Authorization: `Bearer ${authToken}` } }
    );
    expect(getResponse.data.name).toBe('Test User');
  });
});

Load Testing

// test/load/k6-script.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorRate = new Rate('errors');

export const options = {
  stages: [
    { duration: '2m', target: 100 },  // Ramp up
    { duration: '5m', target: 100 },  // Sustain
    { duration: '2m', target: 200 },  // Spike
    { duration: '5m', target: 200 },  // Sustain spike
    { duration: '2m', target: 0 },  // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95% of requests under 500ms
    errors: ['rate<0.01'],  // Error rate under 1%
  },
};

export default function() {
  const response = http.get(`${__ENV.API_URL}/users`);

  const success = check(response, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
  });

  errorRate.add(!success);
  sleep(1);
}

Rollback Procedures

Automated Rollback

// lib/constructs/deployment/safe-deployment.ts
import { Construct } from 'constructs';
import { Alarm, TreatMissingData } from 'aws-cdk-lib/aws-cloudwatch';
import { RestApi } from 'aws-cdk-lib/aws-apigateway';
import { IFunction } from 'aws-cdk-lib/aws-lambda';
import { Topic } from 'aws-cdk-lib/aws-sns';
import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions';
import { LambdaSubscription } from 'aws-cdk-lib/aws-sns-subscriptions';
import { CfnOutput } from 'aws-cdk-lib';

export class SafeDeployment extends Construct {
  constructor(scope: Construct, id: string, props: {
    api: RestApi;
    alarmThreshold: number;
    rollbackFunction: IFunction;
  }) {
    super(scope, id);

    // Create CloudWatch alarm
    const alarm = new Alarm(this, 'DeploymentAlarm', {
      metric: props.api.metricServerError(),
      threshold: props.alarmThreshold,
      evaluationPeriods: 2,
      treatMissingData: TreatMissingData.NOT_BREACHING,
    });

    // SNS topic for notifications
    const topic = new Topic(this, 'RollbackTopic');
    alarm.addAlarmAction(new SnsAction(topic));

    // Lambda for automated rollback
    topic.addSubscription(
      new LambdaSubscription(props.rollbackFunction)
    );

    // Manual rollback command
    new CfnOutput(this, 'RollbackCommand', {
      value: `aws lambda invoke --function-name ${props.rollbackFunction.functionName} --payload '{"action":"rollback"}' response.json`,
    });
  }
}

// src/deployment/rollback-handler.ts
import { SNSEvent } from 'aws-lambda';
import { CodeDeployClient, StopDeploymentCommand } from '@aws-sdk/client-codedeploy';

// Helper functions
async function switchTraffic(version: string): Promise<void> {
  // Implementation for traffic switching
}

async function notifySlack(message: { channel: string; message: string }): Promise<void> {
  // Implementation for Slack notification
}

export const handler = async (event: SNSEvent) => {
  console.log('Initiating rollback:', JSON.stringify(event, null, 2));

  const codedeploy = new CodeDeployClient({});

  // Stop current deployment
  await codedeploy.send(new StopDeploymentCommand({
    deploymentId: process.env.CURRENT_DEPLOYMENT_ID,
    autoRollbackEnabled: true,
  }));

  // Revert traffic to previous version
  await switchTraffic('blue'); // Assuming green was failing

  // Notify team
  await notifySlack({
    channel: '#alerts',
    message: 'Automatic rollback initiated due to high error rate',
  });
};

Performance Optimization

Lambda Performance Tuning

// lib/constructs/performance/optimized-function.ts
import { Construct } from 'constructs';
import { Duration, Stack } from 'aws-cdk-lib';
import { NodejsFunction, NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Architecture, CfnFunction, CfnAlias } from 'aws-cdk-lib/aws-lambda';

// Base function interface
interface ServerlessFunctionProps extends NodejsFunctionProps {
  config: {
    stage: string;
  };
}

export class OptimizedFunction extends NodejsFunction {
  constructor(scope: Construct, id: string, props: ServerlessFunctionProps & {
    enableProvisioning?: boolean;
    enableSnapStart?: boolean;
  }) {
    super(scope, id, {
      ...props,
      memorySize: props.memorySize || 1024,
      architecture: Architecture.ARM_64, // Better price/performance
      environment: {
        ...props.environment,
        NODE_OPTIONS: '--enable-source-maps --max-old-space-size=896',
        AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
      },
    });

    // Provisioned concurrency for critical functions
    if (props.enableProvisioning && props.config.stage === 'prod') {
      const version = this.currentVersion;

      new CfnAlias(this, 'ProvisionedAlias', {
        functionName: this.functionName,
        functionVersion: version.version,
        name: 'provisioned',
        provisionedConcurrencyConfig: {
          provisionedConcurrentExecutions: 5,
        },
      });
    }

    // SnapStart for Java functions
    if (props.enableSnapStart) {
      const cfnFunction = this.node.defaultChild as CfnFunction;
      cfnFunction.snapStart = {
        applyOn: 'PublishedVersions',
      };
    }
  }
}

API Gateway Optimization

// lib/constructs/performance/cached-api.ts
import { Construct } from 'constructs';
import { Duration } from 'aws-cdk-lib';
import { RestApi, RestApiProps } from 'aws-cdk-lib/aws-apigateway';

export class CachedApi extends RestApi {
  constructor(scope: Construct, id: string, props: RestApiProps & {
    cacheConfig?: {
      ttlMinutes: number;
      encrypted: boolean;
      clusterSize: string;
    };
  }) {
    super(scope, id, {
      ...props,
      deployOptions: {
        ...props.deployOptions,
        cachingEnabled: true,
        cacheClusterEnabled: true,
        cacheClusterSize: props.cacheConfig?.clusterSize || '0.5',
        cacheDataEncrypted: props.cacheConfig?.encrypted ?? true,
        cacheTtl: Duration.minutes(props.cacheConfig?.ttlMinutes || 5),
        methodOptions: {
          '/*/*': {
            cachingEnabled: true,
            cacheKeyParameters: [
              'method.request.path.proxy',
              'method.request.querystring.page',
            ],
          },
        },
      },
    });
  }
}

Monitoring and Observability

Comprehensive Monitoring Stack

// lib/stacks/monitoring-stack.ts
import { Stack, StackProps, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Dashboard, GraphWidget, Alarm } from 'aws-cdk-lib/aws-cloudwatch';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

// Interface for ApiStack
interface ApiStack extends Stack {
  api: any; // RestApi from aws-apigateway
  functions: NodejsFunction[];
}

export class MonitoringStack extends Stack {
  constructor(scope: Construct, id: string, props: {
    apiStack: ApiStack;
    stage: string;
  }) {
    super(scope, id);

    // Create dashboard
    const dashboard = new Dashboard(this, 'ServiceDashboard', {
      dashboardName: `my-service-${props.stage}`,
    });

    // API metrics
    dashboard.addWidgets(
      new GraphWidget({
        title: 'API Requests',
        left: [props.apiStack.api.metricCount()],
        right: [props.apiStack.api.metricLatency()],
      }),
      new GraphWidget({
        title: 'API Errors',
        left: [
          props.apiStack.api.metric4XXError(),
          props.apiStack.api.metric5XXError(),
        ],
      })
    );

    // Lambda metrics
    const lambdaWidgets = props.apiStack.functions.map(fn =>
      new GraphWidget({
        title: `${fn.functionName} Performance`,
        left: [fn.metricInvocations()],
        right: [fn.metricDuration()],
      })
    );
    dashboard.addWidgets(...lambdaWidgets);

    // Alarms
    this.createAlarms(props.apiStack);
  }

  private createAlarms(apiStack: ApiStack) {
    // API Gateway alarms
    new Alarm(this, 'HighErrorRate', {
      metric: apiStack.api.metric5XXError({
        period: Duration.minutes(5),
        statistic: 'Sum',
      }),
      threshold: 10,
      evaluationPeriods: 2,
    });

    // Lambda alarms
    apiStack.functions.forEach(fn => {
      new Alarm(this, `${fn.node.id}Throttles`, {
        metric: fn.metricThrottles(),
        threshold: 5,
        evaluationPeriods: 2,
      });

      new Alarm(this, `${fn.node.id}Errors`, {
        metric: fn.metricErrors(),
        threshold: 10,
        evaluationPeriods: 2,
      });
    });
  }
}

Distributed Tracing

// lib/constructs/observability/tracing.ts
import { Construct } from 'constructs';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Tracing } from 'aws-cdk-lib/aws-lambda';
import { OptimizedFunction } from '../performance/optimized-function';

// Base function interface
interface ServerlessFunctionProps {
  environment?: Record<string, string>;
}

export class TracedFunction extends OptimizedFunction {
  constructor(scope: Construct, id: string, props: ServerlessFunctionProps) {
    super(scope, id, {
      ...props,
      tracing: Tracing.ACTIVE,
      environment: {
        ...props.environment,
        // _X_AMZN_TRACE_ID is reserved by Lambda and injected per invocation;
        // setting it here is rejected at deploy time.
        AWS_XRAY_CONTEXT_MISSING: 'LOG_ERROR',
        AWS_XRAY_LOG_LEVEL: 'error',
      },
    });

    // Add X-Ray permissions
    this.addToRolePolicy(new PolicyStatement({
      actions: [
        'xray:PutTraceSegments',
        'xray:PutTelemetryRecords',
      ],
      resources: ['*'],
    }));
  }
}

// src/libs/tracing.ts
import { Tracer } from '@aws-lambda-powertools/tracer';

const tracer = new Tracer({
  serviceName: process.env.SERVICE_NAME || 'my-service',
});

export function traceMethod(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;

  descriptor.value = async function(...args: any[]) {
    const segment = tracer.getSegment();
    const subsegment = segment?.addNewSubsegment(propertyKey);

    try {
      const result = await originalMethod.apply(this, args);
      subsegment?.close();
      return result;
    } catch (error) {
      subsegment?.addError(error as Error);
      subsegment?.close();
      throw error;
    }
  };

  return descriptor;
}

Migration Checklist

Pre-Migration

  • Inventory current resources

    • Document all Lambda functions
    • List API Gateway endpoints
    • Map DynamoDB tables and indexes
    • Identify custom resources
    • Note all environment variables and secrets
  • Assess dependencies

    • Review Serverless plugins in use
    • Check for custom CloudFormation resources
    • Identify external service integrations
    • Document IAM roles and policies
  • Plan migration strategy

    • Choose migration pattern (big bang, strangler fig, blue-green)
    • Define rollback procedures
    • Set success criteria
    • Schedule maintenance windows if needed

During Migration

  • Set up CDK project

    • Initialize repository with CDK
    • Configure environments
    • Set up CI/CD pipelines
    • Implement infrastructure tests
  • Migrate components

    • Start with stateless resources
    • Import existing stateful resources
    • Migrate Lambda functions
    • Set up API Gateway
    • Configure authentication
  • Testing

    • Run unit tests
    • Execute integration tests
    • Perform load testing
    • Validate security configurations

Post-Migration

  • Monitor and optimize

    • Set up comprehensive monitoring
    • Configure alerts
    • Review performance metrics
    • Optimize cold starts
  • Documentation

    • Update runbooks
    • Document new deployment procedures
    • Create architecture diagrams
    • Train team on CDK
  • Cleanup

    • Remove old Serverless Framework resources
    • Delete unused IAM roles
    • Clean up S3 deployment buckets
    • Update DNS records

Common Pitfalls and Solutions

1. Resource Naming Conflicts

// Avoid hardcoded names
// Bad
const table = new Table(this, 'Table', {
  tableName: 'users-table', // Will conflict if exists
});

// Good
const table = new Table(this, 'Table', {
  tableName: `${props.serviceName}-${props.stage}-users`,
});

2. State Management

// Separate stateful and stateless resources
import { App } from 'aws-cdk-lib';

const app = new App();

// Stateful resources in separate stack
const dataStack = new DataStack(app, 'DataStack', {
  terminationProtection: true,
});

// Stateless resources can be updated freely
const apiStack = new ApiStack(app, 'ApiStack', {
  tables: dataStack.tables,
});

3. Environment Variable Migration

// Map Serverless variables to CDK
import { Stack, Fn } from 'aws-cdk-lib';

const legacyMappings: Record<string, string> = {
  '${self:service}': props.serviceName,
  '${opt:stage}': props.stage,
  '${opt:region}': Stack.of(this).region,
  '${cf:OtherStack.Output}': Fn.importValue('OtherStack-Output'),
};

When to Stay with Serverless Framework

Certain scenarios benefit more from Serverless Framework than CDK:

  1. Simple CRUD applications with minimal customization needs
  2. Proof-of-concept projects that need rapid prototyping
  3. Teams without TypeScript experience and no bandwidth for training
  4. Applications with heavy plugin dependencies that don’t exist in CDK
  5. Organizations with YAML-only infrastructure policies

Where the Default Holds

Blue-green with an alarm-driven rollback is the default for a production cut-over. Override it when the application is small enough that a maintenance window is honest about its cost, or when it is large enough that a strangler fig transition is the only way to keep the auth layer coherent. The rest of the plan survives either choice: import stateful resources instead of recreating them, assert against the synthesized template, and fire the rollback once in a staging account before production traffic depends on it.

Moving from YAML configuration to TypeScript brings compilation, testing, and code review to infrastructure. That benefit arrives only if the tests run in CI and the rollback path has been exercised. Without those two, CDK is a more expensive way to write CloudFormation.

References

Migrating from Serverless Framework to AWS CDK

A comprehensive 6-part guide covering the complete migration process from Serverless Framework to AWS CDK, including setup, implementation patterns, and best practices.

Progress 6/6 posts completed

Related posts