运维知识
悠悠
2026年7月10日

中小团队零成本自建监控体系,告警从200条/天降到5条,这套Prometheus+Loki+Grafana配置我用了3个月才跑通

每月云监控账单1.2万,告警消息一天200多条没人看,真出故障还是靠人肉SSH上去翻日志。50多个微服务跑在200个节点上,监控面板花里胡哨十几个Dashboard,值班的同学打开看一眼就关了——因为根本找不到重点。

这就是我们去年底的现状。

领导的原话是:"你们这个监控,到底是摆设还是真能用?"

我当时就觉得,这事不能再拖了。

旧架构有多拉胯

先说下当时的情况。公司业务增长快,微服务从年初的十几个膨胀到五十多个,峰值QPS大概1.2万,数据库TPS 8000左右。原来用的是某云厂商的监控服务,说实话刚开始还行,十来个服务的时候够用。但服务一多,问题就全暴露了:

  • 自定义指标加一个得提工单等两天
  • 告警规则只能选预置模板,想写个复杂点的PromQL?不支持
  • 日志查询,5分钟之内的还凑合,跨一小时直接超时
  • 最离谱的是计费——按时间线数量收费,服务一多时间线爆炸,账单跟着爆炸

上个季度有一次核心服务OOM,从告警触发到有人真正看到处理,中间隔了22分钟。22分钟啊,那会儿客户投诉都打到客服那边去了。

选型:为什么是这三件套

ELK我之前搞过,ES的资源消耗是真的离谱。之前那套ES集群光机器成本每月2万多,日志量还没现在大。Datadog之类的SaaS方案更贵,而且敏感数据放外面领导不同意。

最后选了Prometheus + Loki + Grafana:

  • Prometheus — K8s生态下采集时序指标基本没对手,PromQL灵活度天花板
  • Loki — 不做全文索引只索引label,存储成本能压到ES的1/10
  • Grafana — 可视化天花板,而且这三个是同一个生态,集成度天然好

选型容易,落地才是噩梦的开始。

Prometheus部署:从安装到踩坑

基础部署

我们用的是kube-prometheus-stack这个Helm Chart,一把梭部署Prometheus、Alertmanager、Grafana和一堆exporter:

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update

helm install monitoring prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  --create-namespace \
  --set prometheus.prometheusSpec.retention=15d \
  --set prometheus.prometheusSpec.resources.requests.memory=8Gi \
  --set prometheus.prometheusSpec.resources.limits.memory=32Gi \
  --set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=200Gi \
  --set prometheus.prometheusSpec.replicas=2

这里有几个参数说下为什么这么配:

  • retention=15d:本地只存15天,长期存储交给Thanos
  • 内存request 8G、limit 32G:Prometheus吃内存大户,给少了OOM很常见
  • replicas=2:双副本,一个挂了另一个顶上

label基数爆炸问题

跑了两周Prometheus内存飙到32G,查PromQL转圈。排查发现开发同学这么埋的点:

# 错误示范!user_id是高基数值,绝对不能当label
REQUEST_COUNTER.labels(
    service="order-service",
    method="POST",
    endpoint="/api/order",
    user_id=request.user_id,    # 这行就是罪魁祸首
    status_code=response.status
).inc()

正确做法

# label只用低基数枚举值
REQUEST_COUNTER.labels(
    service="order-service",
    method="POST", 
    endpoint="/api/order",
    status_code=response.status
).inc()
# user_id这种高基数信息放到日志或trace里去

怎么发现哪些指标有问题?用这个PromQL查时间线top10:

topk(10, count by (__name__)({__name__=~".+"}))

或者用Prometheus自带的TSDB状态页面 /tsdb-status,能直接看到哪些label的基数最高。

我们砍掉不合理label后:时间线200万 → 40万,内存32G → 14G。

Thanos高可用部署

每个集群的Prometheus配Thanos Sidecar:

