概述

凌晨两点,告警炸了。排查发现某台服务器装了旧版本 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 的工作流程:

步骤动作耗时(参考)
1Packer 启动临时实例20-40s
2SSH 连接实例5-10s
3Provisioner 执行配置3-10min
4停机并打快照1-3min
5注册为 AMI/镜像1-2min
6销毁临时实例10-20s
7Post-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 配置做了几件事:

  1. validate:模板改动时自动检查 HCL 格式和语法
  2. build:main 分支模板变更时构建镜像
  3. scan:构建后自动 Trivy 扫描
  4. 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=canary10% 生产运维触发
全量发布Status=production100% 生产运维触发
退役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 roles15-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 配置复杂度爆炸。小步快跑,先让一个服务的镜像流水线跑通,再横向复制。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Build Images in CI/CD | Packer — HashiCorp,Packer 在 CI/CD 中构建镜像的官方指南,涵盖了不同 CI 平台与 Packer Builder 的集成方式
  2. Packer HCL2 配置文件支持 — 火山引擎,Packer HCL2 语法的参考示例和 source block 配置说明
  3. How to Build Immutable Infrastructure with Packer and CircleCI Workflows — CircleCI,使用 Packer 在 CI 中构建不可变基础设施的实践案例
  4. Using Packer and Ansible to Build Immutable Infrastructure in CodeShip — CodeShip,Packer 与 Ansible Provisioner 集成的工程实践
  5. 运维自动化实战:从手动挣扎到优雅掌控 — CSDN,综合运维自动化工具栈对比,包含 Terraform、Ansible、CI/CD 的分工定位
  6. Linux 自动化部署流水线:如何结合 Ansible 实现 — php.cn,Ansible 在自动化部署流水线中作为"环境执行层"的定位与分工说明