第322篇:CI/CD 在网络变更中的应用
关键词:CI/CD、持续集成、持续交付、网络变更、GitLab CI、Jenkins、灰度发布、配置验证
一、引言
传统网络变更流程存在明显痛点:变更窗口集中在深夜、回滚困难、缺乏自动化验证、人工操作易出错。这些问题的根源在于网络变更长期被视为"手工操作",而非"可验证的软件交付"。
CI/CD(Continuous Integration / Continuous Delivery)理念源自软件工程,其核心思想是:任何变更都应经过自动化构建 → 自动化测试 → 自动化部署的完整流程。将 CI/CD 引入网络变更,意味着每一条配置指令在到达生产设备之前,都要经历严格的自动化校验。
二、网络 CI/CD 架构设计
2.1 整体流程

2.2 核心组件
| 组件 | 工具选型 | 职责 |
|---|---|---|
| 版本控制 | Git(GitLab / GitHub) | 配置代码存储、分支管理、MR 评审 |
| CI/CD 引擎 | GitLab CI / Jenkins | Pipeline 编排、阶段触发、通知 |
| 静态分析 | Batfish、Avisynth | 配置语法检查、冲突检测 |
| 单元/集成测试 | pytest + pytest-network | 接口状态、路由表、ACL 验证 |
| 仿真环境 | Containerlab、EVE-NG | 变更前隔离验证 |
| 部署工具 | Ansible、 NAPALM | 配置下发、回滚 |
三、GitLab CI 配置变更 Pipeline
3.1 .gitlab-ci.yml 配置
stages:
- lint
- unit-test
- integration-test
- staging-deploy
- review
- production-deploy
variables:
PYTEST_ADDOPTS: "-v --tb=short"
ANSIBLE_HOST_KEY_CHECKING: "False"
# 第一阶段:静态检查
config_lint:
stage: lint
image: python:3.11-slim
script:
- pip install pybatfish cfn-lint yamllint
- yamllint configs/*.yaml
- python scripts/batfish_check.py --config configs/$DEVICE_GROUP/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# 第二阶段:单元测试
unit_test:
stage: unit-test
image: python:3.11-slim
script:
- pip install -r requirements-test.txt
- pytest tests/unit/ -v
needs:
- config_lint
# 第三阶段:集成测试
integration_test:
stage: integration-test
image: python:3.11-slim
script:
- pip install -r requirements-test.txt
- pytest tests/integration/ -v --junitxml=report.xml
artifacts:
reports:
junit: report.xml
needs:
- unit_test
# 第四阶段:仿真环境部署
staging_deploy:
stage: staging-deploy
image: ansible/ansible:latest
script:
- ansible-playbook playbooks/staging/deploy.yml
-e "env=staging"
-e "config_ref=$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
environment:
name: staging
needs:
- integration_test
# 第五阶段:人工审批
review:
stage: review
image: alpine:latest
script:
- echo "Waiting for manual approval..."
rules:
- if: $CI_MERGE_REQUEST_EVENT_TYPE == "approve"
when: manual
needs:
- staging_deploy
# 第六阶段:生产环境灰度部署
prod_deploy:
stage: production-deploy
image: ansible/ansible:latest
script:
# 灰度:先部署 20% 设备
- ansible-playbook playbooks/prod/deploy.yml
-e "env=production"
-e "config_ref=$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
-e "deploy_batch=canary"
--limit "group[0:2]"
# 等待验证期
- sleep 300
# 全量部署
- ansible-playbook playbooks/prod/deploy.yml
-e "env=production"
-e "config_ref=$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
-e "deploy_batch=full"
environment:
name: production
rules:
- if: $CI_MERGE_REQUEST_EVENT_TYPE == "approve"
when: manual
needs:
- review
3.2 Batfish 静态分析检查
Batfish 是目前业界最强的网络配置分析工具,可在不连接真实设备的情况下检测配置问题:
# scripts/batfish_check.py
from pybatfish.client import commands as bf
def run_batfish_check(config_dir):
"""运行 Batfish 配置检查"""
# 初始化 Batfish 会话
bf.init_session()
# 创建工作空间
workspace = bf.create_workspace("network_validation")
bf.set_workspace(workspace)
# 初始化网络快照
snapshot = bf.init_snapshot(config_dir)
# 检查项 1: BGP 会话是否可建立
result = bf.q.bgp_session_status().answer()
failed_sessions = result.frame[
result.frame['Status'] != 'ESTABLISHED'
]
if not failed_sessions.empty:
print("BGP 会话异常:")
print(failed_sessions.to_string())
return False
# 检查项 2: ACL 是否阻断预期流量
result = bf.q.tracingOptions(
headers={'ip': {'dstIp': '10.0.0.0/8'}},
edgeRoles='ToDatacenterRouters'
).answer()
# 检查项 3: 路由泄露检测
result = bf.q.routeLeak(
source='External',
destination='Internal'
).answer()
bf.delete_workspace(workspace)
return True
if __name__ == '__main__':
import sys
success = run_batfish_check(sys.argv[1])
sys.exit(0 if success else 1)
四、灰度发布与回滚机制
4.1 灰度部署策略
# inventory/grayscale.yml
all:
children:
canary_group: # 金丝雀组(首批)
hosts:
PE-BJ-01:
PE-SH-01:
wave_1_group: # 第一波
hosts:
PE-BJ-02:
PE-SH-02:
PE-GZ-01:
wave_2_group: # 第二波
hosts:
PE-BJ-03:
PE-SH-03:
PE-GZ-02:
# scripts/grayscale_deploy.py
import time
import ansible_runner
GRAYSCALE_CONFIG = {
'waves': [
{'name': 'canary', 'inventory': 'canary_group', 'wait_seconds': 300},
{'name': 'wave_1', 'inventory': 'wave_1_group', 'wait_seconds': 600},
{'name': 'wave_2', 'inventory': 'wave_2_group', 'wait_seconds': 600},
],
'rollback_on_failure': True,
'health_check': 'check_device_health.py'
}
def grayscale_deploy(config_template):
"""灰度部署主流程"""
for wave in GRAYSCALE_CONFIG['waves']:
print(f"=== 部署批次: {wave['name']} ===")
# 1. 下发配置
result = deploy_batch(wave['inventory'], config_template)
if not result['success']:
# 部署失败,立即回滚当前批次及之前批次
rollback(wave['name'])
raise Exception(f"批次 {wave['name']} 部署失败")
# 2. 等待验证期
print(f"等待 {wave['wait_seconds']} 秒验证期...")
time.sleep(wave['wait_seconds'])
# 3. 健康检查
health = run_health_check(wave['inventory'])
if not health['healthy']:
rollback(wave['name'])
raise Exception(f"批次 {wave['name']} 健康检查失败")
print(f"批次 {wave['name']} 验证通过,继续下一批")
print("全部批次部署完成")
4.2 自动回滚机制
# playbooks/prod/rollback.yml
---
- name: 自动回滚网络配置
hosts: "{{ target_group }}"
gather_facts: no
vars:
backup_dir: "/opt/network-backups/{{ inventory_hostname }}"
tasks:
- name: 获取最近一次成功备份
ansible.builtin.find:
paths: "{{ backup_dir }}"
patterns: "*.cfg"
register: backups
delegate_to: localhost
- name: 回滚配置
ansible.netcommon.config:
src: "{{ backups.files[0].path }}"
backup: yes
backup_options:
dir: "{{ backup_dir }}/rollback_{{ ansible_date_time.iso8601_basic_short }}"
- name: 验证回滚后配置
ansible.builtin.command:
cmd: "python scripts/verify_config.py {{ inventory_hostname }}"
delegate_to: localhost
register: verify_result
- name: 失败告警
ansible.builtin.uri:
url: "https://alerts.example.com/webhook/rollback"
method: POST
body: |
{
"device": "{{ inventory_hostname }}",
"rollback_time": "{{ ansible_date_time.iso8601 }}",
"reason": "自动回滚触发"
}
body_format: json
when: verify_result.rc != 0
五、自动化运维平台架构设计
5.1 前后端分离架构

5.2 API 接口设计
# api/main.py - FastAPI 自动化运维平台
"""
网络自动化运维平台 REST API
支持:设备管理、变更管理、配置采集、健康检查
"""
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks
from pydantic import BaseModel, Field
from typing import List, Optional, Dict
from datetime import datetime
import uuid
app = FastAPI(
title="网络自动化运维平台",
version="1.0.0",
docs_url="/api/docs"
)
# ============ 数据模型 ============
class DeviceCreate(BaseModel):
name: str = Field(..., description="设备名称")
ip: str = Field(..., description="管理 IP")
platform: str = Field(default='huawei_vrpv8', description="设备平台")
role: str = Field(default='access', description="设备角色")
username: str = Field(default='admin')
password: str
site: str = Field(default='default', description="站点")
tags: List[str] = Field(default_factory=list)
class DeviceUpdate(BaseModel):
name: Optional[str] = None
ip: Optional[str] = None
role: Optional[str] = None
tags: Optional[List[str]] = None
class ChangeRequest(BaseModel):
title: str = Field(..., description="变更标题")
description: str = Field(..., description="变更描述")
device_ids: List[str] = Field(..., description="目标设备 ID 列表")
config_template: str = Field(..., description="配置模板路径")
config_params: Dict = Field(default_factory=dict, description="模板参数")
change_type: str = Field(default='P2', description="变更类型 P1-P4")
change_window_start: Optional[datetime] = None
rollback_on_failure: bool = Field(default=True)
class HealthCheckResult(BaseModel):
device_name: str
cpu_usage: float
memory_usage: float
bgp_sessions: int
interface_status: Dict
health_score: float
timestamp: str
# ============ 设备管理 API ============
@app.post("/api/v1/devices", response_model=Dict, status_code=201)
async def create_device(device: DeviceCreate):
"""创建设备"""
# 保存到数据库
db_device = save_device_to_db(device)
return {"id": db_device['id'], "name": device.name}
@app.get("/api/v1/devices", response_model=List[Dict])
async def list_devices(role: Optional[str] = None,
site: Optional[str] = None,
tag: Optional[str] = None):
"""设备列表(支持过滤)"""
return query_devices(role=role, site=site, tag=tag)
@app.get("/api/v1/devices/{device_id}", response_model=Dict)
async def get_device(device_id: str):
"""获取设备详情"""
device = get_device_from_db(device_id)
if not device:
raise HTTPException(status_code=404, detail="设备不存在")
return device
@app.put("/api/v1/devices/{device_id}", response_model=Dict)
async def update_device(device_id: str, device: DeviceUpdate):
"""更新设备信息"""
return update_device_in_db(device_id, device)
@app.delete("/api/v1/devices/{device_id}")
async def delete_device(device_id: str):
"""删除设备"""
delete_device_from_db(device_id)
return {"status": "deleted"}
@app.post("/api/v1/devices/{device_id}/collect", response_model=Dict)
async def collect_device(device_id: str, background_tasks: BackgroundTasks):
"""触发单设备信息采��"""
background_tasks.add_task(run_collection, device_id)
return {"task_id": str(uuid.uuid4()), "status": "started"}
@app.post("/api/v1/devices/batch-collect", response_model=Dict)
async def batch_collect(devices: List[str], background_tasks: BackgroundTasks):
"""批量设备信息采集"""
task_id = str(uuid.uuid4())
background_tasks.add_task(run_batch_collection, devices, task_id)
return {"task_id": task_id, "status": "started"}
# ============ 变更管理 API ============
@app.post("/api/v1/changes", response_model=Dict, status_code=201)
async def create_change(change: ChangeRequest, background_tasks: BackgroundTasks):
"""创建变更请求"""
change_id = str(uuid.uuid4())
save_change_request(change_id, change)
return {"change_id": change_id, "status": "created"}
@app.post("/api/v1/changes/{change_id}/execute")
async def execute_change(change_id: str, background_tasks: BackgroundTasks):
"""执行变更"""
background_tasks.add_task(run_change_execution, change_id)
return {"status": "executing", "change_id": change_id}
@app.get("/api/v1/changes/{change_id}/status", response_model=Dict)
async def get_change_status(change_id: str):
"""获取变更状态"""
return get_change_status_from_db(change_id)
@app.post("/api/v1/changes/{change_id}/rollback")
async def rollback_change(change_id: str):
"""回滚变更"""
run_rollback(change_id)
return {"status": "rolling_back", "change_id": change_id}
@app.get("/api/v1/changes", response_model=List[Dict])
async def list_changes(status: Optional[str] = None,
limit: int = 50):
"""变更列表"""
return query_changes(status=status, limit=limit)
# ============ 健康检查 API ============
@app.get("/api/v1/health", response_model=Dict)
async def system_health():
"""平台健康状态"""
return {
"status": "healthy",
"database": "connected",
"redis": "connected",
"workers_online": get_worker_count(),
}
@app.get("/api/v1/devices/{device_id}/health", response_model=HealthCheckResult)
async def device_health(device_id: str):
"""设备健康状态"""
return get_device_health(device_id)
@app.get("/api/v1/devices/health-summary", response_model=Dict)
async def health_summary():
"""设备健康汇总"""
return {
"total_devices": get_total_devices(),
"healthy": get_healthy_count(),
"warning": get_warning_count(),
"critical": get_critical_count(),
}
# ============ 认证与权限 ============
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = 3600
@app.post("/api/v1/auth/login", response_model=TokenResponse)
async def login(credentials: LoginRequest):
"""用户登录"""
token = generate_jwt_token(credentials.username)
return TokenResponse(access_token=token)
5.3 数据库模型设计
# models/database.py - SQLAlchemy 数据库模型
"""
自动化运维平台数据库模型
"""
from sqlalchemy import (
Column, String, Integer, Float, Boolean, Text, DateTime,
ForeignKey, JSON, Enum as SAEnum, Index
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, sessionmaker
from sqlalchemy.create_engine import create_engine
from datetime import datetime
Base = declarative_base()
class Device(Base):
"""设备表"""
__tablename__ = 'devices'
id = Column(String(36), primary_key=True)
name = Column(String(128), unique=True, nullable=False, index=True)
ip = Column(String(45), nullable=False)
platform = Column(String(64), default='huawei_vrpv8')
role = Column(String(32), default='access')
site = Column(String(64), default='default', index=True)
username = Column(String(64))
password_encrypted = Column(String(256))
port = Column(Integer, default=22)
tags = Column(JSON, default=list)
last_collected_at = Column(DateTime)
last_health_score = Column(Float)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# 关联
collections = relationship("DeviceCollection", back_populates="device")
changes = relationship("ChangeRequest", back_populates="device")
class DeviceCollection(Base):
"""设备采集记录表"""
__tablename__ = 'device_collections'
id = Column(String(36), primary_key=True)
device_id = Column(String(36), ForeignKey('devices.id'), index=True)
task_id = Column(String(36), index=True)
cpu_usage = Column(Float)
memory_usage = Column(Float)
bgp_sessions = Column(Integer)
ospf_neighbors = Column(Integer)
interface_count = Column(Integer)
up_interfaces = Column(Integer)
down_interfaces = Column(Integer)
temperature = Column(Float)
health_score = Column(Float)
raw_data = Column(JSON)
errors = Column(JSON, default=list)
collected_at = Column(DateTime, default=datetime.now)
device = relationship("Device", back_populates="collections")
class ChangeRequest(Base):
"""变更请求表"""
__tablename__ = 'change_requests'
id = Column(String(36), primary_key=True)
title = Column(String(256), nullable=False)
description = Column(Text)
change_type = Column(String(8), default='P2')
status = Column(String(32), default='draft')
# draft | submitted | approved | executing | success | failed | rolled_back
requestor = Column(String(64))
approver = Column(String(64))
approved_at = Column(DateTime)
executed_at = Column(DateTime)
completed_at = Column(DateTime)
config_template = Column(String(256))
config_params = Column(JSON)
rollback_on_failure = Column(Boolean, default=True)
pre_backup_path = Column(String(512))
result = Column(JSON)
error_message = Column(Text)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class ChangeDeviceMapping(Base):
"""变更-设备映射表"""
__tablename__ = 'change_device_mappings'
id = Column(String(36), primary_key=True)
change_id = Column(String(36), ForeignKey('change_requests.id'), index=True)
device_id = Column(String(36), ForeignKey('devices.id'), index=True)
deploy_status = Column(String(32), default='pending')
# pending | deploying | success | failed | rolled_back
deploy_output = Column(Text)
error_message = Column(Text)
deployed_at = Column(DateTime)
class AuditLog(Base):
"""审计日志表"""
__tablename__ = 'audit_logs'
id = Column(String(36), primary_key=True)
user = Column(String(64))
action = Column(String(64)) # create_device | deploy_config | rollback
resource_type = Column(String(32)) # device | change | collection
resource_id = Column(String(36))
details = Column(JSON)
ip_address = Column(String(45))
created_at = Column(DateTime, default=datetime.now)
# 数据库初始化
engine = create_engine('postgresql://admin:password@localhost:5432/network_automation')
Session = sessionmaker(bind=engine)
Base.metadata.create_all(engine)
5.4 部署配置脚本
# docker-compose.deploy.yml - 生产环境部署
version: '3.8'
services:
# API 网关
nginx:
image: nginx:alpine
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/certs:/etc/nginx/certs
depends_on:
- api-server
networks:
- platform-net
# API 服务(2 实例实现高可用)
api-server:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- DATABASE_URL=postgresql://admin:${DB_PASSWORD}@postgres:5432/network_automation
- REDIS_URL=redis://redis:6379/0
- JWT_SECRET=${JWT_SECRET}
depends_on:
- postgres
- redis
deploy:
replicas: 2
resources:
limits:
cpus: '1.0'
memory: 512M
networks:
- platform-net
# Celery Worker 集群
celery-worker:
build:
context: ./backend
dockerfile: Dockerfile
command: celery -A tasks worker -l info -c 10
environment:
- DATABASE_URL=postgresql://admin:${DB_PASSWORD}@postgres:5432/network_automation
- REDIS_URL=redis://redis:6379/0
depends_on:
- postgres
- redis
deploy:
replicas: 3
networks:
- platform-net
# 数据库
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=network_automation
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
- ./scripts/init_db.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "127.0.0.1:5432:5432"
networks:
- platform-net
# Redis
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
ports:
- "127.0.0.1:6379:6379"
networks:
- platform-net
# 前端
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
networks:
- platform-net
volumes:
pg_data:
redis_data:
networks:
platform-net:
driver: bridge
#!/bin/bash
# deploy.sh - 一键部署脚本
set -e
echo "=== 网络自动化运维平台部署脚本 ==="
# 检查依赖
command -v docker >/dev/null 2>&1 || { echo "Docker 未安装"; exit 1; }
command -v docker-compose >/dev/null 2>&1 || { echo "docker-compose 未安装"; exit 1; }
# 环境变量
source .env || { echo "请创建 .env 文件"; exit 1; }
# 构建镜像
echo "[1/4] 构建 Docker 镜像..."
docker-compose -f docker-compose.deploy.yml build
# 启动服务
echo "[2/4] 启动服务..."
docker-compose -f docker-compose.deploy.yml up -d
# 等待服务就绪
echo "[3/4] 等待服务就绪..."
sleep 10
# 运行数据库迁移
echo "[4/4] 运行数据库迁移..."
docker-compose -f docker-compose.deploy.yml exec api-server \
flask db upgrade
echo ""
echo "=== 部署完成 ==="
echo "前端访问: http://localhost:8080"
echo "API 文档: http://localhost/api/docs"
echo "Swagger: http://localhost/api/docs"
六、总结
| 维度 | 传统变更 | CI/CD 变更 | 改善幅度 |
|---|---|---|---|
| 变更准备 | 手工编写配置脚本 | Git 分支 + MR 模板 | 标准化程度大幅提升 |
| 变更验证 | 变更窗口临时验证 | 多层自动化测试 | 故障率降低 70%+ |
| 变更执行 | 深夜窗口逐台操作 | Pipeline 灰度自动下发 | 时间从小时级降至分钟级 |
| 回滚机制 | 手工回退配置 | 一键回滚 + 自动触发 | 回滚时间从30分钟降至2分钟 |
| 变更审计 | 变更后的事后回顾 | 全流程 Git 记录 + CI 日志 | 可追溯性 100% |
网络 CI/CD Pipeline 视图

故障案例:BGP 配置变更事故与 CI/CD 防护
某运营商在一次 BGP 路由策略变更中,因一条 ACL 规则编写错误,导致核心链路路由被意外过滤,造成 4 个地市断网 2 小时。若使用 CI/CD Pipeline,可在以下环节拦截该问题:
- Lint 阶段:Batfish 检测到 ACL 规则与现网路由表不匹配,阻断 Pipeline
- 仿真阶段:EVE-NG 仿真环境中验证 BGP 会话状态异常
- 灰度阶段:即使前两步漏过,金丝雀组仅影响 2 台设备,健康检查发现路由丢失后立即回滚
思考题
- 在网络 CI/CD Pipeline 中,如何平衡"自动化程度"与"人工审核"?哪些变更类型可以完全自动化,哪些必须保留人工审批环节?
- 灰度部署的批次划分有哪些策略?在核心网(如 PE 设备)与接入网(如 CE/AG 设备)的灰度策略应有什么区别?
下篇预告:第323篇《网络即代码(Network as Code)理念》——将网络配置视为代码,用软件工程的方法管理网络的全生命周期。