# prometheus的额外配置,开启Sidecar需要的external_labels
apiVersion: monitoring.coreos.com/v1
kind: Prometheus
metadata:
  name: k8s
  namespace: monitoring
spec:
  externalLabels:
    cluster: cluster-beijing-01    # 每个集群必须有唯一标识
    region: cn-north-1
  thanos:
    image: quay.io/thanos/thanos:v0.35.1
    objectStorageConfig:
      key: thanos-storage.yaml
      name: thanos-objstore-config

对象存储配置(S3兼容):

# thanos-storage.yaml
type: S3
config:
  bucket: "prometheus-thanos-data"
  endpoint: "s3.cn-north-1.amazonaws.com.cn"
  access_key: "${AWS_ACCESS_KEY_ID}"
  secret_key: "${AWS_SECRET_ACCESS_KEY}"

Thanos Query做跨集群聚合查询:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: thanos-query
  namespace: monitoring
spec:
  replicas: 2
  template:
    spec:
      containers:
      - name: thanos-query
        image: quay.io/thanos/thanos:v0.35.1
        args:
        - query
        - --store=dnssrv+_grpc._tcp.thanos-sidecar-beijing.monitoring.svc
        - --store=dnssrv+_grpc._tcp.thanos-sidecar-shanghai.monitoring.svc
        - --store=dnssrv+_grpc._tcp.thanos-sidecar-guangzhou.monitoring.svc
        - --store=dnssrv+_grpc._tcp.thanos-store-gateway.monitoring.svc
        - --query.replica-label=prometheus_replica

Compactor的坑——降低并发防OOM:

thanos compact \
  --data-dir=/tmp/thanos-compact \
  --objstore.config-file=/etc/thanos/storage.yaml \
  --retention.resolution-raw=14d \
  --retention.resolution-5m=30d \
  --retention.resolution-1h=90d \
  --block-sync-concurrency=2 \        # 默认20太高,16G内存扛不住
  --compact.concurrency=1

Loki部署和优化

Simple Scalable模式部署

helm repo add grafana https://grafana.github.io/helm-charts

helm install loki grafana/loki \
  --namespace monitoring \
  --set deploymentMode=SimpleScalable \
  --set loki.storage.type=s3 \
  --set loki.storage.s3.endpoint=s3.cn-north-1.amazonaws.com.cn \
  --set loki.storage.s3.bucketnames=loki-logs-data \
  --set loki.storage.s3.region=cn-north-1 \
  --set loki.schemaConfig.configs[0].from="2024-01-01" \
  --set loki.schemaConfig.configs[0].store=tsdb \
  --set loki.schemaConfig.configs[0].object_store=s3 \
  --set loki.schemaConfig.configs[0].schema=v13 \
  --set loki.schemaConfig.configs[0].index.prefix=loki_index_ \
  --set loki.schemaConfig.configs[0].index.period=24h

关键性能配置

Loki的values.yaml里这些参数很关键:

loki:
  limits_config:
    max_query_series: 5000          # 限制单查询返回的series数
    max_query_parallelism: 32       # 查询并发度
    max_entries_limit_per_query: 10000
    split_queries_by_interval: 1h   # 大查询拆分成1h粒度并行
    query_timeout: 5m
    
  chunk_store_config:
    chunk_cache_config:
      embedded_cache:
        enabled: true
        max_size_mb: 2048           # 给chunk缓存分2G内存
        
  ingester:
    chunk_encoding: snappy          # 换成snappy,写入性能+30%
    chunk_target_size: 1572864      # 1.5MB,比默认值大

Alloy采集配置

// alloy配置 - 采集K8s容器日志
discovery.kubernetes "pods" {
  role = "pod"
}

discovery.relabel "pod_logs" {
  targets = discovery.kubernetes.pods.targets
  
  rule {
    source_labels = ["__meta_kubernetes_namespace"]
    target_label  = "namespace"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_name"]
    target_label  = "pod"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_container_name"]
    target_label  = "container"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_label_app"]
    target_label  = "app"
  }
}

