Shell/Python 运维自动化:配置管理与批量部署的工程化实践

cover

一、运维的"重复劳动":100 台服务器,手动配置要多久?

运维团队最常见的场景:新上线一个服务,需要在 100 台服务器上安装依赖、修改配置、重启进程。手动 SSH 到每台服务器执行命令,即使每台只需 2 分钟,100 台也要 3 个多小时。更糟糕的是,手动操作的一致性无法保证——第 47 台服务器的配置文件少了一个逗号,排查这个 typo 又花了 2 小时。

配置管理和批量部署是运维自动化的基石。Ansible、SaltStack 等工具提供了声明式的配置管理能力,但在特定场景下(如无法安装 Agent 的受限环境、需要精细控制执行逻辑的复杂部署),Shell 脚本和 Python 脚本仍然是最灵活的选择。关键在于如何将"灵活"与"可靠"结合——脚本既要有足够的控制力,又要有完善的错误处理和幂等性保证。

二、配置管理的架构与幂等性设计

配置管理的核心原则是"幂等性"——同一份配置执行多次,结果始终一致。非幂等操作(如 echo "config" >> file)在重复执行时会产生重复内容,导致配置错误。幂等操作(如 lineinfile 或先检查再写入)则确保最终状态与期望一致。

flowchart TD
    A[配置清单 YAML] --> B[配置渲染引擎<br/>Jinja2 模板]
    B --> C[目标配置文件]
    C --> D[差异比较<br/>当前 vs 目标]
    D --> E{是否有差异?}
    E -->|否| F[跳过,无需变更]
    E -->|是| G[应用变更]
    G --> H[验证变更结果]
    H --> I{验证通过?}
    I -->|是| J[记录变更日志]
    I -->|否| K[自动回滚]
    K --> L[告警通知]

    subgraph "幂等性保证"
        M[检查当前状态]
        N[仅在状态不一致时变更]
        O[变更后验证]
    end

    D --> M
    G --> N
    H --> O

批量部署的四个阶段:

  1. 预检:检查目标主机的连通性、磁盘空间、依赖版本
  2. 分发:将配置文件和二进制包推送到目标主机
  3. 执行:按序执行部署步骤(停止服务 → 更新文件 → 启动服务)
  4. 验证:检查服务健康状态,确认部署成功

三、工程化运维脚本的完整实现

配置管理框架

# config_manager.py — 声明式配置管理框架
# 设计意图:以 YAML 声明期望状态,自动计算差异并应用变更,
# 确保幂等性和可回滚性

import yaml
import hashlib
import shutil
import logging
from pathlib import Path
from dataclasses import dataclass
from typing import List, Dict, Optional
from datetime import datetime

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@dataclass
class ConfigChange:
    """配置变更记录"""
    path: str
    action: str          # create / modify / delete
    old_hash: Optional[str]
    new_hash: str
    timestamp: datetime


