运维自动化中的幂等性设计原则

一、什么是幂等性

幂等性(Idempotency) 是数学和计算机科学中的重要概念,指一个操作无论执行多少次,产生的结果都相同。

在运维自动化中,幂等性意味着:

脚本执行一次和执行一百次,系统最终状态一致。

1.1 为什么重要

1
2
3
4
5
6
7
8
# 非幂等操作示例
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
# 执行 3 次后:3 行重复配置

# 幂等操作示例
grep -q "nameserver 8.8.8.8" /etc/resolv.conf || \
echo "nameserver 8.8.8.8" >> /etc/resolv.conf
# 执行 3 次后:1 行配置(与执行 1 次相同)

非幂等的代价:

  • 重复执行导致配置污染
  • 重试机制失效
  • 回滚困难
  • 无法安全地「再跑一遍」

二、幂等性设计模式

2.1 模式一:状态检查 + 条件执行

核心思想: 先检查目标状态,仅在需要时执行变更。

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
# 非幂等
apt install -y nginx

# 幂等
if ! dpkg -l | grep -q "^ii.*nginx"; then
apt install -y nginx
echo "Nginx installed"
else
echo "Nginx already installed, skip"
fi

通用模板:

1
2
3
4
5
6
7
8
9
10
11
ensure_state() {
local target_state="$1"
local current_state=$(get_current_state)

if [ "$current_state" != "$target_state" ]; then
apply_change "$target_state"
log "Changed from $current_state to $target_state"
else
log "Already in desired state, skip"
fi
}

2.2 模式二:原子操作 + 结果验证

核心思想: 操作要么完全成功,要么完全失败,不留中间状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/bash
# 非原子操作
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak
cat > /etc/nginx/nginx.conf << 'EOF'
...新配置...
EOF
systemctl reload nginx
# 如果第 3 步失败,配置文件已损坏

# 原子操作
TEMP_CONF=$(mktemp)
cat > "$TEMP_CONF" << 'EOF'
...新配置...
EOF

# 验证新配置
if nginx -t -c "$TEMP_CONF" 2>/dev/null; then
cp "$TEMP_CONF" /etc/nginx/nginx.conf
systemctl reload nginx
echo "Config updated successfully"
else
rm "$TEMP_CONF"
echo "Config validation failed, aborting"
exit 1
fi

2.3 模式三:声明式配置

核心思想: 描述「应该是什么」,而不是「怎么做」。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
# 命令式(非幂等)
useradd -m deploy
echo "deploy ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

# 声明式(幂等)
# 使用 Ansible
- name: Ensure deploy user exists
user:
name: deploy
state: present
create_home: yes

- name: Ensure deploy has sudo access
lineinfile:
path: /etc/sudoers
line: "deploy ALL=(ALL) NOPASSWD:ALL"
state: present
validate: 'visudo -cf %s'

Shell 实现声明式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
declare_state() {
local resource="$1"
local desired="$2"
local current=$(get_resource_state "$resource")

if [ "$current" != "$desired" ]; then
set_resource_state "$resource" "$desired"
echo "Changed $resource: $current -> $desired"
fi
}

# 使用
declare_state "user:deploy" "present"
declare_state "service:nginx" "running"
declare_state "port:80" "open"

2.4 模式四:幂等键(Idempotency Key)

核心思想: 为每个操作生成唯一标识,避免重复执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/bin/bash
# 使用锁文件作为幂等键
IDEMPOTENCY_KEY="deploy_app_v1.2.3_$(hostname)"
LOCK_FILE="/var/run/idempotency/${IDEMPOTENCY_KEY}.lock"

acquire_lock() {
mkdir -p /var/run/idempotency

if [ -f "$LOCK_FILE" ]; then
# 检查锁是否过期(防止死锁)
local lock_age=$(($(date +%s) - $(stat -c %Y "$LOCK_FILE")))
if [ $lock_age -gt 3600 ]; then
echo "Stale lock detected, removing"
rm -f "$LOCK_FILE"
else
echo "Operation already in progress or completed"
return 1
fi
fi

echo $$ > "$LOCK_FILE"
trap "rm -f $LOCK_FILE" EXIT
return 0
}

if acquire_lock; then
echo "Executing deployment..."
# 部署逻辑
deploy_application
echo "Deployment completed"
else
echo "Skipping duplicate execution"
fi

三、常见场景的幂等实现

3.1 文件操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
# 场景:确保配置文件存在且内容正确

