利用 Buildah 为运行在 AWS EKS 上的混合前端架构提供统一容器化构建


我们团队维护着一个日益复杂的混合前端生态。历史悠久的内部运营平台,基于 Ant Design 构建,稳定但技术栈陈旧;而面向市场的新一代官网和产品展示页,则全面拥抱了基于 Tailwind CSS 和 Radix UI 的 Shadcn UI,追求极致的定制化与现代感。这种技术栈的异构性直接传导到了我们的 CI/CD 流程上,引发了一系列棘手的问题:两个项目拥有两套独立的、由不同工程师维护的 Dockerfile 和构建脚本,CI 流水线冗长且充满重复逻辑,构建出的镜像大小、分层结构、基础镜像版本都存在差异。更糟糕的是,CI Runner 上的 Docker aemon 偶尔会无响应,导致整个构建队列阻塞,这在需要快速迭代的业务场景下是不可接受的。

我们的目标很明确:为这两个截然不同的前端应用建立一个统一、高效、可维护的容器化构建流程,并将其无缝部署到 AWS EKS 集群。这意味着需要剥离对 Docker 守护进程的依赖,并用一套标准化的工具链来抹平不同前端框架带来的构建差异。

初步构想:剥离守护进程,统一构建入口

最初的痛点集中在 CI 环境中的 Docker Daemon。它是一个重量级的单点故障,并且在并发构建任务下资源争抢严重。我们开始调研无守护进程的容器构建工具,Buildah 进入了我们的视野。与 Docker 不同,Buildah 是一组命令行工具,它允许你在一个普通的 Linux 进程中创建和操作 OCI(Open Container Initiative)兼容的容器镜像。这几个特性非常吸引我们:

  1. 无守护进程 (Daemonless): 每个构建任务都是一个独立的进程,天然隔离,非常适合 CI/CD 这种短暂、并行的环境。
  2. 完全的脚本化能力: Buildah 的命令 (from, copy, run, config) 可以直接在 Shell 脚本中调用,这为我们编写一个能处理复杂逻辑的“元构建脚本”提供了可能。
  3. Rootless 支持: 可以在非 root 用户下运行,显著提升了 CI 流水线的安全性。

我们的核心思路是,用一个统一的 build.sh 脚本作为所有前端应用的构建入口。这个脚本负责解析目标应用(是 AntD 平台还是 Shadcn 网站),准备构建上下文,然后调用 Buildah 命令来执行实际的镜像构建和推送。这样一来,CI 流水线的配置将变得极其简单,只需要调用这个脚本即可,所有的复杂性都被封装在脚本内部。

技术选型决策:为何是 Buildah 而非其他

在确定使用无守护进程工具后,我们也评估了 Kaniko。Kaniko 在 Kubernetes 环境中表现出色,它在容器内执行 Dockerfile 的每一条指令。然而,我们的 CI Runner 是基于 EC2 的虚拟机,而非 Pod。在这种环境下,Buildah 的原生命令行体验和更灵活的脚本集成能力更具优势。我们可以细粒度地控制构建的每一个步骤,甚至可以在构建过程中挂载外部卷、执行复杂的宿主机命令,这是 Kaniko 难以做到的。

对于前端框架的选择,我们决定维持现状。Ant Design 的内部平台已经承载了大量业务逻辑,重构成本巨大。而 Shadcn UI 在新项目上的开发效率和设计自由度得到了设计和产品团队的一致好评。强行统一技术栈在我们的场景下并不现实,因此,接受并管理这种“混合”状态,才是更务实的工程决策。

步骤化实现:从构建脚本到 EKS 部署

我们的项目结构如下:

/
├── services/
│   ├── admin-dashboard/  # Ant Design 项目
│   │   ├── src/
│   │   ├── package.json
│   │   ├── .webpackrc.js
│   │   └── Containerfile
│   └── public-website/   # Shadcn UI (Next.js) 项目
│       ├── app/
│       ├── package.json
│       ├── next.config.js
│       └── Containerfile
├── scripts/
│   └── build.sh          # 统一构建脚本
└── .github/
    └── workflows/
        └── ci.yml        # GitHub Actions 流水线

1. 为异构应用定制 Containerfile

尽管我们的目标是统一构建流程,但这不意味着使用同一个 Containerfile。每个应用的技术栈不同,其最优的构建方式也不同。我们需要为它们分别编写高效的多阶段构建文件。

services/admin-dashboard/Containerfile (基于 Webpack 的 AntD 项目):

# ---- Base Stage: 依赖安装 ----
# 使用一个特定的 Node.js 版本以保证环境一致性
FROM docker.io/library/node:18.18.2-slim AS base
WORKDIR /app
# 仅复制 package.json 和 lock 文件,利用构建缓存
COPY package.json yarn.lock ./
# 在真实项目中,这里可能需要配置私有 npm registry
RUN yarn install --frozen-lockfile

# ---- Build Stage: 应用构建 ----
FROM base AS builder
WORKDIR /app
COPY . .
# 设置环境变量,例如 API 地址,CI=true 可以避免一些交互式提示
ENV API_URL=https://api.prod.example.com CI=true
# 执行构建命令
RUN yarn build

# ---- Final Stage: 生产镜像 ----
# 使用 Nginx 作为静态文件服务器,镜像体积非常小
FROM docker.io/library/nginx:1.25.3-alpine
# 从 builder 阶段复制构建产物到 Nginx 的静态目录
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制自定义的 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# Nginx 启动命令
CMD ["nginx", "-g", "daemon off;"]

这里的关键是多阶段构建。base 阶段负责安装依赖,这一层在 yarn.lock 不变时可以被完美缓存。builder 阶段执行实际的编译打包。最终的 final 阶段只从 builder 阶段拷贝编译好的静态文件,基础镜像换成了轻量的 nginx:alpine,最终镜像体积可以从几个 GB 骤降到几十 MB。

services/public-website/Containerfile (基于 Next.js 的 Shadcn UI 项目):

# ---- Base Stage: 依赖安装 ----
FROM docker.io/library/node:18.18.2-slim AS base
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# ---- Build Stage: 应用构建 ----
FROM base AS builder
WORKDIR /app
# 复制包括源代码和配置在内的所有文件
# .next/cache 可以在 CI 中通过缓存机制加速后续构建
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build

# ---- Final Stage: 生产镜像 ----
# Next.js 独立部署模式需要一个 Node.js 环境
FROM docker.io/library/node:18.18.2-slim AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

# 创建一个低权限用户来运行应用,增强安全性
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# 从 builder 阶段复制必要的产物
# Next.js 的 standalone 模式会自动将所有依赖打包
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT 3000

# 启动 Next.js 服务器
CMD ["node", "server.js"]

这个 Containerfile 同样采用了多阶段构建,但最终阶段是基于 node:slim 的,因为 Next.js 需要 Node.js 运行时。我们还实践了安全最佳实践:创建一个非 root 用户 (nextjs) 来运行应用。--chown 参数确保了复制过来的文件属于这个低权限用户。

2. 统一构建脚本 scripts/build.sh

这是整个方案的核心。它是一个健壮的 Shell 脚本,作为所有构建任务的唯一入口。

#!/bin/bash

# build.sh: A unified container image build script using Buildah.
#
# Usage: ./scripts/build.sh <service_name>
# Example: ./scripts/build.sh admin-dashboard
#
# Environment Variables:
#   - REGISTRY_URL: The URL of the container registry (e.g., 123456789012.dkr.ecr.us-east-1.amazonaws.com)
#   - IMAGE_TAG: The tag for the image, defaults to 'latest'
#   - PUSH_IMAGE: If 'true', push the image to the registry. Defaults to 'false'.

# --- Configuration and Initialization ---

# 脚本出错时立即退出
set -e
# 使用未定义的变量时报错
set -u
# 管道中任何一个命令失败都算作失败
set -o pipefail

# 检查依赖: buildah, git
command -v buildah >/dev/null 2>&1 || { echo "Error: buildah is not installed."; exit 1; }
command -v git >/dev/null 2>&1 || { echo "Error: git is not installed."; exit 1; }

# --- Argument Parsing ---

