基于 Keepalived 的传统 Web 高可用架构的容器化改造与可观测性升级
一、项目背景与架构总览
1.1 项目目标
搭建一套生产级高可用、读写分离、可观测的 Web 业务系统。当主业务节点(Node-1)的 Tomcat 故障时,VIP 自动漂移到备节点(Node-2),保障查询业务不中断;同时通过 Prometheus 监控栈对核心组件进行告警。
1.2 物理拓扑
| 节点角色 | 主机 IP | 部署组件 |
|---|---|---|
| 业务节点 1(主) | 192.168.110.169 | Keepalived (Master)、Nginx、Tomcat、MySQL 8.4 (Master:3306)、node_exporter、mysqld_exporter |
| 业务节点 2(备) | 192.168.110.170 | Keepalived (Backup)、Nginx、Tomcat、MySQL 8.4 (Slave:3307)、node_exporter、mysqld_exporter |
| 监控节点 | 192.168.110.163 | Prometheus、Alertmanager、Grafana(Docker-Compose 部署) |
| VIP(虚拟 IP) | 192.168.110.200 | 由 Keepalived 在主备之间漂移 |
1.3 核心设计原则
原则一:Nginx 只代理本机 Tomcat(127.0.0.1:8080),不跨节点转发。
这保证了故障域的隔离。VIP 飘到哪台机器,流量就打到哪台机器的本地 Tomcat。如果 Nginx 配置了跨节点转发,VIP 漂移就失去了意义。
原则二:MySQL 主从独立于高可用层。
Node-1 永远持有 Master,Node-2 永远持有 Slave。VIP 漂移只影响 Tomcat 接入,不影响数据库复制链路。这避免了脑裂和双写冲突。
原则三:监控做减法。
重点监控数据库存活和复制延迟(决定业务是否可用),主机性能指标(CPU/内存)只做展示,不设告警,避免告警疲劳。
二、入口与业务层:Keepalived + Nginx + Tomcat
2.1 Keepalived 配置与解析
Node-1(Master)配置
bash
[root@node1 ~]# cat /etc/keepalived/keepalived.conf
global_defs {
router_id LVS_MASTER 节点在集群中的唯一标识
}
vrrp_script chk_app { 配置健康检测脚本
script "/etc/keepalived/check_app.sh" 健康检测脚本路径
interval 2 每 2 秒执行
fall 3 连续失败 3 次判定故障
rise 2 连续成功 2 次判定恢复
}
vrrp_instance VI_1 {
state MASTER 初始角色
interface ens160 绑定网卡,实际网卡用ip a查看
virtual_router_id 51 VRRP 组 ID;主备必须一致,同一广播域内唯一
priority 100 选举优先级,初始为100,Master=100,Backup=90,数值越大优先级越高
advert_int 1 VRRP 心跳间隔,1 秒发送一次通告
unicast_src_ip 192.168.110.169 单播模式(unicast_src_ip),指定对端 IP,避免依赖组播,适合云环境
unicast_peer {
192.168.110.170
}
authentication {
auth_type PASS 认证密码,主备必须一致,简单密码认证
auth_pass 1111 认证密码,主备必须一致,简单密码认证
}
virtual_ipaddress { VIP,`192.168.110.200/24`,故障时自动漂移
192.168.110.200/24
}
track_script { 关联健康检查,脚本失败时自动降低权重,触发 VIP 漂移
chk_app
}
}
配置深度解析:
| 参数 | 含义 | 本项目取值 |
|---|---|---|
router_id |
节点在集群中的唯一标识 | Master 节点为 LVS_MASTER,Backup 为 LVS_BACKUP |
vrrp_script |
自定义健康检查脚本 | 每 2 秒执行,连续失败 3 次判定故障,连续成功 2 次判定恢复 |
state MASTER |
初始角色 | Master 为 MASTER,Backup 为 BACKUP |
interface ens160 |
绑定网卡 | 根据实际网卡名调整(ip a 查看) |
virtual_router_id 51 |
VRRP 组 ID | 主备必须一致,同一广播域内唯一 |
priority |
选举优先级 | Master=100,Backup=90,数值越大优先级越高 |
advert_int 1 |
VRRP 心跳间隔 | 1 秒发送一次通告 |
unicast_src_ip / unicast_peer |
单播模式 | 指定对端 IP,避免依赖组播,适合云环境 |
auth_pass 1111 |
认证密码 | 主备必须一致,简单密码认证 |
virtual_ipaddress |
VIP | 192.168.110.200/24,故障时自动漂移 |
track_script |
关联健康检查 | 脚本失败时自动降低权重,触发 VIP 漂移 |
Node-2(Backup)配置
[root@node2 ~]# cat /etc/keepalived/keepalived.conf
global_defs {
router_id LVS_BACKUP 标识名,备节点为BACKUP
}
vrrp_script chk_app { 调用健康脚本及其存放地址
script "/etc/keepalived/check_app.sh"
interval 2 每两秒执行一次健康检测
fall 3 连续失败3次判定故障,进入fault模式,停止心跳通告,释放vip,优先级暂时失效
rise 2 连续成功2两次判定恢复
}
vrrp_instance VI_1 {
state BACKUP 初始为备用节点
interface ens160 绑定网卡,ip a查看
virtual_router_id 51 组ID,主备一致,保障在统一广播域
priority 90 初始优先级
advert_int 1 VRRP 心跳间隔,1 秒发送一次通告
unicast_src_ip 192.168.110.170 单播模式
unicast_peer {
192.168.110.169 只在节点间互相通信,规避脑裂
}
authentication {
auth_type PASS 简单的认证密码,主备一致
auth_pass 1111
}
virtual_ipaddress { 虚拟VIP,主备一致
192.168.110.200/24
}
track_script { 关联健康检测
chk_app
}
}
🔥 故障切换时间线计算
- 健康检查脚本每 2 秒执行一次
- 连续失败 3 次 判定为故障 → 总检测耗时 6 秒
- Keepalived 判定故障后,Master 权重降低 → Backup 在 1 秒 内感知(
advert_int 1) - 总故障切换时间约 7~8 秒
2.2 健康检查脚本与解析
脚本内容(Node-1 和 Node-2 相同)
[root@node1 ~]# cat /etc/keepalived/check_app.sh
#!/bin/bash
# Keepalived 健康检查:检测 Nginx 80 端口(覆盖 Nginx + Tomcat 整条链路)
# keepalived执行脚本规则:exit 0代表服务正常;exit非0判定故障,触发VIP漂移
CHECK_URL="http://127.0.0.1/" # 访问本机业务入口,检测nginx转发tomcat整条业务链路
TIMEOUT=3 超时时间设置:TCP连接超时 3 秒
# --noproxy '*' 强制不走代理,解决 000 问题 静默模式,只输出 HTTP 状态码
HTTP_CODE=$(curl --noproxy '*' -o /dev/null -s -w "%{http_code}" \ 强制绕过系统代理,避免环境变量导致连接失败
--connect-timeout $TIMEOUT --max-time 5 $CHECK_URL) 连接超时 3 秒,防止脚本卡住
if [ $HTTP_CODE -ge 200 ] && [ $HTTP_CODE -lt 400 ]; then 2xx/3xx 视为成功,4xx/5xx 视为故障
exit 0
else
echo "$(date '+%Y-%m-%d %H:%M:%S') - Check failed, HTTP_CODE: $HTTP_CODE" >> /var/log/keepalived-check.log 失败时记录状态码,方便排错
exit 1
fi
配置深度解析:
| 参数 | 作用 |
|---|---|
CHECK_URL="http://127.0.0.1/" |
检测 Nginx 80 端口,覆盖 Nginx → Tomcat 整条链路 |
--noproxy '*' |
强制绕过系统代理,避免环境变量导致连接失败(典型踩坑点) |
--connect-timeout 3 |
连接超时 3 秒,防止脚本卡住 |
-o /dev/null -s -w "%{http_code}" |
静默模式,只输出 HTTP 状态码 |
HTTP_CODE -ge 200 && -lt 400 |
2xx/3xx 视为成功,4xx/5xx 视为故障 |
/var/log/keepalived-check.log |
失败时记录状态码,方便排错 |
🔥 为什么检测 80 端口而不是 8080?
检测 127.0.0.1:80 覆盖了 Nginx → Tomcat 的整条业务链路。如果只检测 8080,可能 Nginx 挂了但脚本仍返回成功,导致用户访问 80 端口失败但 VIP 不切换。这保证了端到端的可用性验证。
2.3 Nginx 配置与解析
[root@node1 ~]# cat /etc/nginx/nginx.conf
user nginx; # Nginx工作进程运行用户,需要确保站点文件对nginx用户有读取权限,否则403
worker_processes auto; # worker进程数量,auto自动匹配CPU物理核心数;1个master主进程管理多个worker工作进程
events {
worker_connections 4096; # 单个worker进程最大并发连接上限;理论最大连接数=worker_processes * worker_connections
}
http {
include /etc/nginx/mime.types; # 引入mime类型配置,静态资源能被浏览器正确解析,未知类型默认下载
default_type application/octet-stream; # 无法识别文件类型时,默认作为二进制流,浏览器触发文件下载
upstream tomcat_backend { # 反向代理模块:定义后端服务器组,组名称tomcat_backend,用于反向代理调用
server 127.0.0.1:8080; # ← 只代理本机 Tomcat,不写对端 定义后端 Tomcat 地址,**只写 127.0.0.1:8080**
}
server { # 虚拟主机配置块,接收所有 HTTP 请求
listen 80; # 当前虚拟主机监听本机80端口
location / { # 匹配所有请求路径
proxy_pass http://tomcat_backend; # 将请求反向代理给upstream定义的tomcat_backend后端组
proxy_set_header Host $host; # 把客户端请求的原始域名传递给后端Tomcat,后端获取原始域名
proxy_set_header X-Real-IP $remote_addr; # 将客户端真实IP传给后端,Tomcat可拿到真实客户端地址
proxy_connect_timeout 3s; # Nginx连接后端Tomcat的超时时间,3秒与 Keepalived 健康检查的 3 秒超时保持一致连不上直接报错
}
location /nginx_status { # 匹配访问路径 /nginx_status
stub_status on; # 开启Nginx内置状态监控页面,输出连接、请求等运行指标
access_log off; # 关闭该接口的访问日志,减少日志垃圾输出
allow 127.0.0.1; # 仅允许本机127.0.0.1访问该监控接口
deny all; # 拒绝其他所有IP访问,防止监控接口对外泄露
}
}
}
配置深度解析:
| 参数 | 作用 |
|---|---|
worker_processes auto |
自动匹配 CPU 核心数 |
worker_connections 4096 |
单进程最大并发连接数 |
upstream tomcat_backend |
定义后端 Tomcat 地址,只写 127.0.0.1:8080 |
proxy_connect_timeout 3s |
连接超时 3 秒,与健康检查脚本超时匹配 |
stub_status on |
开启 Nginx 状态监控页面(/nginx_status),仅允许本机访问 |
🔥 为什么 upstream 只用 127.0.0.1:8080?
这是本项目高可用的核心设计:Nginx 只代理本机 Tomcat 。如果 upstream 写了 Node-2 的 IP,当 Node-1 故障时,用户请求可能被转发到 Node-2,但此时 VIP 可能还没漂移,导致流量通过 Node-1 的 Nginx 跨节点转发到 Node-2,架构混乱。VIP 控制入口,Nginx 只负责本机代理,职责单一,故障域隔离。
三、数据层:MySQL 8.4 GTID 主从复制
3.1 Docker 容器启动命令
Master(Node-1)
docker run -d \
--name mysql-master \
--restart unless-stopped \
-p 3306:3306 \ 宿主机端口映射
-v /etc/localtime:/etc/localtime:ro \ **时区同步**,确保容器时间与宿主机一致
-v /data/mysql/master/data:/var/lib/mysql \ 数据持久化,防止容器删除丢失数据
-v /data/mysql/master/conf/my.cnf:/etc/my.cnf:ro \ 挂载自定义配置文件
-e MYSQL_ROOT_PASSWORD=123456 \ 设置 root 密码(实验环境)
hb.reg.com/k8s/mysql:8.4.7
Slave(Node-2)
docker run -d \
--name mysql-slave \
--restart unless-stopped \
-p 3307:3306 \
-v /etc/localtime:/etc/localtime:ro \
-v /data/mysql/slave/data:/var/lib/mysql \
-v /data/mysql/slave/conf/my.cnf:/etc/my.cnf:ro \
-e MYSQL_ROOT_PASSWORD=123456 \
hb.reg.com/k8s/mysql:8.4.7
启动参数解析:
| 参数 | 说明 |
|---|---|
-p 3306:3306 / -p 3307:3306 |
宿主机端口映射,Slave 用 3307 避免冲突 |
-v /etc/localtime:/etc/localtime:ro |
时区同步,确保容器时间与宿主机一致 |
-v /data/mysql/xxx/data |
数据持久化,防止容器删除丢失数据 |
-v /data/mysql/xxx/conf/my.cnf |
挂载自定义配置文件 |
-e MYSQL_ROOT_PASSWORD=123456 |
设置 root 密码(实验环境) |
3.2 MySQL 配置文件与解析
Master(Node-1):/data/mysql/master/conf/my.cnf
[root@node1 ~]# docker exec mysql-master cat /etc/my.cnf
[mysqld]
server-id=1 集群内唯一标识,必须不同,node2设置为server-id=1
read-only=0 Master 可读写,Slave 只读:read-only=1
# GTID 核心配置
log-bin=mysql-bin Master 开启 binlog,Slave 不需要
binlog-format=ROW 行级复制,GTID 推荐格式,Slave 不需要
gtid_mode=ON 启用 GTID,主备必须一致,Slave 需要
enforce_gtid_consistency=ON 强制 GTID 一致性
host-cache-size=0 禁用主机名缓存
skip-name-resolve 禁用 DNS 反向解析,加速连接
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
secure-file-priv=/var/lib/mysql-files
user=mysql
pid-file=/var/run/mysqld/mysqld.pid
[client]
socket=/var/run/mysqld/mysqld.sock
Slave(Node-2):/data/mysql/slave/conf/my.cnf
[root@node2 ~]# docker exec mysql-slave cat /etc/my.cnf
[mysqld]
server-id=2
read-only=1
relay-log=mysql-relay-bin Slave 的中继日志
gtid_mode=ON
enforce_gtid_consistency=ON
host-cache-size=0
skip-name-resolve
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
secure-file-priv=/var/lib/mysql-files
user=mysql
pid-file=/var/run/mysqld/mysqld.pid
[client]
socket=/var/run/mysqld/mysqld.sock
配置深度解析:
| 参数 | Master 值 | Slave 值 | 说明 |
|---|---|---|---|
server-id |
1 | 2 | 集群内唯一标识,必须不同 |
read-only |
0 | 1 | Master 可读写,Slave 只读 |
log-bin |
mysql-bin |
无 | Master 开启 binlog,Slave 不需要 |
binlog-format |
ROW |
无 | 行级复制,GTID 推荐格式 |
gtid_mode |
ON |
ON |
启用 GTID,主备必须一致 |
enforce_gtid_consistency |
ON |
ON |
强制 GTID 一致性 |
relay-log |
无 | mysql-relay-bin |
Slave 的中继日志 |
skip-name-resolve |
ON |
ON |
禁用 DNS 反向解析,加速连接 |
host-cache-size |
0 |
0 |
禁用主机名缓存 |
🔥 GTID 核心优势
GTID(全局事务标识符)为每个事务生成唯一 ID,从库根据 GTID 自动定位同步位置,无需手动指定 binlog 文件名 和 Position 。主从切换时,Slave 通过 SOURCE_AUTO_POSITION=1 告诉 Master:"请发送所有我还没执行过的 GTID 事务",Master 自动计算差额并发送,搭建和故障恢复极其简单。
3.3 数据库账号体系
本项目共创建了 3 个数据库账号,各司其职:
| 账号 | 密码 | 用途 | 权限 | 创建方式 |
|---|---|---|---|---|
root |
123456 |
业务应用(Tomcat JSP 连接) | ALL PRIVILEGES |
容器环境变量 MYSQL_ROOT_PASSWORD |
exporter |
exporter_password |
mysqld_exporter 监控采集 | PROCESS, REPLICATION CLIENT, SELECT |
手动 CREATE USER |
repl |
repl_password |
GTID 主从复制同步 | REPLICATION SLAVE |
手动 CREATE USER |
监控账号创建 SQL
sql
-- 创建监控专用账号(最小权限原则)
CREATE USER 'exporter'@'%' IDENTIFIED BY 'exporter_password';
GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'%';
FLUSH PRIVILEGES;
复制账号创建 SQL
sql
-- 创建复制专用账号
CREATE USER 'repl'@'%' IDENTIFIED BY 'repl_password';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
🔥 为什么不用 root 账号做监控?
最小权限原则。监控账号只需读取系统状态表和复制状态信息,不需要对业务数据进行读写。如果监控账号被泄露,影响范围仅限于监控数据,不会导致业务数据被篡改。
3.4 主从关系配置(GTID 自动定位)
在 Slave(Node-2)上执行:
sql
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='192.168.110.169',
SOURCE_PORT=3306,
SOURCE_USER='repl',
SOURCE_PASSWORD='repl_password',
SOURCE_AUTO_POSITION=1; -- GTID 自动定位
START REPLICA;
-- 检查状态(重点关注以下三个字段)
SHOW REPLICA STATUS\G
| 状态字段 | 期望值 | 说明 |
|---|---|---|
Slave_IO_Running |
Yes |
IO 线程正常,从库能连上主库拉取 binlog |
Slave_SQL_Running |
Yes |
SQL 线程正常,正在回放 relay log |
Seconds_Behind_Master |
0 |
从库延迟为 0,数据实时同步 |
3.5 时区一致性验证
bash
# 宿主机时间
date
# Master 容器时间
docker exec mysql-master date
# Slave 容器时间
docker exec mysql-slave date
# MySQL 内部时区
docker exec mysql-master mysql -uroot -p123456 -e "SELECT @@global.time_zone, NOW();"
本实验验证结果:
| 节点 | 宿主机时间 | MySQL 容器时间 | 时区配置 |
|---|---|---|---|
| Node-1 | CST | CST | SYSTEM(跟随宿主机) |
| Node-2 | CST | CST | SYSTEM(跟随宿主机) |
✅ 所有 MySQL 容器通过 -v /etc/localtime:/etc/localtime:ro 挂载,时间与宿主机完全同步。
🔥 为什么监控系统需要容器时间与宿主机一致?
- 主从延迟(
Seconds_Behind_Master) 依赖从库的NOW()与主库 binlog 时间戳对比。如果容器时间漂移,延迟监控会误报。 - Prometheus 指标时间戳:Exporter 采集的时间戳以容器时间为准,如果偏移,Grafana 图表会出现"时间断档"或"数据延迟"。
- 告警触发 :告警规则中的
for: 1m依赖系统时间判断持续时间,时间不准会导致告警误触发。
四、监控层:Prometheus + Alertmanager + Grafana
4.1 Docker-Compose 部署
yaml
[root@jk monitor]# cat docker-compose.yaml
volumes:
prometheus_data: {} Docker 命名卷,数据持久化
grafana_data: {} Docker 命名卷,数据持久化
networks:
monitoring:
driver: bridge
services:
prometheus:
image: hb.reg.com/monitor/prometheus:v3.9.1
container_name: prometheus
restart: always 容器异常退出后自动重启
volumes:
- /etc/localtime:/etc/localtime:ro
- ./prometheus/:/etc/prometheus/
- prometheus_data:/prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --web.console.libraries=/usr/share/prometheus/console_libraries
- --web.console.templates=/usr/share/prometheus/consoles
- --web.enable-lifecycle 启用热加载:`POST /-/reload` 刷新配置,无需重启
- --storage.tsdb.retention.time=30d 数据保留 30 天,自动清理旧数据
networks:
- monitoring
ports:
- 9090:9090
alertmanager:
image: hb.reg.com/monitor/alertmanager:v0.31.1
container_name: alertmanager
restart: always
volumes:
- /etc/localtime:/etc/localtime:ro
- ./alertmanager/:/etc/alertmanager/
command:
- --config.file=/etc/alertmanager/config.yml
- --storage.path=/alertmanager
ports:
- 9093:9093
networks:
- monitoring
grafana:
image: hb.reg.com/monitor/grafana:12.4.0
container_name: grafana
restart: always
volumes:
- /etc/localtime:/etc/localtime:ro
- grafana_data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning
env_file:
- ./grafana/config.monitoring
networks:
- monitoring
ports:
- 3000:3000
depends_on:
- prometheus Grafana 等待 Prometheus 启动后再启动
配置深度解析:
| 参数 | 说明 |
|---|---|
volumes.prometheus_data / grafana_data |
Docker 命名卷,数据持久化 |
restart: always |
容器异常退出后自动重启 |
- /etc/localtime:/etc/localtime:ro |
容器时间与宿主机同步 |
--web.enable-lifecycle |
启用热加载:POST /-/reload 刷新配置,无需重启 |
--storage.tsdb.retention.time=30d |
数据保留 30 天,自动清理旧数据 |
depends_on: - prometheus |
Grafana 等待 Prometheus 启动后再启动 |
🔥 --web.enable-lifecycle 的作用
启用后可以通过 POST /-/reload 端点热加载 Prometheus 配置,无需重启服务。配合 curl -X POST http://localhost:9090/-/reload 使用,做到配置变更不影响数据采集。
4.2 Prometheus 配置与解析
yaml
[root@jk monitor]# cat prometheus/prometheus.yml
global:
scrape_interval: 15s 每 15 秒拉取一次指标
evaluation_interval: 15s 每 15 秒评估一次告警规则
alerting:
alertmanagers: 告警推送到 Alertmanager(容器名:9093)
- static_configs:
- targets:
- 'alertmanager:9093'
rule_files: 加载 `rules/` 目录下所有告警规则
- "rules/*.yml"
scrape_configs: 定义抓取目标,每个 job 一组 targets
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'node_exporter'
static_configs:
- targets:
- '192.168.110.169:9100'
- '192.168.110.170:9100'
- job_name: 'mysqld_exporter'
static_configs:
- targets:
- '192.168.110.169:9104'
- '192.168.110.170:9104'
配置深度解析:
| 参数 | 说明 |
|---|---|
scrape_interval: 15s |
每 15 秒拉取一次指标 |
evaluation_interval: 15s |
每 15 秒评估一次告警规则 |
alerting.alertmanagers |
告警推送到 Alertmanager(容器名:9093) |
rule_files |
加载 rules/ 目录下所有告警规则 |
scrape_configs |
定义抓取目标,每个 job 一组 targets |
4.3 告警规则与解析
bash
[root@jk rules]# cat alert.yml
groups:
- name: 服务告警
rules:
- alert: 服务挂了
expr: up == 0 监控所有 `up` 指标,任何 Target 不可达即触发
for: 10s 持续 10 秒才触发,避免网络抖动误报
labels:
severity: critical 告警级别:`critical`(严重)/ `warning`(警告)
annotations:
summary: "服务异常: {{ $labels.instance }}" 告警描述,`{{ $labels.instance }}` 动态填充目标 IP
description: "{{ $labels.job }} 服务已无法访问"
- alert: MySQL 主从延迟
expr: mysql_global_status_slave_seconds_behind_master > 30 从库延迟超过 30 秒触发,持续 1 分钟才告警
for: 1m 持续 1 分钟才告警
labels:
severity: warning
annotations:
summary: "MySQL 复制延迟: {{ $value }}s"
description: "从库延迟超过30秒,请关注"
配置深度解析:
| 参数 | 说明 |
|---|---|
up == 0 |
监控所有 up 指标,任何 Target 不可达即触发 |
for: 10s |
持续 10 秒才触发,避免网络抖动误报 |
labels.severity |
告警级别:critical(严重)/ warning(警告) |
annotations |
告警描述,{``{ $labels.instance }} 动态填充目标 IP |
mysql_global_status_slave_seconds_behind_master > 30 |
从库延迟超过 30 秒触发,持续 1 分钟才告警 |
🔥 为什么只配置了 2 条告警规则?
告警疲劳是运维大忌 。如果加了 CPU/内存告警,可能服务器突然飙升 80% 就触发告警,但业务可能完全正常。真正的业务中断是数据库不可用或应用无响应。主机性能指标只做展示,不设告警。业务指标优先,资源指标为辅。
🔥 for 参数的作用
for 表示持续时间 。只有当条件持续满足 for 指定的时间后,才会触发告警。例如 up == 0 for: 10s 表示服务必须连续 10 秒不可用才告警,避免瞬时波动导致的告警误报。
4.4 Alertmanager 配置与解析
[root@jk alertmanager]# cat config.yml
global:
smtp_smarthost: 'smtp.163.com:465' 163 邮箱 SMTP 服务器,SSL 端口 465
smtp_from: '135*****24@163.com'
smtp_auth_username: '1359****724@163.com'
smtp_auth_password: 'NXdV****Gybxn7z' 邮箱授权码(非登录密码)
smtp_require_tls: false
route:
group_by: ['alertname'] 按告警名称分组,相同告警合并发送
group_wait: 10s 首次告警等待 10 秒,收集同组告警一起发送
group_interval: 10s 同组新告警间隔 10 秒发送
repeat_interval: 10m 同一告警重复间隔 10 分钟
receiver: email
receivers:
- name: 'email'
email_configs:
- to: '287****56@qq.com'
send_resolved: true 告警恢复后发送恢复通知
inhibit_rules: 抑制规则:
- source_match:
severity: 'critical' critical 告警触发时
target_match:
severity: 'warning' 屏蔽 warning 告警
equal: ['alertname', 'dev', 'instance']
配置深度解析:
| 参数 | 说明 |
|---|---|
smtp_smarthost |
163 邮箱 SMTP 服务器,SSL 端口 465 |
smtp_auth_password |
邮箱授权码(非登录密码) |
group_by: ['alertname'] |
按告警名称分组,相同告警合并发送 |
group_wait: 10s |
首次告警等待 10 秒,收集同组告警一起发送 |
group_interval: 10s |
同组新告警间隔 10 秒发送 |
repeat_interval: 10m |
同一告警重复间隔 10 分钟 |
send_resolved: true |
告警恢复后发送恢复通知 |
inhibit_rules |
抑制规则:critical 告警触发时,屏蔽 warning 告警 |
🔥 inhibit_rules(抑制规则)的作用
当严重级别更高的告警 (critical)触发时,自动屏蔽低级别告警 (warning)。例如:服务挂了(critical)触发时,MySQL 主从延迟(warning)告警被抑制,因为主库都挂了,从库延迟已经没有意义。这有效避免了告警风暴。
4.5 Grafana 配置
[root@jk grafana]# cat config.monitoring
GF_SECURITY_ADMIN_USER=admin 管理员用户名(登录 Grafana
GF_SECURITY_ADMIN_PASSWORD=admin 管理员密码
GF_USERS_ALLOW_SIGN_UP=false 禁止用户自行注册,增强安全性
| 参数 | 说明 |
|---|---|
GF_SECURITY_ADMIN_USER |
管理员用户名(登录 Grafana 使用) |
GF_SECURITY_ADMIN_PASSWORD |
管理员密码 |
GF_USERS_ALLOW_SIGN_UP=false |
禁止用户自行注册,增强安全性 |
五、业务层:Tomcat JSP 动态页面
5.1 Node-1(Master)JSP
下载 MySQL JDBC 驱动
jsp
[root@node1 ~]# cat /data/tomcat/webapps/ROOT/index.jsp
<%@ page contentType="text/html; charset=UTF-8" language="java" %>
<%@ page import="java.sql.*" %>
<html>
<body>
<h1>政企业务系统 - Node-01 (MASTER)</h1>
<%
try {
Class.forName("com.mysql.cj.jdbc.Driver"); 加载 MySQL JDBC 驱动
Connection conn = DriverManager.getConnection( 建立数据库连接
"jdbc:mysql://192.168.110.169:3306/mysql?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true", 实验环境关闭 SSL,避免证书错误,时区统一,避免时间显示异常,允许客户端获取公钥(连接较新 MySQL 需要)
"root", "123456"
);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT NOW(), @@read_only, @@server_id"); 查询当前时间、只读状态、Server ID
if(rs.next()) {
out.println("<p>数据库时间: " + rs.getString(1) + "</p>");
out.println("<p>只读状态: " + rs.getString(2) + " (0=可读写)</p>");
out.println("<p>Server ID: " + rs.getString(3) + "</p>");
}
conn.close();
} catch(Exception e) {
out.println("<p style='color:red'>数据库连接失败: " + e.getMessage() + "</p>");
}
%>
</body>
</html>
5.2 Node-2(Backup)JSP
jsp
[root@node2 ~]# cat /data/tomcat/webapps/ROOT/index.jsp
<%@ page contentType="text/html; charset=UTF-8" language="java" %>
<%@ page import="java.sql.*" %>
<html>
<body>
<h1>政企业务系统 - Node-02 (BACKUP)</h1>
<%
try {
Class.forName("com.mysql.cj.jdbc.Driver");
Connection conn = DriverManager.getConnection(
"jdbc:mysql://192.168.110.170:3307/mysql?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true",
"root", "123456"
);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT NOW(), @@read_only, @@server_id");
if(rs.next()) {
out.println("<p>数据库时间: " + rs.getString(1) + "</p>");
out.println("<p>只读状态: " + rs.getString(2) + " (1=只读)</p>");
out.println("<p>Server ID: " + rs.getString(3) + "</p>");
}
conn.close();
} catch(Exception e) {
out.println("<p style='color:red'>数据库连接失败: " + e.getMessage() + "</p>");
}
%>
</body>
</html>
JSP 代码深度解析:
| 代码片段 | 说明 |
|---|---|
Class.forName("com.mysql.cj.jdbc.Driver") |
加载 MySQL JDBC 驱动 |
DriverManager.getConnection(url, user, password) |
建立数据库连接 |
SELECT NOW(), @@read_only, @@server_id |
查询当前时间、只读状态、Server ID |
useSSL=false |
实验环境关闭 SSL,避免证书错误 |
serverTimezone=UTC |
时区统一,避免时间显示异常 |
allowPublicKeyRetrieval=true |
, |
Node-1 与 Node-2 的差异:
| 差异点 | Node-1 | Node-2 |
|---|---|---|
| 页面标题 | Node-01 (MASTER) |
Node-02 (BACKUP) |
| JDBC URL | 192.168.110.169:3306 |
192.168.110.170:3307 |
| 只读状态显示 | 0=可读写 |
1=只读 |
🔥 读写分离与只读降级的设计
| 状态 | VIP 持有者 | Tomcat 连接的目标 | 业务能力 |
|---|---|---|---|
| 正常 | Node-1 | Master (3306, read_only=0) |
全功能(读写) |
| 故障 | Node-2 | Slave (3307, read_only=1) |
仅查询(只读) |
| 恢复 | Node-1 | Master (3306, read_only=0) |
全功能(自动回切) |
故障时业务降级为只读,数据不会丢失 。因为写操作只有在 Master 可用时才能执行。当 VIP 漂移到 Node-2 时,应用连接的是从库(read_only=1),写操作会被 MySQL 拒绝,不会产生双写冲突或脑裂。
六、监控采集器:Exporter 容器启动
6.1 node_exporter(主机监控)
bash
docker run -d \
--name node_exporter \
--restart unless-stopped \
--network host \ 使用宿主机网络栈,否则无法读取宿主机 `/proc`/`/sys`
--pid host \ 允许采集宿主机进程信息
-v /:/host:ro,rslave \ 将宿主机根目录挂载到容器的 `/host`,只读,`rslave` 确保子挂载传播
hb.reg.com/monitor/node-exporter:v1.10.2 \
--path.rootfs=/host 告诉 exporter 在 `/host` 下找 `/proc`、`/sys` 等路径
参数解析:
| 参数 | 说明 |
|---|---|
--network host |
必须 。使用宿主机网络栈,否则无法读取宿主机 /proc//sys |
--pid host |
允许采集宿主机进程信息 |
-v /:/host:ro,rslave |
将宿主机根目录挂载到容器的 /host,只读,rslave 确保子挂载传播 |
--path.rootfs=/host |
告诉 exporter 在 /host 下找 /proc、/sys 等路径 |
🔥 为什么 node_exporter 必须用 --network host?
node_exporter 需要读取宿主机内核暴露的 /proc、/sys 等虚拟文件系统。这些文件属于宿主机命名空间,只有使用 --network host 和 --pid host 才能让容器与宿主机共享这些命名空间。如果不用 host 网络,采集到的数据将是容器自身的指标,而不是宿主机的。
6.2 mysqld_exporter(MySQL 监控)
bash
docker run -d \
--name mysqld_exporter \
--restart unless-stopped \
--network host \ **必须**。让 Prometheus 通过宿主机 IP:9104 访问
-v /etc/mysql/.my.cnf:/etc/mysql/.my.cnf:ro \ 挂载 MySQL 配置文件,包含数据库连接信息(DSN)
prom/mysqld-exporter:v0.19.0 \
--config.my-cnf=/etc/mysql/.my.cnf 指定配置文件路径,exporter 据此连接 MySQL
参数解析:
| 参数 | 说明 |
|---|---|
--network host |
必须。让 Prometheus 通过宿主机 IP:9104 访问 |
-v /etc/mysql/.my.cnf:/etc/mysql/.my.cnf:ro |
挂载 MySQL 配置文件,包含数据库连接信息(DSN) |
--config.my-cnf |
指定配置文件路径,exporter 据此连接 MySQL |
.my.cnf 文件内容:
[client]
host=192.168.110.169 # 或 192.168.110.170
port=3306 # 或 3307
user=exporter
password=exporter_password
🔥 为什么 mysqld_exporter 也要用 host 网络?
Prometheus 通过宿主机 IP + 9104 端口来拉取指标。如果使用 bridge 模式,端口映射虽然也能暴露,但 host 网络模式性能更好、无 NAT 损耗,且能准确获取客户端的真实 IP。对于监控采集器,推荐统一使用 host 网络。
6.3 Tomcat 容器启动
bash
docker run -d \
--name tomcat \
--restart unless-stopped \
-p 8080:8080 \
-v /data/tomcat/webapps:/usr/local/tomcat/webapps \ 挂载 JSP 页面,方便修改代码无需重建镜像
-v /data/tomcat/logs:/usr/local/tomcat/logs \ 挂载日志目录,便于排查应用错误
hb.reg.com/k8s/tomcat:9.0.106 Tomcat 无需访问宿主机特殊资源,bridge 模式足够
| 参数 | 说明 |
|---|---|
-v /data/tomcat/webapps |
挂载 JSP 页面,方便修改代码无需重建镜像 |
-v /data/tomcat/logs |
挂载日志目录,便于排查应用错误 |
--network bridge(默认) |
Tomcat 无需访问宿主机特殊资源,bridge 模式足够 |
时区说明 :Tomcat 容器未挂载
/etc/localtime,所以容器内时间可能与宿主机不一致。但 JSP 页面通过 JDBC 查询的是 MySQL 的时间(SELECT NOW()),而 MySQL 已挂载时区,所以业务页面显示正常。如果 Tomcat 自身日志时间不对,可以加-v /etc/localtime:/etc/localtime:ro解决。
七、架构总图
用户/浏览器
│
▼
┌─────────────────────────┐
│ VIP: 192.168.110.200 │
│ Keepalived 主备决策 │
│ 健康检查: curl / │
└─────────────────────────┘
│ │
┌────────────┘ └────────────┐
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Node-1 (169) │ │ Node-2 (170) │
│ Keepalived MASTER│◄──────────┤ Keepalived BACKUP│
│ Priority: 100 │ VRRP │ Priority: 90 │
├───────────────────┤ ├───────────────────┤
│ Nginx:80 │ │ Nginx:80 │
│ ↓ 代理 127.0.0.1│ │ ↓ 代理 127.0.0.1│
│ Tomcat:8080 │ │ Tomcat:8080 │
│ ↓ JDBC │ │ ↓ JDBC │
│ MySQL Master │◄─binlog────│ MySQL Slave │
│ :3306, rw=0 │ GTID │ :3307, rw=1 │
├───────────────────┤ ├───────────────────┤
│ node_exporter │ │ node_exporter │
│ mysqld_exporter │ │ mysqld_exporter │
└───────────────────┘ └───────────────────┘
│ │
└──────────────┬───────────────┘
▼
┌─────────────────────┐
│ 监控节点 (163) │
│ Prometheus:9090 │◄── 拉取 169/170:9100,9104
│ Alertmanager:9093 │──► 163邮箱 → QQ邮箱
│ Grafana:3000 │◄── 可视化大屏
└─────────────────────┘
附录一:踩坑实录与解决方案
踩坑 1:容器退出码 255(服务器突然关机导致)
-
现象 :服务器关机后重启,Prometheus 容器
Exited (255),docker logs无任何输出。 -
原因:服务器突然断电,TSDB 的 WAL(预写日志)未完整写入,再次启动时重放失败,进程直接崩溃退出(退出码 255)。
-
排查过程:
docker ps -a | grep prometheus # 看到 Exited (255) docker logs <container-id> # 无输出,说明进程在日志刷新前就崩溃了 -
解决方案:
cd /var/lib/prometheus rm -rf wal/ chunks_head/ # 删除损坏的预写日志和内存块 docker start prometheus -
原理:WAL 是 Prometheus 的预写日志,用于崩溃恢复。如果 WAL 损坏,Prometheus 启动时会卡在 WAL 重放阶段。删除 WAL 后,Prometheus 会从已压缩的 blocks 和 checkpoint 重建索引,丢失最近几小时的数据,但服务恢复可用。
踩坑 2:Keepalived 健康检查脚本返回 1(VIP 不显示)
-
现象 :
ip a看不到 VIP,journalctl -u keepalived显示Script chk_app now returning 1。 -
原因:
- Nginx 未启动(
systemctl status nginx显示inactive) curl被代理环境变量影响,访问127.0.0.1返回000
- Nginx 未启动(
-
排查过程:
bash
/etc/keepalived/check_app.sh echo $? # 返回 1 # 手动 curl 测试 curl -v http://127.0.0.1/ # 可能返回 000 或超时 -
解决方案:
- 启动 Nginx:
systemctl start nginx - 在脚本中添加
--noproxy '*',强制绕过代理 - 启动 Tomcat:
docker start tomcat - 重启 Keepalived:
systemctl restart keepalived
- 启动 Nginx:
踩坑 3:mysqld_exporter 无法连接 Node-2 的 MySQL(3307)
-
现象 :Node-1 的 mysqld_exporter 正常,Node-2 的 mysqld_exporter 报错
connection refused。 -
原因:Node-2 的 MySQL 监听在 3307 端口,但 mysqld_exporter 默认连接 3306。需要显式指定 DSN。
-
解决方案 :在
.my.cnf中指定端口:[client] host=192.168.110.170 port=3307 user=exporter password=exporter_password
踩坑 4:MySQL 监控用户 exporter@'%' 认证失败
-
现象 :
Access denied for user 'exporter'@'172.17.0.1' -
原因 :mysqld_exporter 容器通过桥接网络访问 MySQL,MySQL 看到的来源 IP 是 Docker 网关(如
172.17.0.1),而不是localhost。 -
解决方案 :创建监控用户时使用
'exporter'@'%',允许所有 IP 连接。sql
CREATE USER 'exporter'@'%' IDENTIFIED BY 'password'; GRANT PROCESS, REPLICATION CLIENT, SELECT ON *.* TO 'exporter'@'%'; FLUSH PRIVILEGES;
踩坑 5:容器时间与宿主机不一致导致监控误报
-
现象:MySQL 主从延迟偶尔显示异常值,Grafana 图表时间轴偏移。
-
原因 :容器未挂载
/etc/localtime,时区与宿主机不一致(差 8 小时)。 -
排查过程:
bash
docker exec mysql-master date # 显示 CST 正确 docker exec tomcat date # 如果未挂载可能显示 UTC -
解决方案:在容器启动命令中添加:
bash
-v /etc/localtime:/etc/localtime:ro -
原理 :
Seconds_Behind_Master依赖从库的NOW()与主库 binlog 时间戳对比。如果容器时间漂移,延迟监控会误报。同时 Prometheus 指标时间戳也依赖容器系统时间。
附录二:面试高频问答
Q1:为什么用 Keepalived 不用 LVS?
Keepalived 天然集成 VRRP 协议,配置简单,适合中小规模高可用场景。LVS 是四层负载均衡,适用于大规模流量分发,与本项目目标不同。本项目核心需求是 VIP 自动漂移,Keepalived 是最合适的工具。
Q2:故障切换的总时间是多少?如何优化?
当前切换时间:约 7~8 秒
- 健康检查每 2 秒执行一次,连续失败 3 次 = 6 秒
- VRRP 心跳间隔 1 秒,Backup 在 1 秒内感知
- 合计:6 + 1 = 7 秒
优化方式:
- 缩小
interval为 1 秒 +fall为 2 次 → 切换时间缩短到 3 秒 - 但更频繁的检测会增加系统开销,需要权衡
Q3:如果两台机器网络互通但 Keepalived 都认为是 Master 怎么办?
这就是脑裂(Split-Brain)。产生原因可能是网络分区导致心跳中断,双方都认为自己应该持有 VIP。
预防措施:
- 使用
unicast_peer单播模式,限制通信范围 - 配置认证密码
auth_pass - 防火墙仅允许对端 IP 的 VRRP 协议(proto 112)
应急预案:
bash
# 在 Backup 节点上执行
systemctl restart keepalived # 重置状态
# 或手动释放 VIP
ip addr del 192.168.110.200/24 dev ens160
Q4:Nginx 为什么只代理本机 Tomcat?如果跨节点转发会怎样?
如果 upstream 写了对端 IP:
- Node-1 故障时,VIP 漂移到 Node-2
- 但用户的请求可能通过 Node-2 的 Nginx 转发回 Node-1(如果 Node-1 只是 Tomcat 故障但 Nginx 还活着)
- 导致架构混乱,故障域不清
设计原则:VIP 控制入口,Nginx 只负责本机代理,职责单一,故障域隔离。
Q5:GTID 主从复制相比传统位点复制有什么优势?
| 对比项 | 传统位点复制 | GTID 复制 |
|---|---|---|
| 主从搭建 | 需要手动记录 binlog 文件名和 Position |
只需 SOURCE_AUTO_POSITION=1 |
| 主从切换 | 需要重新找位点,容易出错 | 自动定位,无需人工干预 |
| 故障恢复 | 复杂,可能丢数据 | 简单,数据一致性更好 |
| 适用场景 | 旧版本兼容 | 生产环境最佳实践 |
Q6:为什么监控只配置了 2 条告警规则?
告警疲劳是运维大忌 。如果加了 CPU/内存告警,可能服务器突然飙升 80% 就触发告警,但业务可能完全正常。真正的业务中断是数据库不可用或应用无响应。主机性能指标只做展示,不设告警。业务指标优先,资源指标为辅。
Q7:Prometheus 是 Pull 模型,和 Push 模型有什么区别?
| 对比项 | Pull 模型(Prometheus) | Push 模型(InfluxDB) |
|---|---|---|
| 数据采集 | Prometheus 主动拉取 | 客户端主动推送 |
| 服务发现 | 天然支持(Consul、K8s) | 需要额外配置 |
| 健康检查 | 拉取失败即表示目标不健康 | 需要心跳机制 |
| 适用场景 | 微服务、容器环境 | IoT、批处理任务 |
Pull 模型优势:服务发现灵活、健康检查天然支持、数据采集频率统一由 Prometheus 控制。
Q8:如果监控节点(163)挂了怎么办?
监控节点是旁路系统,不影响业务主链路。故障后:
- 重新拉起 Docker-Compose 服务:
docker-compose up -d - Prometheus 数据保留在
prometheus_data卷中,不丢失 - 拉取从断点继续,历史数据不受影响
改进方案:Prometheus 本身也可以做高可用(联邦集群或 Thanos),但本项目规模不需要。
Q9:为什么选择 Docker 容器化部署?有什么坑?
优点:
- 环境一致性:开发/测试/生产环境完全一致
- 快速恢复:删除 WAL 目录即可修复数据损坏
- 资源隔离:每个组件独立容器,互不影响
踩坑经验:
- 容器时间必须挂载
/etc/localtime,否则监控时间戳会漂移 - Exporter 必须使用
--network host才能读取宿主机指标 - 数据目录必须持久化,否则容器删除数据全丢
- 容器重启策略设为
unless-stopped,避免关机后需要手动启动
Q10:你在这个项目中最大的收获是什么?
- 高可用不是"多部署一套" ,而是需要健康检查、故障感知、自动切换、数据一致性四个环节配合。
- 监控做减法比做加法更难。知道告警什么比知道监控什么更重要。
- 容器化不是银弹。容器虽然方便,但网络模式、时区、权限、持久化都需要仔细设计。
- 踩坑是成长最快的方式。从退出码 255、VIP 不显示到 Exporter 认证失败,每个坑都让我对底层原理理解更深。
文档结束 🚀 祝你秋招顺利