ensure_file_content() {
local file="$1"
local content="$2"
local temp_file=$(mktemp)

echo "$content" > "$temp_file"

# 文件不存在或内容不同则更新
if [ ! -f "$file" ] || ! diff -q "$temp_file" "$file" > /dev/null 2>&1; then
cp "$temp_file" "$file"
echo "File updated: $file"
else
echo "File unchanged: $file"
fi

rm -f "$temp_file"
}

# 使用
ensure_file_content "/etc/hosts.deny" "ALL: ALL"

3.2 服务管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/bin/bash
# 场景:确保服务处于期望状态

ensure_service_state() {
local service="$1"
local desired_state="$2" # running or stopped
local current_state

current_state=$(systemctl is-active "$service" 2>/dev/null || echo "inactive")

case "$desired_state" in
running)
if [ "$current_state" != "active" ]; then
systemctl start "$service"
echo "Service $service started"
else
echo "Service $service already running"
fi
;;
stopped)
if [ "$current_state" = "active" ]; then
systemctl stop "$service"
echo "Service $service stopped"
else
echo "Service $service already stopped"
fi
;;
esac
}

# 使用
ensure_service_state "nginx" "running"
ensure_service_state "telnet" "stopped"

3.3 网络配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
# 场景:确保防火墙规则存在

ensure_firewall_rule() {
local chain="$1"
local rule="$2"

# 检查规则是否已存在
if iptables -C "$chain" $rule 2>/dev/null; then
echo "Rule already exists: $rule"
else
iptables -A "$chain" $rule
echo "Rule added: $rule"
fi
}

# 使用
ensure_firewall_rule "INPUT" "-p tcp --dport 22 -j ACCEPT"
ensure_firewall_rule "INPUT" "-p tcp --dport 80 -j ACCEPT"

3.4 数据库操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/bin/bash
# 场景:确保数据库和用户存在

ensure_mysql_database() {
local db_name="$1"
local mysql_cmd="mysql -u root -p${MYSQL_ROOT_PASSWORD}"

# 检查数据库是否存在
if $mysql_cmd -e "USE $db_name" 2>/dev/null; then
echo "Database $db_name exists"
else
$mysql_cmd -e "CREATE DATABASE $db_name CHARACTER SET utf8mb4"
echo "Database $db_name created"
fi
}

ensure_mysql_user() {
local username="$1"
local password="$2"
local host="$3"
local mysql_cmd="mysql -u root -p${MYSQL_ROOT_PASSWORD}"

# 检查用户是否存在
local user_exists=$($mysql_cmd -N -e \
"SELECT COUNT(*) FROM mysql.user WHERE User='$username' AND Host='$host'")

if [ "$user_exists" -eq 0 ]; then
$mysql_cmd -e "CREATE USER '$username'@'$host' IDENTIFIED BY '$password'"
echo "User $username@$host created"
else
echo "User $username@$host exists"
fi
}

3.5 包管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/bin/bash
# 场景:确保软件包处于期望状态

ensure_package() {
local package="$1"
local state="$2" # present or absent
local pm="$3" # apt/yum/dnf

case "$pm" in
apt)
if [ "$state" = "present" ]; then
if dpkg -l | grep -q "^ii.*$package"; then
echo "Package $package installed"
else
apt install -y "$package"
echo "Package $package installed"
fi
else
if dpkg -l | grep -q "^ii.*$package"; then
apt remove -y "$package"
echo "Package $package removed"
else
echo "Package $package not installed"
fi
fi
;;
yum|dnf)
if [ "$state" = "present" ]; then
if rpm -q "$package" > /dev/null 2>&1; then
echo "Package $package installed"
else
$pm install -y "$package"
echo "Package $package installed"
fi
else
if rpm -q "$package" > /dev/null 2>&1; then
$pm remove -y "$package"
echo "Package $package removed"
else
echo "Package $package not installed"
fi
fi
;;
esac
}

四、幂等性测试方法

4.1 重复执行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/bin/bash
# 测试脚本幂等性

test_idempotency() {
local script="$1"
local test_name="$2"

echo "=== Testing idempotency: $test_name ==="

# 第一次执行
echo "Run 1:"
output1=$($script 2>&1)
state1=$(capture_system_state)

# 第二次执行
echo "Run 2:"
output2=$($script 2>&1)
state2=$(capture_system_state)

# 第三次执行
echo "Run 3:"
output3=$($script 2>&1)
state3=$(capture_system_state)

# 验证状态一致
if [ "$state1" = "$state2" ] && [ "$state2" = "$state3" ]; then
echo "✓ PASS: System state consistent across runs"
else
echo "✗ FAIL: System state changed between runs"
return 1
fi

# 验证输出合理(第二次后应该都是 skip/unchanged)
if echo "$output2" | grep -q "already\|skip\|unchanged"; then
echo "✓ PASS: Subsequent runs detected no changes needed"
else
echo "⚠ WARNING: Subsequent runs may have made unnecessary changes"
fi
}

