1. 项目概述:从零构建一个全自动的容器化部署流水线

在云原生和DevOps的实践中,我经常被问到:“我该如何开始学习容器和CI/CD?” 这是一个非常好的问题。很多人一上来就想直接挑战复杂的Kubernetes集群和微服务架构,结果往往被海量的概念和配置劝退。我的建议是,先从最简单、最直接的路径走通整个闭环,感受自动化部署带来的“魔力”。今天分享的这个实战项目,就是为这个目的设计的:我们将构建一个极简的Node.js网页应用,用Docker将其容器化,然后通过GitHub Actions实现一个全自动的CI/CD流水线,最终部署到Azure Container Instances上。整个过程,你只需要一次推送代码到GitHub,剩下的构建、推送镜像、部署上线全部自动完成。这不仅是学习,更是建立一个可复用的工程实践模板。

为什么选择Azure Container Instances而不是更强大的AKS?这是个关键问题。对于严肃的生产环境,AKS无疑是更佳选择,它提供了完整的Kubernetes生态。但ACI的定位非常精准:它是无服务器的容器实例。你无需管理任何集群节点、控制平面或工作节点,只需提供一个容器镜像,Azure负责运行它。这种“零运维开销”的特性,使其成为演示、开发测试环境、内部工具部署的绝佳选择。它的简单性,能让你专注于理解CI/CD流程本身,而不是被复杂的集群管理分散精力。这个项目适合所有希望将应用现代化、并开始实践自动化部署的开发者,无论你是前端、后端还是全栈。

2. 核心思路与架构设计解析

2.1 技术栈选型与设计哲学

这个项目的核心设计哲学是“最小可行产品”和“关注点分离”。我们不过度设计应用本身,而是将全部精力集中在部署流水线的构建上。因此,技术栈的选择都服务于这个目标:

  1. 应用层 :一个纯静态的Node.js Web服务器。我们没有使用Express、Koa等框架,甚至没有使用任何前端框架。应用仅包含 index.html style.css 和一个极简的 server.js 。目的是消除一切与核心目标无关的复杂性,让应用本身简单到不会成为学习障碍。
  2. 容器化层 :Docker。它是实现环境一致性的基石。通过一个 Dockerfile ,我们定义了应用运行所需的完整环境(Node.js运行时、依赖、代码)。这确保了应用在任何地方(你的笔记本、GitHub的构建服务器、Azure的生产环境)的行为都是一致的。
  3. 镜像仓库 :Azure Container Registry。你可以把它理解为Azure平台内的私有Docker Hub。它是我们流水线的中间站:GitHub Actions构建的镜像被推送到这里,然后ACI从这里拉取镜像并运行。使用ACR而非公共仓库,保证了镜像的安全性和部署速度(同在Azure网络内)。
  4. 计算平台 :Azure Container Instances。如前所述,它是我们的“服务器”。我们无需关心虚拟机、操作系统更新或运行时配置。ACI按秒计费,用完即删,成本极低,非常适合实验。
  5. 自动化引擎 :GitHub Actions。它是整个流水线的大脑和执行者。我们通过一个YAML文件定义了一系列步骤:检出代码、登录Azure、构建镜像、部署容器。GitHub提供了免费的额度,足以支撑个人项目的CI/CD需求。

这个架构的精妙之处在于,每一个环节都是云原生的标准组件,并且它们之间的集成是“原生”的。例如,GitHub Actions有官方的Azure登录Action,ACR支持通过Azure CLI直接进行安全的镜像构建。这种设计使得整个流水线既健壮又易于理解和维护。

2.2 安全与权限管理设计

在自动化流程中,安全是首要考虑。我们不能将敏感凭证(如Azure订阅的访问密钥)硬编码在代码或配置文件里,尤其是公开的仓库。本项目的安全设计遵循了最小权限原则和秘密管理最佳实践:

  1. 服务主体 :我们在Azure Active Directory中创建一个专门用于GitHub Actions的“服务主体”。你可以把它理解为一个机器人账户。我们只为这个账户授予对特定资源组(而非整个订阅)的“贡献者”角色。这样,即使凭证泄露,攻击者的权限也被限制在很小的范围内。
  2. GitHub Secrets :创建服务主体后,我们会得到一组密钥(Client ID, Client Secret, Tenant ID)。这些密钥被以加密形式存储在GitHub仓库的“Secrets”中。在GitHub Actions工作流运行时,这些秘密会被安全地注入到环境变量中,供登录步骤使用。你的工作流YAML文件里永远不会出现明文的密钥。
  3. ACR访问控制 :在部署到ACI时,ACI需要从ACR拉取镜像。我们同样使用服务主体的凭证进行认证,而不是为ACR单独创建管理员账号。这实现了统一的身份管理。

注意 :在真实项目中,对于生产环境,可以考虑使用Azure Key Vault来集中管理密钥,并通过GitHub Actions的 azure/get-keyvault-secrets Action来动态获取,实现更高级别的秘密轮换和管理。

3. 环境准备与项目初始化

3.1 本地开发环境配置

在开始编写任何代码之前,我们需要确保本地环境就绪。这里假设你使用的是macOS或Linux系统(Windows用户建议使用WSL2以获得最佳体验)。

  1. 安装Docker Desktop :这是容器化的基础。前往Docker官网下载并安装Docker Desktop。安装后启动它,确保右下角状态为“Running”。在终端运行 docker --version docker run hello-world 来验证安装成功。Docker Desktop包含了完整的Docker引擎、CLI以及一个轻量级的Kubernetes集群(本项目用不到),是我们进行本地构建和测试的利器。
  2. 安装Azure CLI :这是与Azure资源交互的命令行工具。你可以通过包管理器安装(如macOS的 brew install azure-cli ),或从微软官方下载安装包。安装后,运行 az --version 验证。接下来需要进行登录,运行 az login ,这会打开浏览器让你用你的Azure账户进行认证。成功登录后,你的订阅信息就被CLI管理起来了。
  3. 准备GitHub仓库 :在GitHub上创建一个新的空仓库,例如命名为 azure-ci-cd-demo 。然后将其克隆到本地: git clone https://github.com/你的用户名/azure-ci-cd-demo.git && cd azure-ci-cd-demo

3.2 创建极简Node.js应用

在我们的项目根目录下,创建以下四个文件。记住,应用本身极其简单,重点在于理解文件结构和它们如何被容器化。

package.json : 这是Node.js项目的清单文件,定义了项目名称、版本和启动脚本。

{
  "name": "cicd-demo-app",
  "version": "1.0.0",
  "description": "A minimal web app for CI/CD demo",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "keywords": ["demo", "azure", "ci-cd"],
  "author": "Your Name",
  "license": "MIT"
}

server.js : 一个不足20行的原生Node.js HTTP服务器,用于提供静态文件。

const http = require('http');
const fs = require('fs');
const path = require('path');
const port = process.env.PORT || 3000; // 关键:从环境变量读取端口

const server = http.createServer((req, res) => {
  let filePath = '.' + req.url;
  if (filePath === './') {
    filePath = './index.html';
  }

  const extname = path.extname(filePath);
  let contentType = 'text/html';
  switch (extname) {
    case '.css':
      contentType = 'text/css';
      break;
    case '.js':
      contentType = 'application/javascript';
      break;
  }

  fs.readFile(filePath, (error, content) => {
    if (error) {
      if(error.code == 'ENOENT') {
        // 文件不存在,返回404
        fs.readFile('./404.html', (err, cont) => {
          res.writeHead(404, { 'Content-Type': 'text/html' });
          res.end(cont || 'Page not found');
        });
      } else {
        // 服务器错误
        res.writeHead(500);
        res.end('Sorry, internal error: ' + error.code);
      }
    } else {
      // 成功,返回文件内容
      res.writeHead(200, { 'Content-Type': contentType });
      res.end(content, 'utf-8');
    }
  });
});

server.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

这个服务器脚本有几个细节值得注意:它通过 process.env.PORT 读取环境变量,这为我们在不同环境(本地3000端口,ACI的80端口)运行提供了灵活性。它包含了简单的错误处理,虽然基础,但比直接崩溃更友好。

index.html : 应用的前端界面。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Azure CI/CD 演示</title>
    <link rel="stylesheet" href="style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&display=swap" rel="stylesheet">
</head>
<body>
    <div class="container">
        <header class="header">
            <h1>🚀 自动化部署成功!</h1>
            <p class="subtitle">你的代码已通过 GitHub Actions 自动部署到 Azure Container Instances。</p>
        </header>
        <main class="content">
            <div class="card">
                <h2>当前版本</h2>
                <div class="version-badge">v1.0.0</div>
                <p>这个版本号仅存在于 <code>index.html</code> 文件中。修改它并推送,即可触发一次全新的自动化部署。</p>
            </div>
            <div class="card">
                <h2>技术栈</h2>
                <ul class="tech-stack">
                    <li>Node.js 静态服务器</li>
                    <li>Docker 容器化</li>
                    <li>Azure Container Registry (ACR)</li>
                    <li>Azure Container Instances (ACI)</li>
                    <li>GitHub Actions (CI/CD)</li>
                </ul>
            </div>
            <div class="card">
                <h2>下一步尝试</h2>
                <ol class="next-steps">
                    <li>打开 <code>index.html</code>,将版本号改为 <code>v2.0.0</code>。</li>
                    <li>执行 <code>git add .</code>, <code>git commit -m "Bump to v2.0.0"</code>, <code>git push</code>。</li>
                    <li>前往 GitHub 仓库的 <strong>Actions</strong> 标签页,观看流水线自动运行。</li>
                    <li>约2分钟后,刷新此页面,观察版本号是否已更新。</li>
                </ol>
            </div>
        </main>
        <footer class="footer">
            <p>这是一个用于演示现代 CI/CD 工作流的教学项目。容器化与自动化让部署变得简单可靠。</p>
        </footer>
    </div>
</body>
</html>

style.css : 让页面看起来更专业的样式。

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Inter', sans-serif;
    line-height: 1.6;
    color: #333;
    background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
    min-height: 100vh;
    padding: 20px;
}

.container {
    max-width: 1000px;
    margin: 0 auto;
    background: white;
    border-radius: 16px;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
    overflow: hidden;
}

.header {
    background: linear-gradient(90deg, #0078d4, #00b4ff);
    color: white;
    padding: 3rem 2rem;
    text-align: center;
}

.header h1 {
    font-size: 2.8rem;
    font-weight: 600;
    margin-bottom: 0.8rem;
}

.subtitle {
    font-size: 1.2rem;
    opacity: 0.9;
    font-weight: 300;
}

.content {
    padding: 2.5rem;
}

.card {
    background: #f8f9fa;
    border-left: 5px solid #0078d4;
    border-radius: 10px;
    padding: 1.8rem;
    margin-bottom: 2rem;
    transition: transform 0.2s ease;
}

.card:hover {
    transform: translateY(-3px);
    box-shadow: 0 5px 15px rgba(0, 120, 212, 0.1);
}

.card h2 {
    color: #005a9e;
    margin-bottom: 1rem;
    font-size: 1.5rem;
}

.version-badge {
    display: inline-block;
    background: #0078d4;
    color: white;
    padding: 0.5rem 1.2rem;
    border-radius: 50px;
    font-weight: 600;
    font-size: 1.3rem;
    margin-bottom: 1rem;
    letter-spacing: 0.5px;
}

.tech-stack {
    list-style: none;
}

.tech-stack li {
    padding: 0.5rem 0;
    border-bottom: 1px dashed #dee2e6;
}

.tech-stack li:before {
    content: "✓ ";
    color: #28a745;
    font-weight: bold;
}

.next-steps {
    padding-left: 1.5rem;
    color: #555;
}

.next-steps li {
    margin-bottom: 0.7rem;
}

code {
    background: #e9ecef;
    padding: 0.2rem 0.4rem;
    border-radius: 4px;
    font-family: 'Courier New', monospace;
    font-size: 0.9em;
    color: #d63384;
}

.footer {
    text-align: center;
    padding: 1.5rem;
    background: #f1f3f4;
    color: #666;
    font-size: 0.9rem;
    border-top: 1px solid #dee2e6;
}

现在,你可以在本地运行 node server.js 并访问 http://localhost:3000 来预览这个应用。它应该显示一个美观的页面,展示了当前版本和技术栈。但这只是在你的本地机器上运行,下一步我们将把它装进“集装箱”。

4. 容器化应用:编写Dockerfile与本地测试

4.1 深入理解Dockerfile的每一行

容器化的核心是 Dockerfile ,它是一个文本文件,包含了一系列指令,告诉Docker如何构建我们的镜像。在项目根目录创建 Dockerfile (没有扩展名):

# 第一阶段:使用官方轻量级Node.js镜像作为构建和运行环境
FROM node:20-alpine AS builder

# 设置容器内的工作目录,后续命令都会在这个路径下执行
WORKDIR /usr/src/app

# 首先复制依赖定义文件
COPY package*.json ./

# 安装生产环境依赖(不安装devDependencies)
# 利用Docker的层缓存:如果package.json没变,这层会被复用,加速构建
RUN npm ci --only=production

# 第二阶段:复制应用代码
# 再次使用轻量级镜像,减小最终镜像体积
FROM node:20-alpine

# 设置运行时的工作目录
WORKDIR /usr/src/app

# 从上一阶段(builder)复制已安装的node_modules
COPY --from=builder /usr/src/app/node_modules ./node_modules

# 复制应用源代码
COPY server.js ./
COPY index.html ./
COPY style.css ./

# 声明容器运行时监听的端口
# 这只是一个文档说明,实际映射在运行`docker run`时指定
EXPOSE 3000

# 定义容器启动时执行的命令
# 使用数组格式(exec form)比字符串格式(shell form)更推荐
CMD ["node", "server.js"]

让我们拆解这个Dockerfile的设计考量:

  1. 多阶段构建 :我们使用了两个 FROM 指令。第一阶段( builder )专门用于安装依赖。第二阶段是最终的运行时镜像。我们只从第一阶段复制了 node_modules ,而没有复制 package.json 等文件。这样做的好处是,最终的镜像不包含构建工具和源代码之外的任何文件,体积更小,安全性更高(减少了攻击面)。
  2. 基础镜像选择 node:20-alpine 。Alpine Linux是一个极简的Linux发行版,镜像体积通常只有5MB左右,而Node.js官方基于Alpine的镜像也比基于Debian等发行版的镜像小很多。对于生产环境,小体积意味着更快的拉取速度和更少的安全漏洞。
  3. 依赖安装优化 :使用 npm ci 而不是 npm install npm ci 严格根据 package-lock.json 安装依赖,能确保依赖树的一致性,并且安装速度更快。 --only=production 参数确保不安装 devDependencies ,进一步减小镜像。
  4. 层缓存策略 :Docker构建是分层的,每一行指令都会产生一个层。我们将变化频率最低的指令(如 COPY package*.json ./ )放在前面,变化频率最高的指令(如 COPY 应用代码)放在后面。这样,当我们只修改了应用代码而 package.json 未变时,Docker可以复用之前已构建好的 node_modules 层,极大加速构建过程。
  5. EXPOSE CMD EXPOSE 3000 是元数据,告诉用户这个容器打算监听3000端口。 CMD 定义了容器启动时的默认命令。我们使用数组格式 ["node", "server.js"] ,这能确保信号(如SIGTERM)能正确传递给Node.js进程。

4.2 本地构建与运行测试

在编写完Dockerfile后,务必在本地进行测试,这是保证后续CI/CD流程顺利的关键。

  1. 构建镜像 :在项目根目录(Dockerfile所在目录)打开终端,执行:

    docker build -t cicd-demo-app:local .
    

    -t 参数为镜像打上标签 cicd-demo-app:local . 表示使用当前目录作为构建上下文。观察输出,你会看到Docker一步步执行Dockerfile中的指令。首次构建会下载基础镜像,需要一些时间。

  2. 运行容器 :镜像构建成功后,运行它:

    docker run -d -p 8080:3000 --name my-demo-app cicd-demo-app:local
    
    • -d : 后台运行(detached mode)。
    • -p 8080:3000 : 端口映射,将宿主机的8080端口映射到容器的3000端口。
    • --name my-demo-app : 为容器指定一个名字,便于管理。
    • cicd-demo-app:local : 要运行的镜像标签。
  3. 验证 :打开浏览器,访问 http://localhost:8080 。你应该能看到和之前本地运行Node.js时一模一样的页面。这证明了容器化成功——应用在一个与宿主机隔离的、标准化的环境中正常运行了。

  4. 查看日志与清理

    # 查看容器日志
    docker logs my-demo-app
    # 停止容器
    docker stop my-demo-app
    # 删除容器
    docker rm my-demo-app
    # (可选)删除本地镜像
    docker rmi cicd-demo-app:local
    

实操心得 :在本地成功运行容器是至关重要的一步。如果这里失败,CI/CD流程中必然失败。常见的本地问题包括:Docker Desktop未启动、端口被占用、Dockerfile语法错误、应用代码本身在容器内路径错误等。务必确保本地测试通过后再推送到远程。

5. 配置Azure云资源与服务主体

5.1 使用Azure CLI创建核心资源

所有Azure资源的创建都将通过命令行完成,这本身就是“基础设施即代码”的实践。打开终端,确保已通过 az login 登录。

首先,我们设置一些环境变量,避免在后续命令中反复输入重复信息,也减少出错几率。

# 定义资源组名称(可自定义,需在订阅内唯一)
RESOURCE_GROUP="rg-cicd-demo-$(date +%s)"
# 定义容器注册表名称(全局唯一,只能包含小写字母和数字)
ACR_NAME="acrdemo$(openssl rand -hex 3)"
# 选择区域,建议选择离你近的,如 eastus, westus2, westeurope
LOCATION="eastus"
# 获取当前订阅ID
SUBSCRIPTION_ID=$(az account show --query id -o tsv)

echo "资源组: $RESOURCE_GROUP"
echo "容器注册表: $ACR_NAME"
echo "区域: $LOCATION"
echo "订阅ID: $SUBSCRIPTION_ID"

使用 $(date +%s) (时间戳)和 $(openssl rand -hex 3) (随机数)是为了确保名称唯一,避免因名称冲突导致创建失败。

接下来,创建资源组和容器注册表:

# 创建资源组(逻辑上的容器,用于管理一组相关的Azure资源)
az group create --name $RESOURCE_GROUP --location $LOCATION

# 创建Azure容器注册表(ACR),SKU选择Basic(适合开发测试,成本最低)
az acr create \
  --resource-group $RESOURCE_GROUP \
  --name $ACR_NAME \
  --sku Basic \
  --admin-enabled false # 禁用管理员账户,使用更安全的服务主体认证

创建ACR可能需要一两分钟。 --admin-enabled false 是一个安全最佳实践,它禁用了ACR自带的用户名/密码管理员账户,强制我们使用Azure Active Directory(AAD)进行身份验证,例如接下来要创建的服务主体。

5.2 创建并配置GitHub Actions服务主体

服务主体是自动化流程在Azure中的身份。我们需要创建一个,并赋予它恰好够用的权限。

# 创建服务主体,并将其作用域限定在我们刚创建的资源组
SP_JSON=$(az ad sp create-for-rbac \
  --name "http://github-actions-${ACR_NAME}" \
  --role contributor \
  --scopes /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP \
  --sdk-auth)

echo $SP_JSON

执行这条命令后,会输出一段JSON, 请务必妥善保存这个输出 ,它包含了GitHub Actions连接Azure所需的全部凭证:

{
  "clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "clientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "subscriptionId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "tenantId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "activeDirectoryEndpointUrl": "https://login.microsoftonline.com",
  "resourceManagerEndpointUrl": "https://management.azure.com/",
  ...
}
  • clientId : 服务主体的应用程序ID,对应GitHub Secret AZURE_CLIENT_ID
  • clientSecret : 服务主体的密码/密钥,对应 AZURE_CLIENT_SECRET 这是最敏感的信息
  • subscriptionId : 你的Azure订阅ID,对应 AZURE_SUBSCRIPTION_ID
  • tenantId : 你的Azure AD租户ID,对应 AZURE_TENANT_ID

重要安全警告 clientSecret 是明文密码,一旦泄露,他人可以以此身份操作你资源组内的所有资源。因此,绝对不要将其提交到代码仓库、日志或任何公开场合。我们下一步就把它存入GitHub Secrets。

5.3 在GitHub仓库中配置Secrets

  1. 打开你的GitHub仓库页面。
  2. 点击顶部导航栏的 Settings
  3. 在左侧边栏找到 Secrets and variables -> Actions
  4. 点击 New repository secret
  5. 分别创建四个secret,名称和值对应上面JSON中的字段:
    • Name: AZURE_CLIENT_ID , Value: clientId 的值。
    • Name: AZURE_CLIENT_SECRET , Value: clientSecret 的值。
    • Name: AZURE_TENANT_ID , Value: tenantId 的值。
    • Name: AZURE_SUBSCRIPTION_ID , Value: subscriptionId 的值。

创建完成后,你的Secrets列表应该如下图所示。这些加密的变量将在工作流运行时被安全地注入到环境中。

6. 构建GitHub Actions自动化工作流

6.1 编写工作流YAML文件

GitHub Actions的工作流由YAML文件定义。在项目根目录创建 .github/workflows/deploy-to-aci.yml 文件。路径和文件名是固定的约定。

name: Build and Deploy to Azure Container Instances

# 定义触发条件:当代码推送到main分支时,或手动触发时
on:
  push:
    branches: [ "main" ]
  # 允许在GitHub仓库的Actions页面手动触发工作流,方便测试
  workflow_dispatch:

# 环境变量,方便后续步骤引用,避免硬编码
env:
  RESOURCE_GROUP: rg-cicd-demo-${{ github.run_id }} # 使用运行ID确保唯一性
  ACR_NAME: your_acr_name_here # 替换为你的ACR名称!
  CONTAINER_NAME: cicd-demo-app
  LOCATION: eastus

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest # 工作流运行在GitHub托管的Ubuntu虚拟机上

    steps:
    # 步骤1:检出代码。这是所有CI/CD工作流的第一步。
    - name: Checkout repository
      uses: actions/checkout@v4

    # 步骤2:登录到Azure。使用我们存储在Secrets中的服务主体凭证。
    - name: Login to Azure
      uses: azure/login@v2
      with:
        creds: ${{ secrets.AZURE_CREDENTIALS }} # 推荐使用单个JSON secret,见下方说明
        # 或者使用分开的四个secret:
        # client-id: ${{ secrets.AZURE_CLIENT_ID }}
        # client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
        # tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        # subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

    # 步骤3:登录到Azure容器注册表(ACR)
    - name: Log in to Azure Container Registry
      run: |
        az acr login --name ${{ env.ACR_NAME }}

    # 步骤4:在Azure Cloud中构建并推送Docker镜像
    - name: Build and push image to ACR
      run: |
        az acr build \
          --registry ${{ env.ACR_NAME }} \
          --image ${{ env.CONTAINER_NAME }}:${{ github.sha }} \
          --image ${{ env.CONTAINER_NAME }}:latest \
          --file Dockerfile \
          .

    # 步骤5:部署到Azure Container Instances (ACI)
    - name: Deploy to Azure Container Instances
      run: |
        # 检查容器组是否已存在,如果存在则更新,否则创建
        if az container show --resource-group ${{ env.RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --output none 2>/dev/null; then
          echo "Container instance exists, updating..."
          az container update \
            --resource-group ${{ env.RESOURCE_GROUP }} \
            --name ${{ env.CONTAINER_NAME }} \
            --image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_NAME }}:latest
        else
          echo "Container instance does not exist, creating..."
          az container create \
            --resource-group ${{ env.RESOURCE_GROUP }} \
            --name ${{ env.CONTAINER_NAME }} \
            --image ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_NAME }}:latest \
            --registry-login-server ${{ env.ACR_NAME }}.azurecr.io \
            --registry-username ${{ secrets.AZURE_CLIENT_ID }} \
            --registry-password ${{ secrets.AZURE_CLIENT_SECRET }} \
            --dns-name-label ${{ env.CONTAINER_NAME }}-${{ github.run_number }} \
            --ports 80 \
            --os-type Linux \
            --cpu 1 \
            --memory 1.5 \
            --environment-variables PORT=80 \
            --restart-policy Always
        fi

    # 步骤6:(可选)输出容器访问信息
    - name: Get container FQDN
      run: |
        az container show \
          --resource-group ${{ env.RESOURCE_GROUP }} \
          --name ${{ env.CONTAINER_NAME }} \
          --query ipAddress.fqdn \
          -o tsv

重要提示 :请务必将YAML文件中的 your_acr_name_here 替换为你实际创建的ACR名称(即前面 $ACR_NAME 变量的值)。

6.2 工作流步骤深度解析

  1. 触发机制 on 指令定义了工作流何时运行。 push main 分支是自动触发, workflow_dispatch 提供了手动触发按钮,这在调试时非常有用。
  2. 环境变量 :在 env 部分定义的变量可以在所有步骤中使用。这里我们动态生成了资源组名称 rg-cicd-demo-${{ github.run_id }} ,利用GitHub Actions的运行ID确保每次运行创建的资源组都是唯一的,避免了冲突,也便于清理。 github.sha github.run_number 是GitHub提供的上下文变量,分别代表触发工作流的提交SHA和当前工作流的运行编号。
  3. Azure登录 :我们使用了官方的 azure/login@v2 Action。这里演示了两种传递凭证的方式。更简洁的方式是将之前服务主体输出的整个JSON对象保存为一个名为 AZURE_CREDENTIALS 的GitHub Secret,然后直接使用 creds 参数引用。这比管理四个单独的secret更方便。
  4. ACR构建 az acr build 命令是 关键优化点 。它不是在GitHub的Runner上执行 docker build ,而是将构建上下文(你的代码)上传到Azure,在Azure云服务中执行构建。这样做的好处是:
    • 无需Docker in Docker :GitHub Runner不需要安装或运行Docker守护进程,简化了Runner环境。
    • 性能与网络 :构建在Azure内部完成,镜像直接推送到同区域的ACR,速度极快,且不消耗GitHub Runner的出站带宽。
    • 安全 :构建密钥和中间层不会暴露在Runner上。
    • 我们为镜像打了两个标签: ${{ github.sha }} (唯一的提交哈希,用于精确版本追踪和回滚)和 latest (指向最新构建的镜像)。
  5. ACI部署 :部署步骤使用了条件判断。它先尝试检查同名的ACI容器组是否存在。如果存在,则执行 az container update 来更新其镜像(实现原地升级);如果不存在,则执行 az container create 创建新的容器组。这是一种更健壮的部署逻辑。
    • --dns-name-label : 为容器实例生成一个公共可访问的FQDN(完全限定域名)。我们附加了 run_number 使其唯一。
    • --cpu --memory : 指定容器的计算资源。1个vCPU和1.5GB内存对于这个简单应用绰绰有余,也是ACI免费层(如果可用)或低成本层的典型配置。
    • --environment-variables PORT=80 : 将容器内的环境变量 PORT 设置为80,这样我们的Node.js应用(通过 process.env.PORT || 3000 )就会监听80端口,与ACI对外暴露的端口一致。
    • --restart-policy Always : 确保容器在退出时总是重启,提高可用性。

7. 触发流水线并验证自动化部署

7.1 首次推送与流水线观察

现在,将我们所有的代码和配置文件推送到GitHub仓库的main分支。

# 添加所有文件到暂存区
git add .
# 提交更改
git commit -m "feat: 初始提交 - 包含应用代码、Dockerfile和GitHub Actions工作流"
# 推送到远程main分支
git push origin main

推送完成后,立即打开你的GitHub仓库页面,点击顶部的 Actions 标签页。你会看到一个新的工作流运行已经启动,名称就是“Build and Deploy to Azure Container Instances”。点击进入该次运行,可以实时观察每个步骤的执行情况。

  • 黄色 表示步骤正在执行或排队。
  • 绿色对勾 表示步骤成功。
  • 红色叉号 表示步骤失败。如果失败,可以点击该步骤查看详细的日志输出,这是排查问题的关键。

整个流程通常需要2到4分钟。最耗时的步骤是“Build and push image to ACR”,因为它需要在云端拉取基础镜像并构建。

7.2 获取访问地址与验证部署

当工作流所有步骤都显示绿色对勾后,部署就成功了。如何访问我们的应用呢?

方法一:从工作流日志获取 在工作流运行的详情页面,找到“Deploy to Azure Container Instances”步骤,展开其日志。在最后部分,你应该能看到“Get container FQDN”步骤的输出,它打印了容器的完整域名(FQDN),格式类似 cicd-demo-app-12345678.eastus.azurecontainer.io 。复制这个地址。

方法二:使用Azure CLI查询 如果你本地Azure CLI已登录且配置了正确的订阅,可以运行:

# 请将资源组名称和容器名称替换为你的实际值
az container show \
  --resource-group rg-cicd-demo-你的运行ID \
  --name cicd-demo-app \
  --query ipAddress.fqdn \
  -o tsv

重要提示 :ACI默认通过HTTP在80端口提供服务。现代浏览器(如Chrome)可能会尝试将你重定向到HTTPS,导致连接失败。因此,在浏览器地址栏中访问时, 务必显式地加上 http:// 前缀 ,例如: http://cicd-demo-app-12345678.eastus.azurecontainer.io

打开后,你应该能看到和本地运行一模一样的页面,显示“v1.0.0”。恭喜,你的应用已经成功运行在Azure云上了!

7.3 体验真正的持续部署

现在,让我们来体验CI/CD的“魔法”。打开本地的 index.html 文件,找到显示版本号的地方(大约在第20行),将 v1.0.0 修改为 v2.0.0

保存文件后,再次提交并推送更改:

git add index.html
git commit -m "chore: 更新版本号至 v2.0.0"
git push origin main

再次回到GitHub仓库的 Actions 标签页。你会看到一个新的工作流运行被自动触发。等待它执行完毕(这次构建会更快,因为Docker层缓存可能生效)。

工作流完成后, 不要 手动重启容器。等待大约30秒到1分钟(给Azure负载均衡器和DNS一点时间),然后刷新你的浏览器页面。你会发现,页面上的版本号已经自动变成了 v2.0.0

这个过程完全自动化:代码推送触发了工作流,工作流构建了新的镜像并推送到ACR,然后更新了ACI中的容器组,使其使用新的镜像。整个过程中,你的应用服务没有中断(ACI会先启动新容器,健康检查通过后再停止旧容器,实现无缝更新)。

8. 进阶优化、问题排查与清理

8.1 工作流优化与进阶技巧

基础流水线已经跑通,但我们可以让它更健壮、更专业。

  1. 使用单个JSON Secret :如前所述,将服务主体的完整JSON输出保存为一个名为 AZURE_CREDENTIALS 的Secret,然后在工作流中直接使用 creds: ${{ secrets.AZURE_CREDENTIALS }} ,这样更简洁安全。
  2. 添加构建缓存 :虽然 az acr build 在云端进行,但我们可以通过配置缓存来加速后续构建。修改构建步骤,添加 --platform 和缓存参数(如果适用)。
  3. 镜像扫描与安全 :在推送镜像前,可以集成安全扫描工具。Azure Defender for container registries(ACR高级版功能)或开源工具如Trivy可以集成到工作流中,在构建后对镜像进行漏洞扫描,如果发现高危漏洞则失败。
  4. 多环境部署 :为开发(dev)、预发布(staging)、生产(prod)设置不同的资源组和ACR,通过GitHub环境(Environments)和分支规则来管理。例如,推送到 develop 分支部署到开发环境,打标签时部署到生产环境。
  5. 添加健康检查 :在 az container create 命令中添加 --protocol TCP --port 80 liveness probe readiness probe ,让ACI能够判断容器是否健康,并进行自动恢复。
  6. 使用Azure Key Vault管理密钥 :将数据库连接字符串等应用机密存储在Azure Key Vault中,让ACI在启动时从Key Vault拉取,而不是写在代码或环境变量里。

8.2 常见问题与排查实录

在实践过程中,你可能会遇到以下问题。这里记录了排查思路和解决方法:

问题1:GitHub Actions工作流在“Login to Azure”步骤失败。

  • 错误信息 Error: Credentials could not be authenticated.
  • 排查
    1. 检查GitHub Secrets中的四个值(Client ID, Secret, Tenant ID, Subscription ID)是否与服务主体创建时的输出完全一致,尤其是Secret,确保没有多余的空格或换行。
    2. 服务主体可能已过期或被删除。使用 az ad sp list --display-name "你的服务主体名称" 检查其状态。可以尝试重新创建服务主体并更新Secrets。
    3. 确保服务主体确实被赋予了目标资源组的“Contributor”角色。可以使用 az role assignment list --assignee <clientId> --resource-group <resourceGroup> 来验证。