if [[ $# -ne 1 ]]; then
    echo "Usage: $0 <service_name>"
    echo "Available services: admin-dashboard, public-website"
    exit 1
fi

SERVICE_NAME=$1
SERVICE_PATH="services/${SERVICE_NAME}"

if [[ ! -d "${SERVICE_PATH}" ]]; then
    echo "Error: Service '${SERVICE_NAME}' not found at path '${SERVICE_PATH}'"
    exit 1
fi

# --- Environment Variable Handling ---

# 如果未提供 REGISTRY_URL,则在推送时会报错
REGISTRY_URL=${REGISTRY_URL:-""}
# 默认 tag 为 'latest',但在 CI 中通常会设置为 Git commit hash
DEFAULT_TAG=$(git rev-parse --short HEAD)
IMAGE_TAG=${IMAGE_TAG:-${DEFAULT_TAG}}
PUSH_IMAGE=${PUSH_IMAGE:-"false"}

IMAGE_NAME="${SERVICE_NAME}"
FULL_IMAGE_NAME="${REGISTRY_URL}/${IMAGE_NAME}:${IMAGE_TAG}"
LATEST_IMAGE_NAME="${REGISTRY_URL}/${IMAGE_NAME}:latest"

# --- Main Build Logic ---

echo "--- Starting build for service: ${SERVICE_NAME} ---"
echo "Service Path: ${SERVICE_PATH}"
echo "Full Image Name: ${FULL_IMAGE_NAME}"
echo "Push enabled: ${PUSH_IMAGE}"

# buildah bud (build-using-dockerfile) 是核心命令
# --tag: 指定镜像标签
# --file: 指定 Containerfile 路径
# --pull: 强制拉取最新的基础镜像
# --layers: 启用分层缓存,对 CI 性能至关重要
# --format: 指定镜像格式为 OCI
# 最后的参数是构建上下文路径
buildah bud \
    --tag "${FULL_IMAGE_NAME}" \
    --file "${SERVICE_PATH}/Containerfile" \
    --pull \
    --layers \
    --format oci \
    "${SERVICE_PATH}"

echo "--- Build successful for ${FULL_IMAGE_NAME} ---"

# --- Image Pushing ---

if [[ "${PUSH_IMAGE}" == "true" ]]; then
    if [[ -z "${REGISTRY_URL}" ]]; then
        echo "Error: REGISTRY_URL is not set. Cannot push image."
        exit 1
    fi
    echo "--- Pushing image ${FULL_IMAGE_NAME} ---"
    buildah push "${FULL_IMAGE_NAME}" "docker://${FULL_IMAGE_NAME}"

    # 在真实项目中,我们通常只在主分支上才推 latest 标签
    # 这里为了演示,也一并推送
    echo "--- Pushing image ${LATEST_IMAGE_NAME} ---"
    buildah tag "${FULL_IMAGE_NAME}" "${LATEST_IMAGE_NAME}"
    buildah push "${LATEST_IMAGE_NAME}" "docker://${LATEST_IMAGE_NAME}"

    echo "--- Push successful ---"
else
    echo "--- Skipping image push (PUSH_IMAGE is not 'true') ---"
fi

echo "--- Build process finished for ${SERVICE_NAME} ---"

这个脚本非常实用。它通过参数接收要构建的服务名,通过环境变量接收镜像仓库地址和标签,实现了构建逻辑与 CI/CD 平台的解耦。set -e -u -o pipefail 确保了脚本的健壮性。

3. 集成到 GitHub Actions

现在,我们将这个脚本集成到 GitHub Actions 中。CI 配置文件变得异常简洁。

.github/workflows/ci.yml

name: Build and Deploy Frontend Services

on:
  push:
    branches:
      - main
    paths:
      - 'services/admin-dashboard/**'
      - 'services/public-website/**'
      - 'scripts/build.sh'
      - '.github/workflows/ci.yml'

env:
  # 使用 AWS ECR 作为我们的镜像仓库
  REGISTRY_URL: 123456789012.dkr.ecr.us-east-1.amazonaws.com

jobs:
  build-and-push:
    name: Build and Push Images
    runs-on: ubuntu-latest
    permissions:
      id-token: write # 用于 AWS OIDC 认证
      contents: read

    strategy:
      matrix:
        # 定义需要构建的服务列表
        service: [admin-dashboard, public-website]

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Buildah
        run: |
          sudo apt-get update
          sudo apt-get install -y buildah

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-pusher
          aws-region: us-east-1

      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and Push Service Image
        # 核心步骤:调用我们的统一构建脚本
        run: |
          ./scripts/build.sh ${{ matrix.service }}
        env:
          # 将 secrets 和环境变量传递给脚本
          REGISTRY_URL: ${{ env.REGISTRY_URL }}
          IMAGE_TAG: ${{ github.sha }}
          PUSH_IMAGE: true

  # 部署任务可以放在这里,例如使用 kubectl 或 ArgoCD CLI
  # deploy-to-eks:
  #   needs: build-and-push
  #   ...

这个工作流有几个亮点:

  • 路径触发 (paths): 只有当相关服务的代码或构建脚本变更时,工作流才会被触发,避免了不必要的 CI 运行。
  • 构建矩阵 (matrix): 通过 strategy.matrix,我们可以并行地为 admin-dashboardpublic-website 执行构建任务,大大缩短了总的流水线时间。
  • 职责分离: GitHub Actions 的 YAML 文件只负责环境准备(安装 Buildah、AWS 认证)和调用脚本,所有复杂的构建逻辑都封装在 build.sh 中,易于本地调试和维护。
  • 安全认证: 使用 OIDC 和 aws-actions/configure-aws-credentials 进行身份验证,避免了在 GitHub Secrets 中存储长期有效的 IAM access key。

4. 部署到 AWS EKS

镜像构建并推送到 ECR 后,最后一步就是更新在 EKS 中运行的应用。这里我们以一个简单的 kubectl 命令来演示,但在生产环境中,强烈推荐使用 GitOps 工具如 ArgoCD 或 Flux。

下面是 public-website 的 Kubernetes Deployment 示例:

k8s/public-website-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: public-website
  namespace: frontend
  labels:
    app: public-website
spec:
  replicas: 3
  selector:
    matchLabels:
      app: public-website
  template:
    metadata:
      labels:
        app: public-website
    spec:
      containers:
        - name: public-website
          # 镜像地址会被 CI 流水线动态更新
          image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/public-website:latest
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          # 健康检查对于零停机部署至关重要
          readinessProbe:
            httpGet:
              path: /api/health # 假设我们有一个健康检查端点
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 15
            periodSeconds: 20

在 CI 流水线的 deploy-to-eks 步骤中,可以执行以下命令来触发滚动更新:

kubectl set image deployment/public-website public-website=${REGISTRY_URL}/public-website:${GITHUB_SHA} -n frontend

整个流程的 Mermaid 图示如下:

graph TD
    A[Git Push on main] --> B{GitHub Actions CI};
    B --> C1[Build admin-dashboard];
    B --> C2[Build public-website];

    subgraph "Parallel Jobs via Matrix Strategy"
        direction LR
        C1 -- uses --> D{scripts/build.sh};
        C2 -- uses --> D;
    end
    
    subgraph "build.sh logic"
        direction TB
        D -- calls --> E[buildah bud];
        E -- creates --> F[OCI Image];
        F -- tagged with git SHA --> G[buildah push];
    end

    G --> H[AWS ECR Repository];
    H --> I{Deploy to EKS};
    I -- kubectl set image or GitOps Sync --> J[EKS Cluster];
    J -- pulls image --> K[Running Pods Updated];

最终成果与遗留问题

通过引入 Buildah 和统一的构建脚本,我们成功地解决了最初的痛点。CI 流水线现在更快速、更稳定、更易于维护。新加入的开发者不再需要关心底层容器构建的细节,只需专注于业务代码开发。不同技术栈的前端项目现在能以一种标准化的方式被打包和交付。

当然,这个方案并非终点,它也暴露出一些可以继续优化的方向:

  1. 构建缓存的优化: Buildah 在 CI 环境中的缓存依赖于 CI Runner 本身的持久化。对于 ephemeral runners,每次都需要重新拉取基础镜像和安装依赖。我们可以探索使用 Buildah 的 --cache-to--cache-from 选项,将缓存层推送到一个共享的注册表或 S3 存储桶中,实现跨 CI 任务的缓存共享。
  2. 向 GitOps 的演进: 当前使用 kubectl set image 是一个命令式的部署方式。更好的做法是引入 ArgoCD。CI 流水线在构建完镜像后,只需要更新一个 Git 仓库中的 Kubernetes manifest 文件(例如修改镜像标签),ArgoCD 会自动检测到这个变更并将集群状态同步到最新的声明式配置。这使得部署过程更加透明、可追溯,并且易于回滚。
  3. 安全性扫描: 当前的流水线缺少容器镜像安全扫描环节。我们应该在 buildah push 之前集成 Trivy 或类似的工具,对构建好的镜像进行漏洞扫描,只有扫描通过的镜像才允许被推送到生产环境的 ECR 仓库中。
  4. Monorepo 可能性: 虽然我们接受了多仓库的现状,但如果未来项目间的共享组件(如 UI 库、工具函数)增多,迁移到一个使用 pnpm workspacesTurborepo 的 Monorepo 结构,可能会进一步优化依赖管理和构建效率。统一的构建脚本在这个架构下依然适用,并且能发挥更大的威力。

  目录