DEV Community

Mohsin Sheikhani
Mohsin Sheikhani

Posted on

A Practical Guide to MLOps on AWS: Demand Forecasting with Amazon Bedrock and Automated EC2 Pipelines (Phase 03)

In Phase 02, we transformed raw user interaction events into structured, enriched datasets, organized across bronze, silver, and gold zones in S3, and made them query able through Glue + Athena.

Now in Phase 03, we shift from preparing the data to putting it to work.

This is where AI meets infrastructure:
We’ll use Amazon Bedrock to predict product demand based on historical sales, and architect it the way real systems do.

Demand Forecasting with Amazon Bedrock and Automated EC2 Pipelines

Why does this matter?

  • Forecasting is a batch job, not a real-time interaction.
  • It needs compute, but we don’t want to keep EC2 running 24/7.
  • So we’ll spin up an EC2 instance nightly, run a forecasting script that:
    • Reads gold-zone data
    • Sends it to Bedrock
    • Updates DynamoDB with new demand forecasts
    • Shuts itself down to save cost

All of this is orchestrated via EventBridge and Lambda, forming a complete, automated, cost-efficient forecasting pipeline.

Now, the question here is why we choose EC2 to run the forecasting job?

Because in real-world ML systems, long-running batch jobs like forecasting are often too heavy for Lambda, may require more memory, longer runtimes, or even GPU-based instances. Using EC2 gives us:

  • Run larger forecasting workloads
  • Use GPU-based instances (if needed)
  • Keep costs low by shutting down after completion
  • Full control over compute resources

How the Orchestration Works

We’ve designed this system to be automated and cost-optimized:

  1. Amazon EventBridge triggers a Lambda function nightly (e.g. every 24 hours)
  2. Lambda starts up an EC2 instance
  3. EC2 pulls cleaned sales data from S3 and runs a Python script
  4. The script:
    • Sends data to Amazon Bedrock to forecast the next 7 days
    • Updates DynamoDB with the forecasted_demand per product
    • Shuts down the EC2 instance when the task is done

Step 1 - Provisioning EC2 Forecasting Instance

In this step, we set up an Amazon EC2 instance that will run our demand forecasting script.

We’re using AWS CDK to provision this instance with the following characteristics:

  • Pulls aggregated sales data from the S3 Gold Zone (forecast_ready/)
  • Sends this data to Amazon Bedrock to forecast product demand
  • Updates the forecasted_demand field in the DynamoDB Inventory Table
  • Shuts itself down after the job is completed to avoid unnecessary costs

Configuration Highlights

  • Launched in a VPC with a public subnet (since it needs internet access for Bedrock and S3, we'll move it to private subnet in upcoming phase)
  • Attached to an IAM role that allows:
    • Invoking Bedrock models (bedrock:InvokeModel)
    • Reading from S3
    • Writing to DynamoDB
  • Bootstrapped via a user data script that:
    • Installs required dependencies (aws cli, etc.)
    • Downloads the inventory_forecaster.py script from S3
    • Runs the script
    • Terminates the instance once done

Create a Dedicated File for EC2 Forecasting

Follow the same pattern by organizing this inside a new folder:

mkdir -p lib/constructs/common/compute/
touch lib/constructs/common/compute/forecast-instance.ts
Enter fullscreen mode Exit fullscreen mode

Then paste the following code:

import { Construct } from "constructs";
import {
  Instance,
  InstanceClass,
  InstanceSize,
  InstanceType,
  MachineImage,
  Vpc,
  SecurityGroup,
  Peer,
  Port,
} from "aws-cdk-lib/aws-ec2";
import {
  Role,
  ServicePrincipal,
  ManagedPolicy,
  PolicyStatement,
} from "aws-cdk-lib/aws-iam";
import { Bucket } from "aws-cdk-lib/aws-s3";
import { Table } from "aws-cdk-lib/aws-dynamodb";
import { aws_ec2 as ec2 } from "aws-cdk-lib";

interface ForecastEc2Props {
  vpc: Vpc;
  goldBucket: Bucket;
  dataAssetsBucket: Bucket;
  forecastTable: Table;
}

export class ForecastEc2Instance extends Construct {
  public readonly instance: Instance;

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

    const { vpc, goldBucket, dataAssetsBucket, forecastTable } = props;

    const role = new Role(this, "ForecastEC2Role", {
      assumedBy: new ServicePrincipal("ec2.amazonaws.com"),
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName("CloudWatchAgentServerPolicy"),
        ManagedPolicy.fromAwsManagedPolicyName("AmazonS3ReadOnlyAccess"),
        ManagedPolicy.fromAwsManagedPolicyName("AmazonDynamoDBFullAccess"),
      ],
    });

    role.addToPolicy(
      new PolicyStatement({
        actions: ["bedrock:InvokeModel"],
        resources: ["*"],
      })
    );

    role.addToPolicy(
      new PolicyStatement({
        actions: ["ec2:TerminateInstances"],
        resources: ["*"],
        conditions: {
          StringEquals: {
            "ec2:ResourceTag/Name": "ForecastEC2",
          },
        },
      })
    );

    const securityGroup = new SecurityGroup(this, "ForecastEC2SG", {
      vpc,
      description: "Allow EC2 to access S3/Bedrock/DynamoDB",
      allowAllOutbound: true,
    });

    securityGroup.addIngressRule(
      Peer.anyIpv4(),
      Port.tcp(22),
      "Allow SSH from anywhere"
    );

    const userData = ec2.UserData.forLinux();
    userData.addCommands(
      "sudo yum update -y",
      "sudo yum install -y python3 pip -y",
      "pip3 install boto3 pandas pyarrow",

      "cd /home/ec2-user",

      `curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"`,
      "unzip awscliv2.zip",
      "sudo ./aws/install --update",

      // Export environment variables to .bashrc or directly
      `echo 'export GOLD_BUCKET=${goldBucket.bucketName}' >> /etc/profile`,
      `echo 'export FORECAST_TABLE=${forecastTable.tableName}' >> /etc/profile`,
      "source /etc/profile",

      `aws s3 cp s3://${dataAssetsBucket.bucketName}/scripts/inventory_forecaster.py .`,
      "python3 ./inventory_forecaster.py",

      "shutdown now -h"
    );

    this.instance = new Instance(this, "ForecastEC2", {
      instanceName: "ForecastEC2",
      instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.MEDIUM),
      machineImage: MachineImage.latestAmazonLinux2023(),
      vpc,
      securityGroup,
      role,
      userData,
      associatePublicIpAddress: true,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PUBLIC,
      },
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

In your retail-ai-insights-stack.ts, import the construct:

import { ForecastEc2Instance } from "./constructs/common/compute/forecast-instance";
Enter fullscreen mode Exit fullscreen mode

And instantiate it like this (as you already had):

const forecastInstance = new ForecastEc2Instance(this, "ForecastingEc2", {
  vpc: vpc,
  goldBucket,
  dataAssetsBucket,
  forecastTable: dynamoConstruct.inventoryTable,
});
Enter fullscreen mode Exit fullscreen mode

Deploying the Forecasting Infrastructure

Once the ForecastEc2Instance construct is in place, don't do cdk deploy for now.

Go to AWS Console, and search for Amazon Bedrock, click on the Foundation Models > Model Catalog, on the Providers tab check mark the Anthropic

Amazon Bedrock Foundational Model Catalog - AWS Console

Look for the Claude 3.7 Sonnet, in my case it's on the first row, third column, click on it

In the next screen, you'll see Available to request

Request model access for Claude 3.7 Sonnet

Click on, Request model access

Request model access for Claude 3.7 Sonnet

Now, click for Enable specific models, and search for Claude 3.7 Sonnet, check mark it, and at the very bottom click on Next, and provide random details

Request model access for Claude 3.7 Sonnet - Put in details

After a little while, you should see Access Granted

Request access granted for Claude 3.7 Sonnet

Now that we've access to Bedrock model on our account, we should be good to deploy our resources with:

cdk deploy
Enter fullscreen mode Exit fullscreen mode

Verifying the Results

  • Wait for the EC2 instance status to become "running" in the EC2 console.

Amazon EC2 instances list

  • Then watch it auto-terminate after the script completes execution.

Amazon EC2 instances list - Status terminated

  • Now head over to DynamoDB > Explore Items, and check your table.
  • You’ll see that the forecasted_demand field has been updated for four products (to save cost by avoidings extra calls to Bedrock).

Amazon DynamoDB - Explore items

Step 2 - Automating Forecasting with EventBridge & Lambda

In a real-world scenario, you wouldn’t manually trigger forecasting jobs. Instead, you’d want these predictions to run nightly, every 24 hours, and only spin up compute when needed to save cost.

To do that, we’ll use:

  • Amazon EventBridge to define a scheduled rule (runs every night at 1:00 AM)
  • AWS Lambda to start our EC2 forecasting instance
  • EC2 itself terminates automatically after completing the prediction job

Make a new file

mkdir -p lib/constructs/events
touch lib/constructs/events/schedule-ec2-task.ts
Enter fullscreen mode Exit fullscreen mode

Paste the code that provisions the Lambda, gives it permission to start the EC2 instance, and wires it into the EventBridge rule.

import { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";
import { aws_lambda as lambda, Duration } from "aws-cdk-lib";
import { Rule, Schedule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
import {
  NodejsFunction,
  NodejsFunctionProps,
} from "aws-cdk-lib/aws-lambda-nodejs";
import path from "path";

export class ScheduleForecastTask extends Construct {
  constructor(scope: Construct, id: string, instanceId: string) {
    super(scope, id);

    const startInstanceLambdaProps: NodejsFunctionProps = {
      functionName: "StartInstanceLambda",
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: "handler",
      memorySize: 128,
      entry: path.join(__dirname, "../../../lambda/start-instance/index.js"),
      timeout: cdk.Duration.seconds(10),
      environment: {
        INSTANCE_ID: instanceId,
      },
    };

    const startInstanceLambda = new NodejsFunction(
      this,
      "StartInstanceLambda",
      {
        ...startInstanceLambdaProps,
      }
    );

    startInstanceLambda.addToRolePolicy(
      new PolicyStatement({
        actions: ["ec2:StartInstances"],
        resources: [`arn:aws:ec2:*:*:instance/${instanceId}`],
      })
    );

    new Rule(this, "StartInstanceSchedule", {
      schedule: Schedule.cron({ minute: "0", hour: "1" }),
      targets: [new LambdaFunction(startInstanceLambda)],
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's setup the lambda function

Make up a file

mkdir -p lambda/start-instance && touch lambda/start-instance/index.js
Enter fullscreen mode Exit fullscreen mode

And paste the following code to trigger an ec2 instance

import { EC2Client, StartInstancesCommand } from "@aws-sdk/client-ec2";

const ec2 = new EC2Client({});

exports.handler = async (event) => {
  console.info("Start Instance Lambda event", JSON.stringify(event, null, 2));

  const { instanceId } = event;

  try {
    const command = new StartInstancesCommand({
      InstanceIds: [instanceId],
    });

    await ec2.send(command);

    return { message: "Booting Instance command initiated" };
  } catch (error) {
    console.error("Failed to boot up the instance:", error);
    throw error;
  }
};
Enter fullscreen mode Exit fullscreen mode

Finally, back in your retail-ai-insights-stack.ts, import and call the construct like so:

new ScheduleForecastTask(
  this,
  "ScheduleForecastTask",
  forecastInstance.instance.instanceId
);
Enter fullscreen mode Exit fullscreen mode

Once that done, deploy via

cdk deploy
Enter fullscreen mode Exit fullscreen mode

Once deployed, your forecast job will run automatically every night, keeping your product demand predictions fresh and your compute costs optimized.

Wrapping Up Phase 3 – Scalable Forecasting, Zero Waste

With this phase complete, we’ve automated a core business process, demand forecasting, in a way that’s:

  • AI-driven: Leveraging Amazon Bedrock for predictive insights.
  • Cost-conscious: EC2 only runs when needed, then shuts down.
  • Fully automated: Triggered nightly via EventBridge with no manual intervention.
  • Production-ready: Clean orchestration, secure roles, real-time updates to DynamoDB.

This isn’t just about running a script. It’s about combining compute, AI, storage, and automation into a solution that mirrors how real companies make stocking decisions, every single day, with no human in the loop.

Next up: Let’s use that same user interaction data to drive real-time product recommendations with Amazon Personalize.

Complete Code for the Third Phase

To view the full code for the third phase, checkout the repository on GitHub

🚀 Follow me on LinkedIn for more AWS content!

Top comments (0)