Last Updated: July 23, 2025
This guide demonstrates how to implement time-of-day scaling for SaladCloud container groups using scheduled serverless
functions. Unlike queue-based autoscaling that reacts to workload, time-of-day scaling proactively adjusts resources
based on predictable usage patterns, such as business hours, peak traffic times, or scheduled batch processing windows.
Prerequisites
Before you begin, ensure you have:
- ✅ SaladCloud API Key: You’ll need a valid API key with permissions to manage container groups
- ✅ Organization and Project: An active organization and project in SaladCloud
- ✅ Container Group: An existing container group that you want to scale on a schedule
- ✅ Serverless Platform Access: Account on one of the supported platforms (AWS Lambda, Cloudflare Workers, etc.)
- ✅ Time Zone Planning: Clear understanding of your scaling schedule and time zones
Cost Optimization: Time-of-day scaling is particularly effective for workloads with predictable patterns, allowing
you to scale to zero during off-hours and scale up before peak demand, optimizing both cost and performance.
Overview
Time-of-day scaling works by scheduling functions to run at specific times when you want to change your replica count:
- Schedule functions to trigger at exact times when scaling is needed
- Execute scaling actions directly without checking current time
- Call the SaladCloud API to set the desired replica count
- Handle state transitions (starting/stopping container groups as needed)
This approach is ideal for:
- Business hours scaling: Scale up during work hours, down during nights/weekends
- Batch processing windows: Prepare resources before scheduled jobs
- Global applications: Adjust capacity based on regional peak times
- Cost optimization: Predictably scale to zero during known low-usage periods
Step 1: Plan Your Scaling Schedule
Before implementing, define your scaling schedule:
Example Scaling Patterns
Business Hours Pattern:
Mon-Fri 8:00 AM - 6:00 PM: 10 replicas
Mon-Fri 6:00 PM - 8:00 AM: 2 replicas
Weekends: 1 replica
Global Coverage Pattern:
00:00-06:00 UTC: 5 replicas (APAC peak)
06:00-12:00 UTC: 2 replicas (Low traffic)
12:00-18:00 UTC: 8 replicas (EMEA peak)
18:00-24:00 UTC: 10 replicas (Americas peak)
Batch Processing Pattern:
Daily 2:00 AM: Scale to 20 replicas for overnight processing
Daily 6:00 AM: Scale to 5 replicas for maintenance
Daily 8:00 AM: Scale to 15 replicas for business hours
Daily 10:00 PM: Scale to 0 replicas (overnight shutdown)
Key Considerations
- Time Zones: Use UTC in your functions to avoid daylight saving complications
- Startup Time: Account for SaladCloud’s container startup time (5-15 minutes)
- Overlap Periods: Plan transitions to handle workload handoffs smoothly
- Emergency Scaling: Keep manual override capabilities for unexpected load
Step 2: Choose Your Implementation
Select one of the following serverless platforms for implementing your time-of-day scaling:
Option A: AWS Lambda + EventBridge
- Best for: AWS-heavy environments, complex logic, integration with other AWS services
- Scheduling: EventBridge with cron expressions and event payloads
- Cost: Pay-per-invocation, very cost-effective for periodic scaling
Option B: Cloudflare Workers + Cron Triggers
- Best for: Global distribution, simple logic, edge computing integration
- Scheduling: Built-in cron triggers with environment-based configuration
- Cost: Generous free tier, low latency execution
Option C: Google Cloud Functions + Cloud Scheduler
- Best for: Google Cloud environments, integration with GCP services
- Scheduling: Cloud Scheduler with flexible cron expressions
- Cost: Pay-per-invocation with generous free tier
AWS Lambda Implementation
Here’s a complete AWS Lambda implementation for time-of-day scaling:
Lambda Function Code
import json
import urllib.request
import os
from typing import Dict, Any, Optional
# Configuration from environment variables
SALAD_API_KEY = os.environ['SALAD_API_KEY']
SALAD_ORG = os.environ['SALAD_ORG']
SALAD_PROJECT = os.environ['SALAD_PROJECT']
CONTAINER_GROUP_NAME = os.environ['CONTAINER_GROUP_NAME']
SALAD_BASE_URL = "https://api.salad.com/api/public"
def make_salad_request(method: str, path: str, data: Optional[Dict] = None) -> Dict[str, Any]:
"""Make a request to the SaladCloud API"""
url = f"{SALAD_BASE_URL}{path}"
headers = {
'Content-Type': 'application/json',
'Salad-Api-Key': SALAD_API_KEY
}
if method == 'PATCH':
headers['Content-Type'] = 'application/merge-patch+json'
req_data = None
if data:
req_data = json.dumps(data).encode('utf-8')
request = urllib.request.Request(url, data=req_data, headers=headers, method=method)
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read().decode('utf-8'))
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
raise Exception(f"SaladCloud API error {e.code}: {error_body}")
def get_container_group() -> Dict[str, Any]:
"""Get current container group status"""
path = f"/organizations/{SALAD_ORG}/projects/{SALAD_PROJECT}/containers/{CONTAINER_GROUP_NAME}"
return make_salad_request('GET', path)
def start_container_group():
"""Start the container group"""
path = f"/organizations/{SALAD_ORG}/projects/{SALAD_PROJECT}/containers/{CONTAINER_GROUP_NAME}/start"
return make_salad_request('POST', path)
def stop_container_group():
"""Stop the container group"""
path = f"/organizations/{SALAD_ORG}/projects/{SALAD_PROJECT}/containers/{CONTAINER_GROUP_NAME}/stop"
return make_salad_request('POST', path)
def set_replicas(replicas: int):
"""Set the number of replicas for the container group"""
path = f"/organizations/{SALAD_ORG}/projects/{SALAD_PROJECT}/containers/{CONTAINER_GROUP_NAME}"
return make_salad_request('PATCH', path, {'replicas': replicas})
def lambda_handler(event, context):
"""Main Lambda handler function"""
try:
# Get desired replicas from the event (passed by EventBridge rule)
desired_replicas = event.get('replicas', 0)
action = event.get('action', 'scale') # 'scale', 'start', or 'stop'
print(f"Scaling action: {action}, desired replicas: {desired_replicas}")
# Get current container group status
container_group = get_container_group()
current_replicas = container_group['replicas']
current_state = container_group['current_state']['status']
print(f"Current replicas: {current_replicas}")
print(f"Current state: {current_state}")
# Apply scaling logic based on action
if action == 'stop' or desired_replicas == 0:
if current_state == 'running':
print("Stopping container group...")
stop_container_group()
else:
print("Container group already stopped")
elif action == 'start' or desired_replicas > 0:
if current_state == 'stopped':
print("Starting container group...")
start_container_group()
# Set replicas if different from current
if desired_replicas != current_replicas:
print(f"Setting replicas to {desired_replicas}")
set_replicas(desired_replicas)
else:
print("Replicas already at desired count")
return {
'statusCode': 200,
'body': json.dumps({
'message': 'Scaling operation completed successfully',
'action': action,
'desired_replicas': desired_replicas,
'current_replicas': current_replicas,
'current_state': current_state
})
}
except Exception as e:
print(f"Error during scaling operation: {str(e)}")
return {
'statusCode': 500,
'body': json.dumps({
'error': str(e),
'message': 'Scaling operation failed'
})
}
Deployment Configuration
-
Create the Lambda Function:
- Runtime: Python 3.9 or later
- Timeout: 30 seconds
- Memory: 128 MB (sufficient for API calls)
-
Set Environment Variables:
SALAD_API_KEY=your_salad_api_key
SALAD_ORG=your_organization_name
SALAD_PROJECT=your_project_name
CONTAINER_GROUP_NAME=your_container_group_name
-
Create Multiple EventBridge Rules for Your Schedule:
Business Hours Start (8 AM Monday-Friday):
aws events put-rule \
--name salad-scale-business-start \
--schedule-expression "cron(0 8 ? * MON-FRI *)"
aws events put-targets \
--rule salad-scale-business-start \
--targets "Id"="1","Arn"="arn:aws:lambda:region:account:function:function-name","Input"='{"action":"scale","replicas":15}'
Business Hours End (6 PM Monday-Friday):
aws events put-rule \
--name salad-scale-business-end \
--schedule-expression "cron(0 18 ? * MON-FRI *)"
aws events put-targets \
--rule salad-scale-business-end \
--targets "Id"="1","Arn"="arn:aws:lambda:region:account:function:function-name","Input"='{"action":"scale","replicas":3}'
Batch Processing Start (2 AM Daily):
aws events put-rule \
--name salad-scale-batch-start \
--schedule-expression "cron(0 2 * * ? *)"
aws events put-targets \
--rule salad-scale-batch-start \
--targets "Id"="1","Arn"="arn:aws:lambda:region:account:function:function-name","Input"='{"action":"scale","replicas":25}'
Batch Processing End (6 AM Daily):
aws events put-rule \
--name salad-scale-batch-end \
--schedule-expression "cron(0 6 * * ? *)"
aws events put-targets \
--rule salad-scale-batch-end \
--targets "Id"="1","Arn"="arn:aws:lambda:region:account:function:function-name","Input"='{"action":"scale","replicas":15}'
Weekend Scale Down (Saturday 12 AM):
aws events put-rule \
--name salad-scale-weekend \
--schedule-expression "cron(0 0 ? * SAT *)"
aws events put-targets \
--rule salad-scale-weekend \
--targets "Id"="1","Arn"="arn:aws:lambda:region:account:function:function-name","Input"='{"action":"scale","replicas":1}'
Alternative: Infrastructure as Code (CloudFormation/Terraform)
# CloudFormation template snippet
Resources:
BusinessHoursStartRule:
Type: AWS::Events::Rule
Properties:
ScheduleExpression: 'cron(0 8 ? * MON-FRI *)'
Targets:
- Arn: !GetAtt ScalingFunction.Arn
Id: 'BusinessHoursStart'
Input: '{"action":"scale","replicas":15}'
BusinessHoursEndRule:
Type: AWS::Events::Rule
Properties:
ScheduleExpression: 'cron(0 18 ? * MON-FRI *)'
Targets:
- Arn: !GetAtt ScalingFunction.Arn
Id: 'BusinessHoursEnd'
Input: '{"action":"scale","replicas":3}'
Testing Your Lambda Implementation
Unit Testing:
def test_scaling_events():
"""Test your scaling logic with different events"""
test_events = [
{'action': 'scale', 'replicas': 15}, # Business hours start
{'action': 'scale', 'replicas': 3}, # Business hours end
{'action': 'scale', 'replicas': 25}, # Batch processing start
{'action': 'stop', 'replicas': 0}, # Overnight shutdown
]
for event in test_events:
print(f"Testing event: {event}")
# Simulate the lambda_handler call
result = lambda_handler(event, {})
print(f"Result: {result['statusCode']}")
print("---")
# Run tests
test_scaling_events()
Manual Testing:
# Test scaling to 15 replicas
aws lambda invoke \
--function-name your-scaling-function \
--payload '{"action":"scale","replicas":15}' \
response.json
# Test stopping the container group
aws lambda invoke \
--function-name your-scaling-function \
--payload '{"action":"stop","replicas":0}' \
response.json
Dry-Run Mode:
Add a dry-run mode for testing without actual scaling:
DRY_RUN = os.environ.get('DRY_RUN', 'false').lower() == 'true'
def apply_scaling_action(action_type, *args):
"""Apply scaling action with dry-run support"""
if DRY_RUN:
print(f"DRY RUN: Would execute {action_type} with args: {args}")
return
if action_type == 'start':
start_container_group()
elif action_type == 'stop':
stop_container_group()
elif action_type == 'scale':
set_replicas(args[0])
Cloudflare Workers Implementation
Cloudflare Workers provides a clean single-worker approach where all scaling configuration is defined in the
wrangler.toml
file through environment variables:
Worker Script (src/index.js
)
export default {
async scheduled(event, env, ctx) {
try {
const scalingConfig = {
action: env.SCALING_ACTION,
replicas: parseInt(env.SCALING_REPLICAS || '0'),
name: env.SCALING_NAME || 'Unknown scaling action',
}
console.log(`Executing: ${scalingConfig.name}`)
await executeScalingAction(env, scalingConfig)
} catch (error) {
console.error('Scaling operation failed:', error)
}
},
}
async function executeScalingAction(env, config) {
const { action, replicas, name } = config
const containerGroup = await getContainerGroup(env)
const currentReplicas = containerGroup.replicas
const currentState = containerGroup.current_state.status
console.log(`Current: ${currentReplicas} replicas, state: ${currentState}`)
if (action === 'stop' || replicas === 0) {
if (currentState === 'running') {
await stopContainerGroup(env)
console.log(`${name}: Container group stopped`)
}
} else if (action === 'scale') {
if (currentState === 'stopped') {
await startContainerGroup(env)
}
if (replicas !== currentReplicas) {
await setReplicas(env, replicas)
console.log(`${name}: Scaled to ${replicas} replicas`)
}
}
}
async function getContainerGroup(env) {
const url = `https://api.salad.com/api/public/organizations/${env.SALAD_ORG}/projects/${env.SALAD_PROJECT}/containers/${env.CONTAINER_GROUP_NAME}`
const response = await fetch(url, {
headers: { 'Salad-Api-Key': env.SALAD_API_KEY },
})
if (!response.ok) {
throw new Error(`Failed to get container group: ${response.status} ${await response.text()}`)
}
return response.json()
}
async function startContainerGroup(env) {
const url = `https://api.salad.com/api/public/organizations/${env.SALAD_ORG}/projects/${env.SALAD_PROJECT}/containers/${env.CONTAINER_GROUP_NAME}/start`
const response = await fetch(url, {
method: 'POST',
headers: { 'Salad-Api-Key': env.SALAD_API_KEY },
})
if (!response.ok) {
throw new Error(`Failed to start container group: ${response.status} ${await response.text()}`)
}
}
async function stopContainerGroup(env) {
const url = `https://api.salad.com/api/public/organizations/${env.SALAD_ORG}/projects/${env.SALAD_PROJECT}/containers/${env.CONTAINER_GROUP_NAME}/stop`
const response = await fetch(url, {
method: 'POST',
headers: { 'Salad-Api-Key': env.SALAD_API_KEY },
})
if (!response.ok) {
throw new Error(`Failed to stop container group: ${response.status} ${await response.text()}`)
}
}
async function setReplicas(env, replicas) {
const url = `https://api.salad.com/api/public/organizations/${env.SALAD_ORG}/projects/${env.SALAD_PROJECT}/containers/${env.CONTAINER_GROUP_NAME}`
const response = await fetch(url, {
method: 'PATCH',
headers: {
'Salad-Api-Key': env.SALAD_API_KEY,
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify({ replicas }),
})
if (!response.ok) {
throw new Error(`Failed to set replicas: ${response.status} ${await response.text()}`)
}
}
Wrangler Configuration
The key is to deploy the same worker multiple times with different names and environment variables. Create separate
wrangler.toml
files for each scaling action:
Business Hours Start (wrangler-business-start.toml
):
name = "salad-business-start"
main = "src/index.js"
compatibility_date = "2024-07-01"
[triggers]
crons = ["0 8 * * 1-5"] # 8 AM Monday-Friday
[vars]
SALAD_ORG = "your-organization"
SALAD_PROJECT = "your-project"
CONTAINER_GROUP_NAME = "your-container-group"
SCALING_ACTION = "scale"
SCALING_REPLICAS = "15"
SCALING_NAME = "Business hours start"
Business Hours End (wrangler-business-end.toml
):
name = "salad-business-end"
main = "src/index.js"
compatibility_date = "2024-07-01"
[triggers]
crons = ["0 18 * * 1-5"] # 6 PM Monday-Friday
[vars]
SALAD_ORG = "your-organization"
SALAD_PROJECT = "your-project"
CONTAINER_GROUP_NAME = "your-container-group"
SCALING_ACTION = "scale"
SCALING_REPLICAS = "3"
SCALING_NAME = "Business hours end"
Batch Processing Start (wrangler-batch-start.toml
):
name = "salad-batch-start"
main = "src/index.js"
compatibility_date = "2024-07-01"
[triggers]
crons = ["0 2 * * *"] # 2 AM daily
[vars]
SALAD_ORG = "your-organization"
SALAD_PROJECT = "your-project"
CONTAINER_GROUP_NAME = "your-container-group"
SCALING_ACTION = "scale"
SCALING_REPLICAS = "25"
SCALING_NAME = "Batch processing start"
Overnight Shutdown (wrangler-shutdown.toml
):
name = "salad-overnight-shutdown"
main = "src/index.js"
compatibility_date = "2024-07-01"
[triggers]
crons = ["0 22 * * *"] # 10 PM daily
[vars]
SALAD_ORG = "your-organization"
SALAD_PROJECT = "your-project"
CONTAINER_GROUP_NAME = "your-container-group"
SCALING_ACTION = "stop"
SCALING_REPLICAS = "0"
SCALING_NAME = "Overnight shutdown"
Weekend Scale Down (wrangler-weekend.toml
):
name = "salad-weekend-scale"
main = "src/index.js"
compatibility_date = "2024-07-01"
[triggers]
crons = ["0 0 * * 6"] # Saturday midnight
[vars]
SALAD_ORG = "your-organization"
SALAD_PROJECT = "your-project"
CONTAINER_GROUP_NAME = "your-container-group"
SCALING_ACTION = "scale"
SCALING_REPLICAS = "1"
SCALING_NAME = "Weekend scale down"
Deployment Commands
Deploy each worker with its specific configuration:
# Install Wrangler CLI
npm install -g wrangler
# Login to Cloudflare
wrangler login
# Set your API key as a secret for each worker
wrangler secret put SALAD_API_KEY --config wrangler-business-start.toml
wrangler secret put SALAD_API_KEY --config wrangler-business-end.toml
wrangler secret put SALAD_API_KEY --config wrangler-batch-start.toml
wrangler secret put SALAD_API_KEY --config wrangler-shutdown.toml
wrangler secret put SALAD_API_KEY --config wrangler-weekend.toml
# Deploy all workers
wrangler deploy --config wrangler-business-start.toml
wrangler deploy --config wrangler-business-end.toml
wrangler deploy --config wrangler-batch-start.toml
wrangler deploy --config wrangler-shutdown.toml
wrangler deploy --config wrangler-weekend.toml
Simplified Deployment Script
Create a deploy.sh
script to automate the process:
#!/bin/bash
set -e
echo "Setting up SaladCloud time-of-day scaling workers..."
# Array of worker configurations
workers=("business-start" "business-end" "batch-start" "shutdown" "weekend")
# Set API key for all workers
for worker in "${workers[@]}"; do
echo "Setting API key for $worker..."
wrangler secret put SALAD_API_KEY --config "wrangler-$worker.toml"
done
# Deploy all workers
for worker in "${workers[@]}"; do
echo "Deploying $worker..."
wrangler deploy --config "wrangler-$worker.toml"
done
echo "All workers deployed successfully!"
echo "Your time-of-day scaling is now active."
Make it executable and run:
chmod +x deploy.sh
./deploy.sh
Testing Your Cloudflare Workers Implementation
Manual Trigger Testing:
# Trigger a specific worker manually (bypasses cron schedule)
wrangler triggers deploy --config wrangler-business-start.toml
# View worker logs in real-time
wrangler tail salad-business-start
# View logs for a specific worker
wrangler tail salad-business-end --format pretty
Testing with Dry-Run Mode:
Add a DRY_RUN
environment variable to your wrangler.toml
for testing:
# Add to any wrangler-*.toml file for testing
[vars]
SALAD_ORG = "your-organization"
SALAD_PROJECT = "your-project"
CONTAINER_GROUP_NAME = "your-container-group"
SCALING_ACTION = "scale"
SCALING_REPLICAS = "15"
SCALING_NAME = "Business hours start"
DRY_RUN = "true" # Add this for testing
Then update your worker code to support dry-run:
async function executeScalingAction(env, config) {
const { action, replicas, name } = config
const isDryRun = env.DRY_RUN === 'true'
if (isDryRun) {
console.log(`DRY RUN: ${name} - Would ${action} to ${replicas} replicas`)
return
}
// ... rest of your scaling logic
}
Testing Individual Workers:
# Test each worker configuration
wrangler deploy --config wrangler-business-start.toml --dry-run
wrangler deploy --config wrangler-business-end.toml --dry-run
wrangler deploy --config wrangler-batch-start.toml --dry-run
# Deploy in test mode first
wrangler deploy --config wrangler-business-start.toml --env staging
Step 3: Best Practices
Scheduling Considerations
-
Account for Startup Time: SaladCloud containers can take 5-15 minutes to start
- Schedule scale-up 15-30 minutes before you need the capacity
- Use multiple scaling events rather than trying to predict exact timing
-
Minimize Unnecessary Executions:
- Only schedule functions when you need to change replica counts
- Each cron trigger should have a specific scaling purpose
- Avoid overlapping schedules that might conflict
-
Handle Time Zones Properly:
- Use UTC in your cron expressions to avoid daylight saving issues
- Convert business hours to UTC when setting up schedules
- Document your schedule clearly for future maintenance
Error Handling and Reliability
import time
from functools import wraps
def retry_on_failure(max_retries=3, delay=5):
"""Decorator to retry failed operations"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_retries - 1:
raise
print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
return None
return wrapper
return decorator
@retry_on_failure(max_retries=3, delay=5)
def reliable_set_replicas(replicas):
"""Set replicas with automatic retry on failure"""
return set_replicas(replicas)
Cost Optimization
- Scale to Zero: Use
0
replicas during guaranteed low-usage periods
- Gradual Scaling: Implement stepped scaling instead of jumping to max replicas
- Weekend Patterns: Reduce capacity during weekends unless needed
def calculate_stepped_replicas(base_replicas, current_replicas):
"""Implement gradual scaling to avoid sudden cost spikes"""
max_step = 5 # Maximum replicas to add/remove at once
if base_replicas > current_replicas:
return min(base_replicas, current_replicas + max_step)
elif base_replicas < current_replicas:
return max(base_replicas, current_replicas - max_step)
return current_replicas
Troubleshooting
Common Issues
-
Function Not Triggering:
- Verify cron expressions are correct
- Check function permissions and environment variables
- Review platform-specific logs
-
API Authentication Errors:
- Ensure API key is correctly set as environment variable
- Verify API key has necessary permissions
- Check for trailing spaces in environment variables
-
Incorrect Scaling:
- Test your time calculation logic with various dates
- Verify timezone handling (use UTC consistently)
- Check for off-by-one errors in hour comparisons
-
Container Group Not Responding:
- Allow 5-15 minutes for scaling operations to complete
- Check container group status in SaladCloud portal
- Verify container group name matches exactly
def debug_scaling_decision(current_time, desired_replicas):
"""Print detailed debugging information"""
print(f"Debug Info:")
print(f" Current UTC time: {current_time}")
print(f" Weekday: {current_time.weekday()} (Monday=0)")
print(f" Hour: {current_time.hour}")
print(f" Desired replicas: {desired_replicas}")
# Test each schedule rule
for name, schedule in SCALING_SCHEDULE.items():
weekday = current_time.weekday()
hour = current_time.hour
if weekday in schedule['days']:
start = schedule['start_hour']
end = schedule['end_hour']
if start > end: # Overnight
matches = hour >= start or hour < end
else:
matches = start <= hour < end
print(f" Rule '{name}': {'MATCHES' if matches else 'no match'}")
print(f" Days: {schedule['days']}, Hours: {start}-{end}, Replicas: {schedule['replicas']}")
Integration with Existing Autoscaling
You can combine time-of-day scaling with queue-based autoscaling:
def hybrid_scaling_logic(current_time, queue_length=None):
"""Combine time-based and queue-based scaling"""
# Get base replicas from time-of-day schedule
base_replicas = calculate_desired_replicas(current_time)
# If queue data is available, adjust based on demand
if queue_length is not None:
# Scale up if queue is growing
if queue_length > 10:
queue_replicas = min(queue_length // 2, 20) # 2 jobs per replica, max 20
return max(base_replicas, queue_replicas)
return base_replicas
Next Steps
- 📊 Monitor Performance: Set up dashboards to track scaling effectiveness
- 🔧 Optimize Schedule: Adjust timing based on actual usage patterns
- 🚨 Add Alerting: Implement notifications for scaling failures
- 📈 Cost Analysis: Track cost savings from optimized scaling
- 🔄 Backup Strategy: Consider hybrid queue-based scaling for unexpected load