4.2 状态验证清单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
# 系统状态捕获函数

capture_system_state() {
echo "=== PACKAGES ==="
dpkg --get-selections 2>/dev/null | sort

echo "=== SERVICES ==="
systemctl list-units --type=service --state=running --no-pager | sort

echo "=== USERS ==="
cut -d: -f1 /etc/passwd | sort

echo "=== FIREWALL ==="
iptables-save 2>/dev/null | sort

echo "=== CRON ==="
crontab -l 2>/dev/null | sort

echo "=== FILES ==="
# 关键配置文件 checksum
md5sum /etc/hosts /etc/resolv.conf /etc/hostname 2>/dev/null | sort
}

五、幂等性反模式

5.1 反模式一:盲目追加

1
2
3
4
5
6
# ❌ 错误
echo "127.0.0.1 example.com" >> /etc/hosts

# ✓ 正确
grep -q "127.0.0.1 example.com" /etc/hosts || \
echo "127.0.0.1 example.com" >> /etc/hosts

5.2 反模式二:无条件创建

1
2
3
4
5
# ❌ 错误
useradd -m deploy

# ✓ 正确
id deploy >/dev/null 2>&1 || useradd -m deploy

5.3 反模式三:忽略返回值

1
2
3
4
5
6
7
8
9
# ❌ 错误
systemctl restart nginx
# 即使失败也继续执行

# ✓ 正确
if ! systemctl restart nginx; then
echo "Failed to restart nginx"
exit 1
fi

5.4 反模式四:硬编码路径

1
2
3
4
5
6
7
8
# ❌ 错误(可能覆盖已有文件)
cp config.conf /etc/app/config.conf

# ✓ 正确(先备份再替换)
if [ -f /etc/app/config.conf ]; then
cp /etc/app/config.conf /etc/app/config.conf.bak.$(date +%s)
fi
cp config.conf /etc/app/config.conf

六、工具推荐

6.1 配置管理工具(内置幂等)

工具 语言 特点
Ansible Python/YAML Agentless,声明式,学习曲线低
Puppet Ruby 成熟稳定,适合大规模
Chef Ruby 灵活,适合复杂场景
SaltStack Python 高性能,支持实时执行

6.2 Shell 幂等辅助库

1
2
3
4
5
6
# 使用 bash-boilerplate
source https://raw.githubusercontent.com/academic/bash-boilerplate/master/boilerplate.sh

ensure_command git
ensure_directory /var/log/app
ensure_file /etc/app/config.yml

6.3 自研框架示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/bin/bash
# lib/idempotent.sh

idempotent_ensure() {
local resource_type="$1"
local resource_name="$2"
local desired_state="$3"

local state_file="/var/lib/idempotent/${resource_type}_${resource_name}.state"
mkdir -p /var/lib/idempotent

local current_state="unknown"
[ -f "$state_file" ] && current_state=$(cat "$state_file")

if [ "$current_state" = "$desired_state" ]; then
echo "[SKIP] $resource_type:$resource_name already $desired_state"
return 0
fi

echo "[CHANGE] $resource_type:$resource_name $current_state -> $desired_state"
# 执行变更逻辑...
apply_change "$resource_type" "$resource_name" "$desired_state"

echo "$desired_state" > "$state_file"
return 0
}

# 使用
source lib/idempotent.sh
idempotent_ensure "package" "nginx" "installed"
idempotent_ensure "service" "nginx" "running"

七、总结

7.1 幂等性设计检查清单

  • 操作可重复执行而不产生副作用
  • 有状态检查机制
  • 有失败回滚或补偿机制
  • 日志记录变更而非仅记录执行
  • 支持并发执行(锁机制)
  • 有超时和重试策略

7.2 核心原则

  1. 声明优于命令:描述目标状态,而非执行步骤
  2. 检查先于变更:先确认需要变更,再执行
  3. 原子优于分段:要么全成功,要么全失败
  4. 可验证优于信任:执行后验证结果是否符合预期

7.3 最后一句话

好的运维脚本应该像数学公式:无论代入多少次,结果始终一致。


延伸阅读: