工业级脚本封装:从能跑到可靠,一套可复用的Shell工程化骨架
1. 先想清楚脚本为什么需要工业级封装我大概是从第三次被线上告警半夜叫醒之后才开始认真琢磨这件事的。起因很常见一个数据备份脚本开发的时候本机怎么跑怎么顺一放到生产环境的定时任务里就各种幺蛾子——目录不存在、环境变量没加载、上一步失败了下一步还在跑、输出的日志全是乱码。那会儿我的脚本写法还很原始一个 .sh 文件加一串命令堆到底出了问题只能一行行加 echo 去猜。后来见得多了才明白脚本这件事跟盖房子是一个道理一次性脚本就像搭临时帐篷能用就行但脚本一旦要进流水线、要定时跑、要交接给同事维护那就是要盖楼了。楼盖不盖得稳靠的不是混凝土而是结构设计——这就是脚本封装的意义。所谓工业级脚本封装说人话就是让一个脚本具备三样东西可靠性、可复用性、可交接性。可靠性是指无论跑在什么环境、什么时间点它都知道自己该干什么、干到哪一步了、失败了怎么退可复用性是指换个目录、换个项目、换台机器改几个参数就能接着用而不是把代码翻个底朝天可交接性是指别人拿到你的脚本十分钟能看懂它在做什么并且敢改、改完敢跑。这三点听起来虚落地其实都对应具体的编码习惯和结构设计也正是这篇文章要展开讲的核心。不管你是做运维、搞自动化测试、写数据处理流水线还是给硬件测试台架写控制脚本这套思路都是通用的。1.1 从能跑到能上生产差在哪接触过不少刚入行的朋友包括当年的我对脚本的认知就是能跑就行。这个标准放到生产环境基本等于裸奔。我总结下来一个脚本从能跑到能上生产至少要补齐五块东西参数化。路径、IP、账号、时间窗口这类随时会变的东西绝不能写死在代码里否则每换一个环境就要动一次源码。容错。每一步都可能失败失败之后要能感知、能停下来、能决定是否重试还要能留痕。日志。脚本跑完不是结束别人要有据可查跑了什么、结果是什么、耗时多久、在哪一步挂的。幂等。同一个脚本在同样条件下重复跑不应该产生副作用至少要保证允许安全地重跑。备份脚本重复跑不能覆盖旧备份初始化脚本重复跑不能重复建表。退出码与信号。脚本的返回值必须明确这是它和外部调度系统沟通的唯一语言。定时任务、CI 流水线、监控平台全部靠退出码判断成功失败。注意这五件事没有一件是靠把功能写出来就自然带出来的全部要靠封装结构去承载。举个例子幂等性不是一个函数里加个 if 就叫幂等而是你从一开始就规定脚本执行的第一步永远是做前置检查检查通过才往下走。这就是设计思路和能跑就行写法的本质差别。1.2 把三个核心特征落到代码上为了后面实操时能对号入座我先用一张表把特征—做法—解决的问题对应起来特征对应做法避免的问题可靠性函数级错误处理 统一退出码规范链路中间断了一环也不知道可复用配置外置 公共函数库换个环境就得改源码可交接日志规范 注释约定 固定目录结构三个月后自己都看不懂这三条其实就是我反复强调的封装核心。可靠性靠的是训练有素的错误处理而不是靠运气可复用性靠的是把变量和逻辑分开而不是祈祷下次参数不变可交接性靠的是一眼能看懂的工程结构而不是注释写得越多越好。理解了这几条下面就可以聊具体怎么设计了。2. 动手写码之前先把封装框架定下来很多脚本写烂不是写的过程中出了多少 bug而是压根没有框架。拿到需求就打开编辑器从头敲敲到哪算哪这是脚本失控的第一大原因。工业级封装的第一步恰恰是动手之前先花十分钟把结构定下来。2.1 先分四层入口、逻辑、公共库、配置我习惯把一个成体系的脚本工程拆成四个层不管是用 Shell 写还是用 Python 写思路都一样入口层bin。真正被外部调用的脚本参数校验、流程编排都在这一层一个入口只负责一件事。逻辑层业务脚本或 lib 下的模块。具体业务操作比如备份数据、拉取文件、调用接口按功能拆成独立模块。公共库lib。日志函数、错误处理函数、环境检查函数这类跟具体业务无关、但每个脚本都要用的底层能力。配置层conf。所有环境相关、易变的参数统一放在配置文件里脚本负责读取不负责兜底。为什么要分这么细因为四层各自的变更频率不同配置天天变逻辑隔三差五变公共库基本不变。如果全混在一个文件里每改一个路径都要重新过一遍几百行代码风险陡增。分层之后改配置不碰代码改逻辑不碰公共库排查问题也能按层定位这就是工程化的第一步。2.2 参数化是封装的灵魂参数的来源有三个优先级从高到低依次是命令行参数、环境变量、配置文件。命令行参数是最高优先级因为它一次性的、明确的环境变量适合部署平台注入的敏感信息比如密码、Token配置文件适合一组相对稳定的环境差异项。切忌把这三类混为一谈更忌讳的是把配置硬编码在脚本里。最常见的反例就是脚本里直接写死了一个数据库 IP换环境还得先打开文件搜 IP。我自己后来养成的习惯是脚本里不允许出现任何裸的环境相关信息每看到一个 IP、路径、用户名条件反射式地问一句——这玩意儿下次会不会变会变就给我进配置。2.3 对外契约参数、退出码、日志三件套封装得好的脚本本质上是一个对外有清晰契约的产品。这个契约包含三件套。第一参数规范。脚本有哪些必填参数、哪些可选参数、参数之间有没有依赖必须在入口处用 usage 函数说清楚。第二退出码规范。0 表示成功1 表示业务失败2 表示参数错误128 以上留给系统信号。不要在业务逻辑里乱打 exit 1 就完事要让外部调度系统能区分参数错了和执行失败了。第三日志规范。日志要带上时间戳和级别且区分标准输出和错误输出——正常的进度信息走 stdout错误信息走 stderr这样外部系统才能分别捕获。这三件套是在写第一行业务代码之前就要定的规矩而不是写完了再补。很多脚本事故追根到底都是契约不清晰导致的定时任务报错分不清是没传参数还是执行失败日志和错误混在一起排错时只能大海捞针。3. 实操搭一套可以直接抄作业的脚本骨架理论说了不少接下来我们直接动手用 Shell 脚本搭一套完整的封装骨架这套结构我已经在多个项目里复用过了。先声明后面所有代码都是我在实际工作中使用过的简化版但结构和核心逻辑可以直接照搬。3.1 目录结构长什么样一个工程化脚本项目我的经典结构是这样的my_script_project/ ├── bin/ │ └── backup_job.sh # 入口脚本一个入口只干一件事 ├── lib/ │ ├── common.sh # 公共库日志、错误、检查函数 │ └── backup_lib.sh # 业务库备份相关函数 ├── conf/ │ └── app.conf # 环境配置路径、IP、参数 ├── log/ # 运行日志目录git 忽略 └── README.md # 一句话说明怎么用这套结构的好处是任何人拿到项目十分钟内就能定位入口在 bin公共能力在 lib环境差异在 conf日志在 log。就算不是我写的脚本我也敢改因为我知道改了哪里影响不到哪里。项目再大一点还可以加 tests 目录放冒烟测试脚本但框架保持这个底座不动。3.2 公共函数库日志、错误、环境检查公共库是整个骨架的地基我一般至少会放三个能力进去logging、错误终止、环境检查。先看日志和错误处理#!/usr/bin/env bash # lib/common.sh # 公共函数库所有脚本共用的基础能力 LOG_DIR${LOG_DIR:-./log} log_init() { mkdir -p ${LOG_DIR} LOG_FILE${LOG_DIR}/run_$(date %Y%m%d_%H%M%S).log } log_info() { echo [$(date %Y-%m-%d %H:%M:%S)] [INFO] $* | tee -a ${LOG_FILE} } log_error() { echo [$(date %Y-%m-%d %H:%M:%S)] [ERROR] $* | tee -a ${LOG_FILE} 2 } die() { log_error $* exit 1 } require_cmd() { local cmd for cmd in $; do if ! command -v ${cmd} /dev/null 21; then die required command not found: ${cmd} fi done } run_step() { local step_name$1 shift log_info START: ${step_name} if $; then log_info OK: ${step_name} else local code$? log_error FAIL: ${step_name} (exit code: ${code}) exit ${code} fi }这里有几个细节值得说透。第一log_init单独拿出来是因为日志目录可能在配置里被改掉必须在加载配置之后再初始化。第二log_error把错误同时写到了日志和 stderr这样即使没人看日志外部系统也能从标准错误流感知异常这个习惯在 CI 流水线里特别重要。第三run_step是一个典型的包装器wrapper它不关心业务是什么只管记录开始、执行、判断结果、记录结束这个思路跟接口封装是同一个道理——把通用逻辑收拢到一层业务代码就只需要关心自己那一步怎么实现。3.3 配置外置与入参解析配置文件用极其简单的键值对格式不要在这里炫技。请看# conf/app.conf PROJECT_DIR/data/projects/demo BACKUP_DIR/data/backups DB_HOST127.0.0.1 DB_USERbackup_user DB_NAMEdemo_db RETRY_TIMES3 REMOTE_HOSTbackup-server.internal REMOTE_PATH/remote/backups加载配置的函数放在 common.sh 里load_config() { local conf_file$1 if [[ ! -f ${conf_file} ]]; then die config file not found: ${conf_file} fi # shellcheck disableSC1090 source ${conf_file} }配置文件的加载方式用的是source也就是让配置内容直接在脚本上下文中展开成变量。这么做对 Shell 脚本最方便但有个前提配置文件绝不能夹带可执行逻辑否则等于敞开了代码注入的口子。如果你对这个有顾虑生产环境宁可改用更严格的方式——逐行解析仅允许KEYVALUE格式其他一律拒绝。入口脚本的参数解析用内置的 getopts 就够了#!/usr/bin/env bash # bin/backup_job.sh set -Eeuo pipefail SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) source ${SCRIPT_DIR}/../lib/common.sh source ${SCRIPT_DIR}/../lib/backup_lib.sh usage() { echo Usage: $0 -p project_name -d target_date YYYYMMDD [-h] echo -p project name, required echo -d target date, required echo -h show this help exit 0 } while getopts p:d:h opt; do case ${opt} in p) PROJECT_NAME${OPTARG} ;; d) TARGET_DATE${OPTARG} ;; h) usage ;; *) usage ;; esac done if [[ -z ${PROJECT_NAME:-} || -z ${TARGET_DATE:-} ]]; then die both -p and -d are required fi # 校验日期格式防止脏参数进入到业务逻辑 if [[ ! ${TARGET_DATE} ~ ^[0-9]{8}$ ]]; then die target date must be YYYYMMDD format: ${TARGET_DATE} fi load_config ${SCRIPT_DIR}/../conf/app.conf log_init require_cmd tar gzip mysql main注意这里SCRIPT_DIR的写法。很多脚本喜欢用cd $(dirname $0)这在被相对路径调用、或者从别的目录执行时都会出问题。用BASH_SOURCE[0]配合pwd拿到的是脚本的真实路径无论从哪里调用都能正确定位脚本所在位置。这个细节就是那种不踩坑永远不知道、踩了才知道值钱的地方。3.4 主流程脚本先检查、再执行、最后收尾主流程的逻辑应该是先检查、再执行、最后收尾顺序不能乱。我在实际项目里从没见哪次事故是因为顺序太严密导致的反而见过无数次跳过前置检查直接干活的翻车。下面是业务库的一个简化示例#!/usr/bin/env bash # lib/backup_lib.sh # 业务函数库围绕备份场景的独立函数 ensure_dirs() { mkdir -p ${BACKUP_DIR}/${PROJECT_NAME} [[ -d ${BACKUP_DIR}/${PROJECT_NAME} ]] || die cannot create backup dir } dump_mysql() { # 真正的备份逻辑这一步失败由 run_step 统一处理 mysqldump -h${DB_HOST} -u${DB_USER} ${DB_NAME} \ ${BACKUP_DIR}/${PROJECT_NAME}/${DB_NAME}_${TARGET_DATE}.sql } main() { run_step precheck_dirs ensure_dirs run_step dump_database dump_mysql run_step archive_files archive_project run_step upload_backup upload_to_remote log_info backup job finished: ${PROJECT_NAME}/${TARGET_DATE} }为什么要让 main 函数来做编排因为这样一来入口脚本剩下的部分就只剩参数解析和调用任何人读入口脚本一眼就看清整个任务有多少步、按什么顺序执行。要调顺序、加步骤、删步骤都只改 main 函数不会碰到底层实现。这就是分层的价值入口管契约main 管编排函数管实现各司其职。再补充一个生产环境几乎必备的并发保护。定时任务最怕的不是失败而是上一次还没跑完下一次又启动了两个实例同时操作同一批数据。用 flock 可以轻松解决LOCK_FILE/tmp/${PROJECT_NAME}_backup.lock exec 9${LOCK_FILE} flock -n 9 || die another backup instance is running, exit这几行要放在 main 之前执行作用是通过文件锁保证同一时刻只有一个实例在跑。拿到锁才继续拿不到就退出。网上很多抢票脚本、自动化脚本翻车有一部分就是并发控制没做好同一批任务被重复执行了两遍。4. 常见问题与排错实录封装做得再好脚本也难免要面对各种诡异的环境问题。我自己这几年踩过的坑不少挑几个高频的、特别有代表性的写在这里全是血泪经验。4.1 命令找不到的幽灵PATH 与环境变量遇到过这种情况没有你在终端里敲命令明明能正常执行可一旦放进定时任务或者服务里就报command not found更常见的是在 Windows 的 PowerShell 里敲新装的命令直接提示无法将xxx项识别为 cmdlet、函数、脚本文件或可运行程序的名称。这个问题的根源几乎都是 PATH 不一致。终端会话会加载用户的 profile 文件把各种命令目录追加进 PATH而定时任务、服务进程这类非交互式环境加载的是最小化环境变量PATH 往往只有系统默认的几个目录。解决办法有三个按优先级推荐第一脚本入口处主动export PATH把所需命令的绝对路径目录加进去第二公共库里用require_cmd做前置检查缺了直接报缺哪个命令而不是让后续步骤带着谜之报错第三实在重要的命令直接在代码里写绝对路径比如/usr/local/bin/mysqldump。至于 Windows 的 PowerShell 场景原因多半是三个装完工具没有重新登录导致 PATH 未刷新、执行策略默认禁用了脚本、或者执行.ps1脚本时没加.\前缀。处理方式也直接检查$env:Path里有没有对应目录没有就追加到用户 PATH执行策略用Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser调用当前目录的脚本用.\script.ps1。这类问题几乎占了命令行新手求助帖的一半本质上不是脚本逻辑有 bug而是运行环境没有对齐。4.2 退出码被管道吞掉这是 Shell 脚本最隐蔽的坑之一也是最容易导致假成功的地方。看这个例子mysqldump -h${DB_HOST} -u${DB_USER} ${DB_NAME} | gzip backup.sql.gz echo exit code: $?如果 mysqldump 中途失败$?返回的是谁的值是管道最后一个命令 gzip 的退出码不是 mysqldump 的。gzip 通常能正常完成于是脚本天真地认为备份成功了实际上产出的是一个残缺的压缩包。数据备份场景里这种假成功比真失败危险一百倍因为它不会触发告警。解决办法是两行命令组合set -o pipefail mysqldump -h${DB_HOST} -u${DB_USER} ${DB_NAME} | gzip backup.sql.gzpipefail会让管道中任何一个命令失败时整个管道的返回值为非零。这也解释了为什么我在入口脚本里写了set -Eeuo pipefail-e让脚本遇到未捕获的错误就退出-u让使用了未定义变量直接报错-o pipefail防止管道吞错-E让 ERR trap 在函数中也能生效。这四个组合就是 Shell 脚本工业级的安全底座没有它们脚本就是在走钢丝。4.3 引号与特殊字符的地雷脚本里一个引号的疏忽轻则运行结果不对重则可能造成数据损坏。最常见的两个场景路径里有空格、文件名里有特殊符号。处理路径必须养成一个肌肉记忆所有变量引用一律加双引号除非你明确知道为什么不加。rm -rf ${DIR}/没加引号如果${DIR}为空展开后就变成了rm -rf /这种事不是段子是真实发生过的事故。正确写法是rm -rf ${DIR}/处理文件列表时也不要写for file in $(ls *.txt)因为文件名一旦带空格就会被拆成两个词。更稳妥的是用 shell 的通配符展开for file in ${BACKUP_DIR}/*.txt; do [[ -e ${file} ]] || continue 处理 ${file} done还有 Windows 和 Linux 混用时的回车符问题。在 Windows 上编辑过的脚本拿到 Linux 上一跑可能报出诡异的$\r: command not found。这是因为行尾的 CRLF 被当成命令的一部分了。这时候用dos2unix script.sh或者sed -i s/\r$// script.sh清洗一下就行。封装脚本时如果预期要跨平台最好在 README 里明确写出这个坑能帮后来人省半小时。4.4 幂等性失效的几种现场幂等性是最容易承诺、最难做到的。我在项目里见过三种典型的幂等失效现场。第一种是重复下载脚本每次运行都去拉同一个大文件网不好时拉半截下次又重头来。解法是下载前先检查本地文件是否存在且校验和一致if [[ -f ${LOCAL_FILE} ]] echo ${EXPECTED_MD5} ${LOCAL_FILE} | md5sum -c /dev/null 21; then log_info file exists and checksum ok, skip download else download_file fi第二种是重复建表建目录建表没有 IF NOT EXISTS建目录用mkdir而不是mkdir -p第二次跑就直接报错。设计阶段要明确这个操作重复执行的后果是安全还是危险危险操作必须加防重入判断。第三种是残留状态污染上一次异常退出留下的临时文件干扰了下一次的正常判断。解法就是我在骨架里写的 trap 收尾——无论成功失败退出前统一清理临时文件和锁。cleanup() { rm -f ${TMP_DIR}/*.tmp flock -u 9 2/dev/null || true } trap cleanup EXIT5. 最后聊聊封装不是越抽象越好写了这么多最后我必须泼一盆冷水封装是有成本的不是越抽象越好。我见过一些人把脚本工程化做到了走火入魔的程度——几十个函数、抽象层叠抽象层为了优雅牺牲了直接性。脚本跟大型软件系统不一样它的核心优势本来是轻量、直白、快速解决问题一旦搞成三层以上间接调用维护成本反而超过了收益。我的个人经验是三条准则。第一入口脚本里能一眼看完整个流程是封装成功的标志。如果读一个脚本要翻三个文件才能捋清它干了啥说明抽象过度了。第二公共函数只有被用到第二次时才值得提取写着写着自然就会知道哪些逻辑会在多个脚本里重复那时候再抽比一开始就凭空设计更准确。第三注释写为什么而不是是什么。代码本身已经说明了它在做什么注释的价值在于解释为什么这么做——比如这里必须用绝对路径因为定时任务不加载用户环境这种注释才是后来人真正需要的。另外再好的封装也代替不了测试。别光测成功路径要刻意测失败路径把目录权限改成不可写把磁盘塞满一点把网络断开把输入参数传错看脚本是不是都能体面地失败、正确地退出、留下有用的日志。我每次交付一个新脚本都会先把它折腾一遍确认它在各种恶劣条件下不会误报成功、不会留下半截数据才敢让人接手。这套封装思路我在数据备份、自动化部署、环境初始化、定时巡检里反复用每次都能帮我省下大量排障时间。如果你手里正有一堆原始脚本在裸奔不妨从今天开始挑一个最常跑的脚本按这个结构重新整理一遍。过程不复杂收益却是长期的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →