Kubecost の Prometheus メトリクスから Pod のラベルごとのコストを算出する

kubernetesawsprometheus

Kubecost は Kubernetes のコストを可視化し最適化するツール。 AWS が EKS optimized bundle を提供しており一部 Enterprise 相当の機能を使うことができる。

Helm でインストールする。Promehteus はデフォルトでインストールされるが、すでにあるものを用いることもできる。

Prometheus を CDK でインストールして Recording rules で集計したデータを New Relic に Remote write することでデータ量を節約する - sambaiz-net

cluster.addHelmChart('KubecostHelmChart', {
  chart: 'cost-analyzer',
  repository: 'oci://public.ecr.aws/kubecost/cost-analyzer',
  namespace: 'kubecost',
  release: 'kubecost',
  version: '2.3.1',
  values: {
    global: {
      grafana: {
        enabled: false,
        proxy: false,
      },
      /*
      prometheus: {
        enabled: false, // If false, use an existing Prometheus install.
        fqdn: "http://kube-prometheus-stack-prometheus.prometheus.svc.cluster.local:9090",
      },
      */
    },
    /*
    /*
    serviceMonitor: {
      enabled: true, // Set this to true to create ServiceMonitor for Prometheus operator
      additionalLabels: {
        release: 'kube-prometheus-stack',
      },
    },
    */
    prometheus: {
      server: {
        global: {
          external_labels: {
            cluster_id: cluster.clusterName,
          },
        },
      }
    },
    // https://raw.githubusercontent.com/kubecost/cost-analyzer-helm-chart/develop/cost-analyzer/values-eks-cost-monitoring.yaml
    kubecostFrontend: {
      image: 'public.ecr.aws/kubecost/frontend',
    },
    kubecostModel: {
      image: 'public.ecr.aws/kubecost/cost-model',
    },
    forecasting: {
      enabled: false,
    },
  },
})

ダッシュボードからリソースがどれくらい効率的に使われているか、

どうすればコストを最適化できるかを確認できる。

Kubecost は次のような Prometheus のメトリクスを提供する。 CPUとRAMのコストはインスタンスコストを設定の単価で按分したものになっている。

# Cumulative cpu time consumed
container_cpu_usage_seconds_total{cpu="total", endpoint="https-metrics", id="/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod24c72e02_8120_4259_a3af_7f6030bfbced.slice", instance="10.0.159.230:10250", job="kubelet", metrics_path="/metrics/cadvisor", namespace="kube-system", node="ip-10-0-159-230.ec2.internal", pod="kube-proxy-9wr4l", service="kube-prometheus-stack-kubelet"}

# Kubernetes labels converted to Prometheus labels
kube_pod_labels{container="cost-model", endpoint="tcp-model", instance="10.0.145.101:9003", job="kubecost-cost-analyzer", label_controller_revision_hash="6c75fb6796", label_k8s_app="kube-proxy", label_pod_template_generation="1", namespace="kube-system", pod="kube-proxy-9wr4l", service="kubecost-cost-analyzer", uid="24c72e02-8120-4259-a3af-7f6030bfbced"}

# Hourly cost per vCPU on this node
node_cpu_hourly_cost{arch="amd64", container="cost-model", endpoint="tcp-model", instance="ip-10-0-159-230.ec2.internal", instance_type="m5.2xlarge", job="kubecost-cost-analyzer", namespace="kubecost", node="ip-10-0-159-230.ec2.internal", pod="kubecost-cost-analyzer-8cb64b444-5whwh", provider_id="aws:///us-east-1a/i-03d19e8ad331842a5", region="us-east-1", service="kubecost-cost-analyzer"}

これらを用いた次のクエリでラベルごとに1時間ごとのコストを算出することができる。container_cpu_usage_seconds_total が秒単位なのに対して、 node_cpu_hourly_cost や node_ram_hourly_cost は時間単位なのに注意する。

PromQLでメトリクスを取得・集計する - sambaiz-net

sum(
  # CPU
  max(max_over_time(kube_pod_labels{label_k8s_app!=""}[1h])) by (pod, label_k8s_app)
  * on (pod) group_left()
  sum(
    increase(container_cpu_usage_seconds_total{container!=""}[1h])
    * on(node) group_left() min(min_over_time(node_cpu_hourly_cost[1h])) by (node)
    / 3600
  ) by (pod)

  + on (pod) group_left()
  # RAM
  max(max_over_time(kube_pod_labels{label_k8s_app != ""}[1h])) by (pod, label_k8s_app)
  * on (pod) group_left()
  sum(
    avg_over_time(container_memory_working_set_bytes[1h])
    * on(node) group_left() min(min_over_time(node_ram_hourly_cost[1h])) by (node)
    / 1024 / 1024 / 1024
  ) by (pod)
) by (label_k8s_app)

デフォルトでインスタンスコストはオンデマンドの料金で計算される。スポットの料金を反映させるには Cost and Usage Report を連携する方法もあるが更新されるのに最大で48時間程度かかる。 ほかの方法として Spot instance data feed を読む方法もあって、 createSpotDatafeedSubscription で指定した Bucket に各スポットインスタンスの料金が保存される。 その際 s3:*Acl がないと The specified bucket does not exist or does not have enough permissions エラーになったので追加した。 なお、この Spot instance data feed はアカウントに一つしか登録できないのに留意する必要がある。

CDKのAwsCustomResourceでAWSのAPIを呼ぶ - sambaiz-net

const spotDataFeedBucket = new s3.Bucket(this, 'KubecostSpotDatafeedBucket', {
  bucketName: `${cluster.clusterName}-kubecost-spot-datafeed`,
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  // https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-data-feeds.html#using-spot-instances-dfs3
  accessControl: s3.BucketAccessControl.BUCKET_OWNER_FULL_CONTROL,
  objectOwnership: s3.ObjectOwnership.OBJECT_WRITER,
})

// https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateSpotDatafeedSubscription.html
new cdk.custom_resources.AwsCustomResource(this, 'CreateSpotDatafeedSubscription', {
  onCreate: {
    service: 'ec2',
    action: 'createSpotDatafeedSubscription',
    parameters: {
      Bucket: spotDataFeedBucket.bucketName,
    },
    physicalResourceId: cdk.custom_resources.PhysicalResourceId.of(spotDataFeedBucket.bucketName),
  },
  onDelete: {
    service: 'ec2',
    action: 'deleteSpotDatafeedSubscription',
  },
  policy: cdk.custom_resources.AwsCustomResourcePolicy.fromStatements([
    new iam.PolicyStatement({
      actions: ['ec2:CreateSpotDatafeedSubscription', 'ec2:DeleteSpotDatafeedSubscription'],
      resources: ['*'],
    }),
    new iam.PolicyStatement({
      actions: ['s3:*Acl'],
      resources: [spotDataFeedBucket.bucketArn],
    }),
  ])
}).node.addDependency(spotDataFeedBucket)

kubecostProductConfigs でこの Bucket を指定し、読む権限を ServiceAccount に与えるとスポットの料金が反映される。 スポットインスタンスを表す spotLabel については、マネージドノードグループや Karpenter のラベルは自動で参照されるので、これらを用いている場合は設定しなくてもよい。

const role = new cdk.aws_iam.Role(this, 'KubecostRole', {
  roleName: `${cluster.clusterName}-kubecost`,
  assumedBy: new iam.WebIdentityPrincipal(
    cluster.openIdConnectProvider.openIdConnectProviderArn,
    {
      StringEquals: new cdk.CfnJson(
        this,
        'KubecostRoleStringEquals',
        {
          value: {
            [`${cluster.clusterOpenIdConnectIssuer}:aud`]:
                'sts.amazonaws.com',
            [`${cluster.clusterOpenIdConnectIssuer}:sub`]:
                'system:serviceaccount:kubecost:kubecost-cost-analyzer',
          },
        }
      ),
    }
  ),
  inlinePolicies: {
    'SpotDataFeed': new cdk.aws_iam.PolicyDocument({
      statements: [
        new cdk.aws_iam.PolicyStatement({
          actions: [
            's3:ListAllMyBuckets',
            's3:ListBucket',
            's3:List*',
            's3:Get*',
          ],
          resources: [spotDataFeedBucket.bucketArn, `${spotDataFeedBucket.bucketArn}/*`],
        }),
      ],
    }),
  }
})

cluster.addHelmChart('KubecostHelmChart', {
  ...
  values: {
    ...
    kubecostProductConfigs: {
      projectID: cdk.Stack.of(this).account,
      awsSpotDataRegion: cdk.Stack.of(this).region,
      awsSpotDataBucket: spotDataFeedBucket.bucketName,
    },
    serviceAccount: {
      annotations: {
        'eks.amazonaws.com/role-arn': role.roleArn,
      },
    }
  },
})