第327篇:ChatOps:用飞书/钉钉机器人操作网络

关键词:ChatOps、飞书机器人、钉钉机器人、Webhook、交互式运维、对话式操作、消息推送、网络运维


一、引言

ChatOps 的核心思想是:将运维操作从"登录服务器执行命令"转变为"在聊天工具中通过对话完成"。运维人员在飞书或钉钉群聊中发送一条消息,机器人自动执行对应的运维任务并返回结果。这种方式降低了运维操作的门槛,提高了操作的可追溯性和协作效率。

在网络运维场景中,ChatOps 的典型应用包括:

  • 一键查询设备状态(BGP 会话、接口利用率、CPU/内存)
  • 批量执行配置命令
  • 变更结果实时通知
  • 告警消息智能推送与确认
  • 巡检报告定时生成与推送

二、飞书机器人架构设计

2.1 整体架构

整体架构 - 图示

2.2 飞书自定义机器人(Webhook 模式)

飞书自定义机器人通过 Webhook URL 接收消息推送:

# bot/feishu_bot.py
import requests
import json

class FeishuBot:
    def __init__(self, webhook_url):
        self.webhook_url = webhook_url

    def send_text(self, text):
        """发送纯文本消息"""
        payload = {
            "msg_type": "text",
            "content": {
                "text": text
            }
        }
        return self._send(payload)

    def send_rich_text(self, elements):
        """发送富文本消息"""
        payload = {
            "msg_type": "interactive",
            "card": {
                "config": {"wide_screen_mode": True},
                "header": {
                    "title": {"tag": "plain_text", "content": "网络运维报告"},
                    "template": "blue"
                },
                "elements": elements
            }
        }
        return self._send(payload)

    def send_markdown(self, title, content):
        """发送 Markdown 消息"""
        payload = {
            "msg_type": "interactive",
            "card": {
                "elements": [
                    {
                        "tag": "markdown",
                        "content": content
                    }
                ],
                "header": {
                    "title": {"tag": "plain_text", "content": title}
                }
            }
        }
        return self._send(payload)

    def _send(self, payload):
        """发送消息到飞书"""
        try:
            resp = requests.post(self.webhook_url,
                               json=payload,
                               timeout=10)
            result = resp.json()
            if result.get('code') != 0:
                print(f"发送失败: {result}")
            return result
        except Exception as e:
            print(f"发送异常: {e}")
            return None

2.3 交互式应用机器人(事件订阅模式)

如需接收用户消息并回复,需创建飞书开放平台应用:

# bot/command_router.py
import re
from flask import Flask, request, jsonify

app = Flask(__name__)

# 命令注册表
COMMANDS = {}

def register(pattern, handler):
    """注册命令装饰器"""
    def decorator(func):
        COMMANDS[re.compile(pattern)] = func
        return func
    return decorator

@register(r'^/status\s+(\S+)')
def cmd_status(device_name):
    """查询设备状态"""
    return execute_status_query(device_name)

@register(r'^/bgp\s+(\S+)')
def cmd_bgp(device_name):
    """查询 BGP 状态"""
    return execute_bgp_query(device_name)

@register(r'^/ping\s+(\S+)\s+(\S+)')
def cmd_ping(src_device, dst_ip):
    """执行 Ping 测试"""
    return execute_ping(src_device, dst_ip)

@register(r'^/interface\s+(\S+)')
def cmd_interface(device_name):
    """查询接口状态"""
    return execute_interface_query(device_name)

@register(r'^/backup\s+(\S+)')
def cmd_backup(device_name):
    """备份设备配置"""
    return execute_backup(device_name)

@app.route('/webhook/feishu', methods=['POST'])
def feishu_webhook():
    """飞书事件回调"""
    data = request.json

    # 获取用户消息文本
    if data.get('event', {}).get('message'):
        text = data['event']['message'].get('text', '')
        sender = data['event']['sender']['sender_id']['open_id']

        # 路由到对应命令
        for pattern, handler in COMMANDS.items():
            match = pattern.match(text)
            if match:
                args = match.groups()
                try:
                    result = handler(*args)
                    # 构造回复消息
                    reply = format_reply(result)
                    return send_reply(data, reply, sender)
                except Exception as e:
                    return send_reply(data, f"执行失败: {str(e)}", sender)

        # 未知命令
        return send_reply(data,
            "未知命令。可用命令:\n"
            "/status <设备名> - 查询设备状态\n"
            "/bgp <设备名> - 查询 BGP 状态\n"
            "/ping <源设备> <目标IP> - Ping 测试\n"
            "/interface <设备名> - 接口状态\n"
            "/backup <设备名> - 备份配置",
            sender)

    return jsonify({"code": 0})

