1. 引导脚本的职责边界
一句话总结: 引导脚本要把「一台空机器」变成「一台可用的机器」,它必须可重复执行、可观测、可回退。
引导脚本(bootstrap)通常以 curl | bash 的形式出现在新机器、容器镜像构建、CI 容器初始化三种场景。它的用户是「还没有任何工具」的环境,所以不能依赖任何非 POSIX 的东西,也不能假设网络与包管理器一定可用。
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
die() { printf '错误: %s\n' "$*" >&2; exit 1; }
1.1 三条设计原则
一句话总结: 幂等、快速失败、可观测——引导脚本的三条铁律,缺一条就会在生产里翻车。
command -v git >/dev/null 2>&1 || install_git # 幂等:不重复劳动
set -euo pipefail # 快速失败:不吞错误
log "步骤 3/8: 安装依赖" # 可观测:知道停在哪
1.2 常见失败模式
一句话总结: 网络抖动、包管理器被锁、权限不足、磁盘满——引导脚本的失败几乎都来自这四类。
# 检测包管理器锁(Debian/Ubuntu)
fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
&& die "apt 被其它进程占用,稍后重试"
avail=$(df -Pk / | awk 'NR==2 {print $4}')
(( avail > 1048576 )) || die "根分区剩余空间不足 1GB"
2. 系统与依赖探测
一句话总结: 先探测「我是谁、我有什么」,再决定「我要装什么」,这是所有引导脚本的第一步。
detect_os() {
if [[ -f /etc/os-release ]]; then . /etc/os-release; echo "${ID:-unknown}"
elif [[ "$(uname -s)" == "Darwin" ]]; then echo "macos"
else echo "unknown"; fi
}
case "$(detect_os)" in
ubuntu|debian) PKG=apt ;;
centos|rhel|rocky) PKG=yum ;;
alpine) PKG=apk ;;
macos) PKG=brew ;;
*) die "不支持的发行版" ;;
esac
2.1 架构与工具探测
一句话总结: 架构决定二进制包选择,工具存在性决定是否需要安装,两者都要在入口处问清楚。
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) die "不支持的架构: $(uname -m)" ;;
esac
missing=()
for c in curl tar gzip jq git; do
command -v "$c" >/dev/null 2>&1 || missing+=("$c")
done
(( ${#missing[@]} == 0 )) || log "缺少命令: ${missing[*]}"
2.2 网络可用性
一句话总结: 装包前先确认能连上镜像源,否则会卡在超时上很久才失败。
curl -fsS --max-time 5 -o /dev/null https://mirrors.example.com/ \
|| die "镜像源不可达"
getent hosts mirrors.example.com >/dev/null 2>&1 || die "DNS 解析失败"
3. 幂等的包安装
一句话总结: 幂等的关键在于「先判断已安装」,而不是「安装失败就忽略」。
install_if_missing() {
local cmd="$1" pkg="${2:-$1}"
if command -v "$cmd" >/dev/null 2>&1; then
log "$cmd 已安装,跳过"; return 0
fi
log "安装 $pkg"; pkg_install "$pkg"
}
3.1 包管理器差异抽象
一句话总结: 把「更新索引」与「安装」抽象成两个函数,业务代码就不必关心发行版。
pkg_update() {
case "$PKG" in
apt) apt-get update -qq ;; yum) yum makecache -q ;;
apk) apk update -q ;; brew) brew update >/dev/null ;;
esac
}
pkg_install() {
case "$PKG" in
apt) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" ;;
yum) yum install -y -q "$@" ;; apk) apk add --no-cache "$@" ;;
brew) brew install "$@" ;;
esac
}
3.2 非交互与静默
一句话总结: 引导脚本绝不能弹出交互提示,环境变量与命令行参数要双管齐下。
export DEBIAN_FRONTEND=noninteractive
export DEBCONF_NONINTERACTIVE_SEEN=true
export NEEDRESTART_MODE=a # 避免 needrestart 阻塞
yum install -y -q --setopt=assumeyes=1 "$@"
3.3 二进制安装与校验
一句话总结: 直接下载二进制时要校验哈希,网络中间人改包是真实存在的风险。
install_binary() {
local name="$1" url="$2" sha256="$3" dest="/usr/local/bin/$name" tmp
tmp=$(mktemp)
curl -fsSL -o "$tmp" "$url" || die "下载失败: $url"
echo "${sha256} ${tmp}" | sha256sum -c - >/dev/null 2>&1 \
|| die "哈希校验失败: $name"
install -m 0755 "$tmp" "$dest"; rm -f "$tmp"
}
4. 配置落盘与模板渲染
一句话总结: 配置文件生成要「模板 + 变量替换 + 权限收紧」,且要能识别内容是否已是最新。
render_template() {
local tpl="$1" out="$2" tmp="${2}.tmp.$$"
envsubst < "$tpl" > "$tmp"; chmod 0644 "$tmp"
if [[ -f "$out" ]] && cmp -s "$tmp" "$out"; then
rm -f "$tmp" # 内容相同则不覆盖,保留 mtime
else
mv "$tmp" "$out"
fi
}
4.1 目录与权限
一句话总结: 配置目录、数据目录、日志目录的属主与权限要在脚本里显式设定,不依赖默认 umask。
install -d -m 0755 /etc/myapp
install -d -m 0750 -o myapp -g myapp /var/lib/myapp /var/log/myapp
chmod 0600 /etc/myapp/secrets.env && chown root:root /etc/myapp/secrets.env
4.2 配置漂移检测
一句话总结: 脚本要能报告「当前配置与期望不一致」,而不是默默覆盖用户改动。
check_drift() {
local tpl="$1" cur="$2"
[[ -f "$cur" ]] || { log "配置缺失: $cur"; return 1; }
cmp -s <(envsubst < "$tpl") "$cur" && return 0
log "配置已漂移: $cur"
diff -u <(envsubst < "$tpl") "$cur" | head -n 20 >&2 || true
return 1
}
4.3 环境变量文件
一句话总结: 用
env文件而不是写进 profile,避免污染交互 shell 且方便容器注入。
umask 077
printf 'APP_ENV=%s\nAPP_PORT=%s\n' "${APP_ENV:-prod}" "${APP_PORT:-8080}" \
> /etc/myapp/env
chmod 0600 /etc/myapp/env
5. 失败回滚与清理
一句话总结: 引导失败时要回到「可重试」的状态,而不是留下半成品让人手工收拾。
# 回滚栈:登记逆操作,失败时逆序执行
ROLLBACK=()
push_rollback() { ROLLBACK+=("$1"); }
on_error() {
local code=$? i
log "第 $LINENO 行失败,退出码 $code,开始回滚"
for (( i=${#ROLLBACK[@]}-1; i>=0; i-- )); do
eval "${ROLLBACK[$i]}" || log "回滚失败: ${ROLLBACK[$i]}"
done
exit "$code"
}
trap on_error ERR
5.1 使用回滚栈
一句话总结: 每个有副作用的操作后面立刻登记回滚动作,顺序不能颠倒。
# 仅在本次确实安装时才登记卸载
if ! command -v redis-server >/dev/null 2>&1; then
pkg_install redis-server; push_rollback "pkg_remove redis-server"
fi
install -d /opt/myapp; push_rollback "rm -rf /opt/myapp"
5.2 临时文件与锁
一句话总结: 用 mktemp 与 flock 保证并发执行安全,临时文件由 trap 统一清理。
exec 9>/var/lock/myapp-bootstrap.lock # 单实例锁
flock -n 9 || die "已有引导脚本在运行"
TMPDIR_BOOT=$(mktemp -d)
trap 'rm -rf "$TMPDIR_BOOT"' EXIT
5.3 幂等重试
一句话总结: 回滚后环境应回到与首次执行前等价的状态,这样重跑一次就能成功。
for attempt in 1 2 3; do
./bootstrap.sh && { log "引导成功(第 $attempt 次)"; break; }
log "第 $attempt 次失败,清理后重试"; sleep $(( attempt * 5 ))
done
6. 自检与健康验证
一句话总结: 引导脚本的最后一步必须是自检:服务能起、端口能通、功能可用,三者缺一不可。
self_check() {
local failed=0
systemctl is-active --quiet myapp || { log "服务未运行"; failed=1; }
ss -lntp 2>/dev/null | grep -q ':8080 ' || { log "端口未监听"; failed=1; }
curl -fsS --max-time 5 http://127.0.0.1:8080/healthz >/dev/null \
|| { log "健康检查失败"; failed=1; }
return "$failed"
}
6.1 重试式自检
一句话总结: 服务启动需要时间,自检要带重试与超时,而不是立刻判定失败。
wait_healthy() {
local url="$1" max="${2:-30}" i=0
while (( i < max )); do
curl -fsS --max-time 3 "$url" >/dev/null 2>&1 \
&& { log "健康检查通过"; return 0; }
sleep 2; i=$(( i + 1 ))
done
log "健康检查超时: $url"; return 1
}
6.2 自检报告
一句话总结: 把自检结果汇总成一份报告,机器可读也让运维一眼看清状态。
printf '{"os":"%s","arch":"%s","service":"%s"}\n' "$OS" "$ARCH" \
"$(systemctl is-active myapp 2>/dev/null || echo unknown)"
7. 实战:云主机初始化脚本
一句话总结: 把探测、安装、配置、自检串成一条有回滚的流水线,就是云主机 user-data 的标准形态。
#!/usr/bin/env bash
set -euo pipefail
exec > >(tee -a /var/log/bootstrap.log) 2>&1
log() { printf '[%s] %s\n' "$(date -Iseconds)" "$*"; }
die() { log "FATAL: $*"; exit 1; }
[[ $EUID -eq 0 ]] || die "需要 root 权限"
# --- 回滚栈 ---
ROLLBACK=()
push_rollback() { ROLLBACK+=("$1"); }
on_error() {
local code=$? i
log "失败于第 $LINENO 行,开始回滚"
for (( i=${#ROLLBACK[@]}-1; i>=0; i-- )); do
eval "${ROLLBACK[$i]}" 2>/dev/null || true
done
exit "$code"
}
trap on_error ERR
# --- 基础依赖 ---
. /etc/os-release
[[ "${ID}" == "ubuntu" || "${ID}" == "debian" ]] || die "不支持的发行版: ${ID}"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates jq >/dev/null
# --- 应用用户与目录 ---
if ! id myapp >/dev/null 2>&1; then
useradd --system --home /var/lib/myapp --shell /usr/sbin/nologin myapp
push_rollback "userdel -r myapp 2>/dev/null || true"
fi
install -d -m 0750 -o myapp -g myapp /var/lib/myapp /var/log/myapp
# --- 配置 ---
umask 077
printf 'APP_ENV=%s\nAPP_PORT=%s\n' "${APP_ENV:-prod}" "${APP_PORT:-8080}" \
> /etc/myapp/env
chmod 0600 /etc/myapp/env
# --- 应用二进制(校验哈希) ---
install_binary myapp \
"https://dl.example.com/myapp/v1.2.3/myapp-linux-amd64" \
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
# --- systemd 单元 ---
cat > /etc/systemd/system/myapp.service <<'UNIT'
[Unit]
Description=MyApp Service
After=network-online.target
[Service]
User=myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/local/bin/myapp
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload && systemctl enable --now myapp
# --- 自检 ---
wait_healthy "http://127.0.0.1:8080/healthz" 30 \
|| die "自检失败,见 /var/log/bootstrap.log"
trap - ERR
log "引导完成,服务已就绪"
7.1 可重复执行验证
一句话总结: 引导脚本写完后必须连跑两次,第二次应当全部走「跳过」分支并成功退出。
./bootstrap.sh && echo "第一次 OK"
./bootstrap.sh && echo "第二次 OK(幂等)"
7.2 失败注入测试
一句话总结: 故意让某一步失败,验证回滚栈是否把环境清理干净,这是引导脚本唯一的有效测试手段。
FAULT_AT=5 ./bootstrap.sh || true # 注入失败
id myapp >/dev/null 2>&1 && echo "回滚不彻底"
8. 总结
| 环节 | 要点 |
|---|---|
| 原则 | 幂等、快速失败、可观测,三条铁律缺一不可 |
| 探测 | 先识别发行版、架构、缺失命令,再决定装什么 |
| 安装 | 先判断已安装再装,抽象 pkg_update/pkg_install |
| 配置 | 模板渲染 + 内容比对 + 权限收紧,检测漂移 |
| 回滚 | 回滚栈登记逆操作,ERR trap 逆序执行 |
| 并发 | flock 单实例锁,mktemp 临时目录,trap 清理 |
| 自检 | 进程、端口、功能三层验证,带重试与超时 |
| 验证 | 连跑两次验幂等,故障注入验回滚 |
引导脚本的质量标准只有一个:在任何时候中断,机器都处于「可以重跑」的状态。幂等判断让重复执行安全,回滚栈让失败可收拾,自检让「成功」不再是猜测。把这三件事做进模板,任何一台新机器都能被一条命令带到可用状态,这也是自动化运维最扎实的地基。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。