首页 / 资讯中心 / 文章详情

Shell脚本做IaC:轻量、透明、可重复的基础设施自动化

Shell脚本做IaC:轻量、透明、可重复的基础设施自动化 ★ FEATURED ARTICLE
最近在整理团队的部署流程时发现一个特别有意思的现象一提基础设施即代码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 21; then dnf install -y ${packages[]} elif command -v yum /dev/null 21; then yum install -y ${packages[]} elif command -v apt-get /dev/null 21; 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_USERdeploy APP_DIR/opt/myapp SSH_KEY_URLhttps://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 21; 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 21 || sysctl -p $SYSCTL_CONF /dev/null 21 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 21; then firewall-cmd --permanent --add-servicehttp firewall-cmd --permanent --add-servicehttps firewall-cmd --reload elif command -v ufw /dev/null 21; 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-cmdUbuntu系用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 pipefailcurl下载密钥超时内部Git服务器地址不通检查网络策略增加重试机制变量为空导致配置被清空未开启set -u或未做参数校验开启set -u并对关键变量显式校验系统重启后服务未启动安装完未设置开机自启使用systemctl enable4.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 已有初始化脚本实例在运行请稍后重试 fiflock是Linux上的原子操作能避免脚本被并发触发。对这个看起来“不可能”的问题加一把锁是最省事的做法。第三个坑是环境差异。同一个脚本在预发布环境测试完全正常一到生产环境就报错。排查后发现生产环境比预发布环境多了个旧版本Nginxinstall_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_ALLC。它能避免不同语言环境下命令输出格式不一致导致grep匹配失败——这种问题在中文环境服务器上极其隐蔽坑过一次就明白了。
阅读完成 · 觉得有帮助?
咨询建站