☰
Shell脚本做IaC:轻量、透明、可重复的基础设施自动化
2026/10/10 12:30:42 网站建设 项目流程

最近在整理团队的部署流程时,发现一个特别有意思的现象:一提基础设施即代码(IaC),大家脑子里蹦出来的全是Terraform、Ansible、CloudFormation这些工具,好像不上这类框架就不好意思管自己的方案叫IaC。今天我想聊的是一条更轻、更朴素的路径——用Shell脚本做IaC管理。

这个思路源于一个小团队的实际需求:云主机数量不多、规模不大,迁到Kubernetes的时机还不成熟,但又确实受够了手动登录服务器敲命令的重复劳动。用Shell脚本把基础设施的初始化、配置、部署动作固化成代码,放进Git仓库管理,一套脚本跑完所有环境——听起来不复杂,实际落地时踩了不少坑,也有不少值得展开聊的细节。

这篇文章就围绕Shell脚本如何实现IaC管理展开,拆几个核心问题:为什么选择Shell而不是直接上配置管理工具?脚本怎么设计才能做到幂等和可靠?完整的初始化脚本长什么样?以及我在实操中反复踩过的那些坑。无论你是刚接触DevOps的开发者,还是想给现有基础设施管理流程“瘦身”的运维,这篇文章应该都能提供一些可落地的参考。

1. 为什么会在IaC体系里选择Shell脚本

1.1 先想清楚一个问题:你缺的到底是工具还是流程

很多团队上IaC工具链,初衷是解决“配置漂移”和“手动操作不可控”,但实际投入产出比并不高。最典型的场景我见过不少:二十几台服务器,环境差异不大,部署次数也不算频繁,结果不看具体需求就把Terraform、Ansible、Packer一套全上了,光维护这些工具的版本兼容和插件升级就占用了大量时间。

基础设施即代码的本质,是让基础设施的创建、变更和恢复过程变得可审查、可重复、可自动化。它是一个管理思路,而不是某个具体工具的代名词。你要的是一套“能用代码描述基础设施状态,并且能通过执行代码让实际环境收敛到目标状态”的机制。至于这个机制是用什么语言实现的,反而没那么关键。

如果团队规模小、基础设施规模也不大,Shell脚本完全可以承担这个角色。它不需要额外的运行时依赖,不需要学习HCL或者YAML的Schema规则,几乎所有Linux环境自带Bash解释器,SSH能通的地方脚本就能跑。对很多业务团队来说,“少引入一个重型工具”本身就是降低成本的重要一步。

1.2 Shell脚本的真正定位:轻量、透明、快速落地

Shell脚本做IaC有它独特的优势,而且这些优势在中小规模场景里非常突出。

第一是透明。脚本就是顺序执行的命令集合,任何一个人打开脚本就能看到每一条命令在做什么,不需要理解抽象层。Terraform的State文件、Ansible的Handler机制,理解成本其实都不低。Shell脚本的整个执行过程对团队所有人几乎零门槛,出了问题直接用bash -x跑一遍定位就行。

第二是启动快。写一个初始化脚本往往十几分钟就能出一个可用版本,不需要初始化模块仓库、不需要设计Provider配置、不需要考虑执行计划。PM说下周要上线新环境,你今天下午就能把初始化流程脚本化。

第三是复用成本低。一段配置Nginx的脚本、一段优化内核参数的脚本,本质上是知识沉淀,在多个项目之间复制粘贴稍作修改就能用。维护成本分散在业务代码仓库里,而不是集中在一个独立的IaC代码库中——对某些团队来说,这种“每个项目自带基础设施脚本”的模式反而更直观,团队看到业务代码的同时就能看到关联的部署脚本。

1.3 什么情况下应果断放弃Shell方案

我得诚实一点:Shell脚本做IaC有非常明确的适用边界,某些场景下你最好别用。

