vector で kubernetes の container log を CloudWatch Logs に転送する

# Vector とは

[vector](https://vector.dev/) は timber とともに買収され datadog がメンテナンスしているオープンソースプロジェクトのようです。([Datadog acquires Timber Technologies](https://www.datadoghq.com/blog/datadog-acquires-timber-technologies-vector/))

**A lightweight, ultra-fast tool for building observability pipelines**

ということで Rust で書かれたメトリクスやログ収集のためのツールです。今回はこれを使って Kubernetes (EKS) のコンテナログを CloudWatch Logs に送信するために使ってみます。

## Vector Components

https://vector.dev/components/

Vector component を **Source** → **Transform** → **Sink** と繋げることでデータの収集、変換、ルーティングを簡単に行うことができます。

今回はシンプルな構成で次のものを使います。

- Source: [kubernetes_logs](https://vector.dev/docs/reference/configuration/sources/kubernetes_logs/)
- Transform: [remap](https://vector.dev/docs/reference/configuration/transforms/remap/) (JSON ログの parse)
- Sink: [aws_cloudwatch_logs](https://vector.dev/docs/reference/configuration/sinks/aws_cloudwatch_logs/)

# Helm を使って Kubernetes に deploy する

Agent を DaemonSet として deploy し、各 node から直接 CloudWatch Logs に送るという構成でセットアップします。
EKS ですので CloudWatch Logs へのアクセスには IRSA を使うこととします。(Pod Identity は試していない)

EKS は 1.30
node の OS は Amazon Linux 2023

## Helm chart

Helm chart は次のリポジトリで管理されており、https://helm.vector.dev で公開されています。

https://github.com/vectordotdev/helm-charts

```bash
helm repo add vector https://helm.vector.dev
```

```
$ helm search repo vector/vector
NAME                    	CHART VERSION	APP VERSION           	DESCRIPTION                                       
vector/vector           	0.37.0       	0.42.0-distroless-libc	A lightweight, ultra-fast tool for building obs...
vector/vector-agent     	0.21.3       	0.19.3                	A Helm chart to collect Kubernetes logs with Ve...
vector/vector-aggregator	0.21.3       	0.19.3                	A Helm chart to aggregate data with Vector        
vector/vector-shared    	0.2.1        	                      	Shared dependencies of Vector Helm charts         
```

今回試したのは helm chart の version が 0.37.0 で、vector の version は 0.42.0 です。

## values

```yaml
# DaemonSet として deploy する
role: Agent

# IRSA 用の IAM Role を指定
serviceAccount:
  annotations:
    eks.amazonaws.com/role-arn: ${roleArn}

# 全ての node に deploy する
tolerations:
  - operator: Exists

# node 内の優先度を上げる
podPriorityClassName: system-node-critical

# aggregator としての deploy ではないので Service は不要
service:
  enabled: false
serviceHeadless:
  enabled: false

logLevel: warn

livenessProbe:
  httpGet:
    path: /health
    port: api

# config ファイルの定義用変数
customConfig:
  # host の /var/lib/vector が /vector-data-dir に mount されるため、customConfig を使う場合はここを指定する必要がある
  data_dir: /vector-data-dir

  # livenessProbe のために api を有効化
  api:
    enabled: true
    address: 0.0.0.0:8686
    playground: false

  sources:
    # kubernetes_logs でログファイルから読み出す
    container_logs:
      type: kubernetes_logs
      auto_partial_merge: true
      delay_deletion_ms: 60000
      glob_minimum_cooldown_ms: 60000
      ignore_older_secs: 3600
      max_line_bytes: 32768
      read_from: beginning
      rotate_wait_secs: 300
      use_apiserver_cache: true

  transforms:
    # remap component を使ってログが JSON で出力されている場合に parse する
    parsed_container_logs:
      # inputs に sources で定義された名前を指定
      inputs:
        - container_logs
      type: remap
      # VRL で処理内容を定義
      source: |
        structured = parse_json(.message) ?? parse_nginx_log(.message, "combined") ?? parse_nginx_log(.message, "error") ?? null
        if structured != null {
          .data = structured
          del(.message)
        }

  sinks:
    # CloudWatch Log に送る
    cloudwatch:
      type: aws_cloudwatch_logs
      # inputs に sources や transforms で定義した名前を指定
      inputs:
        - parsed_container_logs
      group_name: ${cloudwatch_log_group_name}
      create_missing_group: false
      # LogStream の名前は複数の node で被らないように指定する
      stream_name: "{{`{{`}}kubernetes.pod_namespace{{`}}`}}-{{`{{`}}kubernetes.pod_name{{`}}`}}-{{`{{`}}kubernetes.container_name{{`}}`}}"
      create_missing_stream: true
      region: ap-northeast-1
      auth:
        region: ap-northeast-1
      # buffer には memory と disk が選択可能
      buffer:
        type: memory
        max_events: 500
        when_full: block
      encoding:
        codec: json
```

# Vector Remap Language (VRL)

https://vector.dev/docs/reference/vrl/

transform の [remap](https://vector.dev/docs/reference/configuration/transforms/remap/) component の source の中身は VRL という vector の DSL でよく使われる操作が function として定義されています。

今回は parse_json() を使って JSON で出力されたコンテナのログを parse して、CloudWatch Logs Insights で直接クエリできるようにしました。他にもいくつかよくあるログフォーマット用の parser も存在するので `??` で繋げてみました。こうすることで parse に失敗した場合に次の parser を試すといったことが可能です。

コンテナの出力は message という field に入っており、[kubernetes_logs](https://vector.dev/docs/reference/configuration/sources/kubernetes_logs/) source は API Server から metadata を付与してくれます。JSON 内の項目がこの metadata を上書きしないようにここでは data という field に parse の結果を入れています。Google の Cloud Logging に倣って jsonPayload とするのも良いでしょう。`del()` で元の message を削除しています。

```
structured = parse_json(.message) ?? parse_nginx_log(.message, "combined") ?? parse_nginx_log(.message, "error") ?? null
if structured != null {
  .data = structured
  del(.message)
}
```

[VRL playground](https://playground.vrl.dev/) や vector CLI の [vrl サブコマンド](https://playground.vrl.dev/)で VRL の動作確認もできます。

https://playground.vrl.dev/

# CloudWatch Logs に送られたログ

[kubernetes_logs](https://vector.dev/docs/reference/configuration/sources/kubernetes_logs/) ではデフォルトで多くの metadata が付与されます。
これらは [pod_annotation_fields](https://vector.dev/docs/reference/configuration/sources/kubernetes_logs/#pod_annotation_fields) や [namespace_annotation_fields](https://vector.dev/docs/reference/configuration/sources/kubernetes_logs/#namespace_annotation_fields), [node_annotation_fields](https://vector.dev/docs/reference/configuration/sources/kubernetes_logs/#node_annotation_fields) で制御可能です。

```json
{
    "file": "/var/log/pods/kube-system_kube-proxy-fj9vw_23986ffa-791b-4c26-a8b5-e1ddf5bf4604/kube-proxy/0.log",
    "kubernetes": {
        "container_id": "containerd://c44b2ee3caca7594bd45f54203cb5b3887b3bc01b021c0a646378556d315e77c",
        "container_image": "111222333444.dkr.ecr.ap-northeast-1.amazonaws.com/eks/kube-proxy:v1.31.1-minimal-eksbuild.2",
        "container_image_id": "111222333444.dkr.ecr.ap-northeast-1.amazonaws.com/eks/kube-proxy@sha256:1f678e1d10559dec62ad6640a96e1deec17f3e1354e3002290a8bbc08da67c78",
        "container_name": "kube-proxy",
        "namespace_labels": {
            "kubernetes.io/metadata.name": "kube-system"
        },
        "node_labels": {
            "beta.kubernetes.io/arch": "amd64",
            "beta.kubernetes.io/instance-type": "m7i.4xlarge",
            "beta.kubernetes.io/os": "linux",
            "eks.amazonaws.com/capacityType": "ON_DEMAND",
            "eks.amazonaws.com/nodegroup": "system",
            "eks.amazonaws.com/nodegroup-image": "ami-1234567890abcdef0",
            "eks.amazonaws.com/sourceLaunchTemplateId": "lt-1234567890abcdef0",
            "eks.amazonaws.com/sourceLaunchTemplateVersion": "8",
            "failure-domain.beta.kubernetes.io/region": "ap-northeast-1",
            "failure-domain.beta.kubernetes.io/zone": "ap-northeast-1c",
            "k8s.io/cloud-provider-aws": "991feb0579d6e31b9de4b36d824a2fe8",
            "kubernetes.io/arch": "amd64",
            "kubernetes.io/hostname": "ip-10-0-19-208.ap-northeast-1.compute.internal",
            "kubernetes.io/os": "linux",
            "node.kubernetes.io/instance-type": "m7i.4xlarge",
            "topology.ebs.csi.aws.com/zone": "ap-northeast-1c",
            "topology.k8s.aws/zone-id": "apne1-az1",
            "topology.kubernetes.io/region": "ap-northeast-1",
            "topology.kubernetes.io/zone": "ap-northeast-1c",
            "workload/system": "true"
        },
        "pod_ip": "10.0.19.208",
        "pod_ips": [
            "10.0.19.208"
        ],
        "pod_labels": {
            "controller-revision-hash": "775c97d6d",
            "k8s-app": "kube-proxy",
            "pod-template-generation": "2"
        },
        "pod_name": "kube-proxy-fj9vw",
        "pod_namespace": "kube-system",
        "pod_node_name": "ip-10-0-19-208.ap-northeast-1.compute.internal",
        "pod_owner": "DaemonSet/kube-proxy",
        "pod_uid": "23986ffa-791b-4c26-a8b5-e1ddf5bf4604"
    },
    "message": "I1201 11:28:39.520230       1 proxier.go:822] \"SyncProxyRules complete\" ipFamily=\"IPv4\" elapsed=\"162.318057ms\"",
    "source_type": "kubernetes_logs",
    "stream": "stderr"
}
```

JSON 出力されていたログの場合

```json
{
    "data": {
        "author": "アルフレッド・テニソン",
        "length": "75",
        "message": "過ぎ去りし麗しき日々は、再び我が元に返り来たらず。",
        "method": "GET",
        "severity": "info",
        "time": "2024-11-29T08:30:34.613521319Z",
        "uri": "/?n=100",
        "uuid": "82d6e748-6449-484b-bf1e-65df7e7628b9"
    },
    "file": "/var/log/pods/meigen_meigen-6559557bfb-vs6jv_3336ef94-b0f5-413f-a79b-bfe1e7f4c67f/meigen/0.log",
    "kubernetes": {省略},
    "source_type": "kubernetes_logs",
    "stream": "stderr"
}
```

# 終わりに

以上、とりあえず DaemonSet として vector を deploy して CloudWatch Logs にログを転送する例でした。
安定運用できるかは要検証ですが、Fluentd、Fluent Bit の代替として検討しようかという今日この頃です。