三、钉钉机器人实现

钉钉机器人的实现与飞书类似,但 API 格式不同:

# bot/dingtalk_bot.py
import requests
import json
import hashlib
import hmac
import base64
import time

class DingTalkBot:
    def __init__(self, webhook_url, secret=None):
        self.webhook_url = webhook_url
        self.secret = secret

    def _sign(self):
        """钉钉签名验证"""
        if not self.secret:
            return self.webhook_url

        timestamp = str(round(time.time() * 1000))
        string_to_sign = f'{timestamp}\n{self.secret}'
        hmac_code = hmac.new(
            self.secret.encode('utf-8'),
            string_to_sign.encode('utf-8'),
            digestmod=hashlib.sha256
        ).digest()
        sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
        return f"{self.webhook_url}&timestamp={timestamp}&sign={sign}"

    def send_text(self, content, at_mobiles=None):
        """发送文本消息"""
        payload = {
            "msgtype": "text",
            "text": {"content": content},
            "at": {
                "atMobiles": at_mobiles or [],
                "isAtAll": False
            }
        }
        resp = requests.post(self._sign(), json=payload)
        return resp.json()

    def send_markdown(self, title, text, at_mobiles=None):
        """发送 Markdown 消息"""
        payload = {
            "msgtype": "markdown",
            "markdown": {
                "title": title,
                "text": text
            },
            "at": {
                "atMobiles": at_mobiles or [],
                "isAtAll": False
            }
        }
        resp = requests.post(self._sign(), json=payload)
        return resp.json()

    def send_action_card(self, title, content, buttons):
        """发送 ActionCard(含按钮的卡片消息)"""
        btns = []
        for btn in buttons:
            btns.append({
                "title": btn['title'],
                "actionURL": btn['url'],
                "backgroundColor": btn.get('color', '#1F7BCC')
            })

        payload = {
            "msgtype": "actionCard",
            "actionCard": {
                "title": title,
                "text": content,
                "btnOrientation": "0",
                "btns": btns
            }
        }
        resp = requests.post(self._sign(), json=payload)
        return resp.json()

四、告警推送与变更通知

4.1 告警智能推送

# bot/alert_pusher.py
from bot.feishu_bot import FeishuBot

class AlertPusher:
    """告警推送器"""

    ALERT_TEMPLATES = {
        'critical': {
            'title': '🚨 严重告警',
            'color': 'red',
            'at_all': True
        },
        'major': {
            'title': '⚠️ 主要告警',
            'color': 'orange',
            'at_all': False
        },
        'warning': {
            'title': '💡 警告',
            'color': 'yellow',
            'at_all': False
        }
    }

    def push_alert(self, alert):
        """推送告警"""
        severity = alert.get('severity', 'warning')
        template = self.ALERT_TEMPLATES.get(severity,
                                            self.ALERT_TEMPLATES['warning'])

        content = (
            f"**设备**: {alert['device']}\n"
            f"**类型**: {alert['type']}\n"
            f"**描述**: {alert['description']}\n"
            f"**时间**: {alert.get('timestamp', 'N/A')}\n"
            f"**建议**: {alert.get('suggestion', '请检查设备状态')}"
        )

        self.bot.send_markdown(template['title'], content)

    def push_change_notification(self, change):
        """推送变更通知"""
        status_icon = {
            'success': '✅',
            'failed': '❌',
            'in_progress': '🔄'
        }.get(change['status'], '📋')

        content = (
            f"**变更ID**: {change['id']}\n"
            f"**标题**: {change['title']}\n"
            f"**状态**: {status_icon} {change['status']}\n"
            f"**设备**: {', '.join(change['devices'])}\n"
            f"**操作人**: {change['requestor']}\n"
            f"**详情**: {change.get('detail', '')}"
        )

        self.bot.send_markdown(
            f"📋 变更通知 [{change['status']}]",
            content
        )

4.2 定时巡检报告推送

# bot/scheduled_report.py
from apscheduler.schedulers.background import BackgroundScheduler