class ConfigManager:
    """声明式配置管理器"""

    def __init__(self, state_dir: str = "/var/lib/config-manager"):
        self.state_dir = Path(state_dir)
        self.state_dir.mkdir(parents=True, exist_ok=True)
        self.changes: List[ConfigChange] = []

    def apply(self, manifest_path: str) -> dict:
        """应用配置清单"""
        with open(manifest_path) as f:
            manifest = yaml.safe_load(f)

        results = {
            "total": 0,
            "changed": 0,
            "unchanged": 0,
            "failed": 0,
            "details": [],
        }

        for item in manifest.get("files", []):
            results["total"] += 1
            try:
                changed = self._apply_file(item)
                if changed:
                    results["changed"] += 1
                else:
                    results["unchanged"] += 1
                results["details"].append({
                    "path": item["path"],
                    "status": "changed" if changed else "unchanged",
                })
            except Exception as e:
                results["failed"] += 1
                results["details"].append({
                    "path": item["path"],
                    "status": "failed",
                    "error": str(e),
                })

        for item in manifest.get("packages", []):
            results["total"] += 1
            try:
                changed = self._apply_package(item)
                if changed:
                    results["changed"] += 1
                else:
                    results["unchanged"] += 1
                results["details"].append({
                    "package": item["name"],
                    "status": "changed" if changed else "unchanged",
                })
            except Exception as e:
                results["failed"] += 1
                results["details"].append({
                    "package": item["name"],
                    "status": "failed",
                    "error": str(e),
                })

        return results

    def _apply_file(self, item: dict) -> bool:
        """应用文件配置(幂等)"""
        path = Path(item["path"])
        content = item["content"]
        owner = item.get("owner", "root")
        group = item.get("group", "root")
        mode = item.get("mode", "0644")

        # 计算目标内容的哈希
        target_hash = hashlib.sha256(content.encode()).hexdigest()

        # 检查当前状态
        if path.exists():
            current_content = path.read_text()
            current_hash = hashlib.sha256(current_content.encode()).hexdigest()

            if current_hash == target_hash:
                # 内容一致,无需变更
                return False

            # 内容不一致:备份后更新
            self._backup(path)
            path.write_text(content)
            old_hash = current_hash
            action = "modify"
        else:
            # 文件不存在:创建
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(content)
            old_hash = None
            action = "create"

        # 设置权限和属主
        import os
        os.chmod(path, int(mode, 8))
        os.chown(path, self._get_uid(owner), self._get_gid(group))

        # 记录变更
        self.changes.append(ConfigChange(
            path=str(path),
            action=action,
            old_hash=old_hash,
            new_hash=target_hash,
            timestamp=datetime.now(),
        ))

        logger.info(f"{action}: {path}")
        return True

    def _apply_package(self, item: dict) -> bool:
        """应用包配置(幂等)"""
        import subprocess

        name = item["name"]
        state = item.get("state", "present")

        # 检查包是否已安装
        check_cmd = f"dpkg -l {name} 2>/dev/null | grep -q '^ii'"
        is_installed = subprocess.call(
            check_cmd, shell=True, stdout=subprocess.DEVNULL
        ) == 0

        if state == "present" and not is_installed:
            subprocess.check_call(
                ["apt-get", "install", "-y", name],
                stdout=subprocess.DEVNULL
            )
            logger.info(f"installed: {name}")
            return True
        elif state == "absent" and is_installed:
            subprocess.check_call(
                ["apt-get", "remove", "-y", name],
                stdout=subprocess.DEVNULL
            )
            logger.info(f"removed: {name}")
            return True

        return False

    def rollback(self) -> int:
        """回滚所有变更"""
        rolled_back = 0
        for change in reversed(self.changes):
            backup_path = self._get_backup_path(change.path)
            if backup_path.exists():
                shutil.copy2(str(backup_path), change.path)
                rolled_back += 1
                logger.info(f"rolled back: {change.path}")
            elif change.action == "create":
                # 创建的文件:删除
                Path(change.path).unlink(missing_ok=True)
                rolled_back += 1
                logger.info(f"removed created file: {change.path}")

        self.changes.clear()
        return rolled_back

    def _backup(self, path: Path):
        """备份当前文件"""
        backup_path = self._get_backup_path(str(path))
        backup_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(str(path), str(backup_path))

    def _get_backup_path(self, original_path: str) -> Path:
        """获取备份文件路径"""
        safe_name = original_path.replace("/", "_")
        return self.state_dir / f"{safe_name}.bak"

    def _get_uid(self, username: str) -> int:
        """获取用户 UID"""
        import pwd
        try:
            return pwd.getpwnam(username).pw_uid
        except KeyError:
            return 0

    def _get_gid(self, groupname: str) -> int:
        """获取组 GID"""
        import grp
        try:
            return grp.getgrnam(groupname).gr_gid
        except KeyError:
            return 0

批量部署脚本

#!/bin/bash
# batch_deploy.sh — 批量部署脚本
# 设计意图:在多台目标主机上并行执行部署,包含预检、
# 分发、执行和验证四个阶段,支持滚动部署和失败回滚

set -euo pipefail

# ---- 配置 ----
HOSTS_FILE="${HOSTS_FILE:-hosts.txt}"
DEPLOY_DIR="/opt/app"
MAX_PARALLEL=10
ROLLING_BATCH=5    # 滚动部署:每批 5 台
ROLLBACK_ON_FAIL=1 # 失败时自动回滚

# ---- 日志 ----
LOG_DIR="/var/log/deploy"
mkdir -p "$LOG_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOG_FILE="$LOG_DIR/deploy_${TIMESTAMP}.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

# ---- 预检 ----
preflight_check() {
    local host=$1
    log "[PREFLIGHT] Checking $host..."

    # 检查 SSH 连通性
    if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$host" "echo ok" &>/dev/null; then
        log "[PREFLIGHT] FAILED: Cannot connect to $host"
        return 1
    fi

    # 检查磁盘空间(至少 1GB)
    local disk_avail
    disk_avail=$(ssh "$host" "df -BG $DEPLOY_DIR | awk 'NR==2{print \$4}' | tr -d 'G'")
    if [[ "$disk_avail" -lt 1 ]]; then
        log "[PREFLIGHT] FAILED: $host has less than 1GB disk space"
        return 1
    fi

    # 检查当前服务状态
    local service_status
    service_status=$(ssh "$host" "systemctl is-active app-service 2>/dev/null || echo unknown")
    log "[PREFLIGHT] $host: disk=${disk_avail}G, service=$service_status"
    return 0
}

# ---- 部署单台主机 ----
deploy_host() {
    local host=$1
    local log_prefix="[DEPLOY] $host"

    log "$log_prefix: Starting deployment..."

    # Step 1: 停止服务(优雅停止)
    if ! ssh "$host" "systemctl stop app-service" 2>/dev/null; then
        log "$log_prefix: WARN - Service stop failed, may not be running"
    fi

    # Step 2: 备份当前版本
    ssh "$host" "cp -r $DEPLOY_DIR/current $DEPLOY_DIR/previous 2>/dev/null || true"

    # Step 3: 分发新版本
    if ! scp -r ./dist/* "$host:$DEPLOY_DIR/current/"; then
        log "$log_prefix: FAILED - File distribution error"
        return 1
    fi

    # Step 4: 更新配置
    if ! ssh "$host" "cd $DEPLOY_DIR/current && python3 config_manager.py apply manifest.yml"; then
        log "$log_prefix: FAILED - Configuration error"
        # 回滚:恢复上一版本
        if [[ "$ROLLBACK_ON_FAIL" -eq 1 ]]; then
            ssh "$host" "rm -rf $DEPLOY_DIR/current && mv $DEPLOY_DIR/previous $DEPLOY_DIR/current"
        fi
        return 1
    fi

    # Step 5: 启动服务
    if ! ssh "$host" "systemctl start app-service"; then
        log "$log_prefix: FAILED - Service start error"
        return 1
    fi

    # Step 6: 健康检查(等待最多 30 秒)
    local retries=6
    local wait_sec=5
    local healthy=false

    for i in $(seq 1 $retries); do
        if ssh "$host" "curl -sf http://localhost:3000/health" &>/dev/null; then
            healthy=true
            break
        fi
        sleep $wait_sec
    done

    if [[ "$healthy" != "true" ]]; then
        log "$log_prefix: FAILED - Health check timeout"
        # 回滚
        if [[ "$ROLLBACK_ON_FAIL" -eq 1 ]]; then
            ssh "$host" "systemctl stop app-service"
            ssh "$host" "rm -rf $DEPLOY_DIR/current && mv $DEPLOY_DIR/previous $DEPLOY_DIR/current"
            ssh "$host" "systemctl start app-service"
        fi
        return 1
    fi

    log "$log_prefix: Deployment successful"
    return 0
}

# ---- 滚动部署 ----
rolling_deploy() {
    local hosts=("$@")
    local total=${#hosts[@]}
    local failed=0
    local succeeded=0

    log "Starting rolling deployment: $total hosts, batch size $ROLLING_BATCH"

    for ((i=0; i<total; i+=ROLLING_BATCH)); do
        local batch=("${hosts[@]:i:ROLLING_BATCH}")
        local batch_num=$((i / ROLLING_BATCH + 1))
        local batch_total=$(( (total + ROLLING_BATCH - 1) / ROLLING_BATCH ))

        log "=== Batch $batch_num/$batch_total ==="

        # 并行部署当前批次
        local pids=()
        for host in "${batch[@]}"; do
            deploy_host "$host" &
            pids+=($!)
        done

        # 等待当前批次完成
        local batch_failed=0
        for pid in "${pids[@]}"; do
            if ! wait "$pid"; then
                batch_failed=$((batch_failed + 1))
            fi
        done

        if [[ "$batch_failed" -gt 0 ]]; then
            log "Batch $batch_num: $batch_failed hosts failed"
            failed=$((failed + batch_failed))

            # 如果批次失败率超过 50%,停止部署
            if [[ "$batch_failed" -gt "$((ROLLING_BATCH / 2))" ]]; then
                log "Too many failures in batch $batch_num, stopping deployment"
                break
            fi
        else
            succeeded=$((succeeded + ${#batch[@]}))
            log "Batch $batch_num: All hosts deployed successfully"
        fi
    done

    log "=== Deployment Summary ==="
    log "Total: $total, Succeeded: $succeeded, Failed: $failed"

    return $((failed > 0 ? 1 : 0))
}

# ---- 主流程 ----
main() {
    # 读取主机列表
    if [[ ! -f "$HOSTS_FILE" ]]; then
        log "ERROR: Hosts file not found: $HOSTS_FILE"
        exit 1
    fi

    mapfile -t hosts < "$HOSTS_FILE"
    log "Loaded ${#hosts[@]} hosts from $HOSTS_FILE"

    # 预检
    local valid_hosts=()
    for host in "${hosts[@]}"; do
        if preflight_check "$host"; then
            valid_hosts+=("$host")
        fi
    done

    if [[ ${#valid_hosts[@]} -eq 0 ]]; then
        log "ERROR: No valid hosts after preflight check"
        exit 1
    fi

    log "Preflight passed: ${#valid_hosts[@]}/${#hosts[@]} hosts"

    # 执行滚动部署
    rolling_deploy "${valid_hosts[@]}"
}

main "$@"

四、运维自动化的 Trade-offs

脚本 vs 配置管理工具:Shell/Python 脚本灵活但缺乏标准化,Ansible 等工具标准化但灵活性受限。脚本适合一次性任务和特殊场景,工具适合标准化运维。建议核心基础设施(服务器初始化、安全加固)使用 Ansible,业务部署和特殊操作使用脚本。

并行度与风险控制:并行部署可以大幅缩短时间,但增加了"同时出错"的风险。滚动部署(分批执行)在速度和风险之间取得平衡——每批部署后验证,失败则停止。建议生产环境使用 5-10 台/批的滚动部署,测试环境可以全量并行。

幂等性的实现成本:确保每个操作幂等需要额外的检查逻辑(如先判断文件是否存在再写入),增加了脚本复杂度。但非幂等操作在重复执行时的调试成本远高于幂等性的实现成本。建议对所有配置文件操作实现幂等性,对不可逆操作(如数据库迁移)实现显式的确认机制。

回滚的完整性:文件回滚相对简单(恢复备份),但数据库变更和服务状态回滚更复杂。一个已经执行的 ALTER TABLE 无法简单回滚,一个已经发送的通知无法撤回。需要在部署计划中为每个步骤定义回滚操作,并在自动化脚本中实现。

五、总结

Shell/Python 运维自动化通过声明式配置管理和滚动批量部署,将运维操作从"手动逐台执行"推向"自动化批量处理"。核心原则是幂等性(同一操作多次执行结果一致)和滚动部署(分批执行降低风险)。但脚本与工具的选择、并行度与风险控制的平衡、幂等性的实现成本和回滚的完整性是需要权衡的因素。在实际落地中,建议核心基础设施使用 Ansible 等工具标准化管理,业务部署使用脚本实现精细控制,所有操作实现幂等性保证,生产环境使用滚动部署策略。运维自动化的目标不是"消除人工",而是"让机器做重复的事,让人做决策的事"。

更多推荐