vmsingle: один бинарник VictoriaMetrics вместо всего стека Prometheus

Published: 2026-06-09

Этот сайт живёт на одноузловом k0s-кластере на небольшом VPS, и на том же VPS крутятся сами сайты, почтовый сервер и полдюжины прокси. kube-prometheus-stack хочет больше памяти, чем всё это вместе взятое. Замена — vmsingle, single-node VictoriaMetrics: скрейпит, хранит и отвечает на запросы одним бинарником с лимитом 512 МБ. Без Prometheus, без оператора, без CRD.


Почему не kube-prometheus-stack

На рабочем многоузловом кластере kube-prometheus-stack — очевидный выбор: оператор, ServiceMonitor'ы, HA-пары. На VPS с двумя ядрами картина другая:

  • один только Prometheus в покое ест 400–700 МБ даже со скромным списком таргетов
  • оператор, admission-вебхуки и CRD добавляют поды, которые на одном узле не делают ничего полезного
  • абстракция ServiceMonitor не нужна, когда все таргеты можно перечислить руками

У vmsingle есть встроенный скрейпер, который принимает обычные Prometheus scrape_configs как есть. Один под заменяет Prometheus + оператор, говорит на PromQL (плюс расширения MetricsQL) и приносит с собой VMUI для ad-hoc запросов.

Helm-релиз

Чарт — vm/victoria-metrics-single, разворачивается из values-файла:

yamlserver:
  fullnameOverride: "vmsingle-server"
  retentionPeriod: "90d"
  persistentVolume:
    enabled: true
    existingClaim: vmsingle-data
  resources:
    limits:
      cpu: 500m
      memory: 512Mi
    requests:
      cpu: 50m
      memory: 128Mi
  extraArgs:
    enableTCP6: "true"
    envflag.enable: "true"
    loggerFormat: json
    vmalert.proxyURL: http://vmalert.monitoring.svc.cluster.local:8080
    memory.allowedPercent: "20"

Два флага здесь важны:

  • memory.allowedPercent: 20 — VictoriaMetrics рассчитывает размер внутренних кэшей как процент от доступной памяти. Значение по умолчанию (60%) берётся от cgroup-лимита, а когда на узле живёт ещё куча всего, кэши безопаснее держать маленькими. Скорость запросов на датасете такого размера не страдает.
  • vmalert.proxyURL — VMUI проксирует вкладки алертинга в vmalert, и сработавшие правила видны в том же UI.

Срок хранения 90 дней для домашнего кластера — с запасом, и всё равно укладывается в считанные гигабайты: VictoriaMetrics сжимает медленно меняющиеся ряды сильно меньше байта на сэмпл.

Хранилище: hostPath PV с Retain

Storage class в кластере нет, поэтому PV объявлен руками и прибит к каталогу на узле:

yamlapiVersion: v1
kind: PersistentVolume
metadata:
  name: vmsingle-data
spec:
  capacity:
    storage: 20Gi
  accessModes: [ReadWriteOnce]
  persistentVolumeReclaimPolicy: Retain
  hostPath:
    path: /var/lib/victoria-metrics
    type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: vmsingle-data
  namespace: monitoring
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 20Gi
  volumeName: vmsingle-data
  storageClassName: ""

storageClassName: "" — несущая конструкция: пустая строка отключает динамический провижнинг, иначе PVC повиснет в Pending в ожидании провижионера по умолчанию, которого нет. Retain означает, что 90 дней метрик переживут helm uninstall.

Scrape-конфиг

scrape.enabled: true включает встроенный скрейпер. Конфиг — обычный синтаксис Prometheus:

yamlscrape:
  enabled: true
  config:
    global:
      scrape_interval: 30s
      scrape_timeout: 10s
    scrape_configs:
    - job_name: node
      static_configs:
      - labels:
          instance: k0s-node
        targets:
        - node-exporter-prometheus-node-exporter.monitoring.svc.cluster.local:9100

    - job_name: kube-state-metrics
      static_configs:
      - targets:
        - kube-state-metrics.monitoring.svc.cluster.local:8080

    - job_name: traefik
      static_configs:
      - labels:
          instance: traefik
        targets:
        - traefik.traefik.svc.cluster.local:9101
      metric_relabel_configs:
      - source_labels: [service]
        regex: '(.+)-[0-9a-f]{16}@kubernetescrd'
        replacement: '$1'
        target_label: service