def generate_daily_report():
    """生成每日巡检报告"""
    devices = load_device_list()
    report_lines = ["## 📊 每日网络巡检报告", ""]
    report_lines.append(f"| 设备 | CPU | 内存 | BGP会话 | 接口Down |")
    report_lines.append(f"|------|-----|------|---------|----------|")

    for dev in devices:
        stats = get_device_stats(dev)
        report_lines.append(
            f"| {dev['name']} | {stats['cpu']}% | "
            f"{stats['memory']}% | {stats['bgp_sessions']} | "
            f"{stats['down_interfaces']} |"
        )

    return '\n'.join(report_lines)

# 每天上午 8:30 推送巡检报告
scheduler = BackgroundScheduler()
scheduler.add_job(
    func=lambda: AlertPusher().bot.send_markdown(
        "每日巡检报告", generate_daily_report()),
    trigger='cron',
    hour=8, minute=30
)
scheduler.start()

五、容器化网络运维平台:Docker/K8s 集成

5.1 Docker 容器编排配置

# Dockerfile - 网络运维工具容器
FROM python:3.11-slim

WORKDIR /app

# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libffi-dev libssl-dev && \
    rm -rf /var/lib/apt/lists/*

# 安装 Python 依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制应用代码
COPY bot/ ./bot/
COPY tools/ ./tools/
COPY config/ ./config/

# 暴露 Webhook 端口
EXPOSE 8080

# 健康检查
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD python -c "import requests; requests.get('http://localhost:8080/health')"

# 启动服务
CMD ["uvicorn", "bot.app:app", "--host", "0.0.0.0", "--port", "8080"]
# docker-compose.yml
version: '3.8'

services:
  # ChatOps 后端服务
  chatops-bot:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - FLYBOOK_WEBHOOK_URL=${FLYBOOK_WEBHOOK_URL}
      - DINGTALK_WEBHOOK_URL=${DINGTALK_WEBHOOK_URL}
      - DB_CONNECTION=postgresql://admin:password@postgres:5432/chatops
      - REDIS_URL=redis://redis:6379/0
    volumes:
      - ./config:/app/config:ro
      - ./logs:/app/logs
      - ./backups:/app/backups
    depends_on:
      - postgres
      - redis
    restart: unless-stopped
    networks:
      - chatops-net

  # 数据库
  postgres:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=chatops
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=${DB_PASSWORD:-password}
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"
    networks:
      - chatops-net

  # 缓存与任务队列
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    ports:
      - "6379:6379"
    networks:
      - chatops-net

  # Prometheus 监控
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
      - prom_data:/prometheus
    ports:
      - "9090:9090"
    networks:
      - chatops-net

  # Grafana 可视化
  grafana:
    image: grafana/grafana:latest
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
    volumes:
      - grafana_data:/var/lib/grafana
      - ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
    ports:
      - "3000:3000"
    depends_on:
      - prometheus
    networks:
      - chatops-net

  # 采集 Worker(后台任务)
  collector-worker:
    build:
      context: .
      dockerfile: Dockerfile
    command: python -m scripts.scheduler
    environment:
      - DB_CONNECTION=postgresql://admin:password@postgres:5432/chatops
      - REDIS_URL=redis://redis:6379/0
    volumes:
      - ./inventory:/app/inventory:ro
      - ./output:/app/output
    depends_on:
      - postgres
      - redis
    restart: unless-stopped
    networks:
      - chatops-net

volumes:
  pg_data:
  redis_data:
  prom_data:
  grafana_data:

networks:
  chatops-net:
    driver: bridge

5.2 K8s 网络策略与部署

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: chatops-bot
  namespace: network-ops
  labels:
    app: chatops-bot
    tier: backend
spec:
  replicas: 2
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: chatops-bot
  template:
    metadata:
      labels:
        app: chatops-bot
        tier: backend
    spec:
      serviceAccountName: chatops-bot
      containers:
        - name: chatops-bot
          image: registry.internal/chatops-bot:v1.2.0
          ports:
            - containerPort: 8080
              name: http
          env:
            - name: FLYBOOK_WEBHOOK_URL
              valueFrom:
                secretKeyRef:
                  name: chatops-secrets
                  key: feishu-webhook
            - name: DB_CONNECTION
              valueFrom:
                secretKeyRef:
                  name: chatops-secrets
                  key: db-connection
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          volumeMounts:
            - name: config-volume
              mountPath: /app/config
              readOnly: true
            - name: log-volume
              mountPath: /app/logs
      volumes:
        - name: config-volume
          configMap:
            name: chatops-config
        - name: log-volume
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: chatops-bot
  namespace: network-ops
spec:
  selector:
    app: chatops-bot
  ports:
    - port: 80
      targetPort: 8080
      name: http
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: chatops-network-policy
  namespace: network-ops
spec:
  podSelector:
    matchLabels:
      app: chatops-bot
  policyTypes:
    - Ingress
    - Egress
  ingress:
    # 只允许 Ingress Controller 访问
    - from:
        - namespaceSelector:
            matchLabels:
              name: ingress-nginx
      ports:
        - port: 8080
  egress:
    # 允许访问数据库
    - to:
        - podSelector:
            matchLabels:
              app: postgres
      ports:
        - port: 5432
    # 允许访问 Redis
    - to:
        - podSelector:
            matchLabels:
              app: redis
      ports:
        - port: 6379
    # 允许访问飞书/钉钉 API
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
      ports:
        - port: 443
    # 允许 DNS 解析
    - to:
        - namespaceSelector: {}
      ports:
        - port: 53
          protocol: UDP
---
# ConfigMap: 配置文件
apiVersion: v1
kind: ConfigMap
metadata:
  name: chatops-config
  namespace: network-ops
data:
  scheduler_config.yaml: |
    tasks:
      - name: "每小时采集"
        trigger: cron
        cron: { hour: "*" }
        devices_file: /app/inventory/devices.yaml
        max_workers: 20

5.3 容器日志采集方案

# monitoring/filebeat.yml - Filebeat 日志采集配置
filebeat.inputs:
  # 采集 ChatOps Bot 日志
  - type: container
    paths:
      - /var/lib/docker/containers/*chatops-bot*/*-json.log
    processors:
      - add_kubernetes_metadata:
          host: ${NODE_NAME}
          matchers:
            - logs_path:
                logs_path: "/var/lib/docker/containers/"

  # 采集网络设备 SSH 会话日志
  - type: log
    paths:
      - /app/logs/*.log
    multiline.pattern: '^\d{4}-\d{2}-\d{2}'
    multiline.negate: true
    multiline.match: after

# 输出到 Elasticsearch
output.elasticsearch:
  hosts: ["${ELASTICSEARCH_HOST:elasticsearch:9200}"]
  index: "chatops-logs-%{+yyyy.MM.dd}"

# 管道配置
setup.ilm.enabled: false
setup.template.name: "chatops"
setup.template.pattern: "chatops-logs-*"
# bot/log_collector.py - 容器内日志采集
"""
容器化环境下的日志采集与上报
"""
import logging
import json
import sys
from datetime import datetime
from logging.handlers import RotatingFileHandler


class ContainerLogHandler(logging.Handler):
    """
    容器日志处理器
    以 JSON 格式输出到 stdout,便于日志采集系统(Fluentd/Filebeat)收集
    """

    def __init__(self, service_name: str = "chatops-bot"):
        super().__init__()
        self.service_name = service_name
        self.writer = sys.stdout

    def emit(self, record: logging.LogRecord):
        try:
            log_entry = {
                'timestamp': datetime.utcnow().isoformat() + 'Z',
                'level': record.levelname,
                'service': self.service_name,
                'message': record.getMessage(),
                'module': record.module,
                'function': record.funcName,
                'line': record.lineno,
            }
            if record.exc_info and record.exc_info[0]:
                log_entry['exception'] = self.formatException(record.exc_info)

            # 输出 JSON 格式到 stdout
            self.writer.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
            self.writer.flush()
        except Exception:
            self.handleError(record)


def setup_logging(service_name: str = "chatops-bot",
                  level: int = logging.INFO):
    """配置容器化日志"""
    root_logger = logging.getLogger()
    root_logger.setLevel(level)

    # 移除默认处理器
    for handler in root_logger.handlers[:]:
        root_logger.removeHandler(handler)

    # 添加容器日志处理器(JSON 格式输出到 stdout)
    container_handler = ContainerLogHandler(service_name=service_name)
    container_handler.setFormatter(logging.Formatter('%(message)s'))
    root_logger.addHandler(container_handler)

    # 同时写入本地文件(用于容器内调试)
    file_handler = RotatingFileHandler(
        '/app/logs/app.log',
        maxBytes=50 * 1024 * 1024,  # 50MB
        backupCount=5,
        encoding='utf-8'
    )
    file_handler.setFormatter(logging.Formatter(
        '%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s'
    ))
    root_logger.addHandler(file_handler)


# K8s 日志采集 Pipeline
# Container stdout → Filebeat/Fluentd → Logstash → Elasticsearch → Kibana

5.4 监控告警集成代码

# bot/metrics_exporter.py - Prometheus 指标导出
"""
Prometheus 指标导出器
暴露网络运维平台的运行指标
"""
from prometheus_client import start_http_server, Counter, Histogram, Gauge, Info
import time


# 指标定义
COMMAND_EXEC_TOTAL = Counter(
    'chatops_command_exec_total',
    '命令执行总次数',
    ['command_type', 'status']  # 标签
)

COMMAND_EXEC_DURATION = Histogram(
    'chatops_command_exec_duration_seconds',
    '命令执行耗时(秒)',
    ['command_type'],
    buckets=(0.1, 0.5, 1, 2, 5, 10, 30, 60)
)

ACTIVE_DEVICES = Gauge(
    'chatops_active_devices',
    '活跃设备数量'
)

DEVICE_HEALTH_SCORE = Gauge(
    'chatops_device_health_score',
    '设备健康评分',
    ['device_name']
)

BOT_INFO = Info(
    'chatops_bot',
    'ChatOps 机器人信息'
)


class MetricsCollector:
    """指标采集器"""

    def __init__(self):
        BOT_INFO.info({
            'version': '1.2.0',
            'platform': 'chatops-network'
        })
        # 启动 Prometheus 指标服务(端口 9091)
        start_http_server(9091)

    def record_command(self, command_type: str, success: bool,
                      duration: float):
        """记录命令执行指标"""
        status = 'success' if success else 'failure'
        COMMAND_EXEC_TOTAL.labels(
            command_type=command_type, status=status
        ).inc()
        COMMAND_EXEC_DURATION.labels(
            command_type=command_type
        ).observe(duration)

    def update_device_count(self, count: int):
        """更新活跃设备数"""
        ACTIVE_DEVICES.set(count)

    def update_device_health(self, device_name: str, score: float):
        """更新设备健康评分"""
        DEVICE_HEALTH_SCORE.labels(
            device_name=device_name
        ).set(score)


# Grafana 告警规则示例
# monitoring/alerts.yml
"""
groups:
  - name: chatops_alerts
    rules:
      - alert: HighCommandFailureRate
        expr: |
          rate(chatops_command_exec_total{status="failure"}[5m])
          / rate(chatops_command_exec_total[5m]) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "命令失败率超过 10%"
          description: "过去 5 分钟命令失败率 >10%"

      - alert: DeviceHealthCritical
        expr: chatops_device_health_score < 50
        for: 10m
        labels:
          severity: critical
        annotations:
          summary: "设备健康评分低于 50"
          description: "{{ $labels.device_name }} 健康评分: {{ $value }}"

      - alert: SlowCommandExecution
        expr: |
          histogram_quantile(0.95, rate(chatops_command_exec_duration_seconds_bucket[5m])) > 30
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "命令执行 P95 延迟超过 30 秒"
"""

六、总结

核心概念 说明
ChatOps 通过即时通讯工具(飞书/钉钉/Slack)执行运维操作的工作模式
机器人 Webhook 企业 IM 平台提供的消息推送接口,用于主动发送通知
交互式菜单 通过按钮菜单实现人机交互,降低操作门槛
安全控制 操作确认、权限校验、审计日志是 ChatOps 安全的三大支柱
事件驱动架构 设备告警 → 告警平台 → ChatOps 后端 → IM 推送,实现自动化闭环
容器化部署 Docker + K8s 实现 ChatOps 平台的弹性伸缩和高可用
可观测性 Prometheus 指标 + ELK 日志 + Grafana 看板,三位一体监控

思考题

  1. 通过聊天机器人执行网络操作时,如何确保操作的安全性(如权限控制、操作确认、防误操作)?
  2. ChatOps 适合哪些运维场景?哪些操作不应通过 ChatOps 执行?

下篇预告:第328篇《大模型辅助网络故障诊断实践》——利用大语言模型(LLM)辅助网络故障根因分析与处置方案生成。