How to Start and Stop AWS EC2 Instances Automatically

Introduction

I was working on a gig that needed to have an on-premise infrastructure and from time to time we needed to take servers out for on-field testing and during that time the servers were inaccessible for developers. We decided to create an AWS Serverless stack that we can use for our development and test environment. In our first attempt, we created the stack but we noticed if we want to use the serverless stack we need to modify our reverse proxy and some project configs. And with this change, the development environment was going to be a bit different than our production environment.

In our second attempt, we decided to not complicate things and just create an AWS EC2 server identical to our production server. With this approach, we could deploy our applications to this new server without any extra effort. Everything looked good but we didn't want to pay for the time that the instance was idle. That's why we wanted to start/stop the instance at a set time. We also wanted this process to be automated without manual intervention so developers don't need to worry about it every day. We also calculated that by turning the instance off for 12 hours per day and on weekends, we could reduce our costs by 65%.

Stopping the instance is relatively straightforward using CloudWatch alarms since there is a stop instance action on CloudWatch and we can easily stop an instance based on a set time. However, for starting the instance we needed to come up with a workaround solution.

In this article, I'm going to show you how to start/stop EC2 instances. I'll code all the required steps in AWS CDK so you can easily integrate it into your existing stack if you're using AWS CDK. Even if you're using CloudFormation directly, you can use the generated CloudFormation code and apply it to your stack manually.

Create AWS CDK Project

When it comes to IaC (Infrastructure as code) we have a couple of options. Either we can go with the native CloudFormation which is the main approach for deploying the resources on AWS or we can use some intermediate tools which are more readable and they compile to the CloudFormation script.

Although CloudFormation is always the most complete version of the two, it's not always the most readable and concise version. With the other approaches like AWS CDK, we can write the infrastructure code in our preferred language and benefit from auto-complete and well-thought defaults.

If you're going to follow me in this tutorial, you need to have an active AWS account and have the AWS CDK locally installed.

You can follow these instructions on the Amazon website for getting started with AWS CDK.

Create EC2 Instance

I assume that you've followed the "Getting Started" section on the AWS CDK tutorial and you have a working CDK application. I'll use TypeScript throughout this article but feel free to set up your CDK application in your preferred language. In this section, we're going to create an EC2 instance. Before that, we'll need to create a virtual private network. We'll create the AWS VPC using the following code:

import * as ec2 from "aws-cdk-lib/aws-ec2";
    
    const vpc = new ec2.Vpc(this, "VPC", {
      maxAzs: 1,
      subnetConfiguration: [
        {
          name: "soheil-subnet",
          subnetType: ec2.SubnetType.PUBLIC,
        },
      ],
    });

As you can see the code is very clear and concise thanks to the CDK's well-written library. We're creating a VPC with maximum availability zones of 1 and we give it a name and make it public so we can easily connect to it. Now we can create an EC2 in this VPC using the following code:

const instance = new ec2.Instance(this, "soheil-instance", {
      vpc,
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T2,
        ec2.InstanceSize.MICRO
      ),
      machineImage: new ec2.AmazonLinuxImage(),
    });

We create a micro instance from class T2 and we use "Amazon Linux" image for the operating system. Although the current configuration creates the EC2 instance, we're unable to communicate with the instance because the incoming ports are closed and there is no SSH key assigned to the machine. We need to modify our code to make this instance accessible.

First, we create an IAM role that can be assigned to our EC2 instance:

import * as iam from "aws-cdk-lib/aws-iam";
    
    const role = new iam.Role(this, "soheil-iam-role", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com")
    });

Then we open the SSH, HTTP and HTTPS ports in our EC2. We'll accomplish that by creating a security group:

const securityGroup = new ec2.SecurityGroup(this, "soheil-security-group", { vpc });
    securityGroup.addIngressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(22),
      "allow ssh access from the world"
    );
    securityGroup.addIngressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(80),
      "allow http access from the world"
    );
    securityGroup.addIngressRule(
      ec2.Peer.anyIpv4(),
      ec2.Port.tcp(443),
      "allow https access from the world"
    );

And lastly we want to generate a key pair in AWS EC2 console and use it's name in our EC2 instance. So the final EC2 code will be like this:

  const instance = new ec2.Instance(this, "soheil-instance", {
        vpc,
    +   role,
    +   securityGroup,
        instanceType: ec2.InstanceType.of(
          ec2.InstanceClass.T2,
          ec2.InstanceSize.MICRO
        ),
        machineImage: new ec2.AmazonLinuxImage(),
    +   keyName: "raceapp-instance-key", // name of the key pair for ssh
      });

Now you can deploy your stack, SSH to your instance and verify that your resources have been created successfully:

npm run cdk deploy

Start/Stop Scheduling