问题2:工作流在“Build and push image to ACR”步骤失败。

  • 错误信息 ERROR: The resource with name 'xxx' and type 'Microsoft.ContainerRegistry/registries' could not be found in subscription.
  • 排查
    1. 检查YAML文件中 env.ACR_NAME 的值是否正确,是否与你通过CLI创建的ACR名称完全一致(区分大小写)。
    2. 检查服务主体是否有权限访问该ACR。除了资源组的Contributor角色,可能还需要ACR特定的角色(如 AcrPush )。可以运行 az role assignment create --assignee <clientId> --scope /subscriptions/<subId>/resourceGroups/<rgName>/providers/Microsoft.ContainerRegistry/registries/<acrName> --role AcrPush 来授予推送权限。

问题3:容器部署成功,但无法通过浏览器访问(连接被拒绝/超时)。

  • 排查
    1. 确认协议 :ACI默认是HTTP。确保浏览器访问的是 http:// 开头的地址,而不是 https://
    2. 检查端口 :我们的应用在容器内监听 process.env.PORT || 3000 ,而我们在 az container create 中设置了环境变量 PORT=80 ,并指定了 --ports 80 。所以容器内监听80,ACI对外暴露80,这是正确的。如果应用在容器内仍监听3000,则无法访问。确保环境变量传递成功。
    3. 查看容器日志 :使用Azure CLI az container logs --resource-group <rgName> --name <containerName> 查看容器内部的应用日志,确认Node.js服务器是否成功启动,是否有错误。
    4. 检查ACI状态 az container show --resource-group <rgName> --name <containerName> ,查看 provisioningState 是否为 Succeeded ipAddress fqdn 字段是否正常。

问题4:更新镜像后,访问网站发现还是旧版本。

  • 排查
    1. 浏览器缓存 :这是最常见的原因。使用Ctrl+F5强制刷新浏览器,或打开浏览器开发者工具的“网络”选项卡,勾选“禁用缓存”。
    2. ACI更新延迟 az container update 命令发出后,ACI需要时间拉取新镜像并重启容器。等待1-2分钟再试。
    3. DNS缓存 :公共DNS记录可能有TTL(生存时间)。可以尝试使用其他设备或网络访问,或者通过容器的IP地址直接访问(从 az container show 的输出中获取 ipAddress.ip )。
    4. 确认镜像标签 :确保工作流中 az container update 命令使用的镜像标签(如 :latest )确实指向了新构建的镜像。可以登录Azure门户,进入ACR,查看镜像的“最近推送”时间。

8.3 资源清理

实验完成后,为了避免产生不必要的费用,请务必清理资源。最彻底的方式是删除整个资源组,这会删除组内所有资源(ACR, ACI等)。

# 请将资源组名称替换为你实际使用的名称
az group delete --name rg-cicd-demo-你的运行ID --yes --no-wait
  • --yes : 跳过确认提示。
  • --no-wait : 不等待删除操作完成就返回命令提示符。

你也可以在Azure门户中手动删除资源组。进入Azure门户,导航到“资源组”,找到你的资源组,点击“删除资源组”,输入资源组名称确认即可。

9. 从实验到生产:技术路径演进思考

通过这个实验,你已经掌握了现代CI/CD流水线的核心模式:代码变更触发自动化工作流,工作流构建不可变的容器镜像,并将镜像部署到无服务器容器平台。这个模式是通用的,可以平滑地迁移到更强大的Azure服务上。

  • 下一步:Azure Container Apps 如果你需要自动扩缩容(根据HTTP请求量、CPU使用率等自动调整容器实例数量)以及基于事件(如消息队列)的触发,ACA是完美的下一步。它将ACI的无服务器特性与Kubernetes的弹性结合了起来。你的工作流只需将部署命令从 az container create 改为 az containerapp update

  • 进阶:Azure Kubernetes Service 当你需要运行复杂的微服务、有严格的网络策略需求(如服务网格)、或需要蓝绿部署、金丝雀发布等高级部署策略时,AKS是终极选择。学习曲线较陡,但能力最强。你的CI/CD流水线最终会使用 kubectl apply -f deployment.yaml 来更新Kubernetes集群中的部署。

  • 简化:Azure App Service 如果你的应用只是一个传统的Web应用(如.NET Core, Java Spring Boot, Python Django),不想操心容器,那么Azure App Service提供了极简的部署体验(Git推送、ZIP部署等),并内置了自动扩缩容、流量管理等功能。它抽象了底层基础设施,让你更专注于代码。

无论选择哪条路径,你今天构建的GitHub Actions工作流的核心逻辑——认证、构建、推送、部署——都将保持不变。变化的只是最后一步部署的目标平台和命令。这才是这个实验最大的价值:它为你提供了一个坚实、可扩展的自动化部署基础模板。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