容器监控与日志

容器监控与日志

写在前面

容器化上半场解决了"怎么跑起来",下半场要解决的是"跑得好不好、出了事能不能快速定位"。本篇会搭一套完整的可观测性体系:Prometheus + Grafana 做监控,Loki 做日志聚合,然后用一个真实案例展示怎么用这套体系排查问题。

一、可观测性的三大支柱

支柱 回答的问题 工具
Metrics(指标) 系统现在是什么状态? Prometheus、Grafana
Logs(日志) 当时发生了什么? Loki、ELK
Traces(链路追踪) 一个请求经过了哪些服务? Jaeger、Zipkin

本篇讲前两个。Traces 链路追踪涉及 OpenTelemetry,内容比较多,以后单独开一篇。

二、容器自带的基础监控

2.1 docker stats

最基础的:

docker stats

CONTAINER   CPU %   MEM USAGE / LIMIT   MEM %   NET I/O          BLOCK I/O          PIDS
api         2.5%    312MB / 1GB         31.2%   1.2GB / 890MB    0B / 0B            23
mysql       45.2%   4.2GB / 8GB         52.5%   12GB / 8.4GB     1.2GB / 450MB      287

优点:简单 缺点:只能看当前瞬时值,不能看历史趋势,不能告警

2.2 Docker 内置的 metric endpoint

Docker 19+ 暴露 Prometheus 格式的 metrics,默认没开:

# 编辑 daemon.json
sudo vi /etc/docker/daemon.json

{
  "metrics-addr": "0.0.0.0:9323",
  "experimental": true
}

sudo systemctl restart docker
curl http://localhost:9323/metrics | grep engine_daemon
# 看到各种 engine_* 指标

但这只能看 docker daemon 本身,看不到容器内部。要看容器内部,得用 cAdvisor。

三、Prometheus + cAdvisor + Grafana 监控体系

3.1 整体架构

┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐
│ cAdvisor│───▶│Prometheus│───▶│ Grafana │    │AlertManager│──▶ PagerDuty/Slack
└─────────┘    └─────────┘    └─────────┘    └─────────┘
     ↑              ↑              ↑
     │              │              │
容器运行时指标    拉取并存储       可视化展示

每个组件的职责:

3.2 docker-compose 部署

# compose-monitoring.yaml
services:
  cadvisor:
    image: gcr.io/cadvisor/cadvisor:v0.47.2
    restart: unless-stopped
    privileged: true
    devices:
      - /dev/kmsg
    volumes:
      - /:/rootfs:ro
      - /var/run:/var/run:ro
      - /sys:/sys:ro
      - /var/lib/docker/:/var/lib/docker:ro
      - /dev/disk/:/dev/disk:ro
    ports:
      - "8080:8080"
    networks:
      - monitoring

  prometheus:
    image: prom/prometheus:v2.51.0
    restart: unless-stopped
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    command:
      - --config.file=/etc/prometheus/prometheus.yml
      - --storage.tsdb.path=/prometheus
      - --storage.tsdb.retention.time=30d
      - --web.enable-lifecycle
    ports:
      - "9090:9090"
    depends_on:
      - cadvisor
    networks:
      - monitoring

  grafana:
    image: grafana/grafana:10.4.0
    restart: unless-stopped
    volumes:
      - grafana-data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
    environment:
      GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
      GF_USERS_ALLOW_SIGN_UP: false
    ports:
      - "3000:3000"
    depends_on:
      - prometheus
    networks:
      - monitoring

  alertmanager:
    image: prom/alertmanager:v0.27.0
    restart: unless-stopped
    volumes:
      - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
    command:
      - --config.file=/etc/alertmanager/alertmanager.yml
    ports:
      - "9093:9093"
    networks:
      - monitoring

  node-exporter:
    image: prom/node-exporter:v1.7.0
    restart: unless-stopped
    pid: host
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - --path.procfs=/host/proc
      - --path.sysfs=/host/sys
      - --path.rootfs=/rootfs
      - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc|var/lib/docker/.+)($$|/)
    networks:
      - monitoring