So far we created an EC2 instance using AWS CDK with proper permissions so we can connect to it. In this section, we want to automate starting/stopping our instance so we don't incur extra costs while our servers are idle. For achieving this we're going to use AWS CloudWatch Events.

CloudWatch Events can monitor different metrics of our resources and act based on the criteria we define. For example, we can set criteria on our instance CPU utilisation that whenever the average CPU utilisation is below 10% for an hour to stop the instance. However, in this tutorial, I want to use a different aspect of CloudWatch events which is based on a schedule rather than resource metrics.

With this feature we can define a cron expression and based on that CloudWatch will act. Currently, CloudWatch supports stopping an instance but starting an instance is not supported yet. That's why we need to create a Lambda function and tell CloudWatch to run our function as the action of the time trigger. For consistency, I'll use Lambda functions for both starting and stopping the instances in this tutorial.

First, lets create two Lambda functions to start/stop the instance:

import { aws_lambda as lambda } from "aws-cdk-lib";
    
    const startCode = `
      const AWS = require('aws-sdk');
      exports.handler = async (event, context, callback) => {
        const ec2 = new AWS.EC2({ region: event.instanceRegion });
        return ec2.startInstances({ InstanceIds: [event.instanceId] }).promise()
          .then(() => "Successfully started " + event.instanceId)
          .catch(err => console.log(err));
      };
    `;
    
    const stopCode = `
      const AWS = require('aws-sdk');
      exports.handler = async (event, context, callback) => {
        const ec2 = new AWS.EC2({ region: event.instanceRegion });
        return ec2.stopInstances({ InstanceIds: [event.instanceId] }).promise()
          .then(() => "Successfully stopped " + event.instanceId)
          .catch(err => console.log(err));
      };
    `;
    
    const startFunction = new lambda.Function(this, "start-instances", {
      runtime: lambda.Runtime.NODEJS_12_X,
      handler: "index.handler",
      code: lambda.Code.fromInline(startCode),
    });
    
    
    const stopFunction = new lambda.Function(this, "stop-instances", {
      runtime: lambda.Runtime.NODEJS_12_X,
      handler: "index.handler",
      code: lambda.Code.fromInline(stopCode),
    });

I used inline code for simplicity, However, you can define your functions in a separate file and CDK will zip them into an S3 bucket for you. These functions take two arguments, one for instance ID and the other for the region on which the instance is running. If you deploy and try to run them manually you'll notice that they don't have permission to communicate with the EC2 instance. And that makes sense since we didn't specify any role for our functions which tells the AWS that they should be able to communicate with the EC2 instance. We use AWS AmazonEC2FullAccess managed policy and assign it to our functions:

const ec2FullAccessPolicy = iam.ManagedPolicy.fromAwsManagedPolicyName(
      "AmazonEC2FullAccess"
    );
    startFunction.role?.addManagedPolicy(ec2FullAccessPolicy);
    stopFunction.role?.addManagedPolicy(ec2FullAccessPolicy);

Deploy the functions and verify that they can start/stop your instance by manually running them in AWS console. Please note that you need to pass instance ID and instance region to the functions to run.

If the previous steps are successful, now is the time to create the timer using the AWS events library. First, we define a Rule with a cron expression, and then an action for the timer. The following code will define the rule and the target:

import * as events from "aws-cdk-lib/aws-events";
    import * as targets from "aws-cdk-lib/aws-events-targets";
    
    const startRule = new events.Rule(this, "start-rule", {
      schedule: events.Schedule.expression("cron(0 22 ? * SUN-THU *)"), // 22:00 UTC
    });
    
    startRule.addTarget(new targets.LambdaFunction(startFunction, {
      event: events.RuleTargetInput.fromObject({
        instanceId: instance.instanceId,
        instanceRegion: 'ap-southeast-2'
      })
    }));
    
    const stopRule = new events.Rule(stack, "stop-rule", {
      schedule: events.Schedule.expression("cron(0 7 ? * MON-FRI *)"), // 07:00 UTC
    });
    
    stopRule.addTarget(
      new targets.LambdaFunction(stopFunction, {
        event: events.RuleTargetInput.fromObject({
          instanceId: instance.instanceId,
          instanceRegion: 'ap-southeast-2',
        }),
      })
    );

The cron expression is at 22:00 UTC from Sunday to Thursday which translates to 09:00 AM AEST on weekdays. You can calculate the proper time for your time zone. Please note that the cron expression only works with UTC hence in case of daylight saving your schedule might be off for the daylight saving offset in your area. If you want to cater for this situation without modifying your rules, you should extend your schedule by daylight saving offset value.

Conclusion

Scheduling the start/stop of your instance is a very useful technique for development environments or when you want to do some work with your instance at a set time. Although it's not natively supported by AWS CloudWatch events, it's not complicated to set it up using the AWS CDK. You need to code some extra steps once but it will benefit you and your team a lot in the future with the reduced running cost of your infrastructure.