当你管理的基础设施超过几十台,或者涉及大量云资源的创建销毁(比如按需批量拉起几十台虚拟机、管理对象存储桶、VPC网络拓扑),Shell脚本就会非常吃力。资源之间复杂依赖关系的编排、变更计划的预览回滚、云资源生命周期管理,这些正是Terraform这类工具的价值所在。硬用Shell脚本去写云API的调用循环,迟早会被State管理的复杂度反噬。

另外,如果你的合规审计要求很严格,需要完整记录每次基础设施变更的“计划—审批—执行—记录”闭环,那么Shell脚本这种偏自由风格的执行方式,就需要额外投入大量精力去补审计能力。这种场景下成熟工具自带的干跑、计划输出和状态锁定功能,可以省掉很多麻烦。

所以我在团队里遵循的原则是:小规模、快交付、环境简单的场景,Shell脚本是高效的;大规模、复杂依赖、严格审计的场景,果断引入专业工具。这不丢人,反而是对工具边界有清晰认知的表现。

2. 用Shell做IaC的几个核心设计要点

2.1 幂等性:脚本要能反复执行而不产生副作用

幂等是IaC脚本的第一原则。什么叫幂等?同一个脚本在同一台机器上执行三次,第一次是“初始化”,第二次和第三次不应该产生任何破坏性变更——不该重复追加配置、不该重复创建目录、不该重复安装软件导致冲突。

举个最常见的反例。很多人写初始化脚本会直接写:

echo "server { listen 80; root /var/www/html; }" > /etc/nginx/conf.d/default.conf

第一次跑没问题,配置写进去了。第二次跑同样一段代码,还是这个结果。配置文件内容确实一样,所以这个写法其实是幂等的——但如果配置里有动态部分,比如添加了一个节点IP到白名单列表,那重复执行就会导致重复追加。

真正需要注意的场景是:

  • 配置文件需要增量追加:先检查是否已存在某个标记字符串,不存在才追加
  • 创建用户、组、系统服务:先检查是否已存在
  • 安装软件包:不同包管理器的幂等能力不同,需要主动判断
  • 修改系统参数:先读取当前值,和目标值比较后再决定是否写入

一个简单的模式是“先判断,后操作”。比如创建部署目录:

DEPLOY_DIR="/opt/myapp" if [ ! -d "$DEPLOY_DIR" ]; then mkdir -p "$DEPLOY_DIR" fi

又比如往/etc/hosts添加一条记录:

if ! grep -q "192.168.10.20 db-server" /etc/hosts; then echo "192.168.10.20 db-server" >> /etc/hosts fi

这类写法看似基础,却是整个IaC脚本能放心重复执行的基础。我见过太多“脚本只能跑一次”的尴尬场景:第一次成功部署后,第二次跑直接报错,最后只能靠手工清理现场。所以在设计脚本架构时,把“可重复执行”当作一个强制约束来对待,比事后补救划算得多。

2.2 错误处理:set -euo pipefail只是开始

Shell脚本的错误处理经常被低估。很多人写脚本开头会加set -e,意思是“任何一条命令失败就立即退出”,但实际用起来很快会发现一堆问题。

set -e在管道场景下表现并不佳,比如执行cmd1 | cmd2时,管道最终的退出码是最后一个命令的退出码,如果cmd1挂了但cmd2成功,脚本会继续跑。所以需要set -o pipefail,让管道中任意一条命令失败都触发退出。

还有set -u——使用未定义变量时立即报错。这个开关能抓出一大批潜在问题:比如某个环境变量拼写错误,结果传入了一个空值,表面上看命令执行成功,实际上配置已经被污染了。开启set -u后,这类问题会直接在源头暴露。

我的脚本开头一般是:

#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t'

最后一行设置IFS为换行符和制表符,避免文件路径或参数中包含空格时被错误拆分成多个字段。

但光有这些还不够。set -e有一个非常容易被坑到的地方:当你主动“期待”某条命令失败时,比如用grep判断某个配置是否已存在,grep找不到匹配会返回非零退出码,这时如果没有保护,整个脚本就挂了。所以这类主动判断的场景需要用条件分支来包一层:

if grep -q "some-key" /etc/app.conf 2>/dev/null; then echo "配置已存在,跳过" else echo "some-key = on" >> /etc/app.conf fi

放在if条件里的命令不会触发set -e,这算是Shell里一个不太直观、但极其重要的规则。每个做脚本IaC的人都应该把这个规则内化成直觉。

2.3 状态追踪与日志审计

基础设施脚本跑完后,怎么确认它真的执行成功了?这就是状态追踪要解决的事。

我的做法分两层。第一层是脚本输出规范,每条关键步骤都用统一格式打印执行结果:

log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" } warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" } fail() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2 exit 1 }

用统一的log函数打印日志,有几个好处:时间戳可以让你在排查问题时定位时序、输出风格一致方便收集到日志平台、后续想加颜色高亮或写入日志文件都只需要改这一个函数。

第二层是状态文件。在主机上维护一个目录,比如/var/lib/myapp-provision/,里面用零字节标记文件记录已完成的步骤:

MARKER_DIR="/var/lib/myapp-provision" mark_done() { touch "$MARKER_DIR/$1.done" } is_done() { [ -f "$MARKER_DIR/$1.done" ] } if ! is_done "nginx-install"; then install_nginx mark_done "nginx-install" fi

这种“打点”式的状态跟踪方式特别适合多步骤的初始化脚本:如果第4步失败了,修复问题后重新跑,脚本会跳过已经完成的前3步,直接从第4步继续。这在断点续跑场景下能省大量时间。脚本维护者看到哪些.done文件存在,也能直观判断这台机器曾经执行过哪些初始化操作。

2.4 敏感信息处理

脚本里不可避免地会接触到密码、Token、私钥这类敏感信息。直接硬编码在脚本里是最糟糕的做法,因为脚本会进入Git仓库,以后每次git历史翻出来都能看到明文密钥。

两件事要做。

第一,脚本本身不保存任何敏感值,只从外部环境变量或独立的密钥文件中读取:

export DB_PASSWORD="${DB_PASSWORD:?DB_PASSWORD环境变量未设置}"

这里用${VAR:?message}的写法,如果环境变量未设置,脚本会立即报错退出,不会带着空值继续跑。比单纯set -u更主动地暴露配置缺失问题。

第二,密钥文件与脚本分离,并在.gitignore中排除:

# .gitignore *.pem *.key secrets.env .env

在CI/CD流水线中,密钥从平台的Secret管理功能注入运行环境;在本地,开发者从团队的临时密码库或保险库获取。脚本里只有读取行为,没有存储行为,密码自然不会泄露到代码仓库里。

3. 一个可复用的基础设施初始化脚本实战

3.1 需求场景设定

为了不空谈理论,我直接用一个实际场景来走一遍完整实现。

假设团队需要初始化一台新的CentOS Stream服务器,这台服务器用于部署一个基于Python的Web应用,需要完成以下基础设施配置:

  • 创建专用的部署用户deploy,加入wheel组并配置SSH密钥登录
  • 安装Nginx、Python 3.11、Git、基础编译工具链
  • 配置系统参数:文件描述符上限、TCP BBR拥塞控制算法
  • 创建应用部署目录并设置正确的属主和权限
  • 配置防火墙规则,只开放80、443和运维端口(默认22)
  • 将所有配置固化到脚本,能够重复执行不出问题

这套需求在真实业务里非常典型:不是大规模云资源编排,而是一台服务器从“裸机”到“可部署状态”的流程自动化。用Shell脚本实现再合适不过。

3.2 脚本骨架与函数库设计

我不建议把整个初始化流程写成一个几百行的巨型脚本。更好的做法是拆成两部分:一个公共函数库和一个主执行脚本。

函数库lib.sh:

#!/usr/bin/env bash # 公共函数库,主脚本通过 source 加载 set -euo pipefail IFS=$'\n\t' # 日志输出 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" } warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" } fail() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2 exit 1 } # 状态跟踪 MARKER_DIR="/var/lib/app-provision" ensure_marker_dir() { [ -d "$MARKER_DIR" ] || mkdir -p "$MARKER_DIR" } mark_done() { ensure_marker_dir touch "$MARKER_DIR/$1.done" } is_done() { [ -f "$MARKER_DIR/$1.done" ] } # 安装软件包(兼容 yum/dnf/apt) install_packages() { local packages=("$@") if command -v dnf >/dev/null 2>&1; then dnf install -y "${packages[@]}" elif command -v yum >/dev/null 2>&1; then yum install -y "${packages[@]}" elif command -v apt-get >/dev/null 2>&1; then apt-get update apt-get install -y "${packages[@]}" else fail "无法识别的包管理器" fi }

这个库里的ensure_marker_dir会确保状态目录存在,在脚本还没创建任何目录之前先把状态目录建好,这样后面每一步的mark_done才不会报错。

主脚本provision.sh:

#!/usr/bin/env bash # 服务器初始化脚本 # 用法: bash provision.sh source "$(dirname "$0")/lib.sh" # 环境检查 if [ "$(id -u)" -ne 0 ]; then fail "此脚本需要以root用户运行" fi APP_USER="deploy" APP_DIR="/opt/myapp" SSH_KEY_URL="https://git.internal.example.com/deploy.pub" log "开始执行服务器初始化,目标用户: ${APP_USER}" # 步骤1: 创建部署用户 if ! is_done "user-create"; then log "创建部署用户 ${APP_USER}" if id "$APP_USER" >/dev/null 2>&1; then log "用户已存在,跳过创建" else useradd -m -s /bin/bash "$APP_USER" usermod -aG wheel "$APP_USER" fi log "配置SSH免密登录" mkdir -p "/home/${APP_USER}/.ssh" curl -fsSL "$SSH_KEY_URL" -o "/home/${APP_USER}/.ssh/authorized_keys" chown -R "${APP_USER}:${APP_USER}" "/home/${APP_USER}/.ssh" chmod 700 "/home/${APP_USER}/.ssh" chmod 600 "/home/${APP_USER}/.ssh/authorized_keys" mark_done "user-create" fi # 步骤2: 安装基础软件 if ! is_done "packages-install"; then log "安装基础软件包" install_packages nginx git python3 python3-pip gcc make openssl-devel systemctl enable nginx mark_done "packages-install" fi # 步骤3: 配置系统参数 if ! is_done "sysctl-config"; then log "配置内核参数" SYSCTL_CONF="/etc/sysctl.d/99-app-tune.conf" cat > "$SYSCTL_CONF" <<EOF fs.file-max = 65535 net.core.somaxconn = 65535 net.ipv4.tcp_fastopen = 3 EOF sysctl --system >/dev/null 2>&1 || sysctl -p "$SYSCTL_CONF" >/dev/null 2>&1 mark_done "sysctl-config" fi # 步骤4: 创建应用目录 if ! is_done "app-dir"; then log "创建应用部署目录 ${APP_DIR}" mkdir -p "$APP_DIR" chown "${APP_USER}:${APP_USER}" "$APP_DIR" chmod 755 "$APP_DIR" mark_done "app-dir" fi # 步骤5: 配置防火墙规则 if ! is_done "firewall-config"; then log "配置防火墙规则" if command -v firewall-cmd >/dev/null 2>&1; then firewall-cmd --permanent --add-service=http firewall-cmd --permanent --add-service=https firewall-cmd --reload elif command -v ufw >/dev/null 2>&1; then ufw allow 80/tcp ufw allow 443/tcp fi mark_done "firewall-config" fi log "所有初始化步骤执行完毕"

3.3 核心执行流程拆解

这段脚本看起来不长,但里面有不少需要仔细解释的设计决定。

先看步骤1的SSH密钥安装。密钥文件从内部Git服务器的固定URL下载,而不是直接嵌入脚本。这样做的好处是:团队成员增加或离职,只要在Git服务器上更新公钥文件,新服务器初始化时就天然只包含当前活跃的密钥。