loki.source.kubernetes "pods" {
  targets    = discovery.relabel.pod_logs.output
  forward_to = [loki.process.pipeline.receiver]
}

loki.process "pipeline" {
  // 提取JSON日志中的level字段作为label
  stage.json {
    expressions = { level = "level" }
  }
  stage.labels {
    values = { level = "" }
  }
  // 丢弃debug级别日志,减少存储
  stage.drop {
    expression = ".*"
    source     = "level"
    value      = "debug"
  }
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://loki-gateway.monitoring.svc:3100/loki/api/v1/push"
  }
}

告警治理:完整配置

Alertmanager路由配置

# alertmanager-config.yaml
global:
  resolve_timeout: 5m

route:
  receiver: 'default-webhook'
  group_by: ['alertname', 'cluster', 'namespace']
  group_wait: 30s          # 同一组告警等30s再发,合并通知
  group_interval: 5m       # 同一组5分钟内有新告警才重新发
  repeat_interval: 4h      # 同一条告警4小时重复一次
  routes:
  - match:
      severity: critical
    receiver: 'phone-call'
    group_wait: 10s        # P0级别等10s就发
    repeat_interval: 10m
  - match:
      severity: warning
    receiver: 'wechat-oncall'
  - match:
      severity: info
    receiver: 'wechat-channel'
    
# 抑制规则:底层告警抑制上层衍生告警
inhibit_rules:
  # 节点挂了,抑制该节点上所有pod告警
  - source_match:
      alertname: NodeDown
    target_match_re:
      alertname: PodCrashLooping|PodNotReady|ContainerOOMKilled
    equal: ['node']
    
  # 集群级别故障抑制服务级别告警
  - source_match:
      severity: critical
      scope: cluster
    target_match:
      severity: warning
    equal: ['cluster']

receivers:
- name: 'phone-call'
  webhook_configs:
  - url: 'http://alert-gateway.monitoring.svc:8080/api/v1/phone'
    send_resolved: true
- name: 'wechat-oncall'
  webhook_configs:
  - url: 'http://alert-gateway.monitoring.svc:8080/api/v1/wechat/oncall'
    send_resolved: true
- name: 'wechat-channel'
  webhook_configs:
  - url: 'http://alert-gateway.monitoring.svc:8080/api/v1/wechat/channel'
- name: 'default-webhook'
  webhook_configs:
  - url: 'http://alert-gateway.monitoring.svc:8080/api/v1/wechat/default'

几条核心告警规则

# prometheus-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: core-alerts
  namespace: monitoring
