FinOps (Financial Operations) -- практика управления затратами на облачную инфраструктуру. Объединяет инженеров, финансистов и бизнес для принятия обоснованных решений о расходах.
package finops
import (
"fmt"
"math"
"sort"
)
const (
cpuThreshold = 0.40
memoryThreshold = 0.50
diskThreshold = 0.60
observationDays = 14
)
// Instance holds metrics for a cloud instance.
type Instance struct {
InstanceID string `json:"instance_id"`
InstanceType string `json:"instance_type"`
AvgCPU float64 `json:"avg_cpu"`
MaxCPU float64 `json:"max_cpu"`
AvgMemory float64 `json:"avg_memory"`
MaxMemory float64 `json:"max_memory"`
DiskUsage float64 `json:"disk_usage"`
MonthlyCost float64 `json:"monthly_cost"`
ObservationDays int `json:"observation_days"`
}
// Action represents a recommended optimization action.
type Action struct {
Type string `json:"type"`
Reason string `json:"reason"`
EstimatedSavings float64 `json:"estimated_savings"`
}
// Recommendation contains right-sizing advice for an instance.
type Recommendation struct {
InstanceID string `json:"instance_id"`
InstanceType string `json:"instance_type"`
CurrentCost float64 `json:"current_cost"`
Actions []Action `json:"actions"`
TotalSavings float64 `json:"total_savings"`
}
// Recommend generates right-sizing recommendations for a list of instances.
func Recommend(instances []Instance) []Recommendation {
var recs []Recommendation
for _, inst := range instances {
if inst.ObservationDays < observationDays {
continue
}
var actions []Action
// Completely idle
if inst.AvgCPU < 0.05 && inst.AvgMemory < 0.10 {
actions = []Action{{
Type: "terminate",
Reason: "Instance appears idle (CPU < 5%, Memory < 10%)",
EstimatedSavings: inst.MonthlyCost,
}}
} else {
if inst.AvgCPU < cpuThreshold && inst.MaxCPU < 0.80 {
actions = append(actions, Action{
Type: "downsize_cpu",
Reason: fmt.Sprintf("Average CPU %.1f%%, max %.1f%%", inst.AvgCPU*100, inst.MaxCPU*100),
EstimatedSavings: math.Round(inst.MonthlyCost*0.30*100) / 100,
})
}
if inst.AvgMemory < memoryThreshold && inst.MaxMemory < 0.80 {
actions = append(actions, Action{
Type: "downsize_memory",
Reason: fmt.Sprintf("Average memory %.1f%%, max %.1f%%", inst.AvgMemory*100, inst.MaxMemory*100),
EstimatedSavings: math.Round(inst.MonthlyCost*0.25*100) / 100,
})
}
if inst.DiskUsage < diskThreshold {
actions = append(actions, Action{
Type: "downsize_disk",
Reason: fmt.Sprintf("Disk usage %.1f%%", inst.DiskUsage*100),
EstimatedSavings: math.Round(inst.MonthlyCost*0.10*100) / 100,
})
}
}
if len(actions) > 0 {
var total float64
for _, a := range actions {
total += a.EstimatedSavings
}
recs = append(recs, Recommendation{
InstanceID: inst.InstanceID,
InstanceType: inst.InstanceType,
CurrentCost: inst.MonthlyCost,
Actions: actions,
TotalSavings: total,
})
}
}
sort.Slice(recs, func(i, j int) bool {
return recs[i].TotalSavings > recs[j].TotalSavings
})
return recs
}
// Instance holds observed metrics for a cloud instance.
public sealed record Instance(
string InstanceId,
string InstanceType,
double AvgCpu,
double MaxCpu,
double AvgMemory,
double MaxMemory,
double DiskUsage,
decimal MonthlyCost,
int ObservationDays);
// Action represents a recommended optimization action.
public sealed record Action(string Type, string Reason, decimal EstimatedSavings);
// Recommendation contains right-sizing advice for one instance.
public sealed record Recommendation(
string InstanceId,
string InstanceType,
decimal CurrentCost,
IReadOnlyList<Action> Actions,
decimal TotalSavings);
// RightSizingAdvisor picks instance sizes from real consumption.
public static class RightSizingAdvisor
{
private const double CpuThreshold = 0.40; // 40% average CPU
private const double MemoryThreshold = 0.50; // 50% average memory
private const double DiskThreshold = 0.60; // 60% disk usage
private const int ObservationDays = 14; // minimum observation period
public static IReadOnlyList<Recommendation> Recommend(IEnumerable<Instance> instances)
{
var recommendations = new List<Recommendation>();
foreach (var inst in instances)
{
// Skip instances without enough observation data
if (inst.ObservationDays < ObservationDays)
{
continue;
}
var actions = BuildActions(inst);
if (actions.Count == 0)
{
continue;
}
recommendations.Add(new Recommendation(
inst.InstanceId,
inst.InstanceType,
inst.MonthlyCost,
actions,
actions.Sum(a => a.EstimatedSavings)));
}
// Sort by potential savings (highest first)
return recommendations
.OrderByDescending(r => r.TotalSavings)
.ToList();
}
private static List<Action> BuildActions(Instance inst)
{
// Completely idle: terminating supersedes any downsizing
if (inst.AvgCpu < 0.05 && inst.AvgMemory < 0.10)
{
return
[
new Action(
"terminate",
"Instance appears idle (CPU < 5%, Memory < 10%)",
inst.MonthlyCost),
];
}
var actions = new List<Action>();
if (inst.AvgCpu < CpuThreshold && inst.MaxCpu < 0.80)
{
actions.Add(new Action(
"downsize_cpu",
$"Average CPU {inst.AvgCpu * 100:F1}%, max {inst.MaxCpu * 100:F1}%",
Math.Round(inst.MonthlyCost * 0.30m, 2)));
}
if (inst.AvgMemory < MemoryThreshold && inst.MaxMemory < 0.80)
{
actions.Add(new Action(
"downsize_memory",
$"Average memory {inst.AvgMemory * 100:F1}%, max {inst.MaxMemory * 100:F1}%",
Math.Round(inst.MonthlyCost * 0.25m, 2)));
}
if (inst.DiskUsage < DiskThreshold)
{
actions.Add(new Action(
"downsize_disk",
$"Disk usage {inst.DiskUsage * 100:F1}%",
Math.Round(inst.MonthlyCost * 0.10m, 2)));
}
return actions;
}
}
from collections.abc import Iterable
from dataclasses import dataclass
from decimal import Decimal
from typing import Final
CPU_THRESHOLD: Final = 0.40 # 40% average CPU
MEMORY_THRESHOLD: Final = 0.50 # 50% average memory
DISK_THRESHOLD: Final = 0.60 # 60% disk usage
MIN_OBSERVATION_DAYS: Final = 14 # minimum observation period
@dataclass(frozen=True, slots=True)
class Instance:
"""Observed metrics for a cloud instance."""
instance_id: str
instance_type: str
avg_cpu: float
max_cpu: float
avg_memory: float
max_memory: float
disk_usage: float
monthly_cost: Decimal
observation_days: int
@dataclass(frozen=True, slots=True)
class Action:
"""A recommended optimization action."""
type: str
reason: str
estimated_savings: Decimal
@dataclass(frozen=True, slots=True)
class Recommendation:
"""Right-sizing advice for one instance."""
instance_id: str
instance_type: str
current_cost: Decimal
actions: list[Action]
total_savings: Decimal
def _build_actions(inst: Instance) -> list[Action]:
# Completely idle: terminating supersedes any downsizing
if inst.avg_cpu < 0.05 and inst.avg_memory < 0.10:
return [
Action(
type="terminate",
reason="Instance appears idle (CPU < 5%, Memory < 10%)",
estimated_savings=inst.monthly_cost,
)
]
actions: list[Action] = []
if inst.avg_cpu < CPU_THRESHOLD and inst.max_cpu < 0.80:
actions.append(
Action(
type="downsize_cpu",
reason=f"Average CPU {inst.avg_cpu * 100:.1f}%, max {inst.max_cpu * 100:.1f}%",
estimated_savings=round(inst.monthly_cost * Decimal("0.30"), 2),
)
)
if inst.avg_memory < MEMORY_THRESHOLD and inst.max_memory < 0.80:
actions.append(
Action(
type="downsize_memory",
reason=f"Average memory {inst.avg_memory * 100:.1f}%, max {inst.max_memory * 100:.1f}%",
estimated_savings=round(inst.monthly_cost * Decimal("0.25"), 2),
)
)
if inst.disk_usage < DISK_THRESHOLD:
actions.append(
Action(
type="downsize_disk",
reason=f"Disk usage {inst.disk_usage * 100:.1f}%",
estimated_savings=round(inst.monthly_cost * Decimal("0.10"), 2),
)
)
return actions
def recommend(instances: Iterable[Instance]) -> list[Recommendation]:
"""Generate right-sizing recommendations, richest savings first."""
recommendations: list[Recommendation] = []
for inst in instances:
# Skip instances without enough observation data
if inst.observation_days < MIN_OBSERVATION_DAYS:
continue
actions = _build_actions(inst)
if not actions:
continue
recommendations.append(
Recommendation(
instance_id=inst.instance_id,
instance_type=inst.instance_type,
current_cost=inst.monthly_cost,
actions=actions,
total_savings=sum(
(a.estimated_savings for a in actions), Decimal(0)
),
)
)
recommendations.sort(key=lambda r: r.total_savings, reverse=True)
return recommendations
## Модели ценообразования
On-Demand vs Reserved vs Spot
Модель
Скидка
Обязательство
Риск прерывания
On-Demand
0%
Нет
Нет
Reserved (1 год)
~30-40%
1 год
Нет
Reserved (3 года)
~50-60%
3 года
Нет
Savings Plans
~30-50%
$/час обязательство
Нет
Spot Instances
~60-90%
Нет
Да (2 мин warning)
Spot Instances
<?php
declare(strict_types=1);
/**
* Spot instance management for fault-tolerant workloads
*/
final class SpotInstanceManager
{
/**
* Determine if workload is suitable for spot instances
*/
public function isSpotSuitable(array $workload): array
{
$suitable = true;
$reasons = [];
// Stateless check
if ($workload['stateful'] ?? false) {
$suitable = false;
$reasons[] = 'Stateful workloads risk data loss on interruption';
}
// Duration check
if (($workload['avg_duration_minutes'] ?? 0) > 60) {
$reasons[] = 'Long-running tasks may be interrupted; use checkpointing';
}
// Redundancy check
if (($workload['min_instances'] ?? 1) < 2) {
$reasons[] = 'Single instance -- no redundancy for interruptions';
}
// SLA check
if (($workload['sla_percent'] ?? 99) > 99.9) {
$suitable = false;
$reasons[] = 'High SLA requirement incompatible with spot interruptions';
}
return [
'suitable' => $suitable,
'reasons' => $reasons,
'recommendation' => $suitable
? 'Use spot with diversified instance types and AZs'
: 'Use on-demand or reserved instances',
];
}
/**
* Calculate optimal spot fleet configuration
*
* Diversify across instance types and AZs to reduce interruption risk
*/
public function calculateFleet(
int $requiredCapacity,
array $availableTypes,
array $availableAzs,
): array {
$fleet = [];
// Spread across at least 3 instance types and 2 AZs
$typesCount = min(count($availableTypes), max(3, count($availableTypes)));
$azsCount = min(count($availableAzs), 2);
$capacityPerGroup = (int) ceil(
$requiredCapacity / ($typesCount * $azsCount)
);
for ($t = 0; $t < $typesCount; $t++) {
for ($a = 0; $a < $azsCount; $a++) {
$fleet[] = [
'instance_type' => $availableTypes[$t],
'az' => $availableAzs[$a],
'count' => $capacityPerGroup,
'allocation_strategy' => 'capacity-optimized',
];
}
}
return [
'fleet' => $fleet,
'total_capacity' => $capacityPerGroup * $typesCount * $azsCount,
'diversification' => [
'instance_types' => $typesCount,
'availability_zones' => $azsCount,
],
];
}
}
package finops
import "math"
// Workload describes a workload to evaluate for spot suitability.
type Workload struct {
Stateful bool `json:"stateful"`
AvgDurationMinutes int `json:"avg_duration_minutes"`
MinInstances int `json:"min_instances"`
SLAPercent float64 `json:"sla_percent"`
}
// SpotSuitability holds the result of a spot assessment.
type SpotSuitability struct {
Suitable bool `json:"suitable"`
Reasons []string `json:"reasons"`
Recommendation string `json:"recommendation"`
}
// IsSpotSuitable determines if a workload is appropriate for spot instances.
func IsSpotSuitable(w Workload) SpotSuitability {
suitable := true
var reasons []string
if w.Stateful {
suitable = false
reasons = append(reasons, "Stateful workloads risk data loss on interruption")
}
if w.AvgDurationMinutes > 60 {
reasons = append(reasons, "Long-running tasks may be interrupted; use checkpointing")
}
if w.MinInstances < 2 {
reasons = append(reasons, "Single instance -- no redundancy for interruptions")
}
if w.SLAPercent > 99.9 {
suitable = false
reasons = append(reasons, "High SLA requirement incompatible with spot interruptions")
}
rec := "Use on-demand or reserved instances"
if suitable {
rec = "Use spot with diversified instance types and AZs"
}
return SpotSuitability{Suitable: suitable, Reasons: reasons, Recommendation: rec}
}
// FleetEntry represents one group in a spot fleet.
type FleetEntry struct {
InstanceType string `json:"instance_type"`
AZ string `json:"az"`
Count int `json:"count"`
AllocationStrategy string `json:"allocation_strategy"`
}
// FleetConfig holds the calculated fleet configuration.
type FleetConfig struct {
Fleet []FleetEntry `json:"fleet"`
TotalCapacity int `json:"total_capacity"`
InstanceTypes int `json:"instance_types"`
AvailabilityZones int `json:"availability_zones"`
}
// CalculateFleet computes an optimal spot fleet diversified across types and AZs.
func CalculateFleet(requiredCapacity int, availableTypes, availableAZs []string) FleetConfig {
typesCount := len(availableTypes)
if typesCount < 3 {
typesCount = len(availableTypes)
}
azsCount := len(availableAZs)
if azsCount > 2 {
azsCount = 2
}
capPerGroup := int(math.Ceil(float64(requiredCapacity) / float64(typesCount*azsCount)))
var fleet []FleetEntry
for t := 0; t < typesCount; t++ {
for a := 0; a < azsCount; a++ {
fleet = append(fleet, FleetEntry{
InstanceType: availableTypes[t],
AZ: availableAZs[a],
Count: capPerGroup,
AllocationStrategy: "capacity-optimized",
})
}
}
return FleetConfig{
Fleet: fleet,
TotalCapacity: capPerGroup * typesCount * azsCount,
InstanceTypes: typesCount,
AvailabilityZones: azsCount,
}
}
// Workload describes a workload to evaluate for spot suitability.
public sealed record Workload(
bool Stateful,
int AvgDurationMinutes,
int MinInstances,
double SlaPercent);
// SpotSuitability holds the result of a spot assessment.
public sealed record SpotSuitability(
bool Suitable,
IReadOnlyList<string> Reasons,
string Recommendation);
// FleetEntry represents one group in a spot fleet.
public sealed record FleetEntry(
string InstanceType,
string Az,
int Count,
string AllocationStrategy);
// FleetConfig holds the calculated fleet configuration.
public sealed record FleetConfig(
IReadOnlyList<FleetEntry> Fleet,
int TotalCapacity,
int InstanceTypes,
int AvailabilityZones);
// SpotInstanceManager plans spot usage for fault-tolerant workloads.
public static class SpotInstanceManager
{
private const int MaxAvailabilityZones = 2;
public static SpotSuitability IsSpotSuitable(Workload workload)
{
var suitable = true;
var reasons = new List<string>();
// Stateless check
if (workload.Stateful)
{
suitable = false;
reasons.Add("Stateful workloads risk data loss on interruption");
}
// Duration check
if (workload.AvgDurationMinutes > 60)
{
reasons.Add("Long-running tasks may be interrupted; use checkpointing");
}
// Redundancy check
if (workload.MinInstances < 2)
{
reasons.Add("Single instance -- no redundancy for interruptions");
}
// SLA check
if (workload.SlaPercent > 99.9)
{
suitable = false;
reasons.Add("High SLA requirement incompatible with spot interruptions");
}
return new SpotSuitability(
suitable,
reasons,
suitable
? "Use spot with diversified instance types and AZs"
: "Use on-demand or reserved instances");
}
// CalculateFleet diversifies across instance types and AZs to cut interruption risk.
public static FleetConfig CalculateFleet(
int requiredCapacity,
IReadOnlyList<string> availableTypes,
IReadOnlyList<string> availableAzs)
{
var typesCount = availableTypes.Count;
var azsCount = Math.Min(availableAzs.Count, MaxAvailabilityZones);
if (typesCount == 0 || azsCount == 0)
{
throw new ArgumentException("at least one instance type and one AZ are required");
}
var capacityPerGroup = (int)Math.Ceiling(
(double)requiredCapacity / (typesCount * azsCount));
var fleet = new List<FleetEntry>(typesCount * azsCount);
foreach (var type in availableTypes)
{
foreach (var az in availableAzs.Take(azsCount))
{
fleet.Add(new FleetEntry(type, az, capacityPerGroup, "capacity-optimized"));
}
}
return new FleetConfig(
fleet,
capacityPerGroup * typesCount * azsCount,
typesCount,
azsCount);
}
}
import math
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Final
MAX_AVAILABILITY_ZONES: Final = 2
@dataclass(frozen=True, slots=True)
class Workload:
"""A workload to evaluate for spot suitability."""
stateful: bool
avg_duration_minutes: int
min_instances: int
sla_percent: float
@dataclass(frozen=True, slots=True)
class SpotSuitability:
"""Result of a spot assessment."""
suitable: bool
reasons: list[str]
recommendation: str
@dataclass(frozen=True, slots=True)
class FleetEntry:
"""One group in a spot fleet."""
instance_type: str
az: str
count: int
allocation_strategy: str
@dataclass(frozen=True, slots=True)
class FleetConfig:
"""Calculated fleet configuration."""
fleet: list[FleetEntry]
total_capacity: int
instance_types: int
availability_zones: int
def is_spot_suitable(workload: Workload) -> SpotSuitability:
"""Determine if a workload is appropriate for spot instances."""
suitable = True
reasons: list[str] = []
# Stateless check
if workload.stateful:
suitable = False
reasons.append("Stateful workloads risk data loss on interruption")
# Duration check
if workload.avg_duration_minutes > 60:
reasons.append("Long-running tasks may be interrupted; use checkpointing")
# Redundancy check
if workload.min_instances < 2:
reasons.append("Single instance -- no redundancy for interruptions")
# SLA check
if workload.sla_percent > 99.9:
suitable = False
reasons.append("High SLA requirement incompatible with spot interruptions")
return SpotSuitability(
suitable=suitable,
reasons=reasons,
recommendation=(
"Use spot with diversified instance types and AZs"
if suitable
else "Use on-demand or reserved instances"
),
)
def calculate_fleet(
required_capacity: int,
available_types: Sequence[str],
available_azs: Sequence[str],
) -> FleetConfig:
"""Diversify across instance types and AZs to cut interruption risk."""
types_count = len(available_types)
azs_count = min(len(available_azs), MAX_AVAILABILITY_ZONES)
if types_count == 0 or azs_count == 0:
raise ValueError("at least one instance type and one AZ are required")
capacity_per_group = math.ceil(required_capacity / (types_count * azs_count))
fleet = [
FleetEntry(
instance_type=instance_type,
az=az,
count=capacity_per_group,
allocation_strategy="capacity-optimized",
)
for instance_type in available_types
for az in available_azs[:azs_count]
]
return FleetConfig(
fleet=fleet,
total_capacity=capacity_per_group * types_count * azs_count,
instance_types=types_count,
availability_zones=azs_count,
)
package finops
import (
"context"
"fmt"
"math"
"time"
)
// CostAPI provides cost data for teams.
type CostAPI interface {
GetMonthToDateCost(ctx context.Context, team string) (float64, error)
}
// AlertSender sends budget alerts.
type AlertSender interface {
Send(ctx context.Context, level, message string) error
}
// Budget defines a team's monthly budget and alert thresholds.
type Budget struct {
MonthlyBudget float64 `json:"monthly_budget"`
AlertThresholds []float64 `json:"alert_thresholds"` // e.g., [0.50, 0.80, 1.00]
}
// BudgetResult holds the result of a budget check for one team.
type BudgetResult struct {
Budget float64 `json:"budget"`
CurrentSpend float64 `json:"current_spend"`
SpendPercent float64 `json:"spend_percent"`
ForecastedSpend float64 `json:"forecasted_spend"`
ForecastPercent float64 `json:"forecast_percent"`
Status string `json:"status"`
MonthProgress float64 `json:"month_progress"`
}
// BudgetMonitor checks team budgets and sends alerts.
type BudgetMonitor struct {
costAPI CostAPI
alerts AlertSender
}
// NewBudgetMonitor creates a new BudgetMonitor.
func NewBudgetMonitor(api CostAPI, alerts AlertSender) *BudgetMonitor {
return &BudgetMonitor{costAPI: api, alerts: alerts}
}
// CheckBudgets evaluates each team's budget and triggers alerts.
func (m *BudgetMonitor) CheckBudgets(ctx context.Context, budgets map[string]Budget) (map[string]BudgetResult, error) {
now := time.Now()
daysInMonth := float64(time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, now.Location()).Day())
dayOfMonth := float64(now.Day())
monthProgress := dayOfMonth / daysInMonth
results := make(map[string]BudgetResult, len(budgets))
for team, b := range budgets {
spend, err := m.costAPI.GetMonthToDateCost(ctx, team)
if err != nil {
return nil, fmt.Errorf("cost API error for %s: %w", team, err)
}
spendPct := spend / b.MonthlyBudget
forecast := spend / monthProgress
forecastPct := forecast / b.MonthlyBudget
status := "ok"
for _, threshold := range b.AlertThresholds {
if spendPct >= threshold {
switch {
case threshold >= 1.0:
status = "critical"
case threshold >= 0.8:
status = "warning"
default:
status = "info"
}
msg := fmt.Sprintf("Team %s: %.0f%% of budget used (%.0f%% of month elapsed). Forecast: $%.2f / $%.2f",
team, spendPct*100, monthProgress*100, forecast, b.MonthlyBudget)
_ = m.alerts.Send(ctx, status, msg)
}
}
results[team] = BudgetResult{
Budget: b.MonthlyBudget,
CurrentSpend: math.Round(spend*100) / 100,
SpendPercent: math.Round(spendPct*1000) / 10,
ForecastedSpend: math.Round(forecast*100) / 100,
ForecastPercent: math.Round(forecastPct*1000) / 10,
Status: status,
MonthProgress: math.Round(monthProgress*1000) / 10,
}
}
return results, nil
}
using Microsoft.Extensions.Logging;
// ICostApi provides cost data for teams.
public interface ICostApi
{
Task<decimal> GetMonthToDateCostAsync(string team, CancellationToken ct = default);
}
// IAlertService sends budget alerts.
public interface IAlertService
{
Task SendAsync(string level, string message, CancellationToken ct = default);
}
// Budget defines a team's monthly budget and alert thresholds.
public sealed record Budget(decimal MonthlyBudget, IReadOnlyList<double> AlertThresholds);
// BudgetResult holds the result of a budget check for one team.
public sealed record BudgetResult(
decimal Budget,
decimal CurrentSpend,
double SpendPercent,
decimal ForecastedSpend,
double ForecastPercent,
string Status,
double MonthProgress);
// BudgetMonitor checks team budgets and sends alerts.
public sealed class BudgetMonitor(
ICostApi costApi,
IAlertService alerts,
TimeProvider timeProvider,
ILogger<BudgetMonitor> logger)
{
public async Task<IReadOnlyDictionary<string, BudgetResult>> CheckBudgetsAsync(
IReadOnlyDictionary<string, Budget> budgets,
CancellationToken ct = default)
{
var today = timeProvider.GetUtcNow();
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
var monthProgress = (double)today.Day / daysInMonth;
var results = new Dictionary<string, BudgetResult>(budgets.Count);
foreach (var (team, budget) in budgets)
{
var currentSpend = await costApi.GetMonthToDateCostAsync(team, ct);
var spendPercent = (double)(currentSpend / budget.MonthlyBudget);
// Forecast the full month from the current burn rate
var forecastedSpend = currentSpend / (decimal)monthProgress;
var forecastPercent = (double)(forecastedSpend / budget.MonthlyBudget);
var status = "ok";
// Thresholds are e.g. [0.50, 0.80, 1.00]
foreach (var threshold in budget.AlertThresholds)
{
if (spendPercent < threshold)
{
continue;
}
status = threshold switch
{
>= 1.0 => "critical",
>= 0.8 => "warning",
_ => "info",
};
var message = string.Format(
"Team {0}: {1:F0}% of budget used ({2:F0}% of month elapsed). Forecast: ${3:F2} / ${4:F2}",
team, spendPercent * 100, monthProgress * 100, forecastedSpend, budget.MonthlyBudget);
await alerts.SendAsync(status, message, ct);
logger.LogInformation("budget alert sent for {Team}: {Status}", team, status);
}
results[team] = new BudgetResult(
budget.MonthlyBudget,
Math.Round(currentSpend, 2),
Math.Round(spendPercent * 100, 1),
Math.Round(forecastedSpend, 2),
Math.Round(forecastPercent * 100, 1),
status,
Math.Round(monthProgress * 100, 1));
}
return results;
}
}
import calendar
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from typing import Protocol
logger = logging.getLogger(__name__)
class CostApi(Protocol):
"""Provides cost data for teams."""
async def get_month_to_date_cost(self, team: str) -> Decimal: ...
class AlertService(Protocol):
"""Sends budget alerts."""
async def send(self, level: str, message: str) -> None: ...
@dataclass(frozen=True, slots=True)
class Budget:
"""A team's monthly budget and alert thresholds, e.g. [0.5, 0.8, 1.0]."""
monthly_budget: Decimal
alert_thresholds: list[float]
@dataclass(frozen=True, slots=True)
class BudgetResult:
"""Result of a budget check for one team."""
budget: Decimal
current_spend: Decimal
spend_percent: float
forecasted_spend: Decimal
forecast_percent: float
status: str
month_progress: float
class BudgetMonitor:
"""Checks team budgets and sends alerts."""
def __init__(self, cost_api: CostApi, alerts: AlertService) -> None:
self._cost_api = cost_api
self._alerts = alerts
async def check_budgets(
self, budgets: dict[str, Budget]
) -> dict[str, BudgetResult]:
today = datetime.now(UTC)
days_in_month = calendar.monthrange(today.year, today.month)[1]
month_progress = today.day / days_in_month
results: dict[str, BudgetResult] = {}
for team, budget in budgets.items():
current_spend = await self._cost_api.get_month_to_date_cost(team)
spend_percent = float(current_spend / budget.monthly_budget)
# Forecast the full month from the current burn rate
forecasted_spend = current_spend / Decimal(month_progress)
forecast_percent = float(forecasted_spend / budget.monthly_budget)
status = "ok"
for threshold in budget.alert_thresholds:
if spend_percent < threshold:
continue
if threshold >= 1.0:
status = "critical"
elif threshold >= 0.8:
status = "warning"
else:
status = "info"
await self._alerts.send(
status,
f"Team {team}: {spend_percent * 100:.0f}% of budget used "
f"({month_progress * 100:.0f}% of month elapsed). "
f"Forecast: ${forecasted_spend:.2f} / ${budget.monthly_budget:.2f}",
)
logger.info(
"budget alert sent", extra={"team": team, "status": status}
)
results[team] = BudgetResult(
budget=budget.monthly_budget,
current_spend=round(current_spend, 2),
spend_percent=round(spend_percent * 100, 1),
forecasted_spend=round(forecasted_spend, 2),
forecast_percent=round(forecast_percent * 100, 1),
status=status,
month_progress=round(month_progress * 100, 1),
)
return results
### Автоматическое выключение Dev/Staging
<?php
declare(strict_types=1);
/**
* Schedule-based resource management for non-production environments
*
* Dev/staging environments don't need to run 24/7.
* Running only during business hours saves ~65% of compute costs.
*/
final class EnvironmentScheduler
{
/**
* Business hours: Mon-Fri, 08:00-20:00 local time
*/
private const BUSINESS_HOURS_START = 8;
private const BUSINESS_HOURS_END = 20;
public function __construct(
private readonly CloudApiClient $cloudApi,
private readonly CostTracker $costTracker,
) {}
/**
* Determine if environment should be running right now
*/
public function shouldBeRunning(string $environment, string $timezone): bool
{
if ($environment === 'production') {
return true; // Production always runs
}
$now = new \DateTimeImmutable('now', new \DateTimeZone($timezone));
$hour = (int) $now->format('G');
$dayOfWeek = (int) $now->format('N'); // 1 (Mon) - 7 (Sun)
// Weekends: off
if ($dayOfWeek >= 6) {
return false;
}
// Outside business hours: off
return $hour >= self::BUSINESS_HOURS_START
&& $hour < self::BUSINESS_HOURS_END;
}
/**
* Apply schedule to all non-production environments
*
* @param array<int, array{name: string, timezone: string, resources: array<string>}> $environments
*/
public function applySchedule(array $environments): array
{
$actions = [];
foreach ($environments as $env) {
$shouldRun = $this->shouldBeRunning($env['name'], $env['timezone']);
foreach ($env['resources'] as $resourceId) {
$isRunning = $this->cloudApi->isRunning($resourceId);
if ($shouldRun && !$isRunning) {
$this->cloudApi->start($resourceId);
$actions[] = [
'resource' => $resourceId,
'action' => 'started',
'environment' => $env['name'],
];
} elseif (!$shouldRun && $isRunning) {
$this->cloudApi->stop($resourceId);
$actions[] = [
'resource' => $resourceId,
'action' => 'stopped',
'environment' => $env['name'],
];
$this->costTracker->recordSaving(
resourceId: $resourceId,
hoursSaved: self::BUSINESS_HOURS_END - self::BUSINESS_HOURS_START,
);
}
}
}
return $actions;
}
/**
* Calculate monthly savings from scheduling
*/
public function estimateSavings(float $monthlyOnDemandCost): array
{
$totalHoursInMonth = 730;
$businessHoursPerDay = self::BUSINESS_HOURS_END - self::BUSINESS_HOURS_START; // 12
$businessDaysPerMonth = 22;
$runningHours = $businessHoursPerDay * $businessDaysPerMonth; // 264
$utilizationPercent = $runningHours / $totalHoursInMonth;
$savings = $monthlyOnDemandCost * (1 - $utilizationPercent);
return [
'running_hours' => $runningHours,
'total_hours' => $totalHoursInMonth,
'utilization' => round($utilizationPercent * 100, 1),
'monthly_savings' => round($savings, 2),
'savings_percent' => round((1 - $utilizationPercent) * 100, 1),
];
}
}
package finops
import (
"context"
"math"
"time"
)
const (
businessHoursStart = 8
businessHoursEnd = 20
)
// CloudAPI manages cloud resource lifecycle.
type CloudAPI interface {
IsRunning(ctx context.Context, resourceID string) (bool, error)
Start(ctx context.Context, resourceID string) error
Stop(ctx context.Context, resourceID string) error
}
// ScheduleAction records an action taken on a resource.
type ScheduleAction struct {
Resource string `json:"resource"`
Action string `json:"action"`
Environment string `json:"environment"`
}
// Environment describes a non-production environment with its resources.
type Environment struct {
Name string `json:"name"`
Timezone string `json:"timezone"`
Resources []string `json:"resources"`
}
// ShouldBeRunning determines if an environment should be active right now.
func ShouldBeRunning(environment, timezone string) bool {
if environment == "production" {
return true
}
loc, err := time.LoadLocation(timezone)
if err != nil {
return true // Fail open
}
now := time.Now().In(loc)
weekday := now.Weekday()
// Weekends: off
if weekday == time.Saturday || weekday == time.Sunday {
return false
}
hour := now.Hour()
return hour >= businessHoursStart && hour < businessHoursEnd
}
// ApplySchedule starts or stops resources based on business hours.
func ApplySchedule(ctx context.Context, api CloudAPI, environments []Environment) ([]ScheduleAction, error) {
var actions []ScheduleAction
for _, env := range environments {
shouldRun := ShouldBeRunning(env.Name, env.Timezone)
for _, resID := range env.Resources {
running, err := api.IsRunning(ctx, resID)
if err != nil {
return nil, err
}
if shouldRun && !running {
if err := api.Start(ctx, resID); err != nil {
return nil, err
}
actions = append(actions, ScheduleAction{Resource: resID, Action: "started", Environment: env.Name})
} else if !shouldRun && running {
if err := api.Stop(ctx, resID); err != nil {
return nil, err
}
actions = append(actions, ScheduleAction{Resource: resID, Action: "stopped", Environment: env.Name})
}
}
}
return actions, nil
}
// SavingsEstimate holds estimated savings from scheduling.
type SavingsEstimate struct {
RunningHours int `json:"running_hours"`
TotalHours int `json:"total_hours"`
Utilization float64 `json:"utilization"`
MonthlySavings float64 `json:"monthly_savings"`
SavingsPercent float64 `json:"savings_percent"`
}
// EstimateSavings calculates monthly savings from business-hours-only scheduling.
func EstimateSavings(monthlyOnDemandCost float64) SavingsEstimate {
const totalHours = 730
businessHoursPerDay := businessHoursEnd - businessHoursStart // 12
businessDays := 22
runningHours := businessHoursPerDay * businessDays // 264
utilization := float64(runningHours) / totalHours
savings := monthlyOnDemandCost * (1 - utilization)
return SavingsEstimate{
RunningHours: runningHours,
TotalHours: totalHours,
Utilization: math.Round(utilization*1000) / 10,
MonthlySavings: math.Round(savings*100) / 100,
SavingsPercent: math.Round((1-utilization)*1000) / 10,
}
}
// ICloudApi manages cloud resource lifecycle.
public interface ICloudApi
{
Task<bool> IsRunningAsync(string resourceId, CancellationToken ct = default);
Task StartAsync(string resourceId, CancellationToken ct = default);
Task StopAsync(string resourceId, CancellationToken ct = default);
}
// ICostTracker records realized savings.
public interface ICostTracker
{
Task RecordSavingAsync(string resourceId, int hoursSaved, CancellationToken ct = default);
}
// Environment describes a non-production environment and its resources.
public sealed record Environment(string Name, string Timezone, IReadOnlyList<string> Resources);
// ScheduleAction records an action taken on a resource.
public sealed record ScheduleAction(string Resource, string Action, string Environment);
// SavingsEstimate holds estimated savings from scheduling.
public sealed record SavingsEstimate(
int RunningHours,
int TotalHours,
double Utilization,
decimal MonthlySavings,
double SavingsPercent);
/// <summary>
/// Schedule-based resource management for non-production environments.
///
/// Dev/staging environments don't need to run 24/7. Running them only during
/// business hours saves ~65% of compute costs.
/// </summary>
public sealed class EnvironmentScheduler(ICloudApi cloudApi, ICostTracker costTracker)
{
// Business hours: Mon-Fri, 08:00-20:00 local time
private const int BusinessHoursStart = 8;
private const int BusinessHoursEnd = 20;
public static bool ShouldBeRunning(string environment, string timezone)
{
if (environment == "production")
{
return true; // Production always runs
}
var tz = TimeZoneInfo.FindSystemTimeZoneById(timezone);
var now = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz);
// Weekends: off
if (now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
{
return false;
}
// Outside business hours: off
return now.Hour >= BusinessHoursStart && now.Hour < BusinessHoursEnd;
}
public async Task<IReadOnlyList<ScheduleAction>> ApplyScheduleAsync(
IEnumerable<Environment> environments,
CancellationToken ct = default)
{
var actions = new List<ScheduleAction>();
foreach (var env in environments)
{
var shouldRun = ShouldBeRunning(env.Name, env.Timezone);
foreach (var resourceId in env.Resources)
{
var isRunning = await cloudApi.IsRunningAsync(resourceId, ct);
if (shouldRun && !isRunning)
{
await cloudApi.StartAsync(resourceId, ct);
actions.Add(new ScheduleAction(resourceId, "started", env.Name));
}
else if (!shouldRun && isRunning)
{
await cloudApi.StopAsync(resourceId, ct);
actions.Add(new ScheduleAction(resourceId, "stopped", env.Name));
await costTracker.RecordSavingAsync(
resourceId,
BusinessHoursEnd - BusinessHoursStart,
ct);
}
}
}
return actions;
}
// EstimateSavings calculates monthly savings from business-hours-only scheduling.
public static SavingsEstimate EstimateSavings(decimal monthlyOnDemandCost)
{
const int totalHoursInMonth = 730;
const int businessDaysPerMonth = 22;
var runningHours = (BusinessHoursEnd - BusinessHoursStart) * businessDaysPerMonth; // 264
var utilization = (double)runningHours / totalHoursInMonth;
var savings = monthlyOnDemandCost * (decimal)(1 - utilization);
return new SavingsEstimate(
runningHours,
totalHoursInMonth,
Math.Round(utilization * 100, 1),
Math.Round(savings, 2),
Math.Round((1 - utilization) * 100, 1));
}
}
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from typing import Final, Protocol
from zoneinfo import ZoneInfo
# Business hours: Mon-Fri, 08:00-20:00 local time
BUSINESS_HOURS_START: Final = 8
BUSINESS_HOURS_END: Final = 20
TOTAL_HOURS_IN_MONTH: Final = 730
BUSINESS_DAYS_PER_MONTH: Final = 22
class CloudApi(Protocol):
"""Manages cloud resource lifecycle."""
async def is_running(self, resource_id: str) -> bool: ...
async def start(self, resource_id: str) -> None: ...
async def stop(self, resource_id: str) -> None: ...
class CostTracker(Protocol):
"""Records realized savings."""
async def record_saving(self, resource_id: str, hours_saved: int) -> None: ...
@dataclass(frozen=True, slots=True)
class Environment:
"""A non-production environment and its resources."""
name: str
timezone: str
resources: list[str]
@dataclass(frozen=True, slots=True)
class ScheduleAction:
"""An action taken on a resource."""
resource: str
action: str
environment: str
@dataclass(frozen=True, slots=True)
class SavingsEstimate:
"""Estimated savings from scheduling."""
running_hours: int
total_hours: int
utilization: float
monthly_savings: Decimal
savings_percent: float
def should_be_running(environment: str, timezone: str) -> bool:
"""Determine if an environment should be active right now."""
if environment == "production":
return True # Production always runs
now = datetime.now(UTC).astimezone(ZoneInfo(timezone))
# Weekends: off
if now.weekday() >= 5:
return False
# Outside business hours: off
return BUSINESS_HOURS_START <= now.hour < BUSINESS_HOURS_END
class EnvironmentScheduler:
"""Schedule-based resource management for non-production environments.
Dev/staging environments don't need to run 24/7. Running them only during
business hours saves ~65% of compute costs.
"""
def __init__(self, cloud_api: CloudApi, cost_tracker: CostTracker) -> None:
self._cloud_api = cloud_api
self._cost_tracker = cost_tracker
async def apply_schedule(
self, environments: Iterable[Environment]
) -> list[ScheduleAction]:
actions: list[ScheduleAction] = []
for env in environments:
should_run = should_be_running(env.name, env.timezone)
for resource_id in env.resources:
is_running = await self._cloud_api.is_running(resource_id)
if should_run and not is_running:
await self._cloud_api.start(resource_id)
actions.append(ScheduleAction(resource_id, "started", env.name))
elif not should_run and is_running:
await self._cloud_api.stop(resource_id)
actions.append(ScheduleAction(resource_id, "stopped", env.name))
await self._cost_tracker.record_saving(
resource_id, BUSINESS_HOURS_END - BUSINESS_HOURS_START
)
return actions
def estimate_savings(monthly_on_demand_cost: Decimal) -> SavingsEstimate:
"""Monthly savings from business-hours-only scheduling."""
running_hours = (
BUSINESS_HOURS_END - BUSINESS_HOURS_START
) * BUSINESS_DAYS_PER_MONTH # 264
utilization = running_hours / TOTAL_HOURS_IN_MONTH
savings = monthly_on_demand_cost * Decimal(1 - utilization)
return SavingsEstimate(
running_hours=running_hours,
total_hours=TOTAL_HOURS_IN_MONTH,
utilization=round(utilization * 100, 1),
monthly_savings=round(savings, 2),
savings_percent=round((1 - utilization) * 100, 1),
)
## Тегирование и аллокация
Стратегия тегирования
Тег
Назначение
Пример
team
Аллокация на команду
backend, data, platform
environment
Разделение по среде
production, staging, dev
service
Привязка к сервису
api, worker, scheduler
cost-center
Финансовый центр
engineering, marketing
managed-by
IaC или ручное
terraform, manual
Правило: Тегирование -- фундамент FinOps. Без тегов невозможно понять, кто и за что платит. Установите mandatory tags policy и блокируйте создание ресурсов без обязательных тегов.
Чек-лист оптимизации
Категория
Действие
Типичная экономия
Compute
Right-sizing по метрикам
20-40%
Compute
Spot для stateless workloads
60-90%
Compute
Reserved для baseline нагрузки
30-60%
Compute
Выключение Dev/Staging ночью
65%
Storage
Lifecycle policies (S3 → Glacier)
50-80%
Storage
Удаление неиспользуемых EBS volumes
100%
Database
Right-sizing RDS instances
20-40%
Database
Reserved instances для production DB
30-50%
Network
VPC endpoints вместо NAT Gateway
50-70%
Network
CloudFront для static content
30-50%
Containers
Kubernetes cluster autoscaler
30-50%
Containers
Karpenter для node provisioning
20-40%
Итоги
FinOps объединяет инженеров и финансы для управления облачными затратами
Right-sizing на основе метрик -- первый и самый простой шаг оптимизации
Spot instances дают максимальную экономию для fault-tolerant workloads
Reserved capacity выгодна при стабильной baseline-нагрузке (> 70% utilization)