Везде static_configs — на одном узле со стабильными именами Service'ов service discovery не добавляет ничего, кроме движущихся частей. Полный список таргетов: node-exporter, kube-state-metrics, сам vmsingle, vmalert, Traefik, два экспортёра прокси, три джоба blackbox-exporter (HTTP-аптайм девяти сайтов, TCP-проверки портов прокси, внешняя DNS-связность), kubelet cAdvisor и textfile-коллектор для пиров WireGuard.

Relabel-правило для Traefik заслуживает отдельного абзаца: сервисы из IngressRoute CRD Traefik называет как namespace-name-<16 hex символов>@kubernetescrd. Хеш меняется при изменении роута, и любой дашборд с группировкой по service разваливается. Relabel срезает хеш прямо на этапе скрейпа.

Скрейп cAdvisor через kubelet

Метрики CPU и памяти по контейнерам отдаёт встроенный в kubelet cAdvisor — ему нужны TLS и токен:

yaml- job_name: cadvisor
  scheme: https
  tls_config:
    insecure_skip_verify: true
  bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
  metrics_path: /metrics/cadvisor
  honor_labels: true
  static_configs:
  - targets:
    - 91.184.248.13:10250
  metric_relabel_configs:
  - source_labels: [container]
    regex: ".+"
    action: keep
  - source_labels: [container]
    regex: "POD"
    action: drop

Токен ServiceAccount работает, только если этому ServiceAccount разрешено ходить в API kubelet'а. Это маленький ClusterRole:

yamlapiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: vmsingle-kubelet-metrics
rules:
  - apiGroups: [""]
    resources: ["nodes", "nodes/metrics"]
    verbs: ["get", "list", "watch"]
  - nonResourceURLs: ["/metrics", "/metrics/cadvisor", "/metrics/resource"]
    verbs: ["get"]

привязанный к ServiceAccount vmsingle-server. Два metric_relabel_configs выкидывают ряды pause-контейнеров (container="POD") и агрегаты по cgroup с пустым лейблом container — без них каждый под порождает два-три фантомных дубля, и дашборды считают память дважды.

Стабильное имя сервиса

Одно неочевидное дополнение — обычный ClusterIP Service перед vmsingle:

yamlapiVersion: v1
kind: Service
metadata:
  name: vmsingle-stable
  namespace: monitoring
spec:
  selector:
    app.kubernetes.io/instance: vmsingle
    app.kubernetes.io/name: victoria-metrics-single
  ports:
    - port: 8428
      targetPort: 8428
  type: ClusterIP

Чарт создаёт headless Service, а headless DNS резолвится прямо в IP пода. Во время рестартов CoreDNS (на одном узле это каждая перезагрузка ноды) клиенты с закэшированным старым IP пода ловят connection refused. Grafana и vmalert смотрят на vmsingle-stable — ClusterIP переживает пересоздание пода.

Что может пойти не так

OOMKilled под нагрузкой запросов. Cgroup-лимит 512 МБ, и тяжёлые запросы по длинным диапазонам дают всплески. memory.allowedPercent: 20 держит кэши маленькими; если OOM всё равно случается — сначала поднимайте лимит, а не трогайте флаг: кэши меньше ~100 МБ замедляют вообще всё.

PVC висит в Pending. Почти всегда — отсутствующая пара storageClassName: "" / volumeName на самодельных PV+PVC. Смотрите kubectl -n monitoring describe pvc vmsingle-data: «waiting for first consumer» — нормально, «no persistent volumes available» — биндинг настроен неверно.

Скрейп cAdvisor возвращает 401. ClusterRoleBinding должен указывать на то имя ServiceAccount, которое чарт реально сгенерировал — fullnameOverride его меняет. Проверьте kubectl -n monitoring get sa и subject в биндинге.

Дашборды считают память контейнеров дважды. Прорвались ряды container="POD" и с пустым container. Убедитесь, что metric_relabel_configs есть в работающем конфиге: curl -s localhost:8428/config | grep -A5 cadvisor после port-forward.

Итого

  • Один под vmsingle заменяет Prometheus, оператор и все его CRD — 128 МБ request, потолок 512 МБ
  • Встроенный скрейпер принимает обычные Prometheus scrape_configs; на одном узле статические таргеты лучше service discovery
  • Для cAdvisor нужны RBAC на API kubelet'а и relabel-правила против дублей pause-контейнеров
  • hostPath PV с Retain и явным биндингом через volumeName — без storage class
  • Отдельный ClusterIP Service (vmsingle-stable) спасает от протухшего headless DNS после рестартов CoreDNS
  • Срок хранения 90 дней на этом масштабе стоит единицы гигабайт