用 3 台 Ubuntu 虚拟机,从裸机到"故障 60 秒内告警、恢复 30 秒内自动消除"的完整监控体系。包含 10 个采集目标、18 条告警规则,以及我在过程中踩的 8 个坑。一、为什么要做这套东西
在上一份实习里,我每天的工作之一是按 SOP 巡检服务器:登录、敲命令、看磁盘、看进程、记表格。
这套流程有两个问题:
- 发现问题靠人——巡检一天一次,故障可能在两次巡检之间发生,往往是用户先发现、我们后知道
- 告警靠邮件——邮件容易被淹没,也没有分级,CPU 高和数据库挂了是同一条消息
所以我想搭一套东西,把"人找问题"变成"问题找人"。目标很明确:
- 三台主机、全部关键服务,指标统一采集
- 故障能在 1 分钟内推到我面前
- 恢复后自动消除,不需要人手动关告警
- 告警不能刷屏——这是最容易被忽略的一点
二、最终效果
指标 结果 被监控主机 3 台 采集目标 10 个(全部 UP) 告警规则 18 条(主机 8 / 服务 8 / 监控自身 2) 采集间隔 15 秒 故障发现 MySQL 停止后60 秒内触发 critical 告警 故障恢复 恢复后30 秒内告警自动消除 大屏 8 块面板的统一总览 三、架构设计
┌──────────────────────────────────────┐ │ seek1 192.168.206.11 监控中心 │ │ ┌────────────┐ ┌───────────────┐ │ │ │ Prometheus │→ │ Alertmanager │ │ │ │ :9090 │ │ :9093 │ │ │ └─────┬──────┘ └───────┬───────┘ │ │ │ │ webhook │ │ ┌─────┴──────┐ ┌───────▼───────┐ │ │ │ Grafana │ │ alert-relay │───┼──→ 飞书 │ │ :3000 │ │ :5001 │ │ │ └────────────┘ └───────────────┘ │ │ ┌──────────────────────────────┐ │ │ │ blackbox-exporter :9115 │ │ │ └──────────────────────────────┘ │ └───────┬──────────────────┬───────────┘ 抓取 9100/9104│ │抓取 9100 ▼ ▼ ┌───────────────────┐ ┌────────────────────┐ │ seek2 .12 Nginx │ │ seek3 .13 MariaDB│ │ node_exporter │ │ node + mysqld_exp │ └───────────────────┘ └────────────────────┘为什么这么分:
- 监控中心独立成一台:监控和被监控的东西不能死在同一个地方
- 被监控节点一台上跑 Web、一台上跑数据库:这样"业务指标"和"数据指标"都能覆盖
- 全部走 Docker:环境可重建,换机器 20 分钟能复原
四、环境准备
4.1 三台虚拟机
主机 IP 规格 角色 seek1 192.168.206.11 2C / 2G / 48G 监控中心 seek2 192.168.206.12 2C / 2G / 48G 业务节点(Nginx) seek3 192.168.206.13 2C / 2G / 48G 数据节点(MariaDB) 系统:Ubuntu 24.04 LTS,VMware Workstation 16,NAT 网络。
4.2 网络规划
我用的是 VMware 的 NAT 网段
192.168.206.0/24,网关192.168.206.2。关键点:静态 IP 要避开 DHCP 池(
.128-.254),所以选了.11/.12/.13。4.3 系统初始化(三台都做)
# 时区(默认 UTC,不改的话日志和告警时间会差 8 小时) timedatectl set-timezone Asia/Shanghai # 主机名 hostnamectl set-hostname seek1 # 时间同步 apt install -y chrony && systemctl enable --now chrony4.4 配置静态 IP
Ubuntu 24.04 用 netplan,但要注意cloud-init 会覆盖网络配置,先禁用它:
echo 'network: {config: disabled}' > /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg cat > /etc/netplan/99-static.yaml <<'EOF' network: version: 2 ethernets: ens33: dhcp4: false addresses: [192.168.206.11/24] routes: - to: default via: 192.168.206.2 nameservers: addresses: [223.5.5.5, 119.29.29.29] EOF chmod 600 /etc/netplan/99-static.yaml netplan apply五、部署监控中心(seek1)
5.1 安装 Docker
apt-get update apt-get install -y docker.io docker-compose-v2 systemctl enable --now docker docker --version # Docker version 29.1.3 docker compose version # Docker Compose version 2.40.35.2 配置镜像加速(国内必做)
默认的 Docker Hub 在国内基本连不上,会报:
dial tcp 31.13.75.5:443: connect: connection refused配置镜像加速器:
mkdir -p /etc/docker cat > /etc/docker/daemon.json <<'EOF' { "registry-mirrors": ["https://docker.m.daocloud.io"] } EOF systemctl restart docker验证:
docker pull hello-world # 能拉下来就说明配好了5.3 目录结构
/root/monitor-lab/ ├── docker-compose.yml ├── prometheus/ │ ├── prometheus.yml │ └── rules/ │ ├── host.yml # 主机类 8 条 │ ├── service.yml # 服务类 8 条 │ └── self.yml # 自身 2 条 ├── alertmanager/alertmanager.yml ├── blackbox/blackbox.yml ├── feishu/alert_relay.py └── grafana/ ├── provisioning/ │ ├── datasources/prometheus.yml │ └── dashboards/provider.yml └── dashboards/overview.json5.4 docker-compose.yml
services: prometheus: image: prom/prometheus:v2.53.0 restart: unless-stopped volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./prometheus/rules:/etc/prometheus/rules:ro - prom_data:/prometheus command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.retention.time=15d - --web.enable-lifecycle # 支持热加载 ports: ["9090:9090"] alertmanager: image: prom/alertmanager:v0.27.0 restart: unless-stopped volumes: - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro ports: ["9093:9093"] grafana: image: grafana/grafana:11.1.0 restart: unless-stopped environment: GF_SECURITY_ADMIN_PASSWORD: admin123 GF_USERS_ALLOW_SIGN_UP: "false" volumes: - ./grafana/provisioning:/etc/grafana/provisioning:ro - ./grafana/dashboards:/var/lib/grafana/dashboards:ro ports: ["3000:3000"] blackbox: image: prom/blackbox-exporter:v0.25.0 restart: unless-stopped volumes: - ./blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro ports: ["9115:9115"] alert-relay: image: python:3.11-alpine restart: unless-stopped volumes: - ./feishu:/app:ro environment: FEISHU_WEBHOOK: ${FEISHU_WEBHOOK:-} command: python /app/alert_relay.py ports: ["5001:5001"] volumes: prom_data:启动:
docker compose up -d docker compose ps5.5 Prometheus 配置
global: scrape_interval: 15s evaluation_interval: 15s rule_files: - /etc/prometheus/rules/*.yml alerting: alertmanagers: - static_configs: - targets: ["alertmanager:9093"] scrape_configs: - job_name: prometheus static_configs: - targets: ["localhost:9090"] - job_name: node static_configs: - targets: ["192.168.206.11:9100"] labels: { role: monitor } - targets: ["192.168.206.12:9100"] labels: { role: web } - targets: ["192.168.206.13:9100"] labels: { role: db } - job_name: mysql static_configs: - targets: ["192.168.206.13:9104"] # 拨测:HTTP - job_name: blackbox-http metrics_path: /probe params: { module: [http_2xx] } static_configs: - targets: ["http://192.168.206.12/"] relabel_configs: - source_labels: [__address__] target_label: __param_target - source_labels: [__param_target] target_label: instance - target_label: __address__ replacement: blackbox:9115拨测的 relabel 是固定套路:把目标地址塞进__param_target,再把真正的抓取地址改成 blackbox exporter。5.6 告警规则(18 条)
分三个文件,按故障域拆分:
文件 条数 覆盖 host.yml8 采集失联、CPU、负载、内存(高/耗尽)、磁盘、磁盘预测、inode service.yml8 目标掉线、HTTP 拨测、TCP 端口、证书到期、MySQL 宕机/连接数/慢查询/连接失败 self.yml2 Prometheus 配置热加载失败、Alertmanager 失联 两个我比较满意的规则:
① 预测型磁盘告警(不是"已满"才报,而是"4 小时后会满")
- alert: DiskWillFillIn4Hours expr: predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[1h], 4 * 3600) < 0 for: 10m labels: { severity: critical }② inode 告警(磁盘还有空间但写不进文件,很多人漏掉这个)
- alert: InodeUsageHigh expr: | (1 - node_filesystem_files_free{fstype!~"tmpfs|overlay"} / node_filesystem_files{fstype!~"tmpfs|overlay"}) * 100 > 855.7 Alertmanager:分组、抑制、静默
route: receiver: feishu group_by: ["alertname", "instance"] group_wait: 30s # 新告警先攒 30 秒 group_interval: 5m repeat_interval: 4h routes: - matchers: [severity = critical] receiver: feishu group_wait: 10s repeat_interval: 1h receivers: - name: feishu webhook_configs: - url: "http://alert-relay:5001/webhook" send_resolved: true # 抑制:主机失联时,别再刷它上面的一堆资源告警 inhibit_rules: - source_matchers: [alertname = NodeExporterDown] target_matchers: [alertname =~ "Host.*|Disk.*|Inode.*"] equal: ["instance"] - source_matchers: [alertname = MySQLDown] target_matchers: [alertname =~ "MySQL.*"] equal: ["instance"]抑制是这套系统里最值钱的一条配置——一台机器挂了,如果你不做抑制,30 秒内会收到 8 条告警,人就会开始忽略告警。
5.8 Blackbox 拨测
modules: http_2xx: prober: http timeout: 5s http: preferred_ip_protocol: ip4 valid_status_codes: [200, 201, 204, 301, 302, 401, 403] tcp_connect: prober: tcp timeout: 5s三层探测:
- HTTP:seek2 的 Nginx 首页
- TCP:seek3 的 3306 端口
- HTTPS:外部站点(顺便监控证书到期)
5.9 Grafana 自动配置
不手工点,全部用 provisioning 文件:
# provisioning/datasources/prometheus.yml apiVersion: 1 datasources: - name: Prometheus type: prometheus uid: prometheus # 固定 uid,大盘里引用它 url: http://prometheus:9090 isDefault: true# provisioning/dashboards/provider.yml apiVersion: 1 providers: - name: "lab-dashboards" folder: "运维监控" type: file options: path: /var/lib/grafana/dashboards8 块面板:在线主机、触发中告警、采集目标状态、CPU、内存、磁盘、拨测结果、MySQL 连接数。
5.10 飞书告警中继
Alertmanager 的 webhook 格式飞书不认,需要一个转换层。只用标准库,一个文件:
def build_card(payload): alerts = payload.get("alerts", []) status = payload.get("status", "firing") lines = [] for a in alerts[:8]: lab = a.get("labels", {}) ann = a.get("annotations", {}) icon = {"critical": "🔴", "warning": "🟠"}.get(lab.get("severity")) lines.append(f"{icon} **{lab.get('alertname')}**\n 摘要:{ann.get('summary')}") return { "msg_type": "interactive", "card": { "header": {"title": {"tag": "plain_text", "content": f"告警:{status}"}}, "elements": [{"tag": "div", "text": {"tag": "lark_md", "content": "\n\n".join(lines)}}], }, }没有飞书机器人也能用——
FEISHU_WEBHOOK留空时把卡片打到容器日志,一样能验证链路。六、部署被监控节点
6.1 seek2:Nginx + node_exporter
apt-get install -y nginx prometheus-node-exporter systemctl enable --now nginx prometheus-node-exporter echo 'web-01 on seek2' > /var/www/html/index.html curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/ # 200 curl -s http://127.0.0.1:9100/metrics | head -16.2 seek3:MariaDB + 两个 exporter
MariaDB 已经有,重点是把监控账号和 exporter 装好:
# 1. 建监控专用账号(最小权限) mysql -e "CREATE USER 'exporter'@'localhost' IDENTIFIED BY 'Exporter@123';" mysql -e "GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'localhost';" # 2. 允许远程探测(默认只监听 127.0.0.1,拨测会失败) cat > /etc/mysql/mariadb.conf.d/99-bind.cnf <<'EOF' [mysqld] bind-address = 0.0.0.0 EOF systemctl restart mariadb # 3. 装 mysqld_exporter curl -fsSL -o me.tgz https://github.com/prometheus/mysqld_exporter/releases/download/v0.15.1/mysqld_exporter-0.15.1.linux-amd64.tar.gz tar xzf me.tgz install -m755 mysqld_exporter-0.15.1.linux-amd64/mysqld_exporter /usr/local/bin/systemd 服务:
[Service] User=mysqld_exporter ExecStart=/usr/local/bin/mysqld_exporter \ --config.my-cnf=/etc/mysqld_exporter.cnf \ --web.listen-address=:9104七、验证
# 全部目标是否 UP curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[a-z]*"' | sort | uniq -c # 10 up # 规则是否加载 curl -s http://localhost:9090/api/v1/rules | grep -c alertname打开
http://192.168.206.11:9090/targets,10 个目标全绿;打开 Grafana,8 块面板有数据。八、故障演练(最重要的一步)
监控不能"看起来能用",要能证明它在故障时真的响。
演练步骤:
# 1. 停掉 seek3 的 MySQL systemctl stop mariadb观察结果:
时间 现象 0s MySQL 停止 ~15s mysql_up采集到 0~60s MySQLDown从 pending 转firing,推送到 Alertmanager恢复后 ~30s 告警自动变 resolved Grafana 上看到:
触发中告警从 0 变 1,采集目标里mysql变红 DOWN。恢复:
systemctl start mariadb30 秒内告警自动消除——这就是"告警闭环",也是简历上最值钱的一句话。
九、踩坑记录(8 个真实的坑)
这部分是整篇文章最有价值的内容,网上教程基本不会写。
坑 1:VMware 会覆盖你设置的 MAC 地址
现象:
.vmx里写死ethernet0.generatedAddress = "00:0c:29:11:20:01",开机后变成了00:0c:29:c7:82:b2。原因:
addressType = "generated"时,VMware 会重新生成,忽略你的值。解决:改成静态:
ethernet0.addressType = "static" ethernet0.address = "00:50:56:3f:11:01"(
00:50:56是 VMware 允许手工指定的网段)坑 2:克隆虚拟机会导致三台拿同一个 IP
现象:克隆出三台 Ubuntu,开机后 IP 全是
192.168.206.132,互相抢地址。原因:克隆带着相同的
/etc/machine-id,DHCP 用它算 client id,三台在服务器看来是同一台机器。解决:每台开机后重置:
truncate -s 0 /etc/machine-id systemd-machine-id-setup坑 3:Docker 起不来,报
docker.socketfailed现象:
systemctl start docker失败,日志里docker.socket也是 failed,但手动跑dockerd却正常。原因:之前手动跑过一次
dockerd,留下了/var/run/docker.sock,socket 激活时端口被占。解决:
rm -f /var/run/docker.sock systemctl reset-failed docker.socket docker.service systemctl start docker坑 4:Grafana 加载不了仪表盘,容器里
Permission denied现象:provisioning 日志显示"完成",但仪表盘列表是空的。
原因:配置放在
/root/下,目录权限是drwx---rwx,容器里的 grafana 用户(uid 472)读不到。解决:
chmod -R a+rX /root/monitor-lab docker compose restart grafana教训:容器挂载的目录,权限要按容器内用户来设,不是按宿主机 root 来设。
坑 5:TCP 拨测把 MySQL 的"连接失败"计数打爆
现象:一条
MySQLAbortedConnects告警一直 firing,说"5 分钟内连接失败 20 次",但数据库好好的。原因:Blackbox 每 15 秒连一次 3306 做拨测,连上就断开、不做认证,MySQL 把这种连接记为
aborted_connects。监控自己成了噪音源。解决:按基线调阈值(从 10 提到 60),并在规则里写清楚原因:
# TCP 拨测每 15s 连一次 3306 但不认证,MySQL 会记 4~5 次/分钟的基线。 # 阈值必须高于基线,否则监控自己就是噪音源。 - alert: MySQLAbortedConnects expr: increase(mysql_global_status_aborted_connects[5m]) > 60这个坑值得单独讲:做监控的时候要意识到"监控本身也会产生指标"。
坑 6:MariaDB 只监听 127.0.0.1,端口拨测必失败
现象:
PortUnreachable告警 firing,提示192.168.206.13:3306 连接失败,但本机mysql -uroot能连。原因:MariaDB 默认
bind-address = 127.0.0.1,只监听回环,外部探测当然失败。解决:改成
0.0.0.0(生产环境再用防火墙限制来源)。坑 7:
apt install报 404,镜像里没有这个包现象:
Err: prometheus-node-exporter 1.7.0-1ubuntu0.2 404 Not Found原因:阿里云镜像的
noble-updates同步不全,个别包缺失。解决:改用 GitHub 直接下二进制(GitHub 在这台机器上可达):
curl -fsSL -o ne.tgz https://github.com/prometheus/node_exporter/releases/download/v1.8.2/node_exporter-1.8.2.linux-amd64.tar.gz更快的兜底:从已经装好的机器把二进制
scp过来,比重新下载稳。坑 8:用 shell 传含引号的配置,引号会被吞掉
现象:远程执行
echo '{"a":"b"}' > file,结果文件里是{a:b},JSON 解析失败,Docker 起不来。原因:PowerShell → ssh → bash 三层转义,双引号在某一层被吃掉了。
解决:配置内容先 base64,远端解码:
$json = '{"registry-mirrors":["https://docker.m.daocloud.io"]}' $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) ssh root@host "echo $b64 | base64 -d > /etc/docker/daemon.json"这个技巧在写自动化脚本时非常有用,能绕开所有引号地狱。
十、复盘:这套东西的价值在哪
做得好的地方
- 有闭环:不是"采了数据",而是"故障 → 告警 → 恢复"全链路验证过
- 有收敛:分组 + 抑制 + 分级,避免了告警风暴
- 可重建:全部配置化,
docker compose up -d就能还原- 踩过坑:上面 8 个坑都是真实遇到的,不是抄教程
还能改进的地方
- 单机 Prometheus 不是高可用——监控中心挂了就全瞎,生产要上 Thanos 或双副本
- 静态 targets——主机多了要换成服务发现(Consul / K8s SD)
- 没有长期存储——15 天保留期,历史趋势查不了
- 告警没有升级机制——critical 应该能打电话,不能只发消息
如果重做一次
我会先想清楚告警的消费方是谁:谁在什么时间看什么告警、看到之后做什么动作。监控的价值不在于采了多少指标,而在于每一条告警都有人会行动。没有动作的告警,都是噪音。
附:一键部署清单
# 1. seek1:装 Docker(略) # 2. 上传配置 scp -r monitor-lab root@192.168.206.11:/root/ # 3. 启动 ssh root@192.168.206.11 "cd /root/monitor-lab && docker compose up -d" # 4. seek2 ssh root@192.168.206.12 "apt install -y nginx prometheus-node-exporter" # 5. seek3 ssh root@192.168.206.13 "bash /root/setup-seek3.sh" # 6. 验证 curl -s http://192.168.206.11:9090/api/v1/targets | grep -o '"health":"[a-z]*"' | sort | uniq -c*环境:Ubuntu 24.04 · Docker 29.1.3 · Compose 2.40.3 · Prometheus 2.53 · Grafana 11.1 · VMware Workstation 16*
附:告警面板里的 No data 问题
这个问题是在截取上面那几张图时发现的,单独记一笔。
现象:没有告警时,「触发中告警」面板显示 No data,而不是 0。
原因:
count(ALERTS{alertstate="firing"})在匹配不到任何序列时返回空向量,Grafana 会把空结果渲染成 No data。解决:外面套一层
or vector(0),让空结果回退到 0。count(ALERTS{alertstate="firing"}) or vector(0)启示:监控面板要能区分「没有告警」和「查询失败」。如果只看到 No data,运维会以为监控本身坏了。
从零搭建三节点监控告警平台:Prometheus + Grafana + Alertmanager 实战
RELATED READING
延伸阅读
更多一线实战笔记与深度复盘,助您持续精进