聊 Debian 的 control structures控制结构很多人第一反应是语法if 后面要加 thenfor 循环里变量怎么取值case 的右括号要不要双写。可我在 Debian 上写脚本写了这么多年发现真正的门槛从来不是背语法而是在这套系统里怎么用 if、for、while、case 去解决 apt 包管理、服务编排、配置分发、多版本兼容这些每天都要面对的运维问题。控制结构决定了脚本是“一条道走到黑”还是“见路就转弯”也决定了出问题时你能不能一眼看出病根。这篇文章我会从 Debian 系统的真实运维场景出发把四种最常用的控制结构拆开讲清楚再带一组组合进阶用法和踩坑实录。适合刚开始接触 shell 脚本的入门者也适合写了不少脚本却总觉得组织混乱的熟练工。代码不多但每一段都是我在 Debian 服务器上改过好几轮的成品你可以直接照着用。1. 从运维场景看控制结构为什么不是语法题而是生存题1.1 控制结构在 Debian 系统里的真实角色Debian 系列系统在服务器领域之所以受欢迎其中一个原因是它的包管理和服务模型特别适合自动化而自动化靠的就是一堆 shell 脚本。脚本要干活就得对环境和状态做判断包装了没有服务起没起目录有没有权限源配没配对证书到没到期。这些判断全部要靠控制结构来承载。你会发现每一个 if 本质上都是在回答一个问题这个状态到底存不存在。for是在说这一批对象要挨个处理不许漏。while是在等一个条件稳定下来等到天荒地老也行但要有超时保护。case则是面对多种分散的状态时做分流的枢纽。把它们组合起来一个 Debian 脚本才从“一串命令的堆砌”变成“一个有决策能力的程序”。还有一个容易被忽略的点Debian 本身的版本分支很多。stable、testing、unstable还有树莓派这种衍生场景再加上 Debian 系和 Ubuntu 系经常混在一套运行环境里包名、源地址、服务名都会不一样。控制结构在这里不是锦上添花是让脚本活下来的基本能力。遇到公网上的机器咱们还要考虑源连接超时的情况这时候if配合curl或apt-get的返回码就特别重要。写控制结构的时候脑子里必须有“状态机”的概念否则不如不写。1.2 一个多发行版兼容脚本直接带出 if / case / for我举个真实的例子来把三种结构一次性串起来。假设要给一台新机器自动安装基础工具但这台机器可能是 Debian也可能是 CentOS/RHEL 系。你不能一上来就写apt-get install因为 RPM 系的机器根本没有这个命令。第一版脚本长这样#!/usr/bin/env bash detect_family() { if [ -f /etc/os-release ]; then . /etc/os-release case $ID in debian|ubuntu) echo debian-family return 0 ;; centos|rhel|rocky|almalinux) echo redhat-family return 0 ;; esac fi echo unknown return 1 } family$(detect_family) case $family in debian-family) update_cmdapt-get update install_cmdapt-get install -y ;; redhat-family) update_cmddnf update -y install_cmddnf install -y ;; *) echo 不支持的发行版脚本退出 2 exit 1 ;; esac eval $update_cmd for pkg in vim screen htop; do eval $install_cmd $pkg done这段脚本里if先判断 os-release 存不存在case根据系统 ID 分流到不同包管理器最后for循环批量安装。三个控制结构各司其职代码读起来非常顺。不过说句实话我不建议在脚本里直接用eval虽然它省事但一旦变量里掺了特殊字符很容易把命令搞炸。更稳的方式是把安装动作封装成函数让case只决定用哪个安装函数#!/usr/bin/env bash install_pkg() { case $family in debian-family) apt-get install -y $ ;; redhat-family) dnf install -y $ ;; *) echo unknown family 2; return 1 ;; esac } . /etc/os-release case $ID in debian|ubuntu) familydebian-family ;; centos|rhel|rocky|almalinux) familyredhat-family ;; *) familyunknown ;; esac install_pkg vim screen htop这样把控制结构当“分发器”来用后面维护只需要改一个 case 分支而不是到处找 eval 替换。我在实际维护运维脚本时这种写法后来都成了部门里的模板。2. if / for / while / case四个最常用的 Debian 运维控制结构2.1 if 判断apt 包状态、服务状态、权限检查在 Debian 系统里if最密集的使用场景就是包管理。判断一个包有没有装命令行随手是dpkg -s。check_pkg() { if dpkg -s $1 /dev/null 21; then return 0 fi return 1 } if check_pkg nginx; then echo nginx 已安装 else echo nginx 未安装开始安装... apt-get install -y nginx || echo 安装失败请检查网络或 apt 源 fi这里必须要把输出重定向到 /dev/null还要同时丢弃 stdout 和 stderr因为dpkg -s在包没安装的时候会在 stderr 输出一长串说明文字污染你的脚本日志。判断服务状态也遵循同一个思路。Debian 现在默认跑 systemd简单做法if systemctl is-active --quiet nginx; then echo nginx 运行中 else systemctl start nginx fisystemctl is-active --quiet只在服务 active 时返回 0这个写法可以安全地用于 if 条件。顺便说一下如果你要判断服务是否开机自启就用systemctl is-enabled --quiet nginx原理一样。路径和权限判断也经常出现在控制结构里。比如你想写一个脚本自动往/etc/apt/sources.list.d/里添加 docker 源就得先检查文件存不存在再检查自己有没有权限写。一般这样写if [ -f /etc/apt/sources.list.d/docker.list ]; then echo docker 源文件已存在 else echo deb [archamd64] ... /etc/apt/sources.list.d/docker.list fi if [ ! -w /var/lib/dpkg ]; then echo 当前用户没有 dpkg 写权限请用 root 或 sudo 重新运行 2 exit 1 fiif [ -f ]、if [ -w ]、if [ -x ]是文件判断的基本盘配合!取反就够覆盖绝大多数场景。有一点你要记住Debian 的 /bin/sh 是 dash[[ ]]这个语法在纯 sh 脚本里不能用。如果你的 shebang 写的是#!/bin/sh那就老老实实用[ ]别整那些高扩展性的双中括号如果 shebang 是#!/usr/bin/env bash那随便用。这个坑后面专门展开。2.2 for 循环批量安装依赖、批量处理配置文件Debian 的日常里“批量”两个字太常见了。批量装包、批量改配置、批量拉取远程主机信息全部要靠for。我写批量安装脚本时基本会先在循环里套一个if做去重判断pkg_list(nginx mariadb-server redis-server php-fpm) for pkg in ${pkg_list[]}; do if dpkg -s $pkg /dev/null 21; then echo $pkg 已安装跳过 else echo 正在安装 $pkg apt-get install -y $pkg || echo $pkg 安装失败请查看日志 fi done这个脚本在全新系统上会依次安装四个服务在已经装了部分包的系统上则能自动跳过不会一遍又一遍地重复安装。注意我这里用的是 bash 数组${pkg_list[]}的写法能正确处理包含空格的包名和路径这一点比直接在一个字符串里空格分隔要稳得多。for加通配符遍历是另一种高频玩法。比如想检查/etc/apt/sources.list.d/下所有源文件里到底有哪些有效源可以直接这样for f in /etc/apt/sources.list.d/*.list; do if [ ! -f $f ]; then continue fi echo 文件$f grep -Eh ^(deb|Types:) $f || echo 没有有效 deb 行 done这里有个细节很多人踩过坑如果目录下没有匹配*.list的文件Bash 会把字面量/etc/apt/sources.list.d/*.list当作一个不存在的文件名传给循环。所以循环开头用if [ ! -f $f ]; then continue; fi判断一下再继续是风险最低的写法。continue是控制结构里的“跳过键”遇到不满足条件的就跳到下一次循环别让脚本傻乎乎地处理不存在的文件。2.3 while 循环逐行读文件、等服务和健康检查在处理配置文件时我特别喜欢while read一行一行读而不是for去遍历命令输出因为while read能完整处理每一行里的空格和特殊字符。Debian 下读apt源文件筛选有效行的典型写法while IFS read -r line; do case $line in |#*) continue ;; *) echo 有效源: $line ;; esac done /etc/apt/sources.listIFS是为了保证行首行尾的空格不被吃掉read -r是为了避免反斜杠被当成转义符。重定向放在done后面是最自然的写法这个位置的done file对很多人来说不是直觉顺序但记住一次就不会忘。while的兄弟until在 Debian 脚本里专门负责“等待类”逻辑。比如装完 sshd 后要等它起来attempt0 until systemctl is-active --quiet sshd || [ $attempt -ge 10 ]; do attempt$((attempt 1)) sleep 1 done if systemctl is-active --quiet sshd; then echo sshd 已就绪 else echo sshd 启动超时请检查日志 2 fiuntil的说法是“直到条件成立才停下”写出这句语义比while [ ! 条件 ]更容易让人看懂。加[ $attempt -ge 10 ]是为了防止死循环这在 Cron 自动化任务里尤其重要不然脚本卡死会把整台机器的定时任务串都堵住。健康检查类脚本也常用这个模式后端服务没有起不来的道理但确实需要给它时间。2.4 case 分支处理 Debian 版本差异和指令分发case在控制结构里是“多岔路”的存在最适合处理 Debian 不同版本之间的细微差异。Debian 12 的代号是 bookwormDebian 13 是 trixie两个版本之间有些包名会换软件源地址也会变。写安装脚本时我一般先读/etc/os-release再用case分流. /etc/os-release case $VERSION_CODENAME in bookworm) echo Debian 12 系列 APT_SOURCE_MAINhttp://deb.debian.org/debian ;; trixie) echo Debian 13 系列 APT_SOURCE_MAINhttp://deb.debian.org/debian ;; *) echo 未知版本$VERSION_CODENAME 2 exit 1 ;; esaccase的另一个经典用途是作为“指令分发器”。你写一个服务管理脚本第一个参数是 start/stop/restart后面的动作完全不同这种场景用 if 会写得很啰嗦case 一行一个分支非常清晰case $1 in start) systemctl start nginx ;; stop) systemctl stop nginx ;; restart) systemctl restart nginx ;; *) echo 用法: $0 {start|stop|restart} 2 exit 1 ;; esaccase支持通配符和正则匹配比如start|restart)把两个指令合并处理或者[Rr]estart匹配大小写这些模式让它在维护路由逻辑时比一串 if 可读性好很多。在 Debian 的安装脚本里我习惯把所有“版本敏感”的变量都集中在开头一个 case 里定义后面正文只做引用这样升级到新版本时只需要改一处。3. 从能跑到能维护控制结构组合成 Debian 脚本的进阶套路3.1 函数 控制结构把脚本改造成模块脚本写得多了你会发现纯线性的命令流不靠谱真正好维护的脚本是“函数 控制结构”的组合。比如一个安装前准备函数它内部需要判断 root 权限、判断网络、判断锁文件是否存在。把每个判断拆成小函数主流程就只剩调用#!/usr/bin/env bash set -u require_root() { if [ $(id -u) -ne 0 ]; then echo 请用 root 或 sudo 运行本脚本 2 exit 1 fi } check_apt_lock() { if [ -e /var/lib/dpkg/lock-frontend ]; then echo 检测到 dpkg 锁文件是否有其他 apt 进程在运行 2 return 1 fi return 0 } require_root if check_apt_lock; then echo 锁检查通过开始安装... fi函数的好处是每个函数只干一件事主流程读起来像是“计划书”。你可以把require_root用在所有要在 Debian 上修改系统的脚本里一行调用就补上了权限检查不用每处复制一遍 if。写函数时要注意函数内部的 return 控制结构它在函数里退出函数和脚本整体退出不是一回事。我之前见过有人把exit 1直接写在函数里结果整个脚本直接终止了那可不是期望的“检查失败后继续走另一个分支”。3.2 getopts 循环让脚本变成带参数的小工具一个安装脚本如果只处理固定包名那和硬编码没啥区别。我建议用getopts加case加for把脚本升级成真正的小工具。下面是一个支持-p指定包、-n演练、-v显示版本号的安装脚本骨架#!/usr/bin/env bash set -uo pipefail dry_run0 pkgs() usage() { echo 用法: $0 [-p 包名] [-n] [-v] 2 exit 1 } while getopts p:nvh opt; do case $opt in p) pkgs($OPTARG) ;; n) dry_run1 ;; v) echo $(basename $0) 1.0; exit 0 ;; h) usage ;; *) usage ;; esac done if [ ${#pkgs[]} -eq 0 ]; then usage fi for pkg in ${pkgs[]}; do if [ $dry_run -eq 1 ]; then echo [dry-run] 将会安装: $pkg else apt-get install -y $pkg || echo $pkg 安装失败 fi done这里有几个关键点。getopts p:nvh字符串里p:后面的冒号表示-p必须带一个参数参数存在$OPTARG里。while getopts天然就是控制结构和参数解析的组合拳它会一个个啃掉选项直到没有可处理的参数为止。set -u在这里很关键它可以让你在后续代码里放心地使用${#pkgs[]}万一忘了传-p脚本会立刻报错而不是悄悄用空值硬跑。演练模式是我强烈建议加的。在 Debian 上直接apt-get install会动系统全局状态没经过演练就满世界执行出问题容易慌了神。加-n参数一旦验证逻辑正确后续直接在批处理里跑版本就充满底气。3.3 trap脚本出事后的最后防线跑在 Debian 上的脚本除了正常流程还得处理中断信号和临时文件残留。trap就是干这个的。我写部署脚本时有一个固定习惯任何用mktemp生成的临时文件都注册一个 EXIT 陷阱退出时自动清理tmpfile$(mktemp) trap rm -f $tmpfile EXIT echo 检查配置写入临时文件 cat /etc/nginx/nginx.conf | grep -E server_name|listen | head -5 $tmpfile cat $tmpfile这样无论脚本正常走完还是中途被 kill临时文件都不会留在/tmp下面发臭。如果脚本被 Ctrl-C 打断我还会额外给 INT TERM 信号注册处理cleanup() { echo 脚本被中断清理临时文件 rm -f $tmpfile exit 1 } trap cleanup INT TERMtrap cleanup EXIT和trap cleanup INT TERM的区别在于EXIT 是脚本任何退出路径都会触发INT/TERM 是收到信号时触发。两个配合起来才能覆盖“正常结束”和“非正常打断”两种情况。Debian 的 Cron 环境里尤其要注意trap做清理比到最后手动rm靠谱得多因为你的脚本不知道什么时候会断在哪儿。3.4 数组与关联数组批量管理的现代脚手架Debian 的 sh 只提供列表但如果你愿意用 bash数组和关联数组会大幅提升控制结构的表达力。比如要做一批服务的启停管理名称和说明放在一个关联数组里#!/usr/bin/env bash declare -A services( [nginx]web [mariadb]database [redis-server]cache ) for svc in ${!services[]}; do role${services[$svc]} if systemctl is-active --quiet $svc; then echo $svc$role运行中 else echo $svc$role未运行 fi done${!services[]}这种写法是把关联数组的所有键展开允许你循环处理每一个服务名再通过服务名去索引它的说明用途。这种写在维护几十个服务的 Debian 服务器时特别有用备查信息不用到处写 if。不过要记住这段脚本她不能再叫#!/bin/sh必须写成#!/usr/bin/env bash因为关联数组是 bash 扩展语法。Debian 的/bin/sh是 dash直接跑会报语法错误这是我经常看到的一个误区。4. set -e、管道、引号与 localeDebian 脚本最常见的几个坑4.1 set -e 的“静默退出”陷阱写 Debian 脚本很多人喜欢在开头加set -e意思是“有命令失败就立刻退出”。但set -e有一个反直觉的地方在 if 条件、while/until 条件、case 分支、管道左侧它不会触发退出因为失败本来就是控制流的一部分。真正让人头疼的是函数内部set -e myfunc() { false echo 这一步不会执行 } myfunc echo 整个脚本也不会继续哪怕false是写在函数里由于函数整体是在普通命令位置被调用的false失败后set -e会把整个脚本引爆。这个坑在写 Debian 初始化脚本时特别致命因为你可能只是想“先检查下某项配置”结果某一行命令失败脚本直接中断。我自己的经验是除非你清楚每个命令的退出状态否则不要全局开set -e。更稳妥的做法是局部处理失败的命令比如command || true或者在确实希望失败就退出的大段流程里才打开set -e。如果你确实要用set -e配合set -o pipefail和set -u可以更早暴露问题但这三个一块用会让脚本变得极其严格新手很容易被一堆莫名退出搞晕。我给的有效策略是脚本前 80 行用来解析参数和做检查这段不开set -e到了真正执行修改系统的核心段再打开set -e这样既能防止半成品状态又不会因为小检查误伤流程。4.2 while 管道的变量丢失这是 Debian 脚本里提问率最高的问题之一。假如你想统计apt源文件有多少行count0 cat /etc/apt/sources.list | while read -r line; do count$((count 1)) done echo $count # 输出 0你发现count变量根本传不出来。原因是管道右边的while是在一个子 shell 里跑的循环里修改变量不会影响当前 shell 的count。解决办法有三种改成进程替换、改成文件重定向、或者把结果写进临时文件再读回来。最直观的是进程替换count0 while read -r line; do count$((count 1)) done (cat /etc/apt/sources.list) echo $count或者更直接用文件重定向count0 while read -r line; do count$((count 1)) done /etc/apt/sources.list echo $count这个坑在 Debian 上用apt list --installed、dpkg -l这类命令的输出做统计时特别常见。我现在的习惯是只要循环里需要保留状态变量就一律用重定向或进程替换不要写管道。管道只适合那些“输出不回流”的场景。4.3 引号、空格与 glob控制结构条件里的细节Debian 文件系统里的路径绝大多数没有空格但总有一些用户目录或挂载点名字不按常理出牌。判断路径时忘记加引号是控制结构条件里最不该翻车的翻车点dir/data/my backups if [ -d $dir ]; then # 会被拆成两个参数 echo 目录存在 fi这个 if 在 Bash 里会报“参数太多”或者判断到错误路径。正确写法是必须加双引号if [ -d $dir ]; then echo 目录存在 fi另一个和空格相关的坑是通配符没有匹配时glob 会保留字面量。前面我提到过在 for 循环里加if [ -f $f ]; then continue的原因就在这里。如果你确认目录下会有匹配文件可以不用管但如果脚本要跑在一个文件可能为空的部署环境一定要加上这个防护。也可以使用shopt -s nullglob让没有匹配时 glob 自动展开为空数组这样for循环一次都不会执行也是主流做法。4.4 locale 与编码字符串比较和中文匹配的坑Debian 最小化安装后很多实例的 locale 是C或C.UTF-8这个会影响 shell 脚本里对字符串的比较和 grep 匹配。我在某次 Debian 上处理中文配置文件时遇到一个典型问题用grep 中文关键词匹配不到任何内容但文件里明明有这个关键字。查了半天发现是 locale 的问题LANGC时某些多字节字符会被当作非法的字节序列处理。这时把环境变量调整一下就好了export LC_ALLC.UTF-8 export LANGC.UTF-8如果你的脚本要处理日志、配置文件里中文场景建议在脚本开头统一导出这两个环境变量避免在if里对字符串做比较或grep时发生不符合直觉的结果。尤其是用case $line in *关键词*)这种模式去做中文匹配时保证 locale 正确比什么都重要。另外Debian 包列表里的语言包依赖也会受到 locale 影响。如果脚本里需要临时给系统的某个服务设置语言环境export的位置必须在执行控制结构之前因为控制结构内部启动的子进程会继承当前 shell 的环境变量一旦环境不对里面的判断全都会跟着偏差。4.5 常见问题速查表我把 Debian 运维脚本中遇到的高频问题整理成了一个速查表供排查时快速对号入座。现象可能原因建议处理安装卡住或报 lock另一个 apt/dpkg 进程占用锁先 ps aux脚本报 command not found非交互 shell 的 PATH 少了/usr/sbinshebang 用/usr/bin/env bash脚本内显式export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin数组语法报错用 sh 执行了 bash 脚本检查 shebang 和调用方式用bash script.sh不要sh script.shsystemctl 命令不存在容器或旧系统没有 systemd用service封装或者写if systemctl ...前先command -v systemctl判断命令在 if 里正常脚本外跑失败当前 shell 的退出码被管道或子 shell 吞掉用set -o pipefail或把要检测的命令放在独立变量里检查这个表不是万能的但它覆盖了我在 Debian 服务器上写脚本时几乎八成的现场翻车。重点不是背结论而是排查的时候多问一句当前这个命令在哪一层 shell 里执行它的退出码到底是谁的退出码有没有被管道、子 shell、函数边界给隔离掉。搞清楚这三件套脚本的稳定性立刻上一个台阶。最后再分享一个自己坚持了很久的习惯每写一段if我都会故意模拟“条件不成立”的路径跑一遍看脚本是不是按预期躲开危险动作每写一个for循环我都会跑一张空列表看它会不会把字面量当成文件每写一个case分支我会把兜底的*)放上去确保任何未知状态都有退路。控制结构这东西写的时候多花一分钟生产环境里就能少熬一夜。
