Building Reliable EMR Pipelines With Custom AMIs and Step Functions

OWASP DevSecOps

OWASP DevSecOps

A practical pattern for replacing runtime bootstrap fragility with image-based dependencies, orchestration and retry-aware recovery.

The Problem: Dependency Drift in EMR Pipelines

Amazon EMR remains a strong option for running Apache Spark workloads, but many production-style pipelines still rely on bootstrap actions to install dependencies when a cluster starts. That works for early experiments. It becomes fragile when pipelines need predictable startup behavior, repeatable dependency versions and cleaner recovery from transient infrastructure failures.

The common failure mode is simple: Every cluster launch repeats package installation, network calls and setup scripts. If a package repository is slow, a dependency resolver changes behavior or a security patch needs to be rolled across many jobs, the pipeline team is debugging environment creation instead of data logic.

One way to reduce that operational noise is to treat the EMR runtime as immutable infrastructure: Custom AMIs with pre-baked dependencies, automated image builds, versioned metadata, Step Functions orchestration and failure handling that understands retryable conditions.

Why Custom AMIs Beat Bootstrap Actions

Architecture Overview

┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ CodeCommit │────▶│ Lambda Builder │────▶│ EC2 (Packer) │
│ (requirements) │ │ (trigger AMI │ │ (Custom AMI) │
│ │ │ build) │ │ │
└─────────────────┘ └──────────────────┘ └────────┬────────┘

┌───────────────────────────┘

┌─────────────────┐
│ Golden AMI │
│ (EMR + Python) │
└────────┬────────┘

┌─────────────────┐ │ ┌──────────────────┐
│ Step Functions │◀─────────┴─────────▶│ EMR Cluster │
│ Orchestrator │ │ (Custom AMI) │
└────────┬────────┘ └────────┬─────────┘
│ │
│ Error? │
◀────────────── Retry logic ────────────┘

┌────▼────┐
│ DLQ │ (CloudWatch analysis)
└─────────┘

Part 1: Building the Custom AMI

Step 1: Create the Packer Configuration

Use HashiCorp Packer to automate AMI builds. This creates a reproducible, version-controlled image pipeline.

# emr-custom-ami.pkr.hcl
packer {
required_plugins {
amazon = {
source = “github.com/hashicorp/amazon”
version = “~> 1”
}
}
}

variable “emr_release” {
default = “emr-6.15.0”
}

variable “python_packages” {
default = [
“pandas==2.0.3”,
“numpy==1.24.3”,
“boto3==1.28.25”,
“pyspark==3.4.1”,
“great-expectations==0.17.0”
]
}

source “amazon-ebs” “emr-custom” {
ami_name = “emr-${var.emr_release}-analytics-${formatdate(”YYYYMMDD-hhmm”, timestamp())}”
instance_type = “m5.xlarge”
region = “us-east-1”

# Start from official EMR AMI
source_ami_filter {
filters = {
name = “amzn2-ami-emr-${var.emr_release}*”
root-device-type = “ebs”
virtualization-type = “hvm”
}
owners = [“amazon”]
most_recent = true
}

ssh_username = “ec2-user”

# EBS optimization for Spark
launch_block_device_mappings {
device_name = “/dev/xvda”
volume_size = 100
volume_type = “gp3”
delete_on_termination = true
}

tags = {
Name = “EMR Analytics AMI”
EMRRelease = var.emr_release
BuiltBy = “Packer”
Repository = “github.com/example-org/emr-ami-pipeline”
}
}

build {
sources = [“source.amazon-ebs.emr-custom”]

# Install Python 3.9 (EMR default is often 3.7)
provisioner “shell” {
inline = [
“sudo amazon-linux-extras install python3.9 -y”,
“sudo alternatives –install /usr/bin/python3 python3 /usr/bin/python3.9 1”,
“sudo python3.9 -m pip install –upgrade pip setuptools wheel”
]
}

# Install Python dependencies
provisioner “shell” {
inline = concat(
[“sudo python3.9 -m pip install “],
[join(” “, var.python_packages)]
)
}

# Verify installations
provisioner “shell” {
inline = [
“python3 –version”,
“pip3 list | grep pandas”,
“pip3 list | grep numpy”
]
}

# Clean up for AMI optimization
provisioner “shell” {
inline = [
“sudo yum clean all”,
“sudo rm -rf /var/cache/yum”,
“sudo rm -f /home/ec2-user/.ssh/authorized_keys”,
“sudo rm -f /root/.ssh/authorized_keys”
]
}
}

Step 2: Lambda-Powered AMI Builder

Trigger AMI builds automatically when requirements.txt changes:

# lambda/ami_builder.py
import json
import boto3
import os

ec2 = boto3.client(‘ec2’)
ssm = boto3.client(‘ssm’)

def lambda_handler(event, context):
“””
Triggered by CodeCommit push to requirements.txt
Builds new EMR custom AMI using Packer
“””

# Get latest EMR release from SSM Parameter Store
emr_release = ssm.get_parameter(
Name=’/emr-pipeline/emr-release’
)[‘Parameter’][‘Value’]

# Start Packer build on EC2 instance (builder pattern)
user_data = f”’#!/bin/bash
yum update -y
yum install -y packer git

# Clone repo with Packer templates
cd /tmp
git clone https://github.com/example-org/emr-ami-pipeline.git
cd emr-ami-pipeline

# Run Packer build
packer init emr-custom-ami.pkr.hcl
packer build
-var ’emr_release={emr_release}’
-var ‘python_packages_file=requirements.txt’
emr-custom-ami.pkr.hcl

# Notify Step Functions/SNS on completion
aws sns publish
–topic-arn {os.environ[‘SNS_TOPIC_ARN’]}
–message “AMI build completed”
”’

# Launch builder instance
response = ec2.run_instances(
ImageId=’ami-0c55b159cbfafe1f0′, # Amazon Linux 2
InstanceType=’m5.2xlarge’,
MinCount=1,
MaxCount=1,
IamInstanceProfile={‘Name’: ‘EMR-AMI-Builder’},
UserData=user_data,
TagSpecifications=[{
‘ResourceType’: ‘instance’,
‘Tags’: [
{‘Key’: ‘Name’, ‘Value’: ’emr-ami-builder’},
{‘Key’: ‘AutoTerminate’, ‘Value’: ‘true’}
]
}]
)

return {
‘statusCode’: 200,
‘body’: json.dumps({
‘message’: ‘AMI build initiated’,
‘instanceId’: response[‘Instances’][0][‘InstanceId’]
})
}

Step 3: Store Golden AMI Metadata

Track AMI versions in DynamoDB for pipeline lookups:

# lambda/ami_registry.py
import boto3
from datetime import datetime

dynamodb = boto3.resource(‘dynamodb’)
table = dynamodb.Table(’emr-golden-amis’)

def register_ami(ami_id, emr_release, packages, commit_hash):
“””Register newly built AMI as ‘active’ for Step Functions.”””

# Mark previous AMI as deprecated
table.update_item(
Key={’emr_release’: emr_release, ‘status’: ‘active’},
UpdateExpression=’SET #status = :deprecated’,
ExpressionAttributeNames={‘#status’: ‘status’},
ExpressionAttributeValues={‘:deprecated’: ‘deprecated’}
)

# Register new AMI
table.put_item(Item={
‘ami_id’: ami_id,
’emr_release’: emr_release,
‘status’: ‘active’,
‘python_packages’: packages,
‘commit_hash’: commit_hash,
‘created_at’: datetime.utcnow().isoformat(),
‘ttl’: int((datetime.utcnow().timestamp()) + (90 * 86400)) # 90-day retention
})

Part 2: Step Functions Orchestration

State Machine Definition

{
“Comment”: “EMR Data Pipeline with Custom AMI and Error Handling”,
“StartAt”: “GetGoldenAMI”,
“States”: {
“GetGoldenAMI”: {
“Type”: “Task”,
“Resource”: “arn:aws:states:::lambda:invoke”,
“Parameters”: {
“FunctionName”: “get-golden-ami”,
“Payload”: {
“emr_release”: “emr-6.15.0”
}
},
“ResultPath”: “$.ami”,
“Next”: “CreateEMRCluster”
},

“CreateEMRCluster”: {
“Type”: “Task”,
“Resource”: “arn:aws:states:::elasticmapreduce:createCluster.sync”,
“Parameters”: {
“Name”: “pipeline-${$.executionId}”,
“ReleaseLabel”: “emr-6.15.0”,
“CustomAmiId.$”: “$.ami.Payload.ami_id”,
“LogUri”: “s3://emr-logs-bucket/pipelines/”,
“Instances”: {
“KeepJobFlowAliveWhenNoSteps”: false,
“InstanceFleets”: [
{
“Name”: “Master”,
“InstanceFleetType”: “MASTER”,
“TargetOnDemandCapacity”: 1,
“InstanceTypeConfigs”: [
{“InstanceType”: “m5.xlarge”}
]
},
{
“Name”: “Core”,
“InstanceFleetType”: “CORE”,
“TargetSpotCapacity”: 4,
“InstanceTypeConfigs”: [
{“InstanceType”: “m5.2xlarge”, “WeightedCapacity”: 2},
{“InstanceType”: “m5.4xlarge”, “WeightedCapacity”: 4}
]
}
],
“Ec2SubnetIds”: [“subnet-12345abcde”],
“EmrManagedMasterSecurityGroup”: “sg-master”,
“EmrManagedSlaveSecurityGroup”: “sg-core”
},
“Applications”: [
{“Name”: “Spark”},
{“Name”: “Hadoop”}
],
“ServiceRole”: “EMR_DefaultRole”,
“JobFlowRole”: “EMR_EC2_DefaultRole”
},
“ResultPath”: “$.cluster”,
“Next”: “SubmitSparkJob”
},

“SubmitSparkJob”: {
“Type”: “Task”,
“Resource”: “arn:aws:states:::elasticmapreduce:addStep.sync”,
“Parameters”: {
“ClusterId.$”: “$.cluster.ClusterId”,
“Step”: {
“Name”: “DataProcessing”,
“ActionOnFailure”: “CONTINUE”,
“HadoopJarStep”: {
“Jar”: “command-runner.jar”,
“Args”: [
“spark-submit”,
“–deploy-mode”, “cluster”,
“–conf”, “spark.dynamicAllocation.enabled=true”,
“s3://emr-jobs/process_data.py”,
“–input”, “s3://data-bucket/raw/”,
“–output”, “s3://data-bucket/processed/”
]
}
}
},
“ResultPath”: “$.step”,
“Next”: “CheckStepStatus”
},

“CheckStepStatus”: {
“Type”: “Choice”,
“Choices”: [
{
“Variable”: “$.step.Step.State”,
“StringEquals”: “COMPLETED”,
“Next”: “Success”
},
{
“Variable”: “$.step.Step.State”,
“StringEquals”: “FAILED”,
“Next”: “AnalyzeFailure”
}
],
“Default”: “WaitForCompletion”
},

“WaitForCompletion”: {
“Type”: “Wait”,
“Seconds”: 60,
“Next”: “CheckStepStatus”
},

“AnalyzeFailure”: {
“Type”: “Task”,
“Resource”: “arn:aws:states:::lambda:invoke”,
“Parameters”: {
“FunctionName”: “analyze-emr-failure”,
“Payload”: {
“cluster_id.$”: “$.cluster.ClusterId”,
“step_id.$”: “$.step.Step.Id”,
“attempt_count.$”: “$.attempt_count”
}
},
“ResultPath”: “$.failure_analysis”,
“Next”: “ShouldRetry”
},

“ShouldRetry”: {
“Type”: “Choice”,
“Choices”: [
{
“And”: [
{“Variable”: “$.failure_analysis.Payload.retryable”, “BooleanEquals”: true},
{“Variable”: “$.attempt_count”, “NumericLessThan”: 3}
],
“Next”: “CalculateBackoff”
}
],
“Default”: “SendToDLQ”
},

“CalculateBackoff”: {
“Type”: “Task”,
“Resource”: “arn:aws:states:::lambda:invoke”,
“Parameters”: {
“FunctionName”: “calculate-backoff”,
“Payload”: {
“attempt.$”: “$.attempt_count”
}
},
“ResultPath”: “$.backoff”,
“Next”: “WaitBeforeRetry”
},

“WaitBeforeRetry”: {
“Type”: “Wait”,
“SecondsPath”: “$.backoff.Payload.seconds”,
“Next”: “CreateEMRCluster”
},

“SendToDLQ”: {
“Type”: “Task”,
“Resource”: “arn:aws:states:::sqs:sendMessage”,
“Parameters”: {
“QueueUrl”: “https://sqs.us-east-1.amazonaws.com/123456789/emr-failures-dlq”,
“MessageBody”: {
“execution_id.$”: “$$.Execution.Id”,
“cluster_id.$”: “$.cluster.ClusterId”,
“error.$”: “$.failure_analysis.Payload.error_type”,
“timestamp.$”: “$$.State.EnteredTime”
}
},
“Next”: “PipelineFailed”
},

“Success”: {
“Type”: “Succeed”
},

“PipelineFailed”: {
“Type”: “Fail”,
“Error”: “PipelineFailed”,
“Cause”: “Non-retryable error or max retries exceeded”
}
}
}

Error Analysis Lambda

# lambda/analyze_emr_failure.py
import boto3
import json

emr = boto3.client(’emr’)
logs = boto3.client(‘logs’)

def lambda_handler(event, context):
cluster_id = event[‘cluster_id’]
step_id = event[‘step_id’]
attempt = event.get(‘attempt_count’, 0)

# Get step details
response = emr.describe_step(ClusterId=cluster_id, StepId=step_id)
step_status = response[‘Step’][‘Status’]

error_info = step_status.get(‘FailureDetails’, {})
reason = error_info.get(‘Reason’, ‘Unknown’)
log_file = error_info.get(‘LogFile’, ”)

# Classify error type
retryable_errors = [
‘Spot instance termination’,
‘Container killed on request’,
‘Connection timeout’,
‘ThrottlingException’
]

is_retryable = any(err in reason for err in retryable_errors)

# If spot termination, suggest on-demand retry
if ‘Spot’ in reason and attempt == 2:
is_retryable = False # Third failure, use DLQ

return {
‘retryable’: is_retryable,
‘error_type’: reason,
‘log_file’: log_file,
‘recommendation’: ‘retry_on_demand’ if ‘Spot’ in reason else ‘retry’
}

def calculate_backoff(attempt):
“””Exponential backoff: 30s, 2min, 5min”””
delays = [30, 120, 300]
return {‘seconds’: delays[min(attempt, 2)]}

Part 3: Production Hardening

Spot Instance Handling With Checkpointing

# spark_job.py (runs on EMR)
from pyspark.sql import SparkSession
import boto3
import sys

spark = SparkSession.builder
.appName(“DataPipeline”)
.config(“spark.sql.adaptive.enabled”, “true”)
.config(“spark.checkpoint.dir”, “s3://emr-checkpoints/job/”)
.getOrCreate()

def main():
# Check for existing checkpoint
checkpoint_path = “s3://emr-checkpoints/job/progress/”
s3 = boto3.client(‘s3′)

try:
s3.head_object(Bucket=’emr-checkpoints’, Key=’job/progress/_SUCCESS’)
print(“Resuming from checkpoint…”)
df = spark.read.parquet(checkpoint_path)
except:
print(“Starting fresh…”)
df = spark.read.parquet(“s3://data-bucket/raw/”)

# Process with periodic checkpointing
df = df.filter(df[‘date’] >= ‘2024-01-01’)
df.write.mode(‘overwrite’).parquet(checkpoint_path)

# Final output
df.write.mode(‘overwrite’).parquet(“s3://data-bucket/processed/”)

# Mark completion
s3.put_object(Bucket=’emr-checkpoints’, Key=’job/progress/_SUCCESS’, Body=b”)

if __name__ == “__main__”:
try:
main()
except Exception as e:
print(f”Job failed: {e}”)
sys.exit(1)

Cost Monitoring

# Track spot vs on-demand costs per job
emr = boto3.client(’emr’)
pricing = boto3.client(‘pricing’)

def get_job_cost(cluster_id):
“””Calculate actual cost for EMR job including spot savings.”””

instances = emr.list_instances(ClusterId=cluster_id)
total_cost = 0

for inst in instances[‘Instances’]:
instance_type = inst[‘InstanceType’]
market = inst[‘Market’] # ON_DEMAND or SPOT
hours = (inst[‘Status’][‘Timeline’][‘EndDateTime’] –
inst[‘Status’][‘Timeline’][‘CreationDateTime’]).seconds / 3600

# Get pricing (simplified—use CUR for actuals)
if market == ‘SPOT’:
rate = get_spot_price(instance_type)
else:
rate = get_ondemand_price(instance_type)

total_cost += rate * hours

return total_cost

Operational Impact to Measure

The value of this pattern should be measured with operational metrics rather than treated as an architecture preference. Useful measures include:

When Custom AMIs Don’t Make Sense

  • Ephemeral Clusters (<10 minustes runtime): AMI build time not worth it
  • Highly Dynamic Dependencies: If requirements change daily
  • Small Scale (<5 jobs/day): Bootstrap simplicity wins

Complete Repository Structure

emr-custom-ami-pipeline/
├── packer/
│ ├── emr-custom-ami.pkr.hcl
│ ├── variables.pkrvars.hcl
│ └── scripts/
│ ├── install-python.sh
│ └── verify-packages.sh
├── terraform/
│ ├── lambda-builder.tf
│ ├── step-functions.tf
│ ├── iam-roles.tf
│ └── dynamodb-ami-registry.tf
├── lambda/
│ ├── ami_builder.py
│ ├── ami_registry.py
│ ├── get_golden_ami.py
│ ├── analyze_emr_failure.py
│ └── calculate_backoff.py
├── stepfunctions/
│ └── emr-pipeline-asl.json
└── spark/
└── process_data.py

Conclusion

Custom AMIs can make EMR pipelines more predictable when dependency setup is a recurring source of startup failures. The useful pattern combines:

  • Immutable dependencies (no PyPI at runtime)
  • Intelligent retry logic (spot-aware, exponential backoff)
  • Checkpoint-based recovery (resume from termination)

The outcome is not that every EMR workload needs a custom AMI. The outcome is that dependency setup, patching and recovery become explicit platform concerns instead of repeated per-job setup steps.

For teams running frequent Spark pipelines with stable dependency sets, the AMI build investment can pay for itself through fewer environment failures, faster iterations and simpler maintenance.

Read More

Scroll to Top