基于 Keepalived 的传统 Web 高可用架构的容器化改造与可观测性升级

基于 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 挂载,时间与宿主机完全同步。

🔥 为什么监控系统需要容器时间与宿主机一致?
  1. 主从延迟(Seconds_Behind_Master 依赖从库的 NOW() 与主库 binlog 时间戳对比。如果容器时间漂移,延迟监控会误报。
  2. Prometheus 指标时间戳:Exporter 采集的时间戳以容器时间为准,如果偏移,Grafana 图表会出现"时间断档"或"数据延迟"。
  3. 告警触发 :告警规则中的 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

  • 原因

    1. Nginx 未启动(systemctl status nginx 显示 inactive
    2. curl 被代理环境变量影响,访问 127.0.0.1 返回 000
  • 排查过程

    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

踩坑 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)挂了怎么办?

监控节点是旁路系统,不影响业务主链路。故障后:

  1. 重新拉起 Docker-Compose 服务:docker-compose up -d
  2. Prometheus 数据保留在 prometheus_data 卷中,不丢失
  3. 拉取从断点继续,历史数据不受影响

改进方案:Prometheus 本身也可以做高可用(联邦集群或 Thanos),但本项目规模不需要。


Q9:为什么选择 Docker 容器化部署?有什么坑?

优点

  • 环境一致性:开发/测试/生产环境完全一致
  • 快速恢复:删除 WAL 目录即可修复数据损坏
  • 资源隔离:每个组件独立容器,互不影响

踩坑经验

  1. 容器时间必须挂载 /etc/localtime,否则监控时间戳会漂移
  2. Exporter 必须使用 --network host 才能读取宿主机指标
  3. 数据目录必须持久化,否则容器删除数据全丢
  4. 容器重启策略设为 unless-stopped,避免关机后需要手动启动

Q10:你在这个项目中最大的收获是什么?

  1. 高可用不是"多部署一套" ,而是需要健康检查、故障感知、自动切换、数据一致性四个环节配合。
  2. 监控做减法比做加法更难。知道告警什么比知道监控什么更重要。
  3. 容器化不是银弹。容器虽然方便,但网络模式、时区、权限、持久化都需要仔细设计。
  4. 踩坑是成长最快的方式。从退出码 255、VIP 不显示到 Exporter 认证失败,每个坑都让我对底层原理理解更深。

文档结束 🚀 祝你秋招顺利

相关推荐
Jae den1 小时前
什么是服务器虚拟化?
运维·服务器
凌云若寒1 小时前
CODESOFT试用流程
linux·运维·网络·数据库·sentinel
薄雾晚晴1 小时前
大模型Skill暴论:所有Skill终将消亡?最新Skill方法论与模型增强开发实战
前端·后端·架构
秦jh_1 小时前
222222
docker
云贝贝贝1 小时前
Oracle 运维高频 6 坑:硬日期、AUTOEXTEND、闪回、锁、FRA、双引号
运维·数据库·oracle
java_logo1 小时前
Docker 部署 go2rtc:轻松搭建摄像头多协议流媒体平台
运维·docker·容器·摄像头·rtsp·轩辕镜像·go2rtc
腾渊信息科技公司1 小时前
腾渊科技重磅出品——工业智能体落地实战:从单点AI到智能体集群的架构演进
人工智能·科技·架构·工业智能体·腾渊科技·腾渊信息科技·腾渊工业智能体
OceanBase数据库官方博客1 小时前
深度拆解seekdb:AI Native Database 的技术架构与核心能力
数据库·人工智能·架构
大模型码小白1 小时前
【AI大模型】DeepSeek Harness 深度解析:大模型评测框架的架构与实践
java·运维·人工智能·spring·架构·自动化