volumes:
  prometheus-data:
  grafana-data:

networks:
  monitoring:
    driver: bridge
    internal: false  # 需要外网访问 Grafana

3.3 Prometheus 配置

prometheus.yml:

global:
  scrape_interval: 15s
  evaluation_interval: 15s
  external_labels:
    cluster: prod-cluster-1

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']

rule_files:
  - "alerts/*.yml"

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'cadvisor'
    static_configs:
      - targets: ['cadvisor:8080']

  - job_name: 'node-exporter'
    static_configs:
      - targets: ['node-exporter:9100']

  # 业务服务自带 Prometheus 客户端
  - job_name: 'api'
    metrics_path: /metrics
    static_configs:
      - targets: ['api-host-1:3000', 'api-host-2:3000']

3.4 关键告警规则

alerts/containers.yml:

groups:
  - name: containers
    rules:
      # 容器内存使用超过 90%
      - alert: ContainerMemoryHigh
        expr: |
          (container_memory_usage_bytes / container_spec_memory_limit_bytes) > 0.9          
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Container {{ $labels.name }} memory usage > 90%"
          description: "Container {{ $labels.name }} on host {{ $labels.instance }} has been using > 90% memory for 5 minutes."

      # 容器重启了
      - alert: ContainerRestarting
        expr: increase(container_restart_count[15m]) > 0
        for: 0m
        labels:
          severity: critical
        annotations:
          summary: "Container {{ $labels.name }} restarted"

      # 容器 CPU 持续高负载
      - alert: ContainerCPUHigh
        expr: |
          rate(container_cpu_usage_seconds_total[5m]) > 0.9          
        for: 10m
        labels:
          severity: warning

      # 宿主机磁盘将满
      - alert: NodeDiskSpaceLow
        expr: |
          (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.1          
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Disk space < 10% on {{ $labels.instance }}"

      # 容器 OOM Kill
      - alert: ContainerOOMKilled
        expr: increase(container_oom_events_total[5m]) > 0
        labels:
          severity: critical
        annotations:
          summary: "OOM killed: {{ $labels.name }}"

alerts/application.yml:

groups:
  - name: application
    rules:
      # API P99 延迟 > 1s
      - alert: APIP99LatencyHigh
        expr: |
          histogram_quantile(0.99,
            rate(http_request_duration_seconds_bucket{job="api"}[5m])
          ) > 1.0          
        for: 5m
        labels:
          severity: warning

      # 错误率 > 5%
      - alert: APIErrorRateHigh
        expr: |
          rate(http_requests_total{job="api",status=~"5.."}[5m]) /
          rate(http_requests_total{job="api"}[5m]) > 0.05          
        for: 5m
        labels:
          severity: critical

      # 服务发现失败
      - alert: TargetDown
        expr: up == 0
        for: 2m
        labels:
          severity: critical

3.5 AlertManager 配置

alertmanager.yml:

global:
  resolve_timeout: 5m

route:
  group_by: ['alertname', 'cluster']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'default'
  routes:
    - match:
        severity: critical
      receiver: 'pagerduty'
      repeat_interval: 30m

receivers:
  - name: 'default'
    slack_configs:
      - api_url: 'https://hooks.slack.com/services/XXX/YYY/ZZZ'
        channel: '#alerts'
        title: '{{ .GroupLabels.alertname }}'
        text: '{{ .CommonAnnotations.description }}'

  - name: 'pagerduty'
    pagerduty_configs:
      - service_key: 'YOUR_PAGERDUTY_KEY'
        description: '{{ .CommonAnnotations.summary }}'

inhibit_rules:
  # 高严重度告警抑制低严重度
  - source_match:
      severity: 'critical'
    target_match:
      severity: 'warning'
    equal: ['alertname', 'dev', 'instance']

3.6 Grafana 面板

Grafana 的 Docker 监控面板用 PromQL 写查询语句,几个核心:

容器 CPU 使用率(按服务聚合):

sum by (name) (
  rate(container_cpu_usage_seconds_total{name!=""}[5m])
)

容器内存使用(按服务聚合):

sum by (name) (container_memory_usage_bytes{name!=""})

容器磁盘 IO:

sum by (name) (rate(container_fs_reads_bytes_total[5m]))
sum by (name) (rate(container_fs_writes_bytes_total[5m]))

网络流量:

sum by (name) (rate(container_network_receive_bytes_total[5m]))
sum by (name) (rate(container_network_transmit_bytes_total[5m]))

我们后来用 Grafana 的 Docker Dashboard(ID: 893)作为基础面板,自己加了几张业务相关的。

四、日志体系:Loki

4.1 为什么不用 ELK

ELK(E Elasticsearch + L Logstash + K Kibana)是经典选择,但有几个问题:

Loki 是 Grafana Labs 出的轻量日志方案,设计哲学是"像 Prometheus 一样做日志":

4.2 部署 Loki + Promtail

# compose-logging.yaml
services:
  loki:
    image: grafana/loki:2.9.4
    restart: unless-stopped
    volumes:
      - ./loki-config.yaml:/etc/loki/local-config.yaml:ro
      - loki-data:/loki
    command: -config.file=/etc/loki/local-config.yaml
    ports:
      - "3100:3100"
    networks:
      - monitoring

  promtail:
    image: grafana/promtail:2.9.4
    restart: unless-stopped
    volumes:
      - ./promtail-config.yaml:/etc/promtail/config.yaml:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    command: -config.file=/etc/promtail/config.yaml
    depends_on:
      - loki
    networks:
      - monitoring

volumes:
  loki-data:

4.3 Promtail 配置

promtail-config.yaml:

server:
  http_listen_port: 9080

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: docker
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      # 用 container label 作为 Loki label
      - source_labels: ['__meta_docker_container_label_com_docker_compose_service']
        target_label: 'service'
      - source_labels: ['__meta_docker_container_name']
        target_label: 'container'
      - source_labels: ['__meta_docker_container_log_stream']
        target_label: 'stream'

    # 关键:把容器日志路径替换为 docker logs 能拿到的路径
    - source_labels: ['__meta_docker_container_log_stream']
        target_label: 'log_stream'

  # 也可以用 static 方式直接读宿主机上的容器日志
  - job_name: containers
    static_configs:
      - targets:
          - localhost
        labels:
          job: containerlogs
          __path__: /var/lib/docker/containers/**/*-json.log
    pipeline_stages:
      - docker: {}  # 自动解析 docker json 日志格式

4.4 LogQL 查询

Loki 用 LogQL 查询,基本格式:

{job="containerlogs", service="api"} |= "error"

实战常用查询:

# 某个服务的所有错误日志
{service="api"} |= "ERROR"

# 某个用户的所有操作日志
{service="api"} | json | user_id="12345"

# 5 分钟内同类型错误的数量
sum(rate({service="api"} |= "ERROR" [5m]))

# API 慢请求日志(假设应用记录了 duration)
{service="api"} | json | duration > 1000

4.5 结构化日志的最佳实践

坏实践:

console.log("User " + userId + " tried to login, failed: " + error.message)

输出:User 12345 tried to login, failed: invalid password

这种日志无法在 Loki 里按字段查询。

好实践:用结构化日志(pino / winston / zap):

logger.error({
  event: "login_failed",
  user_id: userId,
  ip: req.ip,
  error: error.message,
  duration_ms: Date.now() - start
})

输出(JSON):

{"level":50,"time":1719136000000,"event":"login_failed","user_id":12345,"ip":"10.0.0.5","error":"invalid password","duration_ms":42}

LogQL 直接按字段查:

{service="api"} | json | event="login_failed" | user_id="12345"

4.6 日志驱动的几个关键决策

决策 1:日志格式(json vs 文本)

用 JSON。理由:

决策 2:日志去重 / 采样

高 QPS 服务的 INFO 日志量巨大,推荐:

决策 3:日志保留期

# loki-config.yaml
limits_config:
  retention_period: 30d  # 默认 30 天

按合规要求调整。我们金融项目保留 180 天,但用 S3 存冷数据。

五、实战排错:API 慢 SQL 引起的雪崩

5.1 故障描述

时间:2024 年 5 月某个工作日 14:23 现象:API P99 延迟从 200ms 突然涨到 8s,持续 12 分钟 影响:下游所有依赖此 API 的服务都出现超时

5.2 第一步:Grafana 看大盘

打开 API 监控面板,看到:

嫌疑:MySQL 出问题导致 API 慢。

5.3 第二步:Loki 查日志

{service="api"} | json | level=50  # 错误日志

发现大量:

{"level":50,"event":"db_query_timeout","query":"SELECT ... FROM orders ...","duration_ms":10000}

慢查询。

{service="api"} | json | event="db_query_timeout"

10 分钟内 8000+ 条,集中在 orders 表的某个查询。

5.4 第三步:Prometheus 看 MySQL 指标

# MySQL slow queries
mysql_global_status_slow_queries

5 分钟内慢查询从 1/min 涨到 200/min。

# MySQL 连接数
mysql_global_status_threads_connected

连接数从 50 涨到 200+(接近 max_connections=200)。

5.5 第四步:进 MySQL 容器查

docker exec -it mysql sh

mysql -u root -p
SHOW PROCESSLIST;

看到一堆 SELECT ... FROM orders WHERE user_id = ? 在等锁。

SHOW ENGINE INNODB STATUS\G

LATEST DETECTED DEADLOCK——有死锁!

5.6 第五步:看慢查询日志

SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 10;

找到元凶:

SELECT id, user_id, status, created_at
FROM orders
WHERE user_id = 12345
  AND created_at > '2024-05-01'
  AND status = 'pending'
ORDER BY created_at DESC;

执行计划:

EXPLAIN SELECT ...;
type: ref
key: idx_user_status
rows: 50000  ← 扫了 5 万行!
Extra: Using filesort

5 万行,带 filesort,每次 8 秒。

5.7 根因

业务新加了一个状态值 pending,但 idx_user_status 索引是 (user_id, status, created_at),索引顺序不对——MySQL 必须扫 5 万行 + 排序。

5.8 修复

立即止血:

-- 加一个正确的索引
ALTER TABLE orders ADD INDEX idx_user_status_created (user_id, status, created_at);

完整修复(代码层):

// 改 SQL,强制走索引
const orders = await db.query(`
  SELECT id, user_id, status, created_at
  FROM orders FORCE INDEX (idx_user_status_created)
  WHERE user_id = ?
    AND status = 'pending'
    AND created_at > ?
  ORDER BY created_at DESC
  LIMIT 20
`, [userId, sevenDaysAgo]);

5.9 这次故障的教训

  1. 必须有大盘——5 秒看出 P99 异常
  2. 必须有日志——3 分钟定位到 MySQL
  3. 必须有监控指标——2 分钟找到慢查询
  4. 必须有慢查询日志——5 分钟定位到具体 SQL
  5. 索引要随业务变——新加 status 值时要 review 索引

这次故障从报警到恢复用了 12 分钟,比之前纯靠 SSH 登服务器捞日志(平均 40 分钟)快了 3 倍。

六、本篇小结

主题 关键点
三大支柱 Metrics / Logs / Traces
监控栈 Prometheus + cAdvisor + Grafana + AlertManager
日志栈 Loki + Promtail,LogQL 查询
关键告警 内存、CPU、重启、磁盘、OOM
结构化日志 JSON 格式,可按字段查询
排错流程 大盘 → 指标 → 日志 → 进程 → SQL

最后一篇讲 从 Docker 到 Kubernetes,把 docker-compose 迁移到 K8s 的全过程,以及我们从 Swarm 切到 K8s 的真实经历。

参考资料