外观
项目 8:服务器初始化脚本综合开发
约 3595 字大约 12 分钟
Shell 模块化配置文件日志函数参数解析
2026-05-31
项目目标
前面三课分别完成了 SSH 安全、防火墙防护、基础软件和容器运行时初始化。本课把这些子脚本整理成一个可维护的 server-init 项目。
最终项目应包含 config、modules、logs、reports 和 README.md,通过 init_server.sh 总入口按配置调用各模块,并在失败时能定位到具体模块。
一、为什么要从"单个脚本"升级为"脚本项目"
单个脚本适合完成一个明确任务,例如只配置 UFW 或只安装 Docker。但当脚本开始同时修改 SSH、防火墙、软件源和容器运行时,继续把所有代码写在一个文件里会带来三个问题:
| 问题 | 表现 | 后果 |
|---|---|---|
| 变量冲突 | 多个脚本都定义 PORT、CONFIG_FILE | 后加载的变量覆盖前面的变量 |
| 失败定位困难 | 只看到"初始化失败" | 不知道是 SSH、防火墙还是 runtime 出错 |
| 回滚说明缺失 | 多个模块都改系统配置 | 出问题后不知道恢复哪个文件 |
脚本项目的核心思想是:总入口只负责编排,具体变更交给模块执行。这本质上是软件工程中"关注点分离"原则在 Shell 脚本中的应用。
1.1 模块化与命名空间隔离
Shell 变量作用域冲突演示
🖥️ 主进程内存 (全局命名空间)
PORTundefined
📄 ssh.sh
PORT=2222echo "SSH: $PORT"📄 firewall.sh
PORT=80echo "FW: $PORT"⚙️ 主流程 (init.sh)
# 回到主流程使用 PORTufw allow $PORT当多个子脚本被 source 加载到同一个 Shell 进程中时,它们共享同一个全局命名空间。这意味着 firewall.sh 里定义的 PORT 变量会覆盖 ssh.sh 里定义的同名变量。解决这个问题的常见手段有三种:
| 手段 | 做法 | 适用场景 |
|---|---|---|
| 变量名前缀 | SSH_PORT、FW_PORT、RT_PORT | 小型项目(<5 个模块) |
| 函数封装 | 脚本只暴露函数,不暴露变量 | 中型项目 |
| 子进程隔离 | 用 bash module.sh 而非 source module.sh | 变量完全隔离,但无法共享函数库 |
本课采用函数封装 + 变量名前缀的组合策略。每个模块只在函数内部使用局部变量(local 声明),需要跨模块共享的配置统一从 config/server.env 读取。
1.2 Shell 脚本的严格模式
编写多文件 Shell 项目时,建议在总入口脚本开头开启严格模式:
set -euo pipefail| 选项 | 作用 | 示例:未开启时的风险 |
|---|---|---|
set -e | 任何命令返回非 0 就立即退出 | cd /nonexistent && rm -rf * 会在错误目录执行 |
set -u | 使用未定义变量时立即退出 | rm -rf /$UNDEFINED_VAR 会变成 rm -rf / |
set -o pipefail | 管道中任一命令失败,整个管道失败 | grep pattern file | wc -l 中 grep 失败不会被发现 |
严格模式下,脚本在任何非预期状态下都会立即终止,而不是带着错误状态继续执行。这对于会修改系统配置的初始化脚本尤其重要——你不能允许脚本在 UFW 放行失败后继续去安装 Docker。
但严格模式也有副作用:某些命令返回非 0 可能是正常的(例如 grep 没找到匹配行)。这种情况用 || true 处理:
grep -q "pattern" /etc/some.conf || true二、推荐目录结构
问题:server-init 中用于存放各功能子脚本的目录名是什么?
每个目录的职责要清晰:
| 路径 | 职责 |
|---|---|
config/server.env | 存放端口、用户、镜像源、runtime 类型等可变配置 |
modules/common.sh | 存放日志、执行命令、参数校验等公共函数 |
modules/ssh.sh | 复用第 5 课 SSH 安全初始化逻辑 |
modules/firewall.sh | 复用第 6 课 UFW 与 fail2ban 逻辑 |
modules/runtime.sh | 复用第 7 课 apt 与 runtime 初始化逻辑 |
logs/ | 保存完整执行日志 |
reports/ | 保存本次初始化报告 |
2.1 公共函数库设计
common.sh 是整个项目的基础设施,它至少应该提供以下函数:
# 带时间戳的日志
log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" | tee -a "$LOG_FILE"; }
log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" | tee -a "$LOG_FILE"; }
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" | tee -a "$LOG_FILE"; }
# 带错误处理的命令执行(dry-run 模式下只打印不执行)
run() {
if [ "$DRY_RUN" = "true" ]; then
log_info "[DRY-RUN] $*"
else
log_info "[EXEC] $*"
eval "$@" || {
log_error "命令执行失败: $*"
return 1
}
fi
}
# 前置条件检查
ensure_root() {
if [ "$(id -u)" -ne 0 ]; then
log_error "此脚本需要 root 权限运行"
exit 1
fi
}run 函数是整个项目的核心:所有修改系统的命令都应该通过它执行,而不是直接写裸命令。这样做乍看增加了代码量,但换来了三个关键能力:
- Dry-run:不改变系统,只打印将执行的命令
- 统一日志:所有命令自动写入日志文件
- 错误统一处理:失败时自动记录,而不是静默跳过
三、配置文件设计
项目配置建议集中放在 config/server.env:
# config/server.env
OPS_USER="ops"
SSH_PORT="2222"
PUBLIC_KEY_FILE="/tmp/ops_key.pub"
BUSINESS_PORTS="80,443"
APT_MIRROR="https://mirrors.tuna.tsinghua.edu.cn/ubuntu"
RUNTIME="containerd"
REGISTRY_MIRROR=""这样做的好处是:同一套脚本可以在不同服务器上复用,只需要更换配置文件,不需要改脚本源码。
配置文件边界
server.env 只保存普通配置,不要写入私钥、账号密码、云平台 Token 等敏感信息。SSH 公钥可以保存路径,私钥必须留在客户端。
3.1 配置文件的加载方式
Shell 中加载配置文件的几种方法:
| 方法 | 命令 | 优缺点 |
|---|---|---|
source | source config/server.env | 直接注入当前 Shell,简单但有注入风险 |
| 逐行解析 | while IFS='=' read -r key value | 安全,但代码量大 |
eval | eval "$(cat config/server.env)" | 极度危险——配置文件中的任何命令都会执行 |
本课使用 source,因为配置文件是我们自己编写的,不涉及不可信输入。但如果将来允许用户传入外部配置文件,务必改用逐行解析方式。
3.2 配置有效性校验
配置文件加载后,应该立即校验必填字段是否存在:
required_vars=("SSH_PORT" "OPS_USER" "APT_MIRROR" "RUNTIME")
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
log_error "配置缺少必填字段: ${var}"
exit 1
fi
done${!var} 是 Shell 的间接引用语法——var 的值是 SSH_PORT,${!var} 展开为 ${SSH_PORT} 的值。这段校验防止你用半成品的配置文件去初始化生产服务器。
四、总入口设计
初始化模块执行流与 Fast-Fail 机制
如果前面的模块失败了,后续模块是否还应该继续执行?
init_server.sh🔍
环境检查 (env_check)
🔐
SSH安全 (ssh_security)
🧱
防火墙 (firewall)
🐳
容器运行 (runtime)
write_report()Terminal - tail -f init.log
总入口 init_server.sh 要完成四件事:
- 读取参数和配置文件。
- 初始化日志文件。
- 按顺序调用模块。
- 汇总执行报告。
模块调用顺序建议如下:
这里把 SSH 放在防火墙前面,是为了先确定最终 SSH 端口,再让防火墙放行该端口。防火墙完成后,再安装基础软件和容器运行时。这个顺序是不可逆的——如果颠倒,防火墙放行的是旧端口,而 SSH 可能已经被迁移到了新端口,结果就是放行了一个不存在的端口而没放行实际使用的端口。
4.1 错误处理与断点机制
模块执行过程中如果失败,总入口需要决定继续还是中止。建议采用"快速失败"策略,但保留已完成的模块状态:
modules=("ssh" "firewall" "runtime")
failed_modules=()
for mod in "${modules[@]}"; do
log_info "========== 开始执行模块: ${mod} =========="
if "${mod}_apply"; then
log_info "========== 模块 ${mod} 执行成功 =========="
else
log_error "========== 模块 ${mod} 执行失败 =========="
failed_modules+=("$mod")
# 是否继续由 FAIL_FAST 配置决定
if [ "${FAIL_FAST:-true}" = "true" ]; then
log_error "快速失败模式启用,终止后续模块执行"
break
fi
fi
done这种设计的好处是:如果 SSH 模块失败(比如密钥文件不存在),脚本不会继续去配置防火墙——因为你知道后续模块依赖的端口信息可能也是错的。
问题:server-init 项目的总入口脚本文件名是什么?
五、模块函数约定
为了避免多个子脚本合并后互相打架,每个模块都应该遵守同一套约定:
| 约定 | 示例 |
|---|---|
| 函数名带模块前缀 | ssh_apply、firewall_apply、runtime_apply |
| 只读取配置变量,不随意改全局变量 | 读取 SSH_PORT,不要重新定义它 |
| 失败时返回非 0 状态 | return 1 或让命令自然失败 |
所有关键命令通过公共 run 函数执行 | 便于 dry-run 和日志记录 |
| 每个模块输出自己的检查结果 | 便于报告汇总 |
防火墙模块实现示例:
# modules/firewall.sh
firewall_apply() {
log_info "开始配置防火墙与 fail2ban"
# 前置检查
if ! command -v ufw >/dev/null 2>&1; then
run sudo apt-get install -y ufw
fi
run sudo ufw allow "${SSH_PORT}/tcp"
# 放行业务端口
IFS=',' read -ra PORTS <<< "$BUSINESS_PORTS"
for port in "${PORTS[@]}"; do
run sudo ufw allow "${port}/tcp"
done
run sudo ufw default deny incoming
run sudo ufw default allow outgoing
log_info "防火墙模块完成"
return 0
}5.1 模块间数据传递
模块之间尽量避免通过全局变量传递数据。如果确实需要(例如 SSH 模块可能修改了端口号),统一通过配置文件或临时文件传递:
# ssh.sh 完成后向共享状态文件写入实际端口
echo "ACTUAL_SSH_PORT=$ACTUAL_PORT" >> "$STATE_FILE"
# firewall.sh 读取共享状态
source "$STATE_FILE"
ufw allow "${ACTUAL_SSH_PORT}/tcp"这比直接通过全局变量传递更安全,因为状态文件的写入和读取可以被日志记录,在排查问题时可以追溯"当时有哪些模块做了什么变更"。
六、日志与报告系统
6.1 日志文件组织
日志文件名应包含时间戳,方便多次执行后对比:
LOG_DIR="logs"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
LOG_FILE="${LOG_DIR}/init-${TIMESTAMP}.log"日志应记录三类信息:
| 类别 | 内容 | 示例 |
|---|---|---|
| 元信息 | 脚本版本、执行时间、主机名、用户 | Host: server01, User: root, Time: 2026-05-31 10:00:00 |
| 执行流 | 每个模块的开始/结束/失败 | [INFO] 开始执行模块: ssh |
| 命令记录 | 实际执行的每一条命令及其输出 | [EXEC] ufw allow 2222/tcp |
6.2 执行报告生成
报告与日志不同:日志是面向排查问题的详细记录,报告是面向人类的执行摘要。报告应该能让人在 30 秒内了解这次初始化做了什么、结果如何、有哪些需要注意的地方。
write_report() {
local REPORT_FILE="reports/report-${TIMESTAMP}.md"
cat > "$REPORT_FILE" << 'HEADER'
# 服务器初始化报告
## 基本信息
HEADER
echo "- **执行时间**:$(date '+%Y-%m-%d %H:%M:%S')" >> "$REPORT_FILE"
echo "- **主机名**:$(hostname)" >> "$REPORT_FILE"
echo "- **当前用户**:$(whoami)" >> "$REPORT_FILE"
echo "- **执行模式**:$([ "$DRY_RUN" = "true" ] && echo 'dry-run' || echo '正式执行')" >> "$REPORT_FILE"
cat >> "$REPORT_FILE" << 'MODULES'
## 模块执行结果
MODULES
for mod in "${modules[@]}"; do
if [[ " ${failed_modules[*]} " =~ " ${mod} " ]]; then
echo "- **${mod}**:❌ 失败" >> "$REPORT_FILE"
else
echo "- **${mod}**:✅ 成功" >> "$REPORT_FILE"
fi
done
cat >> "$REPORT_FILE" << 'ROLLBACK'
## 回滚提示
| 变更项 | 回滚方法 |
| --- | --- |
| SSH 配置 | 恢复 `/etc/ssh/sshd_config.d/` 中的 drop-in 文件 |
| UFW 规则 | `sudo ufw disable` 或 `sudo ufw reset` |
| apt 源 | 恢复 `/etc/apt/sources.list.bak.*` |
ROLLBACK
echo "" >> "$REPORT_FILE"
echo "完整日志:\`${LOG_FILE}\`" >> "$REPORT_FILE"
log_info "报告已生成:${REPORT_FILE}"
}报告至少应包含:
| 项目 | 内容 |
|---|---|
| 基本信息 | 执行时间、主机名、用户、脚本模式 |
| 模块结果 | env、ssh、firewall、runtime 是否成功 |
| 关键配置 | SSH 端口、业务端口、runtime 类型 |
| 回滚提示 | SSH drop-in、UFW disable、apt 源备份位置 |
七、运行与验收
7.1 初始化项目目录
mkdir -p server-init/{config,modules,logs,reports}
touch server-init/README.md把 init_server.sh 放到 server-init/ 根目录,把各模块放到 server-init/modules/。
7.2 dry-run 预览
cd server-init
sudo ./init_server.sh --config config/server.envdry-run 的输出应能让你逐条审阅将执行的命令,确认没有多余的、危险的或顺序错误的操作。
7.3 正式执行
sudo ./init_server.sh --config config/server.env --apply7.4 查看日志与报告
ls -lh logs reports
tail -n 50 logs/init-*.log
cat reports/report-*.md八、提交物清单
| 提交物 | 要求 |
|---|---|
server-init/ 完整目录 | 目录结构清晰,入口和模块分离 |
config/server.env | 配置集中,不能包含私钥和密码 |
logs/init-*.log | 能定位每个模块的开始、结束和失败原因 |
reports/report-*.md | 有执行摘要和回滚提示 |
| 关键截图 | 至少包含一次完整初始化结果和服务状态 |
九、故障排查
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
source modules/xxx.sh 找不到文件 | 当前工作目录不对 | 在脚本中用 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 定位项目根目录 |
| 模块执行顺序混乱 | 总入口没有统一编排 | 所有模块只暴露函数,由 init_server.sh 在 modules 数组中声明调用顺序 |
| 日志里只有最后一条错误 | 命令失败后没有记录模块名 | 调用模块前后都写日志,失败时打印模块名称,使用 set -e 确保失败立即停止 |
| 重新执行脚本产生重复配置 | 模块没有幂等判断 | 写文件前备份,添加规则前先检查是否存在;使用 run 函数统一处理幂等逻辑 |
set -e 导致脚本意外退出 | 某些命令返回非 0 是正常行为 | 对允许失败的命令使用 ` |
| 配置文件中的变量值包含空格 | Shell 的单词拆分导致变量被截断 | 所有变量引用使用双引号:"${MY_VAR}" 而非 ${MY_VAR} |
十、进阶任务
探索任务 1:增加模块选择参数
为 init_server.sh 增加 --only ssh,firewall 参数,只执行指定模块,便于调试和重复验收。同时增加 --skip runtime 参数,跳过某些模块。
探索任务 2:实现配置漂移检测
让脚本在每次执行前比较当前配置和上次执行时的配置,如果发现差异(意味着有人手动改过配置),在报告中用 [WARN] 标记出来。这需要在上次执行成功后将 server.env 复制到 reports/ 作为基准。
探索任务 3:增加超时保护
为每个模块的执行增加超时限制(例如 5 分钟),防止某个模块因网络问题永久阻塞。使用 timeout 命令包裹模块调用,超时后将模块标记为失败并在报告中说明。
