容器监控与日志
容器监控与日志
写在前面
容器化上半场解决了"怎么跑起来",下半场要解决的是"跑得好不好、出了事能不能快速定位"。本篇会搭一套完整的可观测性体系: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
└─────────┘ └─────────┘ └─────────┘ └─────────┘
↑ ↑ ↑
│ │ │
容器运行时指标 拉取并存储 可视化展示
每个组件的职责:
- cAdvisor:装在每个宿主机,采集容器运行时指标
- Prometheus:时序数据库,定期拉取 cAdvisor 指标
- Grafana:可视化面板
- AlertManager:处理告警规则
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)是经典选择,但有几个问题:
- ES 太重(资源占用大,JVM 调优麻烦)
- 全文索引开销大,简单日志查询反而慢
- 运维成本高
Loki 是 Grafana Labs 出的轻量日志方案,设计哲学是"像 Prometheus 一样做日志":
- 索引只存标签(label),不索引日志内容
- 日志按 chunk 压缩存储
- 查询用 LogQL(类似 PromQL)
- 跟 Grafana 无缝集成
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。理由:
- 可解析,Loki 能按字段查询
- 调试时仍然可读
- 几乎所有现代日志库都支持 JSON 输出
决策 2:日志去重 / 采样
高 QPS 服务的 INFO 日志量巨大,推荐:
- 生产环境默认 WARN 级别
- DEBUG 日志按需开启(通过环境变量)
- 健康检查的 access log 单独走(不污染应用日志)
决策 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 监控面板,看到:
- P99 延迟:从 200ms 涨到 8000ms(40 倍)
- 错误率:从 0.1% 涨到 15%
- QPS:没明显变化(不是流量问题)
- API 容器 CPU:从 30% 涨到 90%(被拖慢的迹象)
- MySQL 容器 CPU:从 40% 涨到 95%
嫌疑: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 这次故障的教训
- 必须有大盘——5 秒看出 P99 异常
- 必须有日志——3 分钟定位到 MySQL
- 必须有监控指标——2 分钟找到慢查询
- 必须有慢查询日志——5 分钟定位到具体 SQL
- 索引要随业务变——新加 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 的真实经历。