再看步骤3。这里把内核参数配置输出到/etc/sysctl.d/子目录而不是直接修改/etc/sysctl.conf,这是Linux运维里一个容易被忽略的规范:sysctl.d目录下的每个文件负责一组相关的参数,不会跟发行版自带配置互相踩踏,卸载时也只需要删除对应文件,不必去理解sysctl.conf里哪些行是自己加的。

步骤4创建应用目录并设置独立的属主deploy,而不是用root直接部署。这是一个安全设计:应用进程以最小权限运行,即使被攻破,攻击者拿到的也只是普通用户权限,不是root。目录权限设为755,保证其他用户能读取路径,但只有属主能写。

步骤5的防火墙规则考虑了两种管理器的兼容。CentOS系用firewall-cmd,Ubuntu系用ufw,用command -v判断当前系统装的是哪个,比硬编码一种命令更健壮。

3.4 执行效果与验证

在干净环境上首次执行的效果:

$ bash provision.sh [2024-06-15 10:23:44] [INFO] 开始执行服务器初始化,目标用户: deploy [2024-06-15 10:23:45] [INFO] 创建部署用户 deploy [2024-06-15 10:23:46] [INFO] 配置SSH免密登录 [2024-06-15 10:24:02] [INFO] 安装基础软件包 ... [2024-06-15 10:27:18] [INFO] 所有初始化步骤执行完毕

执行后检查状态,会有几个关键验证点:

# 验证用户 id deploy # 验证端口监听 ss -tlnp | grep -E ':80|:443' # 验证状态标记 ls -la /var/lib/app-provision/

验证通过后再跑一遍脚本,会看到每步都命中“已存在”或“已配置”逻辑,脚本在几十秒内结束,不会做任何重复变更。这就是幂等脚本应该有的样子。

4. 实操中的常见问题与排查技巧

4.1 问题速查表

整理一份高频典型问题对照:

问题现象根本原因解决思路
脚本第二次执行报错缺少幂等判断,重复创建用户/目录在关键步骤前加存在性检查
set -e下脚本莫名中断grep等命令返回非零码放入if条件中或补`
管道中前段命令失败但脚本继续未开启pipefail开启set -o pipefail
curl下载密钥超时内部Git服务器地址不通检查网络策略,增加重试机制
变量为空导致配置被清空未开启set -u或未做参数校验开启set -u并对关键变量显式校验
系统重启后服务未启动安装完未设置开机自启使用systemctl enable

4.2 三个印象深刻的踩坑经历

第一个坑是set -e加grep的组合拳。我之前写过一个判断“已部署的版本号”的脚本片段:

current_version=$(grep "APP_VERSION" /opt/myapp/.env | cut -d= -f2)

如果.env里没有APP_VERSION这一行,grep返回非零码,整个脚本在set -e模式直接退出。当时排查了很久,还以为是部署步骤的问题。后来用bash -x一跑,发现脚本执行到第42行就停了,退回来看才发现是grep的返回值在作怪。从那以后我养成了一个习惯:所有“探测性”命令要么放进if条件,要么显式加上|| true。

第二个坑是并发执行。团队后来引入了一个流水线,两台服务器同时执行同一个初始化脚本,脚本里有一段往/etc/hosts追加主机映射的逻辑。两台机器同时跑,race condition确实存在——但出现问题的不是追加本身,而是状态标记文件。两个进程同时检查is_done都返回false,然后都去执行安装,导致重复操作。解决办法是在脚本入口加一个简单的文件锁:

LOCK_FILE="/tmp/provision.lock" exec 9>"$LOCK_FILE" if ! flock -n 9; then fail "已有初始化脚本实例在运行,请稍后重试" fi

flock是Linux上的原子操作,能避免脚本被并发触发。对这个看起来“不可能”的问题,加一把锁是最省事的做法。

第三个坑是环境差异。同一个脚本在预发布环境测试完全正常,一到生产环境就报错。排查后发现生产环境比预发布环境多了个旧版本Nginx,install_packages里虽然安装了新版,旧配置文件和新的配置模板冲突,导致服务起不来。这个问题的本质是“脚本覆盖了配置但没处理历史遗留文件”。后来在配置步骤前加了一个备份归档逻辑:

if [ -f /etc/nginx/nginx.conf ]; then cp /etc/nginx/nginx.conf "/etc/nginx/nginx.conf.bak.$(date +%s)" fi

改名备份而不是直接覆盖,一旦新配置有问题能迅速回滚。

5. 团队协作中的规范与边界

5.1 代码评审与测试环境先行

脚本化基础设施管理推进到一定程度,你发现真正的问题往往不在技术,而在团队协作规范。

Shell脚本进Git仓库之后,要像对待业务代码一样对待它:必须有代码评审。评审者的目光要聚焦在几条硬性规则上:

  • 是否包含任何形式的硬编码密钥
  • 每个关键步骤是否有幂等判断
  • 是否有明确的失败退出机制
  • 是否使用了未经过验证的自定义函数
  • 脚本是否能在全新环境中独立执行

我们的团队还定了一条规矩:任何初始化脚本必须在干净环境中完整执行一遍后才能合并进主分支。这里的“干净环境”可以是临时拉起的虚拟机或容器。一次次经验证明,很多脚本问题都是“在自己这台机器上能跑,在干净的机器上就跑不通”——因为开发者本机有太多历史环境和手工配置,掩盖了脚本本身缺少的步骤。

5.2 从Shell脚本到配置管理工具的渐进式演进

Shell脚本做IaC不代表永远停留在这个方案。团队基础架构演进了,方案也应该跟着演。

我观察到一个比较合理的演进路径:开始是零散的Shell命令,然后整理成可复用的Shell脚本,脚本越来越复杂后,开始拆分工具体的模块(用户管理脚本、Nginx配置脚本、系统调优脚本)。再往后,如果服务器数量上来了,变量和模板越来越多,你会自然发现自己正在重新实现Ansible或Chef已有的功能——这时候就值得认真评估迁移到配置管理工具了。

反向的教训也存在。我见过一个团队,买了全套配置管理平台的培训,结果实际管理的服务器长期只有三台,Ansible的Playbook写了不少,但大部分时间都花在维护“工具本身”而不是“基础设施”上。这提醒我们:用一个复杂的工具去管理一个简单得多的基础设施,实际上是在用复杂度换安全感,并不一定划算。

用Shell脚本做IaC,长期来看不是终极形态,但它是一个性价比极高的起点。它把你从“手动登服务器”的痛苦中解放出来,又不至于让你一上来就背上一整套新工具链的学习成本。从这个角度讲,它是非常值得投入的“第一级台阶”。

6. 我的几点体会

做了不少项目的脚本化基础设施管理后,有几条体会想最后分享。

第一条,别迷信工具。Terraform有它的宏伟,但你的真实需求可能只是一个几百行的Bash脚本。选择工具的核心标准从来不是“业界最流行”,而是“适配当前团队的规模、技能栈和真实场景”。

第二条,幂等性是脚本IaC的灵魂。一个只敢跑一次的脚本,本质上和手动操作没有区别——你还是会害怕重跑会出事,还是不敢放手让流程自动化。多花一点时间在幂等设计上,回报远超投入。

第三条,脚本IaC不是终点,而是起点。当你发现脚本里开始出现大量重复的模式——状态管理、变量替换、多主机循环——那时候就是考虑升级工具的时机了。不要因为“已经写了这么多脚本”而拒绝迁移,也不要为了“显得专业”而提前迁移。

最后分享一个小技巧:脚本开头的set -euo pipefail可能还不够,建议再加一句export LC_ALL=C。它能避免不同语言环境下命令输出格式不一致导致grep匹配失败——这种问题在中文环境服务器上极其隐蔽,坑过一次就明白了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询