spec:
  groups:
  - name: service-sla
    rules:
    # 错误率超5%持续2分钟
    - alert: HighErrorRate
      expr: |
        (
          sum(rate(http_requests_total{status=~"5.."}[5m])) by (namespace, service)
          /
          sum(rate(http_requests_total[5m])) by (namespace, service)
        ) > 0.05
      for: 2m
      labels:
        severity: critical
      annotations:
        summary: "{{ $labels.service }} 错误率 {{ $value | humanizePercentage }}"
        
    # P99延迟超过2秒
    - alert: HighLatencyP99
      expr: |
        histogram_quantile(0.99, 
          sum(rate(http_request_duration_seconds_bucket[5m])) by (le, namespace, service)
        ) > 2
      for: 5m
      labels:
        severity: warning
      annotations:
        summary: "{{ $labels.service }} P99延迟 {{ $value }}s"
        
  - name: infrastructure
    rules:
    # 节点内存使用率超90%
    - alert: NodeMemoryHigh
      expr: |
        (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.9
      for: 5m
      labels:
        severity: warning
      annotations:
        summary: "节点 {{ $labels.instance }} 内存使用率 {{ $value | humanizePercentage }}"
        
    # Pod频繁重启
    - alert: PodCrashLooping
      expr: |
        increase(kube_pod_container_status_restarts_total[1h]) > 5
      for: 5m
      labels:
        severity: warning
      annotations:
        summary: "{{ $labels.namespace }}/{{ $labels.pod }} 1小时内重启 {{ $value }} 次"
        
  - name: watchdog
    rules:
    # 监控系统自身的存活探测
    - alert: Watchdog
      expr: vector(1)
      labels:
        severity: none
      annotations:
        summary: "Watchdog正常,如果这条告警消失说明Alertmanager挂了"

告警自愈webhook示例

# alert_handler.py - 接收Alertmanager webhook,执行自愈
from flask import Flask, request
import subprocess
import json

app = Flask(__name__)

@app.route('/api/v1/auto-heal', methods=['POST'])
def auto_heal():
    data = request.json
    for alert in data.get('alerts', []):
        alert_name = alert['labels'].get('alertname')
        namespace = alert['labels'].get('namespace')
        pod = alert['labels'].get('pod')
        
        if alert_name == 'PodCrashLooping' and alert['status'] == 'firing':
            # OOM频繁重启的pod,尝试重启deployment
            deploy = pod.rsplit('-', 2)[0]  # 从pod名推断deployment
            cmd = f"kubectl rollout restart deployment/{deploy} -n {namespace}"
            result = subprocess.run(cmd.split(), capture_output=True, timeout=30)
            if result.returncode != 0:
                # 自愈失败,通知人工介入
                notify_oncall(f"自愈失败: {namespace}/{deploy}, 需人工处理")
                
        elif alert_name == 'DiskSpaceHigh':
            node = alert['labels'].get('instance')
            # 清理Docker镜像缓存
            cmd = f"ssh {node} docker system prune -f --filter until=72h"
            subprocess.run(cmd.split(), capture_output=True, timeout=60)
            
    return json.dumps({"status": "ok"}), 200

Grafana Dashboard配置要点

Dashboard按四层组织,用变量实现模板化:

// Dashboard变量配置示例
{
  "templating": {
    "list": [
      {
        "name": "namespace",
        "type": "query",
        "query": "label_values(kube_pod_info, namespace)",
        "refresh": 2
      },
      {
        "name": "service", 
        "type": "query",
        "query": "label_values(http_requests_total{namespace=\"$namespace\"}, service)",
        "refresh": 2
      }
    ]
  }
}

从指标跳转到日志的关键——Grafana Data Source配置里设置Derived Fields:

# Grafana Loki数据源配置
derivedFields:
  - name: TraceID
    matcherRegex: "traceID=(\\w+)"
    url: "http://tempo.monitoring.svc:3200/api/traces/${__value.raw}"
    datasourceUid: tempo-datasource

最终成果

跑了3个多月,硬数据:

指标改造前改造后
月度监控成本1.2万~2000(机器+存储)
日均告警数200+5条
故障发现到响应15-22分钟3分钟内
日志查询P99超时/15s+3s内
监控覆盖率~60%服务100%服务+中间件

整套系统资源占用:约60C100G(Prometheus双副本各16C32G + Thanos 8C16G + Loki三组件各4C8G + Grafana 2C4G)。

成本降了80%,灵活度和故障响应效率都上了一个台阶。

几条可复用的架构准则

  1. 监控不是越多越好,是越准越好。200条告警没人看 = 没有监控。
  2. label设计决定了Prometheus和Loki 80%的使用体验,这个必须在接入前就规范好。
  3. 告警分级+抑制规则是告警体系的灵魂,没有这两样,告警就是噪音制造机。
  4. 先跑通核心链路再逐步完善——一步到位铺太大,很容易搞到一半没人维护。
  5. 监控系统本身需要有独立的存活检测机制,不能只有一条监控链路。

你们公司的监控体系现在是什么状态?是还在用云厂商服务,还是已经自建了?自建过程中踩过什么坑,评论区聊聊,互相避雷。

觉得有用的话帮忙点个转发,让更多人少走弯路。关注我,后续还会持续分享云原生和SRE方向的实战干货。


公众号:耕云躬行录

个人博客:躬行笔记

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码