// cloudflare-worker.js
export default {
async scheduled(controller, env, ctx) {
await handleAutoscaling(env)
},
async fetch(request, env, ctx) {
if (request.method === 'POST') {
await handleAutoscaling(env)
return new Response('Autoscaling triggered', { status: 200 })
}
return new Response('Hardware Metrics Autoscaler', { status: 200 })
},
}
async function handleAutoscaling(env) {
const config = {
saladApiKey: env.SALAD_API_KEY,
saladOrg: env.SALAD_ORG,
saladProject: env.SALAD_PROJECT,
containerGroupName: env.CONTAINER_GROUP_NAME,
gpuScaleUpThreshold: parseFloat(env.GPU_SCALE_UP_THRESHOLD || '85'),
gpuScaleDownThreshold: parseFloat(env.GPU_SCALE_DOWN_THRESHOLD || '50'),
minReplicas: parseInt(env.MIN_REPLICAS || '1'),
maxReplicas: parseInt(env.MAX_REPLICAS || '10'),
}
try {
// Get container group status
const containerGroup = await saladApiRequest(
'GET',
`/organizations/${config.saladOrg}/projects/${config.saladProject}/containers/${config.containerGroupName}`,
config.saladApiKey,
)
if (containerGroup.current_state.status !== 'running') {
console.log('Container group not running, skipping autoscaling')
return
}
// Get running instances
const instancesResponse = await saladApiRequest(
'GET',
`/organizations/${config.saladOrg}/projects/${config.saladProject}/containers/${config.containerGroupName}/instances`,
config.saladApiKey,
)
const runningInstances = instancesResponse.items.filter((i) => i.state === 'running')
// Collect metrics from external store
const metrics = await collectMetricsFromStore(runningInstances, env)
if (metrics.length === 0) {
console.log('No metrics available')
return
}
// Calculate scaling decision
const currentReplicas = containerGroup.replicas
const desiredReplicas = calculateScaling(metrics, currentReplicas, config)
if (desiredReplicas !== currentReplicas) {
await saladApiRequest(
'PATCH',
`/organizations/${config.saladOrg}/projects/${config.saladProject}/containers/${config.containerGroupName}`,
config.saladApiKey,
{ replicas: desiredReplicas },
)
console.log(`Scaled from ${currentReplicas} to ${desiredReplicas} replicas`)
}
} catch (error) {
console.error('Autoscaling error:', error)
}
}
async function saladApiRequest(method, path, apiKey, data = null) {
const url = `https://api.salad.com/api/public${path}`
const headers = {
'Content-Type': 'application/json',
'Salad-Api-Key': apiKey,
}
if (method === 'PATCH') {
headers['Content-Type'] = 'application/merge-patch+json'
}
const response = await fetch(url, {
method,
headers,
body: data ? JSON.stringify(data) : null,
})
if (!response.ok) {
throw new Error(`SaladCloud API error: ${response.status}`)
}
return await response.json()
}
async function collectMetricsFromStore(instances, env) {
const metrics = []
try {
// Option 1: CloudWatch metrics (if using AWS CloudWatch)
if (env.METRICS_SOURCE === 'cloudwatch') {
const cloudwatchUrl = `${env.CLOUDWATCH_API_URL}/metrics/recent`
const response = await fetch(cloudwatchUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.CLOUDWATCH_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
container_group: env.CONTAINER_GROUP_NAME,
instance_ids: instances.map((i) => i.instance_id),
time_range: '5m',
}),
})
if (response.ok) {
const data = await response.json()
return data.metrics || []
}
}
// Option 2: Prometheus metrics
else if (env.METRICS_SOURCE === 'prometheus') {
const prometheusUrl = env.PROMETHEUS_URL || 'http://prometheus:9090'
const endTime = new Date().toISOString()
for (const instance of instances) {
const instanceId = instance.instance_id
const instanceMetrics = {
instance_id: instanceId,
timestamp: Date.now() / 1000,
aggregate: {},
}
// Query each metric type
const queries = {
gpu_utilization: `saladcloud_gpu_utilization_percent{machine_id="${instanceId}",container_group="${env.CONTAINER_GROUP_NAME}"}`,
cpu_utilization: `saladcloud_cpu_utilization_percent{machine_id="${instanceId}",container_group="${env.CONTAINER_GROUP_NAME}"}`,
memory_utilization: `saladcloud_memory_utilization_percent{machine_id="${instanceId}",container_group="${env.CONTAINER_GROUP_NAME}"}`,
gpu_temperature: `saladcloud_gpu_temperature_celsius{machine_id="${instanceId}",container_group="${env.CONTAINER_GROUP_NAME}"}`,
}
for (const [metricKey, query] of Object.entries(queries)) {
try {
const queryUrl = `${prometheusUrl}/api/v1/query?query=${encodeURIComponent(query)}&time=${endTime}`
const response = await fetch(queryUrl)
if (response.ok) {
const data = await response.json()
if (data.status === 'success' && data.data.result.length > 0) {
const value = parseFloat(data.data.result[0].value[1])
instanceMetrics.aggregate[metricKey] = value
}
}
} catch (error) {
console.error(`Error querying ${metricKey} for ${instanceId}:`, error)
}
}
if (Object.keys(instanceMetrics.aggregate).length > 0) {
metrics.push(instanceMetrics)
}
}
return metrics
}
// Option 3: Custom webhook storage (DynamoDB, etc.)
else if (env.METRICS_SOURCE === 'webhook') {
const webhookUrl = `${env.METRICS_WEBHOOK_URL}/metrics/query`
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.METRICS_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
container_group: env.CONTAINER_GROUP_NAME,
instance_ids: instances.map((i) => i.instance_id),
time_range: 300, // Last 5 minutes in seconds
}),
})
if (response.ok) {
const data = await response.json()
return data.metrics || []
}
}
// Option 4: InfluxDB or other time-series database
else if (env.METRICS_SOURCE === 'influxdb') {
const influxUrl = `${env.INFLUXDB_URL}/api/v2/query`
const query = `
from(bucket: "${env.INFLUXDB_BUCKET}")
|> range(start: -5m)
|> filter(fn: (r) => r._measurement == "hardware_metrics")
|> filter(fn: (r) => r.container_group == "${env.CONTAINER_GROUP_NAME}")
|> group(columns: ["instance_id"])
|> last()
`
const response = await fetch(influxUrl, {
method: 'POST',
headers: {
Authorization: `Token ${env.INFLUXDB_TOKEN}`,
'Content-Type': 'application/vnd.flux',
Accept: 'application/csv',
},
body: query,
})
if (response.ok) {
const csvData = await response.text()
return parseInfluxCSV(csvData)
}
}
} catch (error) {
console.error('Failed to collect metrics from store:', error)
}
return metrics
}
function parseInfluxCSV(csvData) {
// Simple CSV parser for InfluxDB response
const lines = csvData.split('\n').filter((line) => line.trim())
const headers = lines[0].split(',')
const metrics = []
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',')
const row = {}
headers.forEach((header, index) => {
row[header.trim()] = values[index]?.trim()
})
if (row.instance_id) {
metrics.push({
instance_id: row.instance_id,
timestamp: new Date(row._time).getTime() / 1000,
aggregate: {
gpu_utilization: parseFloat(row.gpu_utilization) || 0,
cpu_utilization: parseFloat(row.cpu_utilization) || 0,
memory_utilization: parseFloat(row.memory_utilization) || 0,
gpu_temperature: parseFloat(row.gpu_temperature) || 0,
},
})
}
}
return metrics
}
function calculateScaling(metrics, currentReplicas, config) {
const gpuUtils = metrics.map((m) => m.aggregate?.gpu_utilization).filter((util) => util !== undefined)
const cpuUtils = metrics.map((m) => m.aggregate?.cpu_utilization).filter((util) => util !== undefined)
if (gpuUtils.length === 0 && cpuUtils.length === 0) {
return currentReplicas
}
const avgGpuUtil = gpuUtils.length > 0 ? gpuUtils.reduce((a, b) => a + b, 0) / gpuUtils.length : 0
const avgCpuUtil = cpuUtils.reduce((a, b) => a + b, 0) / cpuUtils.length
// Scale up conditions
if ((gpuUtils.length > 0 && avgGpuUtil > config.gpuScaleUpThreshold) || avgCpuUtil > 80) {
return Math.min(currentReplicas + 2, config.maxReplicas)
}
// Scale down conditions
if (currentReplicas > config.minReplicas && avgGpuUtil < config.gpuScaleDownThreshold && avgCpuUtil < 40) {
return Math.max(currentReplicas - 1, config.minReplicas)
}
return currentReplicas
}