概述
凌晨两点,告警炸了。排查发现某台服务器装了旧版本 OpenSSL,存在已知漏洞。安全团队问:还有多少台机器是同样的状态?你说不上来。
这就是手动装服务器的代价。每台机器各自 apt install,装出来的环境千差万别。有人忘了打补丁,有人装了多余包,有人改了内核参数却没记下来。等出事了才发现——你根本不知道线上到底跑了多少种"配置漂移"的机器。
Packer 解决的就是这个问题。它把"装一台服务器"这件事变成代码:你写一个模板,Packer 启动一台临时机器,按模板配置好,打成镜像(AMI、VMDK、Docker 镜像、qcow2 等),然后销毁临时机器。下次要新服务器?直接用镜像启动,秒级就绪,配置完全一致。
这篇文章不教你跑个 Hello World。我聊的是生产级的黄金镜像流水线:从 HCL2 模板设计、Ansible Provisioner 集成、CI/CD 自动触发,到 Trivy 安全扫描、镜像版本管理和灰度发布。每一步都带踩坑实录。
什么叫"黄金镜像"
先说清楚概念。“黄金镜像”(Golden Image)就是经过标准化配置、安全加固、测试通过的机器模板。你拿它启动的每一台服务器,初始状态都一模一样。
打个比方:以前装服务器像手工捏饺子,每个形状都不太一样。用了黄金镜像,就像用了模具——出来的每个饺子大小形状完全相同,质量可控。
| 对比维度 | 手动配置 | 黄金镜像 |
|---|---|---|
| 初始化时间 | 15-45 分钟 | 30-90 秒 |
| 配置一致性 | 依赖人工,经常漂移 | 镜像锁定,完全一致 |
| 安全基线 | 每台可能不同 | 构建时统一加固 |
| 可审计性 | 难以追溯 | 镜像版本可追溯 |
| 回滚能力 | 基本没有 | 切换镜像版本即可 |
| 扩容速度 | 慢,SSH 逐台配置 | 快,直接启动新实例 |
Netflix 2013 年就开始用这个模式了。他们叫 “Bake and Deploy”——先把代码"烤"进 AMI,然后用 Spinnaker 部署。一台机器出问题?直接销毁,用同一镜像启动新的。这比 SSH 上去排查靠谱多了。
Packer 核心概念
Packer 是 HashiCorp 出的开源工具,干的事情很简单:根据模板定义,启动临时机器,跑配置脚本,然后打镜像。机器用完即销毁。
三大核心组件
Packer 的模板(HCL2 语法)由三个核心部分组成:
1. Source(数据源):告诉 Packer 从哪个基础镜像开始构建。比如 AWS 的某个 Ubuntu AMI,或者本地的 ISO 文件。
2. Provisioner(配置器):在临时机器上执行的操作。Shell 脚本、Ansible Playbook、文件上传都行。
3. Post-Processors(后处理器):镜像构建完成后的处理动作。比如推送到镜像仓库、打标签、触发扫描。
Source (基础镜像) → Provisioner (配置) → Post-Processors (后处理)
一图看懂 Packer 的工作流程:
| 步骤 | 动作 | 耗时(参考) |
|---|---|---|
| 1 | Packer 启动临时实例 | 20-40s |
| 2 | SSH 连接实例 | 5-10s |
| 3 | Provisioner 执行配置 | 3-10min |
| 4 | 停机并打快照 | 1-3min |
| 5 | 注册为 AMI/镜像 | 1-2min |
| 6 | 销毁临时实例 | 10-20s |
| 7 | Post-Processor 执行 | 30-60s |
HCL2 vs JSON
Packer 1.7 之前用 JSON 模板,之后全面转向 HCL2(HashiCorp Configuration Language)。HCL2 支持变量、条件判断、循环,比 JSON 灵活得多。
别再用 JSON 模板了。HashiCorp 官方已经推荐 HCL2,JSON 模板处于维护模式,新功能不会向后移植。
项目结构设计
生产级 Packer 项目不该是一个大单文件。我推荐的目录结构:
packer-golden-images/
├── variables.pkr.hcl # 全局变量定义
├── sources.pkr.hcl # 数据源定义
├── build.pkr.hcl # 构建流程定义
├── scripts/ # Shell 配置脚本
│ ├── base-setup.sh # 基础系统配置
│ ├── security-hardening.sh # 安全加固
│ └── cleanup.sh # 清理临时文件
├── ansible/ # Ansible Playbook
│ ├── playbook.yml
│ ├── inventory.ini
│ └── roles/
│ ├── common/
│ ├── monitoring/
│ └── hardening/
├── policies/ # 策略文件
│ └── image-policy.json # 镜像合规策略
└── ci/
└── build-image.sh # CI 构建脚本
为什么拆成多个文件?因为不同角色需要看不同部分。安全团队只关心 security-hardening.sh,运维关心 ansible/roles/common/,平台团队管 sources.pkr.hcl。放一个文件里,改一个地方就得 review 整个模板。
手把手写 Packer 模板
variables.pkr.hcl:变量定义
# ====== 全局变量 ======
variable "aws_region" {
type = string
default = "ap-northeast-1"
description = "构建镜像的 AWS 区域"
}
variable "source_ami_filter_name" {
type = string
default = "ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"
description = "基础 AMI 的名称过滤器,匹配 Ubuntu 22.04 LTS"
}
variable "instance_type" {
type = string
default = "t3.medium"
description = "构建用临时实例规格,t3.medium 够用了"
}
variable "ssh_username" {
type = string
default = "ubuntu"
}
variable "ami_name_prefix" {
type = string
default = "golden-ubuntu-2204"
}
variable "app_version" {
type = string
default = ""
description = "应用版本号,CI 注入,留空则用 timestamp"
}
# 用 timestamp 函数生成镜像名称的后缀
locals {
ami_name = "${var.ami_name_prefix}-${var.app_version != "" ? var.app_version : formatdate("YYYYMMDDhhmmss", timestamp())}"
common_tags = {
Project = "golden-images"
ManagedBy = "packer"
Os = "ubuntu-22.04"
CreatedAt = formatdate("YYYY-MM-DD", timestamp())
}
}
几个要点:
source_ami_filter_name用通配符而不是固定 AMI ID。固定 ID 会导致每次 Ubuntu 更新基础镜像后你的构建就挂。用过滤器自动选最新版,省心。instance_type用t3.medium不用t2.micro。t2.micro内存只有 1GB,跑 Ansible Playbook 时经常 OOM。别省这点钱,构建一次的实例费用几毛钱。locals块定义公共标签。给镜像打标签,后续在 AWS 控制台一眼看到哪些是 Packer 构建的。
sources.pkr.hcl:数据源定义
# ====== AWS EBS 数据源 ======
source "amazon-ebs" "ubuntu-base" {
region = var.aws_region
ami_name = local.ami_name
# 用过滤器自动选择最新的 Ubuntu 22.04 AMI
source_ami_filter {
filters = {
virtualization-type = "hvm"
name = var.source_ami_filter_name
root-device-type = "ebs"
architecture = "x86_64"
}
most_recent = true
owners = ["099720109477"] # Canonical 官方账号
}
instance_type = var.instance_type
ssh_username = var.ssh_username
# 临时安全组:只允许 Packer 构建机的 SSH
temporary_iam_instance_profile_policy_document {
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["ssm:GetParameters", "ssm:GetParameter"]
Resource = "*"
}
]
}
# EBS 根卷配置
launch_block_device_mappings {
device_name = "/dev/sda1"
volume_size = 20
volume_type = "gp3"
delete_on_termination = true
}
tags = merge(local.common_tags, {
Name = "packer-builder-ubuntu"
Purpose = "image-build"
})
}
这里有个坑很多人踩过。owners 字段必须指定。AWS 上任何人都能发布公开 AMI,如果你不指定 owner,可能选到一个被篡改的 AMI。Canonical 官方账号是 099720109477,AWS 官方 Linux 是 137112412989。
安全提醒:永远指定
owners字段。选到恶意 AMI 的后果是你的镜像里预装了后门。
build.pkr.hcl:构建流程
# ====== 构建流程 ======
build {
sources = [
"source.amazon-ebs.ubuntu-base"
]
# ---------- 第一步:Shell 基础配置 ----------
provisioner "shell" {
script = "./scripts/base-setup.sh"
execute_command = "sudo -S sh -c '{{ .Vars }} {{ .Path }}'"
environment_vars = [
"DEBIAN_FRONTEND=noninteractive",
"APP_VERSION=${var.app_version}"
]
}
# ---------- 第二步:Ansible 配置 ----------
provisioner "ansible" {
playbook_file = "./ansible/playbook.yml"
inventory_directory = "./ansible/"
user = var.ssh_username
use_proxy = false
ansible_env_vars = [
"ANSIBLE_HOST_KEY_CHECKING=False",
"ANSIBLE_PIPELINING=True",
"ANSIBLE_PYTHON_INTERPRETER=/usr/bin/python3"
]
extra_arguments = [
"--extra-vars", "app_version=${var.app_version}",
"--extra-vars", "image_build=true"
]
}
# ---------- 第三步:安全加固脚本 ----------
provisioner "shell" {
script = "./scripts/security-hardening.sh"
execute_command = "sudo -S sh -c '{{ .Vars }} {{ .Path }}'"
}
# ---------- 第四步:清理 ----------
provisioner "shell" {
script = "./scripts/cleanup.sh"
execute_command = "sudo -S sh -c '{{ .Vars }} {{ .Path }}'"
}
# ---------- 后处理:打标签 ----------
post-processors {
post-processor "amazon-import" {
only = ["amazon-ebs.ubuntu-base"]
}
post-processor "manifest" {
output_file = "manifest.json"
strip_path = true
custom_data = {
app_version = var.app_version
build_date = formatdate("YYYY-MM-DD", timestamp())
}
}
}
}
注意 Provisioner 的执行顺序。先 Shell 做基础配置(装包、设时区、改内核参数),再 Ansible 做应用层配置(装监控 agent、配置日志收集),最后 Shell 做安全加固和清理。
为什么不全用 Ansible?因为有些操作 Ansible 做起来又慢又绕。比如 apt update && apt upgrade,Shell 一行命令搞定,Ansible 用 apt 模块还得等它解析完整个包树。
实测经验:Shell 跑
apt upgrade大约 2 分钟,Ansible 的apt模块做同样的事要 4-5 分钟。差在 Ansible 每次都要 gather facts 和模块初始化开销。
基础配置脚本
scripts/base-setup.sh
#!/bin/bash
set -euo pipefail
# ============================================================
# 基础系统配置:包更新、时区、内核参数、用户管理
# ============================================================
echo ">>> [1/5] 更新系统包"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get upgrade -y -qq
apt-get install -y -qq \
curl wget vim htop tree jq unzip \
ca-certificates gnupg lsb-release \
software-properties-common \
python3 python3-pip \
chrony
echo ">>> [2/5] 设置时区"
timedatectl set-timezone Asia/Shanghai
echo ">>> [3/5] 配置 chrony 时间同步"
cat > /etc/chrony/chrony.conf << 'EOF'
# 使用阿里云 NTP,国内更稳
server ntp.aliyun.com iburst
server ntp1.aliyun.com iburst
# 允许时间大步调整(新机器时间偏差可能很大)
makestep 1.0 3
rtcsync
logdir /var/log/chrony
EOF
systemctl enable chrony
systemctl restart chrony
echo ">>> [4/5] 内核参数调优"
cat > /etc/sysctl.d/99-golden-image.conf << 'EOF'
# 文件描述符
fs.file-max = 1048576
fs.nr_open = 1048576
# 网络参数
net.core.somaxconn = 4096
net.core.netdev_max_backlog = 16384
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_keepalive_intvl = 15
net.ipv4.ip_local_port_range = 10000 65535
# 内存参数
vm.swappiness = 10
vm.overcommit_memory = 1
vm.max_map_count = 262144
# BBR 拥塞控制
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
EOF
sysctl -p /etc/sysctl.d/99-golden-image.conf
echo ">>> [5/5] 创建运维用户"
if ! id -u ops >/dev/null 2>&1; then
useradd -m -s /bin/bash -G sudo ops
# 禁止密码登录,只允许密钥
passwd -l ops
fi
echo ">>> 基础配置完成"
scripts/security-hardening.sh
#!/bin/bash
set -euo pipefail
# ============================================================
# 安全加固:SSH 加固、防火墙、审计、fail2ban
# ============================================================
echo ">>> [1/6] SSH 加固"
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
cat > /etc/ssh/sshd_config.d/99-hardening.conf << 'EOF'
# 禁止 root 直接登录
PermitRootLogin no
# 只允许密钥认证
PasswordAuthentication no
PubkeyAuthentication yes
# 禁止空密码
PermitEmptyPasswords no
# 限制最大认证尝试次数
MaxAuthTries 3
# 登录超时 120 秒
LoginGraceTime 120
# 禁止 X11 转发(服务器不需要图形界面)
X11Forwarding no
# 禁止端口转发
AllowTcpForwarding no
# 客户端活检测间隔
ClientAliveInterval 300
ClientAliveCountMax 2
# 只允许 ops 用户 SSH
AllowUsers ops
EOF
systemctl restart sshd
echo ">>> [2/6] 配置 UFW 防火墙"
apt-get install -y -qq ufw
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw --force enable
echo ">>> [3/6] 安装 fail2ban"
apt-get install -y -qq fail2ban
cat > /etc/fail2ban/jail.local << 'EOF'
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
backend = systemd
[sshd]
enabled = true
port = 22
EOF
systemctl enable fail2ban
systemctl restart fail2ban
echo ">>> [4/6] 配置审计日志"
apt-get install -y -qq auditd
cat > /etc/audit/rules.d/hardening.rules << 'EOF'
# 监控关键文件变更
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/sudoers -p wa -k sudo
-w /etc/ssh/sshd_config -p wa -k ssh
# 监控系统调用
-a always,exit -F arch=b64 -S chmod -S chown -S umask -k perm
EOF
systemctl enable auditd
systemctl restart auditd
echo ">>> [5/6] 禁用不需要的服务"
SERVICES_TO_DISABLE=(
"cups" # 打印服务
"bluetooth" # 蓝牙
"avahi-daemon" # mDNS 发现
"speech-dispatcher" # 语音
)
for svc in "${SERVICES_TO_DISABLE[@]}"; do
if systemctl list-unit-files | grep -q "$svc"; then
systemctl disable "$svc" 2>/dev/null || true
systemctl mask "$svc" 2>/dev/null || true
fi
done
echo ">>> [6/6] 自动安全更新"
cat > /etc/apt/apt.conf.d/50unattended-upgrades << 'EOF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Automatic-Reboot-Time "04:00";
EOF
apt-get install -y -qq unattended-upgrades
dpkg-reconfigure -f noninteractive unattended-upgrades
echo ">>> 安全加固完成"
scripts/cleanup.sh
#!/bin/bash
set -euo pipefail
# ============================================================
# 清理:减小镜像体积,移除敏感信息
# ============================================================
echo ">>> [1/5] 清理 apt 缓存"
apt-get autoremove -y
apt-get autoclean -y
apt-get clean
rm -rf /var/lib/apt/lists/*
rm -rf /var/cache/apt/archives/*.deb
echo ">>> [2/5] 清理临时文件"
rm -rf /tmp/*
rm -rf /var/tmp/*
rm -rf /var/log/*.gz
rm -rf /var/log/apt/*
rm -rf /root/.bash_history
rm -rf /home/*/.bash_history
rm -rf /var/lib/cloud/instances/*
echo ">>> [3/5] 清理 SSH 主机密钥"
# 镜像不应该带 SSH host key,启动时自动生成
rm -f /etc/ssh/ssh_host_*
cat > /etc/rc.local << 'EOF'
#!/bin/bash
if [ ! -f /etc/ssh/ssh_host_rsa_key ]; then
ssh-keygen -A
fi
systemctl restart sshd
EOF
chmod +x /etc/rc.local
echo ">>> [4/5] 清理机器 ID(systemd 用的唯一标识)"
# 不清理的话,所有用这个镜像启动的机器会共享同一个 ID
echo "" > /etc/machine-id
echo ">>> [5/5] 磁盘填零(减小压缩体积)"
# 对 EBS 快照有意义:填零后快照只存实际数据
dd if=/dev/zero of=/zerofile bs=1M 2>/dev/null || true
rm -f /zerofile
echo ">>> 清理完成"
关于清理脚本里删除 SSH host key 的操作:这步必须做。不删的话,所有用这个镜像启动的实例会共享同一组 host key,SSH 安全形同虚设。删除后通过
rc.local在首次启动时自动重新生成。
Ansible Provisioner 集成
Packer 内置了 Ansible Provisioner,可以把 Ansible Playbook 直接跑在构建中的临时机器上。
ansible/playbook.yml
---
- name: 配置黄金镜像中的应用层组件
hosts: all
become: true
gather_facts: true
pre_tasks:
- name: 确认 Python3 可用
raw: test -f /usr/bin/python3
changed_when: false
- name: 设置 hostname(构建临时用)
hostname:
name: "packer-build-{{ ansible_distribution_version | default('unknown') }}"
roles:
- role: common
tags: [common]
- role: monitoring
tags: [monitoring]
when: not image_build | default(false) | bool or true
- role: hardening
tags: [hardening]
post_tasks:
- name: 验证关键服务已安装
command: "which {{ item }}"
loop:
- curl
- jq
- python3
- chronyc
changed_when: false
- name: 验证 Node Exporter 端口可监听
wait_for:
port: 9100
state: started
timeout: 30
when: "'monitoring' in group_names or true"
ansible/roles/common/tasks/main.yml
---
- name: 创建应用目录结构
file:
path: "{{ item }}"
state: directory
owner: ops
group: sudo
mode: "0755"
loop:
- /opt/app
- /opt/app/bin
- /opt/app/conf
- /opt/app/logs
- /opt/app/data
- name: 安装运行时依赖
apt:
name:
- libssl-dev
- libcurl4-openssl-dev
- libxml2-dev
- zlib1g-dev
state: present
update_cache: true
- name: 配置日志轮转
template:
src: app-logrotate.j2
dest: /etc/logrotate.d/app
mode: "0644"
- name: 配置 sysctl 中的文件描述符限制
pam_limits:
domain: ops
limit_type: "-"
limit_item: nofile
value: "65536"
ansible/roles/monitoring/tasks/main.yml
---
- name: 下载 Node Exporter
unarchive:
src: "https://github.com/prometheus/node_exporter/releases/download/v{{ node_exporter_version }}/node_exporter-{{ node_exporter_version }}.linux-amd64.tar.gz"
dest: /tmp/
remote_src: true
creates: "/tmp/node_exporter-{{ node_exporter_version }}.linux-amd64/node_exporter"
- name: 安装 Node Exporter 二进制
copy:
src: "/tmp/node_exporter-{{ node_exporter_version }}.linux-amd64/node_exporter"
dest: /usr/local/bin/node_exporter
mode: "0755"
remote_src: true
- name: 创建 systemd 服务
template:
src: node_exporter.service.j2
dest: /etc/systemd/system/node_exporter.service
mode: "0644"
notify: enable node_exporter
- name: 确保不在构建阶段启动(镜像启动后才跑)
systemd:
name: node_exporter
enabled: true
state: stopped
when: image_build | default(false) | bool
这里有个关键设计:state: stopped + enabled: true。构建镜像时不要启动服务(临时机器跑完就销毁),但要设为开机自启——这样用镜像启动的新机器会自动拉起服务。
这是我踩过最深的坑。最开始忘了设
state: stopped,Node Exporter 在构建阶段就启动了。Packer 打快照时服务正在跑,快照出来的镜像启动后 systemd 状态混乱——服务显示 running 但实际进程不存在。加一行state: stopped就解决了。
ansible/roles/monitoring/templates/node_exporter.service.j2
[Unit]
Description=Prometheus Node Exporter
After=network.target
[Service]
User=ops
Group=sudo
Type=simple
ExecStart=/usr/local/bin/node_exporter \
--web.listen-address=:9100 \
--collector.systemd \
--collector.processes \
--no-collector.infiniband \
--no-collector.mdadm
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
安全扫描:Trivy 集成
镜像构建完了不代表安全。你需要扫描它有没有已知漏洞。我推荐 Trivy——速度快、误报少、免费。
ci/build-and-scan.sh
#!/bin/bash
set -euo pipefail
# ============================================================
# CI 构建 + 扫描脚本
# 用法: ./ci/build-and-scan.sh <app_version>
# ============================================================
APP_VERSION="${1:-}"
if [ -z "$APP_VERSION" ]; then
APP_VERSION=$(date '+%Y%m%d%H%M%S')
echo "未指定版本号,使用时间戳: $APP_VERSION"
fi
echo "========== [1/5] Packer 初始化 =="
packer init .
echo "Packer plugins 初始化完成"
echo "========== [2/5] Packer 格式校验 =="
packer fmt -check -diff .
if [ $? -ne 0 ]; then
echo "格式检查失败,请运行 packer fmt . 修复"
exit 1
fi
echo "HCL 格式校验通过"
echo "========== [3/5] Packer 构建 =="
PACKER_LOG=1 packer build \
-var "app_version=${APP_VERSION}" \
-var-file=variables.prod.pkr.hcl \
. 2>&1 | tee build.log
# 从 manifest 中提取 AMI ID
AMI_ID=$(jq -r '.builds[0].artifact_id' manifest.json | head -1)
echo "构建完成,AMI: ${AMI_ID}"
echo "========== [4/5] Trivy 漏洞扫描 =="
# 用 Trivy 扫描 AMI(需要 AWS 权限)
trivy server --listen 0.0.0.0:4954 &
TRIVY_PID=$!
sleep 3
# 扫描方式:拉取 AMI 的文件系统清单(通过 AWS Systems Manager)
trivy fs --server http://localhost:4954 \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--format json \
--output scan-result.json \
--exit-code 1 \
/tmp/scan-target/
SCAN_EXIT=$?
kill $TRIVY_PID 2>/dev/null || true
if [ $SCAN_EXIT -ne 0 ]; then
echo "!!! 安全扫描发现 HIGH/CRITICAL 漏洞,构建失败"
echo "扫描结果:"
jq '.Results[] | select(.Vulnerabilities != null) | .Vulnerabilities[] | {ID: .VulnerabilityID, Pkg: .PkgName, Severity: .Severity, Fix: .FixedVersion}' scan-result.json
exit 1
fi
echo "安全扫描通过,无 HIGH/CRITICAL 漏洞"
echo "========== [5/5] 更新镜像版本注册表 =="
# 记录到版本文件,供后续部署使用
echo "{\"ami_id\": \"${AMI_ID}\", \"version\": \"${APP_VERSION}\", \"date\": \"$(date -Iseconds)\", \"scan\": \"passed\"}" >> docs/image-versions.jsonl
echo "版本注册完成"
echo ""
echo "构建成功!"
echo " AMI ID: ${AMI_ID}"
echo " Version: ${APP_VERSION}"
echo " Scan: PASSED"
OPA 策略验证
光扫描还不够。你可能需要确保镜像满足特定的合规要求——比如"不允许安装 telnet"或者"必须有 fail2ban"。用 OPA(Open Policy Agent)做策略检查:
# policies/image_policy.rego
package image.compliance
# 规则1:必须安装 fail2ban
deny[msg] {
not input.installed_packages["fail2ban"]
msg := "合规失败:未安装 fail2ban"
}
# 规则2:不允许安装 telnet
deny[msg] {
input.installed_packages["telnet"]
msg := "合规失败:检测到 telnet 已安装,存在安全风险"
}
# 规则3:SSH 必须禁止密码登录
deny[msg] {
input.ssh_config.password_authentication == "yes"
msg := "合规失败:SSH 仍允许密码登录"
}
# 规则4:必须有自动安全更新
deny[msg] {
not input.unattended_upgrades_enabled
msg := "合规失败:未配置自动安全更新"
}
CI/CD 集成
GitLab CI 配置
# .gitlab-ci.yml
stages:
- validate
- build
- scan
- publish
variables:
PACKER_VERSION: "1.11"
TF_IN_AUTOMATION: "true"
validate_template:
stage: validate
image: hashicorp/packer:${PACKER_VERSION}
script:
- packer init .
- packer fmt -check -diff .
- packer validate -var "app_version=ci-test" .
rules:
- changes:
- "*.pkr.hcl"
- scripts/**/*
- ansible/**/*
build_image:
stage: build
image: hashicorp/packer:${PACKER_VERSION}
script:
- apk add --no-cache jq
- export APP_VERSION=${CI_COMMIT_SHORT_SHA}
- packer build -var "app_version=${APP_VERSION}" .
- AMI_ID=$(jq -r '.builds[0].artifact_id' manifest.json)
- echo "AMI_ID=${AMI_ID}" > build.env
artifacts:
reports:
dotenv: build.env
paths:
- manifest.json
- scan-result.json
expire_in: 30 days
rules:
- if: $CI_COMMIT_BRANCH == "main"
changes:
- "*.pkr.hcl"
- scripts/**/*
- ansible/**/*
needs:
- validate_template
scan_image:
stage: scan
image: aquasec/trivy:latest
script:
- trivy fs --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 --format json --output scan-result.json /tmp/
- trivy fs --severity HIGH,CRITICAL --ignore-unfixed --format table .
needs:
- build_image
rules:
- if: $CI_COMMIT_BRANCH == "main"
publish_image:
stage: publish
image: amazon/aws-cli:latest
script:
- aws ec2 create-tags --resources ${AMI_ID} --tags Key=Status,Value=approved Key=Version,Value=${CI_COMMIT_SHORT_SHA}
- aws ssm put-parameter --name "/golden-images/latest" --value ${AMI_ID} --type String --overwrite
- |
# 保留最近 5 个版本,旧版本标记为 deprecated
aws ec2 describe-images --owners self --filters "Name=tag:Project,Values=golden-images" --query 'sort_by(Images,&CreationDate)[::-1]' > images.json
DEPRECATED_COUNT=$(echo $(echo $(cat images.json | jq length)) - 5 | bc)
if [ $DEPRECATED_COUNT -gt 0 ]; then
for i in $(seq 0 $((DEPRECATED_COUNT - 1))); do
OLD_AMI=$(jq -r ".[$((${i} + 5))].ImageId" images.json)
aws ec2 create-tags --resources ${OLD_AMI} --tags Key=Status,Value=deprecated
echo "Deprecated: ${OLD_AMI}"
done
fi
needs:
- scan_image
rules:
- if: $CI_COMMIT_BRANCH == "main"
这段 CI 配置做了几件事:
- validate:模板改动时自动检查 HCL 格式和语法
- build:main 分支模板变更时构建镜像
- scan:构建后自动 Trivy 扫描
- publish:扫描通过后更新 SSM Parameter Store 中的"最新镜像 ID",旧版本自动打 deprecated 标签
关键设计:最新镜像 ID 存在 AWS Systems Manager Parameter Store 里(
/golden-images/latest),而不是硬编码在部署脚本中。Terraform 部署时用data "aws_ssm_parameter" "latest_ami"动态读取。这样镜像更新后,下次 Terraform apply 自动用新镜像,不需要改代码。
GitHub Actions 配置
如果你们用 GitHub Actions:
name: Build Golden Image
on:
push:
branches: [main]
paths:
- 'packer/**'
- 'scripts/**'
- 'ansible/**'
workflow_dispatch:
inputs:
app_version:
description: 'Image version tag'
required: false
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-packer
aws-region: ap-northeast-1
- name: Setup Packer
uses: hashicorp/packer-github-action@v4
with:
version: latest
- name: Init and Validate
working-directory: packer
run: |
packer init .
packer fmt -check -diff .
packer validate -var "app_version=test" .
- name: Build Image
id: build
working-directory: packer
run: |
VERSION="${{ github.event.inputs.app_version || github.sha }}"
packer build -var "app_version=${VERSION}" .
AMI_ID=$(jq -r '.builds[0].artifact_id' manifest.json)
echo "ami_id=${AMI_ID}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Trivy Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '/tmp/scan-target'
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
format: 'sarif'
output: 'trivy-results.sarif'
exit-code: '1'
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
- name: Publish to SSM
run: |
aws ssm put-parameter \
--name "/golden-images/latest" \
--value "${{ steps.build.outputs.ami_id }}" \
--type String --overwrite
生产级踩坑实录
坑1:SSH 超时导致构建失败
现象:Packer 构建到一半报 Error: timeout while waiting for SSH。
原因:临时实例的安全组没开 SSH 端口,或者 VPC 的 Network ACL 拦截了。
修复:在 source 块中显式指定 security_group_id 或者用 Packer 的 temporary_security_group_source_ip 指定允许的来源 IP。如果你的构建在 CI Runner 里跑,Runner 的 IP 不固定,用 temporary_security_group_source_ip = "0.0.0.0/0" 配合 ssh_timeout 延长。
source "amazon-ebs" "ubuntu-base" {
# ... 其他配置 ...
ssh_timeout = "10m" # 默认 5 分钟,网络差时不够
temporary_security_group_source_ip = "0.0.0.0/0"
}
实测:在跨国 CI Runner(比如 GitHub Actions 的美国 runner 构建亚太区域 AMI)场景下,SSH 连接建立可能要 2-3 分钟。默认 5 分钟超时看似够,但如果碰到 AWS 区域性网络抖动,5 分钟真不一定够。我设为 10 分钟后再没出过问题。
坑2:Ansible Playbook 找不到 Python
现象:Packer 跑到 Ansible Provisioner 阶段报错 module python3 not found。
原因:Ubuntu 22.04 默认装的 Python 在 /usr/bin/python3,但 Ansible 的 gather_facts 默认找 /usr/bin/python。没有软链接就报错。
修复:在 source 块的 Shell Provisioner 里加一行安装 python3 并创建软链接。或者用 ansible_env_vars 指定解释器路径(我在上面的 build.pkr.hcl 里已经加了 ANSIBLE_PYTHON_INTERPRETER=/usr/bin/python3)。
坑3:镜像体积膨胀
现象:构建出来的 AMI 快照 15GB,但实际系统只用了 6GB。
原因:apt update 下载的包缓存没清理,日志文件累积,临时文件残留。
修复:这就是 cleanup.sh 存在的意义。重点操作:
| 清理项 | 节省体积 | 备注 |
|---|---|---|
| apt 缓存 | 1-3 GB | /var/cache/apt/archives/ |
| 日志文件 | 200-500 MB | /var/log/*.gz 等 |
| 临时文件 | 100-200 MB | /tmp/* /var/tmp/* |
| cloud-init 残留 | 50-100 MB | /var/lib/cloud/instances/ |
| 磁盘填零 | 0(压缩后变小) | dd if=/dev/zero 后 rm |
磁盘填零这个操作看着怪,但很重要。EBS 快照是增量备份——删除文件后快照层面数据还在(只是标记为可覆盖)。dd if=/dev/zero 把未使用的磁盘块填零,快照就能识别这些块不需要存储,压缩后体积更小。
坑4:多区域镜像复制
现象:在 ap-northeast-1 构建的 AMI,在 us-east-1 用不了。
原因:AMI 是区域级资源,不跨区域自动可用。
修复:Packer 支持多区域构建,但更常见的做法是构建后用 post-processor 复制:
build {
sources = ["source.amazon-ebs.ubuntu-base"]
# ... provisioners ...
post-processor "amazon-ami-copy" {
regions = ["us-east-1", "eu-west-1", "ap-southeast-1"]
ami_name = local.ami_name
tags = merge(local.common_tags, {
CopiedFrom = var.aws_region
})
}
}
注意:跨区域复制会产生额外费用。AWS 按 GB 收跨区域数据传输费。一个 10GB 的 AMI 复制到 3 个区域,传输费约 3 × 10GB × $0.02/GB = $0.6/次。构建频率高的话,月费用不低。建议只在发布到生产环境时才复制到所有区域,测试镜像只留在构建区域。
坑5:Packer 构建残留临时资源
现象:AWS 账单里出现一堆没见过的 EC2 实例和安全组,都是 packer- 开头。
原因:Packer 构建中断(CI 超时、脚本报错),临时实例和安全组没清理。
修复:两个方案:
方案一:Packer 自带清理机制。构建失败时 Packer 会尝试销毁临时实例。但 CI 超时 kill 掉 Packer 进程后就不管了。
方案二:写一个清理脚本,定期跑:
#!/bin/bash
# cleanup-packer-residuals.sh
# 清理 2 小时前创建的 Packer 残留资源
REGIONS=("ap-northeast-1" "us-east-1")
for REGION in "${REGIONS[@]}"; do
echo ">>> 清理区域: ${REGION}"
# 清理残留 EC2 实例
INSTANCES=$(aws ec2 describe-instances \
--region "$REGION" \
--filters "Name=tag:ManagedBy,Values=packer" "Name=instance-state-name,Values=running,pending" \
--query 'Reservations[].Instances[].InstanceId' \
--output text)
for INSTANCE_ID in $INSTANCES; do
LAUNCH_TIME=$(aws ec2 describe-instances \
--region "$REGION" \
--instance-ids "$INSTANCE_ID" \
--query 'Reservations[0].Instances[0].LaunchTime' \
--output text)
# 只清理 2 小时前启动的(假设构建不会超过 2 小时)
if [ -n "$LAUNCH_TIME" ]; then
AGE_HOURS=$(echo $(($(date +%s) - $(date -d "$LAUNCH_TIME" +%s))) / 3600)
if [ "$AGE_HOURS" -ge 2 ]; then
echo " 终止残留实例: ${INSTANCE_ID} (运行 ${AGE_HOURS}h)"
aws ec2 terminate-instances --region "$REGION" --instance-ids "$INSTANCE_ID"
fi
fi
done
# 清理残留安全组
SGs=$(aws ec2 describe-security-groups \
--region "$REGION" \
--filters "Name=group-name,Values=packer-*" \
--query 'SecurityGroups[].GroupId' \
--output text 2>/dev/null || true)
for SG_ID in $SGs; do
echo " 删除残留安全组: ${SG_ID}"
aws ec2 delete-security-group --region "$REGION" --group-id "$SG_ID" 2>/dev/null || true
done
done
把这个脚本加到 cron 里,每天跑一次。省得月底对账时发现一堆幽灵实例。
镜像生命周期管理
构建出镜像只是第一步。你得管理它的整个生命周期:
构建 → 测试 → 批准 → 灰度发布 → 全量替换 → 退役旧版本
版本管理策略
| 阶段 | 镜像标签 | 谁能用 | 自动化程度 |
|---|---|---|---|
| 刚构建 | Status=pending | 无人 | CI 自动 |
| 扫描通过 | Status=scanned | 测试环境 | CI 自动 |
| 人工审批 | Status=approved | 预发布环境 | 人工触发 |
| 灰度发布 | Status=canary | 10% 生产 | 运维触发 |
| 全量发布 | Status=production | 100% 生产 | 运维触发 |
| 退役 | Status=deprecated | 无(待删除) | 30天后自动 |
这套标签体系的核心思路:镜像不是构建完就直接上生产。它得经过扫描、审批、灰度三道关卡。CI 负责前两步(构建+扫描),后面的灰度和全量发布由运维手动触发。完全自动化不是好事——镜像出了问题,至少还有人在环里。
HCP Packer vs 自建
HashiCorp Cloud Platform(HCP)Packer 提供了托管的镜像版本管理服务,可以追踪"哪个镜像版本在哪台机器上跑了"。对小团队来说很方便。
但如果你不想上 SaaS,自建方案:
# 在构建后写入版本注册表
post-processor "manifest" {
output_file = "manifest.json"
strip_path = true
custom_data = {
version = var.app_version
build_date = formatdate("YYYY-MM-DD'T'HH:mm:ssZ", timestamp())
git_commit = var.git_commit_sha
scan_status = "pending"
release_stage = "pending"
}
}
然后写一个简单脚本把 manifest.json 推到 S3 或数据库里。查询时直接查版本注册表:“生产环境现在跑的是哪个版本的镜像?"——一目了然。
Terraform 联动
镜像构建完了,怎么用?Terraform 部署时动态读取最新镜像:
# main.tf
data "aws_ssm_parameter" "latest_golden_ami" {
name = "/golden-images/latest"
}
# 生产环境 ASG 使用最新黄金镜像
resource "aws_launch_template" "app" {
name = "app-launch-template"
image_id = data.aws_ssm_parameter.latest_golden_ami.value
instance_type = "t3.large"
key_name = "ops-key"
user_data = base64encode(<<-EOF
#!/bin/bash
# 镜像里已经装好了所有基础组件
# user_data 只做运行时配置
echo "Instance started with golden image: $(cat /etc/golden-image-version)"
systemctl start node_exporter
EOF
)
monitoring {
enabled = true
}
tag_specifications {
resource_type = "instance"
tags = {
Name = "app-server"
Managed = "terraform"
Image = "golden"
}
}
}
resource "aws_autoscaling_group" "app" {
name = "app-asg"
vpc_zone_identifier = module.vpc.private_subnets
target_group_arns = [aws_lb_target_group.app.arn]
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
min_size = 3
max_size = 10
desired_capacity = 3
# 滚动更新策略
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 50
instance_warmup = 120
}
triggers = ["tag"]
}
}
instance_refresh 是关键。当 Launch Template 更新(因为镜像 ID 变了),ASG 会自动滚动替换旧实例。每次替换一批,保证 50% 实例健康。这就是不可变基础设施的部署方式——不是 SSH 上去 apt upgrade,而是直接换镜像。
实际效果:镜像更新后,ASG 滚动替换 10 台实例大约 15-20 分钟。每台新实例启动后 90 秒内就 ready 了(因为镜像里什么都装好了)。对比手动 SSH 逐台
apt upgrade的 40 分钟,速度和可靠性都强太多。
成本优化
Packer 构建不是免费的。几个省钱的地方:
| 优化项 | 预估节省 | 做法 |
|---|---|---|
| 实例规格降级 | 40-60% | 构建 CPU 密集型任务用 c5.large,IO 密集用 t3.medium,别用 m5.large 这种大规格 |
| 构建频率 | 30-50% | 不是每次提交都构建。只在合并到 main 分支时触发 |
| EBS 快照清理 | 20-30% | 定期删除 deprecated 镜像的快照 |
| Spot 实例 | 60-80% | 构建 job 用 Spot Runner,断了重跑 |
| 缓存 Ansible roles | 15-20% | 用 ansible-galaxy install 的缓存目录 |
# 定期清理旧镜像脚本
#!/bin/bash
# 清理 30 天前标记为 deprecated 的 AMI
DEPRECATED_AMIS=$(aws ec2 describe-images \
--owners self \
--filters "Name=tag:Status,Values=deprecated" \
--query "Images[?CreationDate<='$(date -d '30 days ago' -Iseconds)'].ImageId" \
--output text)
for AMI_ID in $DEPRECATED_AMIS; do
# 先删快照再删 AMI
SNAPSHOTS=$(aws ec2 describe-images \
--image-ids "$AMI_ID" \
--query 'BlockDeviceMappings[?Ebs.SnapshotId].Ebs.SnapshotId' \
--output text)
aws ec2 deregister-image --image-id "$AMI_ID"
for SNAP_ID in $SNAPSHOTS; do
aws ec2 delete-snapshot --snapshot-id "$SNAP_ID"
done
echo "Cleaned up: ${AMI_ID}"
done
总结
Packer 不是什么新概念了,Netflix 用了十多年。但在国内运维圈,手动装服务器的习惯还非常普遍。原因很简单:学习成本。写 HCL 模板、配 Ansible Provisioner、搞 CI/CD 集成——这套东西搞起来比 apt install 麻烦多了。
但从长远看,不划算。手动配置的"省事"是假省事:省了模板编写的时间,花了排障的时间;省了自动化构建的成本,付了配置漂移的代价。黄金镜像模式的核心价值不是"快”,而是"一致"——线上 100 台服务器,你可以确信它们的环境完全相同,因为它们来自同一个镜像。
落地建议:别一上来就全量迁移。先拿一个服务试水:写好 Packer 模板,跑通 CI 构建,在测试环境验证。跑稳了再逐步推广。我见过太多团队想一步到位,结果模板写到一半就烂尾了——Provisioner 太多、角色划分不清、CI 配置复杂度爆炸。小步快跑,先让一个服务的镜像流水线跑通,再横向复制。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- Build Images in CI/CD | Packer — HashiCorp,Packer 在 CI/CD 中构建镜像的官方指南,涵盖了不同 CI 平台与 Packer Builder 的集成方式
- Packer HCL2 配置文件支持 — 火山引擎,Packer HCL2 语法的参考示例和 source block 配置说明
- How to Build Immutable Infrastructure with Packer and CircleCI Workflows — CircleCI,使用 Packer 在 CI 中构建不可变基础设施的实践案例
- Using Packer and Ansible to Build Immutable Infrastructure in CodeShip — CodeShip,Packer 与 Ansible Provisioner 集成的工程实践
- 运维自动化实战:从手动挣扎到优雅掌控 — CSDN,综合运维自动化工具栈对比,包含 Terraform、Ansible、CI/CD 的分工定位
- Linux 自动化部署流水线:如何结合 Ansible 实现 — php.cn,Ansible 在自动化部署流水线中作为"环境执行层"的定位与分工说明