今天早上又被公司的 CI 编译管道给恶心到了。事情是这样的,运维老哥前两年写了一个 500 多行的 Bash 脚本,用来在部署时自动拉取 Docker 镜像、更新配置、然后进行热滚动重启。结果今天早上由于目标服务器磁盘满了,在执行 docker pull 时直接报错退出。然而,因为脚本里没有加 set -e,它竟然假装什么都没发生,继续往下执行 docker stop 把正在运行的容器给杀掉了,最后又因为拉不到镜像导致新容器起不来,服务整整宕了 20 分钟!这种“默默失败吞掉异常,最后直接崩溃”的鬼故事,在写 Bash 脚本时天天都在上演。

说真的,我一直对动辄几百行的 Bash 胶水脚本嗤之以鼻。Bash 在写三五行临时指令时确实爽快,可一旦涉及到逻辑分支判断、字符串正则解析、或者复杂的异常处理,它就是个定时炸弹。没有类型系统,没有标准的异常捕获(Try-Catch),想读个 JSON 配置文件还得依赖 jq 这种外部黑盒工具,写起来心智负担极大,维护起来更是火葬场。

为了彻底干掉这个隐患,我上周直接用 Python 重新把整个部署工具包给重构了一遍。Python 不仅自带丰富的标准库,还能利用 subprocess.run 极好地控制进程执行流,结合类型标注(Type Hints),代码可读性和安全性简直降维打击 Bash。

下面这段代码是我们重构后的核心执行器模块,用于高安全地执行系统命令并提取标准输出。你可以看到它是如何进行严密的错误校验和异常抛出的:

import subprocess
import logging
import sys
from typing import List, Optional

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)

class CommandExecutionError(Exception):
    """自定义系统命令执行异常"""
    def __init__(self, command: List[str], returncode: int, stdout: str, stderr: str):
        super().__init__(f"Command '{' '.join(command)}' failed with exit code {returncode}")
        self.command = command
        self.returncode = returncode
        self.stdout = stdout
        self.stderr = stderr

def run_system_command(cmd: List[str], timeout_sec: int = 300) -> str:
    """
    高安全地执行系统命令,捕获异常并记录详细日志。
    代替 Bash 中容易被吞掉错误的管道和指令执行。
    """
    logging.info(f"Executing command: {' '.join(cmd)}")
    try:
        result = subprocess.run(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=timeout_sec,
            check=False  # 手动校验返回码,以便记录更详细的信息
        )
        
        stdout_clean = result.stdout.strip()
        stderr_clean = result.stderr.strip()
        
        if result.returncode != 0:
            logging.error(f"Command failed! Exit code: {result.returncode}")
            if stderr_clean:
                logging.error(f"Error output:\n{stderr_clean}")
            raise CommandExecutionError(
                command=cmd,
                returncode=result.returncode,
                stdout=stdout_clean,
                stderr=stderr_clean
            )
            
        logging.info("Command completed successfully.")
        return stdout_clean
        
    except subprocess.TimeoutExpired as e:
        logging.error(f"Command execution timed out after {timeout_sec} seconds.")
        raise e
    except Exception as e:
        logging.error(f"Unexpected error when executing command: {str(e)}")
        raise e

# 实际调用示例
if __name__ == "__main__":
    try:
        # 安全地拉取镜像并校验错误
        run_system_command(["docker", "pull", "nginx:alpine"])
        # 获取当前运行容器列表
        containers = run_system_command(["docker", "ps", "--format", "{{.Names}}"])
        print(f"Active containers:\n{containers}")
    except CommandExecutionError as err:
        logging.critical(f"Deployment interrupted: {err}")
        sys.exit(1)

通过把所有胶水逻辑迁移到 Python 脚本,我们可以极其轻松地使用 try-except 进行分支防御。比如检测到 CommandExecutionError 时,我们可以立即触发回滚机制(回滚到上一个稳定的 Docker 镜像),而不是像 Bash 那样两眼一抹黑、一条道走到黑。

另外,Python 对 JSON 的原生支持(json.loads())让我们再也不用在 Bash 里写各种恶心的 grep / sed / awk 或者拼凑复杂的 jq 过滤表达式了。所有配置文件的读取和校验,直接在内存里映射成强类型的 Pydantic 模型,把所有配置解析错误都堵在运行之前。

现在我们团队内部达成了一个共识:凡是超过 50 行、包含 2 个以上判断条件或者需要依赖外部 CLI 工具输出做分支的脚本,一律禁止用 Bash 编写,必须上 Python。如果需要单文件分发且零依赖的,直接用 Go 写成二进制。不知道大家在维护 CI/CD 胶水脚本时,更倾向于哪种方案?