环境初始化与引导脚本设计

系统讲解引导脚本的工程化设计:系统与依赖探测、幂等的包安装、配置文件落盘、失败回滚与自检,以及云初始化场景下的可重复执行实践。

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 清理
自检进程、端口、功能三层验证,带重试与超时
验证连跑两次验幂等,故障注入验回滚

引导脚本的质量标准只有一个:在任何时候中断,机器都处于「可以重跑」的状态。幂等判断让重复执行安全,回滚栈让失败可收拾,自检让「成功」不再是猜测。把这三件事做进模板,任何一台新机器都能被一条命令带到可用状态,这也是自动化运维最扎实的地基。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战