FinOps 비용 가시성 플랫폼
검토 기준: 2026-09-12. OpenCost 1.121.2 / chart 2.5.31, Kubecost 3.2.4, Kyverno 1.19.1. 검증 범위: Helm 렌더링, Kubernetes 스키마, Terraform 모의 공급자, 로컬 비용 계산·정책 검사. 실제 AWS 청구서 대사와 운영 클러스터 설치를 수행한 결과는 아닙니다.
< 이전: 이벤트 용량 계획 | 목차 | 다음: Tekton Pipelines >
개요
FinOps는 엔지니어링·재무·제품·비즈니스가 기술 지출의 가치를 함께 관리하는 운영 방식입니다. 비용을 줄이는 것만으로 성공을 판단하지 않습니다. 서비스 수준, 성장, 단위 경제성과 비용 귀속의 신뢰도를 함께 봅니다.
이 장에서는 OpenCost의 Kubernetes 할당 모델, AWS 청구 데이터, 팀별 예산을 구분하고 연결합니다. 각 예제의 클러스터 이름·네임스페이스·버킷·IAM 역할은 배포 환경에 맞게 바꿔야 합니다. 설치 명령은 실제 리소스를 만들며, 여기서 수행한 검증은 로컬 검증입니다.
1. FinOps 운영 모델
Inform은 데이터 수집·귀속·가시성을, Optimize는 측정에 근거한 개선을, Operate는 책임·예산·검토 절차의 지속성을 다룹니다. 세 단계는 반복됩니다.
| 역할 | 책임 |
|---|---|
| 플랫폼 | 수집 안정성, 비용 데이터 접근 제어, 도구 업그레이드 |
| 서비스 팀 | 레이블·요청량, 성능 검증, 변경 검토 |
| 재무·FinOps | 청구 대사, 공유 비용 규칙, 예산·예측 |
| 제품·비즈니스 | 단위 비용과 가치, 투자 우선순위 |
Crawl/Walk/Run은 영역별 역량을 설명하는 기준입니다. 모든 조직에 동일한 1–3개월·6–12개월 일정을 적용하지 않습니다. 데이터 품질과 책임이 정착되기 전에 자동 청구나 자동 삭제를 도입하지 않습니다.
2. 비용 데이터와 설치
2.1 서로 다른 비용 수치
| 수치 | 의미 | 주의점 |
|---|---|---|
| 현재 할당 비용률, USD/시간 | 현재 요청량·사용량과 가격 모델의 비용률 | 한 달 실제 지출이 아님 |
| 기간별 할당 모델 비용 | 명시한 기간의 Kubernetes 비용 귀속 | 수집 누락·모델·보관 기간의 영향 |
| CUR 2.0 / Cost Explorer | AWS 청구 기반 비용 | 갱신 지연, 할인·크레딧·세금·상각 기준 |
| 월말 선형 예상 | 누적 비용 ÷ 완료 일수 × 해당 월 일수 | 추세 변화·계절성을 반영하지 못함 |
AWS EC2의 CPU·메모리는 일반적으로 독립적인 청구 항목이 아닙니다. OpenCost의 코어·GiB 가격은 인스턴스 비용을 나누는 모델입니다. 임의 CPU/RAM 단가나 계약 할인 15%를 다시 곱해 청구액이라고 표시하지 않습니다. Cloud Cost 합계와 같은 인프라의 Allocation 합계를 더하면 이중 계산할 수 있습니다.
2.2 OpenCost 설치
관측 스택의 Prometheus Operator와 ServiceMonitor CRD, gp3 StorageClass를 전제로 합니다. EKS의 스토리지 구성에 따라 EBS CSI 또는 Auto Mode용 StorageClass가 필요합니다. 예제의 release: prometheus가 실제 Prometheus ServiceMonitor selector와 일치해야 합니다.
기존 관측 예제의 7일 보관으로 월초부터의 비용을 복원할 수 없습니다. 월간 분석에는 최소 해당 기간을 포함하는 보관 정책과 용량이 필요합니다. 보관 기간을 늘려도 이미 삭제된 데이터는 돌아오지 않습니다. 아래 exporter PVC는 Prometheus 데이터 PVC를 대신하지 않습니다.
opencost-values.yaml
serviceAccount:
create: true
name: opencost
opencost:
mcp:
enabled: false
exporter:
defaultClusterId: eks-production
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 2Gi
persistence:
enabled: true
accessMode: ReadWriteOnce
storageClass: gp3
size: 10Gi
prometheus:
internal:
enabled: true
serviceName: prometheus-kube-prometheus-prometheus
namespaceName: observability
port: 9090
external:
enabled: false
metrics:
serviceMonitor:
enabled: true
namespace: opencost
additionalLabels:
release: prometheus
honorLabels: true
customPricing:
enabled: false
cloudCost:
enabled: false
ui:
enabled: true
ingress:
enabled: falsehelm repo add opencost https://opencost.github.io/opencost-helm-chart
helm repo update opencost
helm upgrade --install opencost opencost/opencost \
--version 2.5.31 --namespace opencost --create-namespace \
-f opencost-values.yaml --wait --timeout 10m
kubectl -n opencost get pods,pvc,svc,servicemonitor
kubectl -n opencost port-forward service/opencost 9090:9090 9003:9003UI는 로컬 9090, API는 9003입니다. opencost.prometheus와 opencost.cloudCost를 exporter 아래에 넣으면 설정이 적용되지 않습니다. Chart 2.5.31의 MCP 기본값은 활성화이므로 이 예제는 명시적으로 끕니다. 비용 MCP를 사용할 경우 인증·접근 범위를 별도로 설계하십시오. 문서 검색용 MCP와 비용 데이터용 MCP는 별개입니다.
2.3 Kubecost 3.x를 선택하는 경우
OpenCost와 별도의 제품·배포 선택입니다. Kubecost 3.x는 ClickHouse 기반 저장소와 finops-agent 수집 구조를 사용합니다. 2.x의 kubecostModel·Prometheus·ETL 설정을 그대로 복사하지 않습니다. 기존 2.x 운영 환경은 공식 마이그레이션 절차의 중간 에이전트 버전, 데이터 재수집과 기능 제약을 확인해야 합니다.
새 chart 저장소는 https://kubecost.github.io/kubecost/입니다. 아래는 제한된 기능의 설치 시작점입니다. Cluster Controller·Admission Controller·forecasting을 명시적으로 끄며, 기존 저장소와 인스턴스를 그대로 대체하는 업그레이드 명령으로 쓰지 않습니다.
kubecost-values.yaml
global:
clusterId: eks-production
defaultStorageClass: gp3
frontend:
enabled: true
service:
type: ClusterIP
localStore:
enabled: true
persistentVolume:
enabled: true
size: 32Gi
storageClass: gp3
finopsagent:
enabled: true
aggregator:
enabled: true
cloudCost:
enabled: false
networkCosts:
enabled: false
clusterController:
enabled: false
kubecostAdmissionController:
enabled: false
forecasting:
enabled: false
ingress:
enabled: false
telemetry:
enabled: falsehelm repo add kubecost https://kubecost.github.io/kubecost/
helm repo update kubecost
helm template kubecost kubecost/kubecost --version 3.2.4 \
--namespace kubecost -f kubecost-values.yaml > kubecost-rendered.yaml
# Review storage, RBAC, images and product entitlements before installation.
helm install kubecost kubecost/kubecost --version 3.2.4 \
--namespace kubecost --create-namespace -f kubecost-values.yamlSSO·세분화 RBAC·다중 클러스터 기능은 사용 제품과 계약의 지원 범위를 확인합니다. SAML/OIDC 설정 키가 있다는 이유만으로 모든 배포에서 기능이 제공되지는 않습니다. global.acknowledged는 Enterprise 메이저 업그레이드 확인에 관한 설정이며 일반 설치의 라이선스 허용 스위치가 아닙니다. 내부 ALB도 사용자 인증을 대신하지 않습니다.
2.4 CUR 2.0, Athena와 OpenCost Cloud Cost
다음 Terraform은 새 CUR 2.0 export·두 S3 버킷·Athena workgroup·OpenCost IRSA 역할을 정의합니다. 기존 버킷/역할을 관리하는 구성이라면 먼저 Terraform 소유권과 import를 확인합니다. 조직 전체 청구 데이터에는 관리 계정의 적절한 권한이 필요합니다.
Glue crawler와 테이블 생성은 이 코드에 포함하지 않습니다. 먼저 Data Exports의 Athena 처리 절차를 따라 첫 파일을 수신하고, export의 data 폴더만 대상으로 Glue 테이블과 파티션을 생성·갱신해야 합니다. manifest·메타데이터 폴더를 같은 테이블에 섞지 않습니다. 실제 생성된 데이터베이스와 테이블 이름을 변수에 넣습니다. Lake Formation으로 보호한다면 별도 권한도 필요합니다.
COST_AND_USAGE_REPORT는 Data Exports SQL의 원본 테이블 이름이며, Athena에서 모든 사용자가 동일하게 조회할 Glue 테이블 이름이라는 뜻은 아닙니다. CUR 2.0은 기존 CUR의 year/month와 다른 billing_period 파티션을 사용하므로 실제 스키마를 검사합니다.
Data Exports는 SSE-S3로 전달합니다. 전달 버킷을 KMS 전용으로 바꾸는 예제를 그대로 적용하지 않습니다. KMS 보호가 필요하면 공식 문서의 전달 후 암호화 처리와 소비자 권한을 함께 설계합니다. 활성 조회 데이터에 복원 절차가 필요한 보관 계층을 적용하면 Athena 조회가 실패할 수 있습니다.
cur.tf
terraform {
required_version = ">= 1.12.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.64.0"
}
}
}
provider "aws" {
region = var.region
}
provider "aws" {
alias = "billing"
region = "us-east-1"
}
variable "region" {
type = string
default = "ap-northeast-2"
}
variable "cur_bucket" {
type = string
}
variable "results_bucket" {
type = string
validation {
condition = var.results_bucket != var.cur_bucket
error_message = "Use separate CUR source and Athena result buckets."
}
}
variable "oidc_provider_arn" {
type = string
}
variable "oidc_issuer" {
type = string
}
variable "glue_database" {
type = string
}
variable "glue_table" {
type = string
}
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}
locals {
account = data.aws_caller_identity.current.account_id
arn = "arn:${data.aws_partition.current.partition}"
issuer = trimprefix(trimsuffix(var.oidc_issuer, "/"), "https://")
buckets = { cur = var.cur_bucket, results = var.results_bucket }
}
resource "aws_s3_bucket" "cost" {
for_each = local.buckets
bucket = each.value
force_destroy = false
}
resource "aws_s3_bucket_public_access_block" "cost" {
for_each = aws_s3_bucket.cost
bucket = each.value.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "cost" {
for_each = aws_s3_bucket.cost
bucket = each.value.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "cost" {
for_each = aws_s3_bucket.cost
bucket = each.value.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_policy" "delivery" {
bucket = aws_s3_bucket.cost["cur"].id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowDataExports"
Effect = "Allow"
Principal = { Service = "bcm-data-exports.amazonaws.com" }
Action = "s3:PutObject"
Resource = "${aws_s3_bucket.cost["cur"].arn}/cur/*"
Condition = {
StringEquals = { "aws:SourceAccount" = local.account }
ArnLike = {
"aws:SourceArn" = "${local.arn}:bcm-data-exports:us-east-1:${local.account}:export/*"
}
}
}]
})
}
resource "aws_bcmdataexports_export" "cur" {
provider = aws.billing
depends_on = [
aws_s3_bucket_policy.delivery,
aws_s3_bucket_public_access_block.cost,
aws_s3_bucket_server_side_encryption_configuration.cost
]
export {
name = "opencost-cur"
data_query {
query_statement = "SELECT * FROM COST_AND_USAGE_REPORT"
table_configurations = {
COST_AND_USAGE_REPORT = {
BILLING_VIEW_ARN = "${local.arn}:billing::${local.account}:billingview/primary"
TIME_GRANULARITY = "HOURLY"
INCLUDE_RESOURCES = "TRUE"
INCLUDE_MANUAL_DISCOUNT_COMPATIBILITY = "FALSE"
INCLUDE_SPLIT_COST_ALLOCATION_DATA = "FALSE"
}
}
}
destination_configurations {
s3_destination {
s3_bucket = aws_s3_bucket.cost["cur"].bucket
s3_prefix = "cur"
s3_region = var.region
s3_output_configurations {
overwrite = "OVERWRITE_REPORT"
format = "PARQUET"
compression = "PARQUET"
output_type = "CUSTOM"
}
}
}
refresh_cadence {
frequency = "SYNCHRONOUS"
}
}
}
resource "aws_athena_workgroup" "opencost" {
name = "opencost-cur"
force_destroy = false
configuration {
enforce_workgroup_configuration = true
publish_cloudwatch_metrics_enabled = true
bytes_scanned_cutoff_per_query = 10737418240
result_configuration {
output_location = "s3://${aws_s3_bucket.cost["results"].bucket}/opencost/"
expected_bucket_owner = local.account
encryption_configuration {
encryption_option = "SSE_S3"
}
}
}
}
resource "aws_iam_role" "opencost" {
name = "opencost-cur-reader"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = var.oidc_provider_arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"${local.issuer}:aud" = "sts.amazonaws.com"
"${local.issuer}:sub" = "system:serviceaccount:opencost:opencost"
}
}
}]
})
}
resource "aws_iam_role_policy" "opencost" {
role = aws_iam_role.opencost.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["athena:StartQueryExecution", "athena:StopQueryExecution",
"athena:GetQueryExecution", "athena:GetQueryResults", "athena:GetWorkGroup"]
Resource = aws_athena_workgroup.opencost.arn
},
{
Effect = "Allow"
Action = ["glue:GetDatabase", "glue:GetDatabases", "glue:GetTable",
"glue:GetTables", "glue:GetPartitions"]
Resource = [
"${local.arn}:glue:${var.region}:${local.account}:catalog",
"${local.arn}:glue:${var.region}:${local.account}:database/${var.glue_database}",
"${local.arn}:glue:${var.region}:${local.account}:table/${var.glue_database}/${var.glue_table}"
]
},
{
Effect = "Allow"
Action = "s3:GetBucketLocation"
Resource = [for b in aws_s3_bucket.cost : b.arn]
},
{
Effect = "Allow"
Action = "s3:ListBucket"
Resource = aws_s3_bucket.cost["cur"].arn
Condition = { StringLike = { "s3:prefix" = ["cur", "cur/*"] } }
},
{
Effect = "Allow"
Action = "s3:ListBucket"
Resource = aws_s3_bucket.cost["results"].arn
Condition = { StringLike = { "s3:prefix" = ["opencost", "opencost/*"] } }
},
{
Effect = "Allow"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.cost["cur"].arn}/cur/*"
},
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload"]
Resource = "${aws_s3_bucket.cost["results"].arn}/opencost/*"
}
]
})
}
output "opencost_role_arn" {
value = aws_iam_role.opencost.arn
}
output "cur_export_arn" {
value = aws_bcmdataexports_export.cur.arn
}
output "athena_results" {
value = "s3://${aws_s3_bucket.cost["results"].bucket}/opencost/"
}glue_database와 glue_table은 조회 대상만 지정하며 테이블을 생성하지 않습니다. 이 구성의 Athena 조회량 제한 10 GiB는 예시입니다. 실패한 쿼리 원인을 확인하고 데이터량에 맞게 조정하십시오. 시간 단위 레코드를 내보내더라도 보고서가 매시간 도착한다는 의미는 아닙니다.
이 예제는 IRSA입니다. 기존 OIDC provider ARN과 issuer를 입력하고, 실제 Pod가 사용하는 ServiceAccount opencost/opencost와 신뢰 정책의 sub를 맞춥니다. EKS Pod Identity를 선택한다면 IRSA annotation 대신 해당 신뢰 정책과 association을 별도로 구성합니다.
cloud-integration.json
{
"aws": {
"athena": [
{
"bucket": "s3://REPLACE_QUERY_RESULTS_BUCKET/opencost/",
"region": "ap-northeast-2",
"database": "REPLACE_GLUE_DATABASE",
"catalog": "AwsDataCatalog",
"table": "REPLACE_GLUE_TABLE",
"workgroup": "opencost-cur",
"account": "123456789012",
"authorizer": {
"authorizerType": "AWSServiceAccount"
}
}
]
}
}opencost-cloud-values.yaml
serviceAccount:
create: true
name: opencost
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/opencost-cur-reader
opencost:
cloudIntegrationSecret: opencost-cloud-integrations
cloudCost:
enabled: true# Replace all placeholders and account IDs in the files first.
kubectl -n opencost create secret generic opencost-cloud-integrations \
--from-file=cloud-integration.json=cloud-integration.json \
--dry-run=client -o yaml | kubectl apply -f -
helm upgrade opencost opencost/opencost --version 2.5.31 \
--namespace opencost -f opencost-values.yaml -f opencost-cloud-values.yamlJSON의 bucket은 Athena 결과 버킷입니다. CUR 원본 버킷이 아닙니다. AWSServiceAccount authorizer는 AWS SDK 기본 자격 증명 체인을 사용하므로 정적 Access Key를 넣지 않습니다. 실제 읽기 권한·조회 결과 쓰기 권한·Secret 마운트·importer 갱신 시각을 모두 확인해야 청구 데이터 연결이 완료됩니다.
비용 할당 태그 활성화 전에는 원하는 열/키가 없을 수 있습니다. 현재 AWS는 관리 계정에서 최대 12개월의 비용 할당 태그 backfill을 지원하지만, 해당 기간에 리소스에 태그가 실제로 존재해야 하며 반영에 시간이 걸립니다. 태그를 나중에 붙여 과거 사실을 새로 만들어 내는 기능은 아닙니다.
3. Showback과 Chargeback
Showback은 사용 조직에 비용을 보여주는 절차이며, Chargeback은 합의한 회계 규칙에 따라 비용을 배분·청구하는 절차입니다. 모델 값이 곧 내부 청구 금액이 되지는 않습니다. 기간·통화·직접/공유/유휴 비용·미귀속 비용·세금·크레딧·환불·반올림 규칙을 먼저 합의합니다.
3.1 레이블과 정책
네임스페이스의 team은 팀 집계에, Pod template의 team·cost-center는 세부 귀속에 사용합니다. Pod 메타데이터와 AWS 비용 할당 태그는 별개의 데이터입니다. 레이블이 없더라도 네임스페이스 귀속은 가능하지만 팀 매핑을 보장하지 않습니다.
Kyverno 1.19.1은 기존 ClusterPolicy에 폐기 예정 경고를 표시합니다. 새 예제는 policies.kyverno.io/v1의 CEL ValidatingPolicy를 사용합니다. 이 CRD와 해당 버전의 컨트롤러가 필요합니다. finops.example.com/enabled=true 네임스페이스만 대상으로 하며 Pod controller의 template 검사도 자동 생성합니다.
cost-labels.yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: finops-pod-labels
spec:
validationActions: [Audit]
evaluation:
admission:
enabled: true
background:
enabled: true
autogen:
podControllers:
controllers: [deployments, statefulsets, daemonsets, jobs, cronjobs]
matchConstraints:
namespaceSelector:
matchLabels:
finops.example.com/enabled: "true"
resourceRules:
- apiGroups: [""]
apiVersions: [v1]
operations: [CREATE, UPDATE]
resources: [pods]
validations:
- expression: >-
['team', 'cost-center'].all(label,
object.metadata.?labels[label].orValue('') != '')
message: "Add team and cost-center labels to the Pod template."resource-requests.yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: finops-container-requests
spec:
validationActions: [Audit]
evaluation:
admission:
enabled: true
background:
enabled: true
autogen:
podControllers:
controllers: [deployments, statefulsets, daemonsets, jobs, cronjobs]
matchConstraints:
namespaceSelector:
matchLabels:
finops.example.com/enabled: "true"
resourceRules:
- apiGroups: [""]
apiVersions: [v1]
operations: [CREATE, UPDATE]
resources: [pods]
validations:
- expression: >-
object.spec.containers.all(c,
has(c.resources) && has(c.resources.requests) &&
['cpu', 'memory'].all(r,
r in c.resources.requests &&
quantity(c.resources.requests[r]).isGreaterThan(quantity('0'))))
message: "Set positive CPU and memory requests for each regular container."Audit는 위반을 차단하지 않습니다. background scan의 보고서와 적용 범위를 확인하고 소유자와 개선한 뒤 필요한 정책만 Deny로 전환합니다. 사용자에게 경고도 보내려면 Warn 동작을 별도로 선택합니다. 로컬 CLI는 Audit 정책에서도 테스트 위반을 실패로 반환할 수 있으므로 이를 실제 admission 차단과 혼동하지 않습니다.
요청량 정책은 일반 컨테이너 각각의 양수 CPU·메모리 request만 검사합니다. init container·Pod-level resource 예산과 limit 전략은 별도 정책입니다. 모든 서비스에 CPU 4개·메모리 8Gi 상한을 일률 적용한다고 적정 크기가 검증되지는 않습니다.
apiVersion: v1
kind: Namespace
metadata:
name: team-backend
labels:
team: backend
finops.example.com/enabled: "true"
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-capacity
namespace: team-backend
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
requests.storage: 200Gi
persistentvolumeclaims: "20"
pods: "100"이 Quota는 예시 자원 상한입니다. 달러 예산이나 모든 클라우드 지출을 제한하는 기능이 아닙니다. 기존 사용량과 autoscaling 최댓값을 고려해 정하고, 초과로 생성이 거부되는 경우를 운영 절차에 포함합니다.
3.2 기간별 할당 API
아래 경로는 OpenCost 1.121.2의 /allocation/compute를 사용합니다. API의 기간·집계·응답 구조는 제품과 버전별로 확인합니다. includeIdle과 shareIdle은 boolean이며 shareIdle=weighted가 아닙니다.
curl --fail --silent --show-error --get \
'http://127.0.0.1:9003/allocation/compute' \
--data-urlencode 'window=2026-09-01T00:00:00Z,2026-09-12T00:00:00Z' \
--data-urlencode 'aggregate=namespace' \
--data-urlencode 'includeIdle=true' \
--data-urlencode 'shareIdle=false'3.3 공유 비용 배분과 합계 보존
다음 값은 실제 청구서가 아닌 계산 검증용 가상 USD 데이터입니다. 직접 비용 6,500, 공유 비용 2,500, 유휴 비용 1,000의 서로 겹치지 않는 풀을 사용합니다. 공유 비용은 직접 비용 비중으로, 유휴 비용은 세 팀에 균등 배분합니다. 센트 미만의 나머지는 largest remainder 규칙으로 배분하고 동률은 팀 이름 순으로 정합니다.
Team Direct Shared Idle Total
A 3000.00 1153.85 333.34 4487.19
B 2000.00 769.23 333.33 3102.56
C 1500.00 576.92 333.33 2410.25
Total 6500.00 2500.00 1000.00 10000.00allocation-example.json
{
"description": "Synthetic reconciled expense pool, not an actual account bill.",
"currency": "USD",
"direct": {
"team-a": "3000.00",
"team-b": "2000.00",
"team-c": "1500.00"
},
"shared": "2500.00",
"idle": "1000.00",
"unallocated": "0.00"
}allocate_costs.py
"""Allocate one reconciled USD expense pool using explicit, conserved cent amounts."""
import argparse
import json
from decimal import Decimal
from fractions import Fraction
def cents(value):
if isinstance(value, bool):
raise ValueError("Money cannot be boolean")
value=Decimal(str(value))
if not value.is_finite() or value < 0:
raise ValueError("Use finite nonnegative expense amounts; handle refunds explicitly")
scaled=value*100
if scaled != scaled.to_integral_value():
raise ValueError("Settle source amounts to cents under an approved rounding policy first")
return int(scaled)
def money(value):
return f"{Decimal(value)/100:.2f}"
def distribute(total, weights):
if not weights:
raise ValueError("At least one allocation target is required")
rational={}
for name, weight in weights.items():
value=Decimal(str(weight))
if not value.is_finite() or value < 0:
raise ValueError("Weights must be finite and nonnegative")
rational[name]=Fraction(value)
denominator=sum(rational.values(),Fraction(0))
if denominator==0:
if total:
raise ValueError("A positive pool cannot be allocated with zero total weight")
return {name:0 for name in weights}
exact={name:Fraction(total)*weight/denominator for name,weight in rational.items()}
result={name:value.numerator//value.denominator for name,value in exact.items()}
remainder=total-sum(result.values())
# Largest remainder; ties resolved by stable target name.
order=sorted(exact,key=lambda name:(-(exact[name]-result[name]),name))
for name in order[:remainder]:
result[name]+=1
assert sum(result.values())==total
return result
def allocate(config):
if config.get("currency")!="USD":
raise ValueError("This example accepts a single USD ledger; do not mix currencies")
direct={name:cents(value) for name,value in config["direct"].items()}
if not direct:
raise ValueError("No teams supplied")
shared=cents(config["shared"])
idle=cents(config["idle"])
unallocated=cents(config.get("unallocated","0"))
shared_alloc=distribute(shared,direct)
idle_alloc=distribute(idle,{name:1 for name in direct})
teams={name:{"direct":money(value),"shared":money(shared_alloc[name]),"idle":money(idle_alloc[name]),
"total":money(value+shared_alloc[name]+idle_alloc[name])} for name,value in sorted(direct.items())}
source_total=sum(direct.values())+shared+idle+unallocated
allocated_total=sum(cents(value["total"]) for value in teams.values())+unallocated
assert source_total==allocated_total
return {"currency":"USD","policy":"Shared weighted by direct cost; idle split equally; unallocated retained",
"rounding":"Exact cents; largest remainder with lexical tie-break",
"teams":teams,"unallocated":money(unallocated),"source_total":money(source_total),
"allocated_total":money(allocated_total),
"limits":["Use one reconciled pool; do not add overlapping Allocation and Cloud Cost totals.",
"This policy is an example, not an inherently fair or mandatory chargeback rule.",
"Refunds, credits, taxes and currency conversion require explicit separate policies."]}
if __name__=="__main__":
parser=argparse.ArgumentParser()
parser.add_argument("input")
args=parser.parse_args()
try:
with open(args.input,encoding="utf-8") as stream:
result=allocate(json.load(stream))
except (ValueError,KeyError,ArithmeticError) as error:
parser.error(str(error))
print(json.dumps(result,ensure_ascii=False,indent=2))python3 allocate_costs.py allocation-example.json배분되지 않은 금액을 삭제하지 않고 unallocated로 남깁니다. 이 계산기는 음수 비용·환불을 자동 재배분하지 않습니다. 실제 회계 규칙에서는 크레딧과 환불, 통화 변환을 별도로 처리합니다. 이 예시 정책이 조직에 가장 공정한 규칙이라는 뜻도 아닙니다.
3.4 Prometheus와 Grafana
다음 rule은 한 클러스터의 Prometheus를 전제로 합니다. 중앙 Prometheus/Thanos에서 여러 클러스터를 조회한다면 모든 집계·join 키에 실제 클러스터 식별자를 유지하십시오. 노드 이름만으로 여러 클러스터를 연결하면 비용이 섞일 수 있습니다.
node_cpu_hourly_cost는 코어당, node_ram_hourly_cost는 GiB당 시간 비용입니다. allocation 지표와 곱하며 같은 시계열의 중복 scrape는 max로 제거합니다. 이름이 비슷한 kubecost_container_cpu_cost 같은 존재하지 않는 지표를 사용하지 않습니다. label join 전에 다음 kube-state-metrics 설정을 기존 관측 chart values에 병합합니다.
kube-state-metrics:
metricLabelsAllowlist:
- namespaces=[team]cost-rules.yaml
groups:
- name: finops-current-rates
rules:
- record: finops:node_cpu_hourly_cost
expr: max by (node) (node_cpu_hourly_cost)
- record: finops:node_ram_hourly_cost
expr: max by (node) (node_ram_hourly_cost)
- record: finops:namespace_cpu_cost_per_hour
expr: |
sum by (namespace) (
max by (namespace, pod, container, node) (container_cpu_allocation{container!=""})
* on (node) group_left finops:node_cpu_hourly_cost
)
- record: finops:namespace_ram_cost_per_hour
expr: |
sum by (namespace) (
max by (namespace, pod, container, node) (container_memory_allocation_bytes{container!=""}) / 1073741824
* on (node) group_left finops:node_ram_hourly_cost
)
- record: finops:namespace_compute_cost_per_hour
expr: finops:namespace_cpu_cost_per_hour + finops:namespace_ram_cost_per_hour
- record: finops:team_compute_cost_per_hour
expr: |
sum by (label_team) (
finops:namespace_compute_cost_per_hour
* on (namespace) group_left (label_team)
max by (namespace, label_team) (kube_namespace_labels{label_team!=""})
)
- alert: OpenCostMetricsUnavailable
expr: absent(node_cpu_hourly_cost)
for: 15m
labels:
severity: warning
annotations:
summary: "OpenCost CPU pricing metrics are absent"
- alert: KubernetesComputeRateAboveReviewThreshold
expr: sum(finops:namespace_compute_cost_per_hour) > 20
for: 30m
labels:
severity: warning
annotations:
summary: "Allocated compute model exceeds the example USD 20/hour threshold"위 파일은 Prometheus rule 파일 형식입니다. Operator에서는 PrometheusRule.spec 아래로 넣고, 해당 리소스를 실제 Prometheus rule selector가 선택하도록 metadata label을 맞춥니다. 20 USD/시간·30분은 예시 검토 임계값이며 모델 기반 값입니다. for는 조건의 지속 시간을 요구할 뿐 오탐을 없애지 않습니다.
Grafana 패널은 finops:namespace_compute_cost_per_hour와 finops:team_compute_cost_per_hour를 사용하고 단위를 USD/hour로 표시합니다. CPU/RAM만의 할당 비용률이며 스토리지·네트워크·컨트롤 플레인·유휴 비용을 모두 포함한 청구액이 아닙니다. * 730 패널을 추가한다면 “고정 730시간 예상”으로 표시해야 합니다. 현재 가격이 사라지면 비용 0으로 대체하지 말고 데이터 누락을 표시합니다.
팀 레이블이 없는 네임스페이스는 팀별 집계에서 빠질 수 있으므로 전체 네임스페이스 합계와 팀 합계의 차이도 확인합니다. namespace 변수나 dashboard folder는 데이터 접근 권한 경계가 아닙니다. 팀 격리가 필요하면 데이터 소스의 서버 측 권한 또는 별도의 테넌트를 구성하고 다른 팀 조회가 거부되는지 테스트합니다.
4. 청구 기반 이상 탐지
AWS Cost Anomaly Detection의 DIMENSIONAL / SERVICE monitor는 서비스별 전체 AWS 비용을 다룹니다. 이름에 “EKS”를 붙여도 EKS 전용이 되지 않습니다. 태그·연결 계정·Cost Category 기반 CUSTOM monitor는 실제 지원 범위와 비용 데이터 가용성을 확인합니다. 기존 monitor가 있다면 ARN을 재사용합니다.
다음은 앞의 Terraform 파일과 같은 디렉터리에 두는 선택 구성입니다. EMAIL 요약은 DAILY, SNS는 IMMEDIATE로 나눕니다. SNS의 IMMEDIATE도 청구 데이터 갱신과 이상 탐지 이후이므로 실시간 과금 차단이 아닙니다. SNS topic에는 별도로 승인된 구독자가 필요합니다. 예제 자체가 Slack 구독을 만들지 않습니다.
anomaly.tf
# Optional: supply an existing monitor ARN to avoid duplicating a SERVICE monitor.
variable "cost_monitor_arn" {
type = string
}
variable "notification_email" {
type = string
}
resource "aws_ce_anomaly_subscription" "daily" {
provider = aws.billing
name = "daily-cost-anomalies"
frequency = "DAILY"
monitor_arn_list = [var.cost_monitor_arn]
threshold_expression {
dimension {
key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
match_options = ["GREATER_THAN_OR_EQUAL"]
values = ["100"]
}
}
subscriber {
type = "EMAIL"
address = var.notification_email
}
}
resource "aws_sns_topic" "anomalies" {
provider = aws.billing
name = "cost-anomalies"
}
resource "aws_sns_topic_policy" "anomalies" {
provider = aws.billing
arn = aws_sns_topic.anomalies.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "costalerts.amazonaws.com" }
Action = "sns:Publish"
Resource = aws_sns_topic.anomalies.arn
Condition = {
StringEquals = { "aws:SourceAccount" = local.account }
ArnLike = {
"aws:SourceArn" = "${local.arn}:ce::${local.account}:anomalysubscription/*"
}
}
}]
})
}
resource "aws_ce_anomaly_subscription" "immediate" {
provider = aws.billing
depends_on = [aws_sns_topic_policy.anomalies]
name = "immediate-cost-anomalies"
frequency = "IMMEDIATE"
monitor_arn_list = [var.cost_monitor_arn]
threshold_expression {
dimension {
key = "ANOMALY_TOTAL_IMPACT_ABSOLUTE"
match_options = ["GREATER_THAN_OR_EQUAL"]
values = ["100"]
}
}
subscriber {
type = "SNS"
address = aws_sns_topic.anomalies.arn
}
}리소스 생성이나 구독 설정에는 실제 알림·비용 영향이 있습니다. KMS로 SNS를 암호화한다면 costalerts.amazonaws.com에 필요한 키 사용 권한과 SourceAccount/SourceArn 범위를 추가로 구성합니다. 예제의 100 USD 임계값은 조직별로 조정합니다.
AWS Budgets Action은 IAM/SCP 적용 외에도 지원되는 EC2/RDS 작업을 수행할 수 있습니다. 다만 예산 갱신과 동작 범위의 제약이 있으므로 모든 지출을 즉시 멈추는 hard cap이라고 설명하지 않습니다.
5. 팀 예산과 정기 리포트
5.1 숫자 예산을 명시적으로 입력
namespace annotation의 문자열을 label_replace로 바꾼다고 숫자 시계열이 되지 않습니다. 여기서는 USD 예산을 JSON으로 입력하고 실제 숫자로 파싱합니다. 예산이 없으면 비율은 null이며, 0·음수 예산과 비정상 숫자는 거부합니다.
budgets.json
{
"currency": "USD",
"namespaces": {
"backend-production": "3000.00",
"frontend-production": "2000.00"
}
}5.2 월간 모델 리포트와 선택적 Slack 전달
다음 스크립트는 Python 3.12 표준 라이브러리만 사용합니다. UTC 기준 완료된 날짜까지 조회하며, --as-of는 제외되는 종료 날짜입니다. 매월 1일에는 직전 완료 월을 보고합니다. API가 비어 있거나 기간 전체가 아닌 여러 step을 반환하면 실패하고 0 USD로 위장하지 않습니다. 실제 데이터 보관 범위와 누락까지 자동 입증하지 않으므로 API warnings와 importer 시각을 확인해야 합니다.
기본 동작은 JSON 파일 생성이며 Slack을 전송하지 않습니다. --send와 --webhook-file을 함께 지정해야 전송합니다. 서로 다른 채널에는 각각의 incoming webhook이 필요하며 payload의 channel로 덮어쓰는 방식은 사용하지 않습니다.
report_costs.py
"""Read-only OpenCost monthly model-cost report and optional reviewed Slack delivery."""
import argparse
import calendar
import json
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
from urllib.parse import urlencode, urlsplit
from urllib.request import Request, build_opener, HTTPRedirectHandler
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def decimal_value(value, name, positive=False):
if value is None or isinstance(value, bool):
raise ValueError(f"{name}: missing/invalid number")
amount=Decimal(str(value))
if not amount.is_finite() or (positive and amount <= 0):
raise ValueError(f"{name}: invalid finite range")
return amount
def usd(value):
return format(value.quantize(Decimal("0.01"),rounding=ROUND_HALF_UP),"f")
def month_window(as_of):
end=date.fromisoformat(as_of)
month_reference=end if end.day>1 else end-timedelta(days=1)
start=month_reference.replace(day=1)
completed=(end-start).days
return start,end,completed,calendar.monthrange(start.year,start.month)[1]
def summarize(payload, budgets, as_of):
start,end,days,month_days=month_window(as_of)
if not isinstance(payload,dict) or not isinstance(budgets,dict) or not isinstance(budgets.get("namespaces",{}),dict):
raise ValueError("Expected API and budget JSON objects")
if budgets.get("currency")!="USD":
raise ValueError("This report requires an explicitly configured USD cost source and budgets")
if payload.get("code",200)!=200 or payload.get("status","success")!="success" or payload.get("errors"):
raise ValueError("Cost API reported an error")
sets=payload.get("data")
if not isinstance(sets,list) or len(sets)!=1 or not isinstance(sets[0],dict) or not sets[0]:
raise ValueError("Expected one nonempty whole-window allocation set; missing data is not zero cost")
namespace_costs={}
for namespace,allocation in sets[0].items():
if not isinstance(allocation,dict) or "totalCost" not in allocation:
raise ValueError(f"{namespace}: missing allocation cost")
namespace_costs[namespace]=decimal_value(allocation["totalCost"],namespace)
total=sum(namespace_costs.values(),Decimal(0))
rows=[]
for namespace,cost in sorted(namespace_costs.items(),key=lambda row:(-row[1],row[0])):
budget=budgets.get("namespaces",{}).get(namespace)
budget_value=None if budget is None else decimal_value(budget,"budget",positive=True)
projected=cost*Decimal(month_days)/Decimal(days)
rows.append({
"namespace":namespace,
"model_cost_to_date_usd":usd(cost),
"linear_month_estimate_usd":usd(projected),
"budget_usd":None if budget_value is None else usd(budget_value),
"model_budget_ratio":None if budget_value is None else str(cost/budget_value),
"linear_estimate_budget_ratio":None if budget_value is None else str(projected/budget_value),
})
return {
"source":"OpenCost allocation model, not an AWS invoice",
"currency":"USD","window_start":start.isoformat()+"T00:00:00Z",
"window_end_exclusive":end.isoformat()+"T00:00:00Z",
"completed_calendar_days":days,"days_in_month":month_days,
"total_model_cost_to_date_usd":usd(total),
"total_linear_month_estimate_usd":usd(total*Decimal(month_days)/Decimal(days)),
"namespaces":rows,"api_warnings":payload.get("warnings",[]),
"limits":[
"Linear estimates assume complete coverage and stable daily cost; inspect retention, gaps and importer freshness.",
"Idle and unallocated buckets are retained; this is not automatic chargeback.",
"Calendar-to-date model cost, a linear estimate and actual billed cost are different quantities.",
"Do not add overlapping cloud-billing and Kubernetes-allocation totals."
]
}
def get_allocation(base_url, as_of):
start,end,_,_=month_window(as_of)
parsed=urlsplit(base_url)
if parsed.scheme not in ("http","https") or not parsed.netloc or parsed.username or parsed.query or parsed.fragment:
raise ValueError("Use an HTTP(S) API base URL without credentials, query or fragment")
query=urlencode({
"window":start.isoformat()+"T00:00:00Z,"+end.isoformat()+"T00:00:00Z",
"aggregate":"namespace","includeIdle":"true","shareIdle":"false","resolution":"1m"
})
request=Request(base_url.rstrip("/")+"/allocation/compute?"+query,
headers={"Accept":"application/json"},method="GET")
with build_opener(NoRedirect).open(request,timeout=30) as response:
if response.status != 200:
raise ValueError(f"Cost API status {response.status}")
body=response.read(10*1024*1024+1)
if len(body)>10*1024*1024:
raise ValueError("Cost API response exceeded the configured limit")
return json.loads(body,parse_float=Decimal)
def slack_payload(report):
# Dynamic names stay in plain_text blocks to avoid markup/mention interpretation.
header=f"Calendar-month Kubernetes model costs — {report['window_end_exclusive'][:10]}"
blocks=[{"type":"header","text":{"type":"plain_text","text":header}},
{"type":"section","text":{"type":"plain_text","text":
f"UTC window: {report['window_start']} to {report['window_end_exclusive']} (exclusive)\n"
f"Model cost: USD {report['total_model_cost_to_date_usd']}\n"
f"Linear estimate: USD {report['total_linear_month_estimate_usd']}\n"
"This is an allocation estimate, not an AWS invoice."}}]
for row in report["namespaces"][:10]:
text=f"{row['namespace']}: USD {row['model_cost_to_date_usd']}"
blocks.append({"type":"section","text":{"type":"plain_text","text":text[:2900]}})
omitted=max(0,len(report["namespaces"])-10)
if omitted:
blocks.append({"type":"section","text":{"type":"plain_text","text":
f"{omitted} additional buckets are included in the total. See the full JSON report."}})
return {"blocks":blocks}
def send_slack(payload, webhook_file):
url=Path(webhook_file).read_text().strip()
parsed=urlsplit(url)
if parsed.scheme!="https" or parsed.hostname not in ("hooks.slack.com","hooks.slack-gov.com"):
raise ValueError("Use a trusted HTTPS Slack incoming-webhook URL file")
body=json.dumps(payload,ensure_ascii=False).encode()
request=Request(url,data=body,headers={"Content-Type":"application/json"},method="POST")
with build_opener(NoRedirect).open(request,timeout=15) as response:
result=response.read(1024).decode().strip()
if response.status!=200 or result!="ok":
raise ValueError("Slack did not acknowledge the message")
def main():
parser=argparse.ArgumentParser()
source=parser.add_mutually_exclusive_group(required=True)
source.add_argument("--input")
source.add_argument("--api-url")
parser.add_argument("--budgets",required=True)
parser.add_argument("--as-of",default=datetime.now(timezone.utc).date().isoformat(),
help="Exclusive UTC reporting end date")
parser.add_argument("--report",default="cost-report.json")
parser.add_argument("--slack-payload",default="slack-payload.json")
parser.add_argument("--print-report",action="store_true",
help="Write model-cost JSON to stdout for controlled log collection")
parser.add_argument("--send",action="store_true")
parser.add_argument("--webhook-file")
args=parser.parse_args()
try:
if args.send and not args.webhook_file:
raise ValueError("--send requires --webhook-file")
payload=(json.loads(Path(args.input).read_text(),parse_float=Decimal) if args.input
else get_allocation(args.api_url,args.as_of))
budgets=json.loads(Path(args.budgets).read_text(),parse_float=Decimal)
report=summarize(payload,budgets,args.as_of)
slack=slack_payload(report)
Path(args.report).write_text(json.dumps(report,ensure_ascii=False,indent=2)+"\n")
Path(args.slack_payload).write_text(json.dumps(slack,ensure_ascii=False,indent=2)+"\n")
if args.print_report:
print(json.dumps(report,ensure_ascii=False))
if args.send:
send_slack(slack,args.webhook_file)
print(f"Report written: {args.report}; Slack delivery: {'requested' if args.send else 'disabled'}")
except (ValueError,KeyError,ArithmeticError,OSError) as error:
parser.exit(1,f"Cost report failed: {error}\n")
if __name__=="__main__":
main()python3 report_costs.py \
--api-url http://127.0.0.1:9003 \
--budgets budgets.json --as-of 2026-09-12 \
--report cost-report.json --slack-payload slack-payload.json가상 데이터로 총 모델 비용 1,610.10 USD, 월말 선형 예상 4,391.18 USD를 검증했습니다. 이는 사용자 계정의 비용이 아닙니다. 전체 기간 30일인 9월의 완료 일수 11일을 사용한 예측이며, 고정 730시간 계산과 다릅니다.
5.3 CronJob 실행
스크립트와 예산 파일을 ConfigMap에 넣습니다. 아래 CronJob은 매일 한국 시간 09:00에 실행되고 Slack 전송 없이 보고서를 stdout에 기록합니다. 데이터의 날짜 경계는 여전히 UTC입니다. 로그에는 내부 비용 정보가 있으므로 로그 접근 권한과 보관 정책을 설정합니다. emptyDir 출력 파일은 Pod 삭제 시 사라집니다.
kubectl -n opencost create configmap finops-report-code \
--from-file=report_costs.py --from-file=budgets.json \
--dry-run=client -o yaml | kubectl apply -f -reporter-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: finops-report
namespace: opencost
spec:
schedule: "0 9 * * *"
timeZone: Asia/Seoul
concurrencyPolicy: Forbid
startingDeadlineSeconds: 1800
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 2
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 180
template:
spec:
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: report
image: python:3.12.13-slim
command: [python, /app/report_costs.py]
args:
- --print-report
- --api-url
- http://opencost.opencost.svc.cluster.local:9003
- --budgets
- /app/budgets.json
- --report
- /output/cost-report.json
- --slack-payload
- /output/slack-payload.json
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: app
mountPath: /app
readOnly: true
- name: output
mountPath: /output
volumes:
- name: app
configMap:
name: finops-report-code
- name: output
emptyDir: {}배포 전 이미지 digest와 플랫폼 지원을 확인하고 고정합니다. 이 예제의 Python 버전과 표준 라이브러리 코드는 로컬에서 검증했으며, 컨테이너 이미지 pull이나 클러스터 CronJob 실행은 수행하지 않았습니다.
Slack 전달을 운영에서 선택하려면 webhook을 Secret의 파일로 마운트하고 --send --webhook-file /secrets/webhook을 추가합니다. webhook을 문서·Git·로그에 쓰지 않습니다. concurrencyPolicy: Forbid와 backoffLimit: 0은 중복 위험을 줄이지만 exactly-once 전달을 보장하지 않습니다. 응답 유실 뒤 재실행하면 중복 메시지가 생길 수 있으므로 전달 기록과 중복 제거가 필요한지 결정합니다.
6. 리소스 라이트사이징
6.1 VPA 권장값 수집
VPA recommender와 CRD가 이미 설치된 환경을 전제로 합니다. Off 모드로 관측하며 동일 workload를 대상으로 여러 VPA를 만들지 않습니다. Goldilocks가 VPA를 관리한다면 아래 수동 VPA와 중복되지 않도록 합니다. target은 권장 request이고 upperBound는 반드시 설정해야 하는 limit이 아닙니다.
vpa.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: backend-api
namespace: team-backend
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: backend-api
updatePolicy:
updateMode: "Off"
resourcePolicy:
containerPolicies:
- containerName: "*"
controlledResources: [cpu, memory]
controlledValues: RequestsOnlyGoldilocks는 namespace opt-in과 VPA 권장값을 보여주는 선택적 도구입니다. 설치 시 현재 chart의 VPA 의존성·컨트롤러 권한·대시보드 접근 제어를 확인하고 기존 recommender를 중복 설치하지 않습니다. namespace에 goldilocks.fairwinds.com/enabled=true를 부여하는 것만으로 비용 절감이나 변경 승인이 이루어지지는 않습니다.
6.2 변경 제안 생성
다음 스크립트는 클러스터를 변경하거나 PR을 만들지 않습니다. kubectl로 읽은 JSON을 입력받아 Deployment/StatefulSet의 컨테이너 이름을 VPA와 맞추고, request가 20% 이상 감소하는 후보를 출력합니다. 기본 API와 단위 파서를 검증한 kubernetes==36.0.3 패키지가 필요합니다.
권장값이 충분한 트래픽·피크·장애 복구 상황을 반영하는지, HPA의 CPU utilization 분모를 바꾸는 영향이 있는지 확인해야 합니다. init container·Pod-level resources는 별도 검토로 남깁니다. 같은 target의 중복 VPA, 모르는 컨테이너, 없는 request는 자동 추정하지 않습니다.
python3 -m venv .venv
.venv/bin/python -m pip install 'kubernetes==36.0.3'
kubectl --context YOUR_CONTEXT get deployments,statefulsets -A -o json > workloads.json
kubectl --context YOUR_CONTEXT get vpa -A -o json > vpas.json
.venv/bin/python recommend_resources.py \
--workloads workloads.json --vpas vpas.json --threshold 0.20 > proposals.jsonrecommend_resources.py
#!/usr/bin/env python3
"""Build review proposals from kubectl JSON snapshots; never patch workloads."""
import argparse
import json
from collections import Counter
from decimal import Decimal
from pathlib import Path
from kubernetes.utils.quantity import parse_quantity
def quantity(value):
parsed = parse_quantity(str(value))
if not parsed.is_finite() or parsed <= 0:
raise ValueError("resource quantity must be finite and positive")
return parsed
def propose(workloads, vpas, threshold=Decimal("0.20")):
if not Decimal(0) < threshold < Decimal(1):
raise ValueError("threshold must be between zero and one")
index = {}
for w in workloads.get("items", []):
key = (w["metadata"].get("namespace", "default"), w["kind"], w["metadata"]["name"])
index[key] = w
results = []
targets = Counter(
(v["metadata"].get("namespace", "default"),
v.get("spec", {}).get("targetRef", {}).get("kind"),
v.get("spec", {}).get("targetRef", {}).get("name"))
for v in vpas.get("items", [])
)
for v in vpas.get("items", []):
namespace = v["metadata"].get("namespace", "default")
target = v.get("spec", {}).get("targetRef", {})
key = (namespace, target.get("kind"), target.get("name"))
row = {"vpa": v["metadata"]["name"], "namespace": namespace,
"kind": key[1], "workload": key[2], "proposals": [], "warnings": []}
if target.get("apiVersion") != "apps/v1" or key[1] not in ("Deployment", "StatefulSet"):
row["warnings"].append("unsupported target: only apps/v1 Deployment/StatefulSet")
elif targets[key] > 1:
row["warnings"].append("duplicate VPA target: remove overlap before proceeding")
elif key not in index:
row["warnings"].append("target missing from workload snapshot")
else:
workload = index[key]
pod = workload["spec"]["template"]["spec"]
containers = {c["name"]: c for c in pod["containers"]}
conditions = v.get("status", {}).get("conditions", [])
if not any(c.get("type") == "RecommendationProvided" and c.get("status") == "True" for c in conditions):
row["warnings"].append("RecommendationProvided is not True")
else:
recommendations = v.get("status", {}).get("recommendation", {}).get("containerRecommendations", [])
if not recommendations:
row["warnings"].append("recommendations missing")
for rec in recommendations:
name = rec.get("containerName")
if name not in containers:
row["warnings"].append(f"unknown container {name}")
continue
container = containers[name]
for resource in ("cpu", "memory"):
current = container.get("resources", {}).get("requests", {}).get(resource)
target_value = rec.get("target", {}).get(resource)
if current is None or target_value is None:
row["warnings"].append(f"{name}/{resource}: missing current request or target")
continue
try:
current_number, target_number = quantity(current), quantity(target_value)
reduction = (current_number - target_number) / current_number
limit = container.get("resources", {}).get("limits", {}).get(resource)
if limit is not None and target_number > quantity(limit):
row["warnings"].append(f"{name}/{resource}: target exceeds existing limit")
continue
except (ValueError, ArithmeticError) as error:
row["warnings"].append(f"{name}/{resource}: invalid quantity ({error})")
continue
if reduction >= threshold:
row["proposals"].append({
"container": name, "resource": resource,
"currentRequest": current, "proposedRequest": target_value,
"requestReductionRatio": str(reduction),
"estimatedBillingSavings": None
})
if pod.get("initContainers") or pod.get("resources"):
row["warnings"].append("init containers and Pod-level resources require separate review")
results.append(row)
return {"mode": "proposal-only", "threshold": str(threshold), "workloads": results,
"limitations": ["No PR, patch, or cluster change is created.",
"VPA history, peak load, HPA interaction, and SLOs require human review.",
"Lower requests do not guarantee fewer nodes or billing savings."]}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--workloads", type=Path, required=True)
parser.add_argument("--vpas", type=Path, required=True)
parser.add_argument("--threshold", type=Decimal, default=Decimal("0.20"))
args = parser.parse_args()
print(json.dumps(propose(json.loads(args.workloads.read_text()),
json.loads(args.vpas.read_text()), args.threshold), indent=2))출력의 estimatedBillingSavings는 null입니다. request 감소는 노드 수 감소나 예약 약정 비용 감소를 보장하지 않기 때문입니다. 담당자는 Git의 실제 manifest를 수정하고 성능 테스트·PR 리뷰·배포 후 SLO 관측을 수행합니다. 이를 자동화하려면 저장소 파일 매핑·인증·중복 PR 처리·CI·승인 규칙을 별도로 구현해야 합니다.
7. 유휴 후보와 거버넌스
7.1 삭제 후보가 아닌 검토 목록
다음 쿼리는 한 클러스터에서 Bound지만 현재 Pod volume reference가 없는 PVC를 찾습니다. gauge의 값이 1인지 비교하고 namespace와 PVC 이름을 함께 사용합니다.
(kube_persistentvolumeclaim_status_phase{phase="Bound"} == 1)
unless on (namespace, persistentvolumeclaim)
kube_pod_spec_volumes_persistentvolumeclaims_info스케일 0인 StatefulSet의 데이터, 복구용 볼륨, 일시 중지된 작업도 여기에 포함될 수 있습니다. 소유자·복구 요구·마지막 사용·snapshot·보존 정책을 확인하기 전에는 삭제하지 않습니다. Deployment 생성 시각이 7일 전이라는 사실은 replica가 7일 내내 0이었다는 증거가 아닙니다. 연속 이력, 샘플 범위와 누락을 확인해야 합니다.
낮은 CPU·메모리 사용률도 버스트나 대기 워크로드의 특성일 수 있습니다. Pod 데이터를 Deployment로 합칠 때 owner 관계(ReplicaSet → Deployment)와 namespace·cluster 식별자를 유지합니다. 네트워크 receive 값만으로 업무 트래픽이나 자원의 필요성을 판단하지 않습니다.
7.2 정기 검토
| 주기 | 확인 내용 |
|---|---|
| 일간 | 수집 누락, importer 시각, 이상 비용, 예산 예측 |
| 주간 | 유휴 후보의 소유자 확인, VPA 제안, SLO 영향 |
| 월간 | 실제 청구 대사, 할인·크레딧·미귀속 비용, 공유 규칙, 단위 경제성 |
월간 보고서에는 기간·통화·모델/청구 구분·데이터 갱신 시각·공유 배분 규칙·미귀속 금액·승인자를 기록합니다. “라벨 100%면 비용 정확도 100%”, “요청량 감소율이 곧 절감률”, “대시보드 필터가 팀 권한” 같은 가정을 피합니다.


