EventBridge Scheduler- Start/Stop ECS instance

EventBridge Scheduler- Start/Stop ECS instance

As I mentioned in my prior post, we used the ECS Fargate instance in my organization. During off hours, we shut down the ECS Fargate instance every day in our lower environment to cut costs.

Here is a prior article where I have explained about Start/Stop EC2 Instnace. This is a simple pattern that starts and stops the ECS Fargate instance using Universal Target.

Important:- This code will shut down 1 ECS instance as API doesn't support multiple ones. In a future article, I will show you how to shut down multiple instances using Step Functions.

API Target and Parameters.

Target :- arn:aws:scheduler:::aws-sdk:ecs:updateService

{
  "Service": "ecs-service",
  "Cluster": "MyCluster",
  "DesiredCount": 0
}

This example uses AWS CLI and CDK (TypeScript). Assuming you already Bootstrapping your account.

Here is the main stack which creates the nested stacks of role, ECSStart and ECSStop.

import {Stack, StackProps} from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {SchedulesRole} from "./scheduler-role";
import {ECSStart} from "./ecs-start";
import {ECSStop} from "./ecs-stop";

export class ECSInstanceStartStopStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);


    // Create Role
    const schedulesRole = new SchedulesRole(this,"SchedulerRoleStack")

    // Create ECSStart schedules
    const ecsStart = new ECSStart(this,"EcsStart", {
      roleArn: schedulesRole.roleArn,
    })

    // Create ECSStop schedules
    const ecsStop = new ECSStop(this,"EcsStop", {
      roleArn: schedulesRole.roleArn,
    })

  }
}

Give minimal permission to start and stop ECS instances.

import { StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import {Effect, PolicyStatement, Role, ServicePrincipal} from "aws-cdk-lib/aws-iam";

export class SchedulesRole extends cdk.NestedStack  {
    private readonly _role: Role;
    constructor(scope: Construct, id: string, props?: StackProps) {
        super(scope, id, props);


        // Add scheduler assumeRole
        this._role  = new Role(this,  "scheduler-ecs-start-stop", {
          assumedBy: new  ServicePrincipal('scheduler.amazonaws.com'),
          roleName: "scheduler-ecs-start-stop"
        })

        // Add policy
        this._role.addToPolicy(  new PolicyStatement( {
            sid: 'ECSStartStopPermissions',
            effect: Effect.ALLOW,
            actions: [
                "ecs:List*",
                "ecs:Describe*",
                "ecs:UpdateService"
            ],
            resources: ["*"], //Give the least privileges
        }))
    }

    get roleArn(): string {
        return this._role.roleArn;
    }
}

Create an ECS start schedule. ECS instance will start at 8 AM CST time.

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import { Role } from "aws-cdk-lib/aws-iam";
import {CfnSchedule} from "aws-cdk-lib/aws-scheduler";

interface ECSStartProps extends cdk.NestedStackProps {
    roleArn: string
}

export class ECSStart extends cdk.NestedStack {
    private readonly role: Role;

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

        // Start ECS Instance 8 am Central Time
        new CfnSchedule(this,"ecs-start-scheduler", {
            name: "ecs-start-scheduler",
            flexibleTimeWindow: {
                mode: "OFF"
            },
            scheduleExpression: "cron(0 8 ? * * *)",
            scheduleExpressionTimezone: 'America/Chicago',
            description: 'Event that start ECS instances',
            target: {
                arn: 'arn:aws:scheduler:::aws-sdk:ecs:updateService',
                roleArn: props.roleArn,
                input: JSON.stringify
                    ({ 
                       Service: "ecs-service", 
                       Cluster: "MYCluster", 
                       DesiredCount: 1 
                    }),                           
            },
        });
    }
}

Create an ECS stop schedule.

import { Construct } from 'constructs';
import * as cdk from 'aws-cdk-lib';
import { Role } from "aws-cdk-lib/aws-iam";
import {CfnSchedule} from "aws-cdk-lib/aws-scheduler";

interface ECSSopProps extends cdk.NestedStackProps {
    roleArn: string
}

export class ECSStop extends cdk.NestedStack {
    private readonly role: Role;

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

        // Start ECS Instance 8 am Central Time
        new CfnSchedule(this,"ecs-stop-scheduler", {
            name: "ecs-stop-scheduler",
            flexibleTimeWindow: {
                mode: "OFF"
            },
            scheduleExpression: "cron(0 20 ? * * *)",
            scheduleExpressionTimezone: 'America/Chicago',
            description: 'Event that start ECS instances',
            target: {
                arn: 'arn:aws:scheduler:::aws-sdk:ecs:updateService',
                roleArn: props.roleArn,
                input: JSON.stringify(
                  { 
                    Service: 'ecs-service', 
                    Cluster: 'MyCluster', 
                    DesiredCount: 0 
                  }),
            },
        });
    }
}

Caution:- Please make sure about your ECS Auto Scaling policy. In my case, ECS instances were kept recycling during start/stop due to the auto-scaling policy was not configured properly. This caused Docker image to keep downloading and costing more $$$.

Deployment Instructions:-

1) git clone git@github.com:awsmantra/eventbridge-schedules-ecs-start-stop.git

2) cd eventbridge-schedules-ecs-start-stop

3) modified ecs-service name and cluster name ecs-start.ts and ecs-stop.ts

4) npm install

5) cdk deploy --all -a "npx ts-node bin/app.ts" --profile dev

Cleanup:-

cdk destroy --profile dev

You can download the source code form here.