Dataops airflow cost optimizer
Skill ivanshamaev/de-agent-skills/group_skills/infra_dataops_group_skills/dataops_airflow_cost_optimizer
Профессиональные Data Engineering Agent Skills для разработки AI Agentic Data Platform
npx -y skills add ivanshamaev/de-agent-skills --skill dataops_airflow_cost_optimizerAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Airflow cost optimization — KubernetesPodOperator right-sizing (request vs actual CPU/memory), spot node tolerations for batch tasks, task consolidation (reduce pod-per-task overhead), pool-based concurrency control, idle worker cleanup, log retention policy (S3 lifecycle), metadata DB right-sizing, CeleryExecutor worker autoscaling (KEDA Kafka/queue depth), DAG run frequency analysis (oversceduled DAGs), cloud cost attribution per DAG
SKILL.md
9.4 KB, as published. Nobody here has run it
Airflow Cost Optimizer
When to Use
- Airflow cloud bill growing disproportionately to pipeline count
- KubernetesExecutor creating excessive pods for small tasks
- Workers over-provisioned with unused CPU/memory
- Log storage accumulating without retention policy
- Teams running redundant pipelines without visibility
KubernetesPodOperator Right-Sizing
# Step 1: Identify tasks with large resource requests vs actual usage
# Check actual usage in Grafana: container_cpu_usage_seconds_total{pod=~"airflow-.*"}
# ❌ Over-provisioned — requesting 4 CPU for a task that uses 200m
from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator
from kubernetes.client import models as k8s
over_provisioned = KubernetesPodOperator(
task_id="transform",
image="my-etl:latest",
container_resources=k8s.V1ResourceRequirements(
requests={"cpu": "4", "memory": "16Gi"}, # wastes 3.8 CPU
limits={"cpu": "4", "memory": "16Gi"},
),
)
# ✅ Right-sized: P95 actual + 20% headroom
right_sized = KubernetesPodOperator(
task_id="transform",
image="my-etl:latest",
container_resources=k8s.V1ResourceRequirements(
requests={"cpu": "250m", "memory": "512Mi"}, # P95 actual
limits={"cpu": "1", "memory": "2Gi"}, # burst headroom
),
)
# Query P95 CPU and memory for each airflow pod in last 7 days
kubectl top pods -n airflow --sort-by=cpu
# Prometheus: P95 CPU per airflow worker pod
quantile_over_time(0.95,
rate(container_cpu_usage_seconds_total{
namespace="airflow",
pod=~"airflow-run-.*"
}[5m])[7d:5m]
) * 1000 # millicores
Spot Nodes for Batch Tasks
# Tolerate spot interruption for non-critical batch tasks
from kubernetes.client import models as k8s
spot_tolerations = [
k8s.V1Toleration(
key="cloud.google.com/gke-spot",
operator="Equal",
value="true",
effect="NoSchedule",
)
]
spot_affinity = k8s.V1Affinity(
node_affinity=k8s.V1NodeAffinity(
preferred_during_scheduling_ignored_during_execution=[
k8s.V1PreferredSchedulingTerm(
weight=100,
preference=k8s.V1NodeSelectorTerm(
match_expressions=[
k8s.V1NodeSelectorRequirement(
key="cloud.google.com/gke-spot",
operator="In",
values=["true"],
)
]
),
)
]
)
)
batch_task = KubernetesPodOperator(
task_id="heavy_batch_transform",
image="spark-etl:latest",
tolerations=spot_tolerations,
affinity=spot_affinity,
is_delete_operator_pod=True,
# Handle SIGTERM from spot preemption
termination_grace_period=120,
)
Task Consolidation
# ❌ Expensive: 10 separate pods for small tasks (10x pod startup overhead)
for table in TABLES:
KubernetesPodOperator(
task_id=f"load_{table}",
image="loader:latest",
arguments=["--table", table],
)
# ✅ Cheaper: 1 pod that processes all tables sequentially
KubernetesPodOperator(
task_id="load_all_tables",
image="loader:latest",
arguments=["--tables", ",".join(TABLES)],
)
# Or: TaskFlow with PythonOperator (no pod overhead)
@task
def load_tables(tables: list[str], ds: str):
for table in tables:
load_table(table, ds)
CeleryExecutor Autoscaling with KEDA
# KEDA ScaledObject for Celery workers
# Scale based on Celery queue depth (Redis)
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: airflow-worker
namespace: airflow
spec:
scaleTargetRef:
name: airflow-worker
pollingInterval: 15
cooldownPeriod: 60
minReplicaCount: 1 # always keep 1 worker (fast startup)
maxReplicaCount: 20
triggers:
- type: redis
metadata:
address: redis-master.airflow.svc.cluster.local:6379
listName: celery # Celery uses a Redis list as queue
listLength: "5" # scale up when queue > 5 tasks per worker
Log Retention Policy
# Terraform: S3 lifecycle for Airflow logs
resource "aws_s3_bucket_lifecycle_configuration" "airflow_logs" {
bucket = aws_s3_bucket.airflow_logs.id
rule {
id = "task-logs-retention"
status = "Enabled"
filter { prefix = "task-logs/" }
# Move to infrequent access after 30 days
transition {
days = 30
storage_class = "STANDARD_IA"
}
# Delete after 90 days
expiration {
days = 90
}
}
}
# Check current log storage size
aws s3 ls s3://my-airflow-logs/task-logs/ --recursive --human-readable --summarize \
| grep "Total Size"
# Find oldest logs
aws s3 ls s3://my-airflow-logs/task-logs/ | sort | head -20
Metadata DB Right-Sizing
-- Identify table sizes driving DB storage cost
SELECT
relname AS table_name,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
pg_size_pretty(pg_relation_size(relid)) AS data_size,
n_live_tup AS live_rows
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;
-- Overly frequent cleanup: remove task instances older than 90 days
DELETE FROM task_instance
WHERE start_date < NOW() - INTERVAL '90 days'
AND state IN ('success', 'failed', 'skipped');
-- Remove XCom values older than 7 days (these can be large)
DELETE FROM xcom
WHERE timestamp < NOW() - INTERVAL '7 days';
Over-Scheduled DAG Detection
-- DAGs that run more frequently than they need to
SELECT
dag_id,
schedule_interval,
COUNT(*) AS run_count,
AVG(EXTRACT(EPOCH FROM (end_date - start_date)) / 60) AS avg_duration_min,
COUNT(*) * AVG(EXTRACT(EPOCH FROM (end_date - start_date)) / 60) AS total_compute_min
FROM dag_run
WHERE state = 'success'
AND start_date > NOW() - INTERVAL '7 days'
GROUP BY dag_id, schedule_interval
HAVING schedule_interval IN ('* * * * *', '*/5 * * * *', '*/15 * * * *') -- frequent schedules
ORDER BY total_compute_min DESC
LIMIT 20;
Cost Attribution by DAG
# Estimate Kubernetes compute cost per DAG
# (requires pod labels with dag_id)
import subprocess, json
def get_pod_costs_by_dag(namespace: str = "airflow") -> dict:
"""Estimate monthly cost per DAG based on pod CPU*seconds."""
result = subprocess.run(
["kubectl", "get", "pods", "-n", namespace,
"-l", "dag_id", "-o", "json"],
capture_output=True, text=True
)
pods = json.loads(result.stdout)
costs = {}
CPU_COST_PER_CORE_HOUR = 0.048 # $/core-hour (e.g., GKE n2-standard-4)
for pod in pods["items"]:
dag_id = pod["metadata"]["labels"].get("dag_id", "unknown")
cpu_req = pod["spec"]["containers"][0]["resources"].get("requests", {}).get("cpu", "100m")
cpu_cores = float(cpu_req.rstrip("m")) / 1000 if cpu_req.endswith("m") else float(cpu_req)
if dag_id not in costs:
costs[dag_id] = 0
costs[dag_id] += cpu_cores * CPU_COST_PER_CORE_HOUR
return dict(sorted(costs.items(), key=lambda x: -x[1]))
Cost Optimization Checklist
[ ] KubernetesPodOperator resource requests match P95 actual usage
[ ] Batch tasks run on spot nodes with preemption handling
[ ] Small tasks consolidated (< 30s tasks use PythonOperator, not KPO)
[ ] KEDA autoscaling on Celery workers (no idle workers overnight)
[ ] S3 log lifecycle: IA after 30d, delete after 90d
[ ] Metadata DB cleaned monthly (task_instance, log, xcom)
[ ] XCom large values stored externally (S3), not in DB
[ ] Over-scheduled DAGs identified and frequency reduced
[ ] Dev/staging Airflow scaled to 0 overnight (CronJob or KEDA cron)
[ ] Cost attribution labels (dag_id, team) on all KPO pods
Anti-Patterns
- One pod per small task — KubernetesExecutor starts a pod for every task, including 5-second SQL checks; consolidate small tasks into PythonOperator.
- Workers running 24/7 in dev — dev workers sit idle 14+ hours/day; use KEDA with minReplicas=0 for non-prod.
- No XCom size limits — XCom stores data in metadata DB; large XComs (DataFrames, lists) bloat the DB and slow scheduler; use S3 for data > 1MB.
- Log retention at never-delete — Airflow logs grow 1-2 GB/day for active clusters; without lifecycle rules, S3 cost accumulates indefinitely.
- Over-scheduling health-check DAGs — a DAG running every minute to "check freshness" costs 1440 pod starts/day; use sensors or reduce frequency to every 15 min.
References
- Airflow KubernetesExecutor:
airflow.apache.org/docs/apache-airflow/stable/core-concepts/executor/kubernetes.html - KEDA Redis scaler:
keda.sh/docs/2.13/scalers/redis-lists/ - S3 lifecycle:
docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-expire-general-considerations.html - Related skills:
[[dataops-airflow-production-readiness]],[[infra-kubernetes-cost-optimizer]],[[de-cost-optimization]]