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 技术栈选型与设计哲学这个项目的核心设计哲学是“最小可行产品”和“关注点分离”。我们不过度设计应用本身而是将全部精力集中在部署流水线的构建上。因此技术栈的选择都服务于这个目标应用层一个纯静态的Node.js Web服务器。我们没有使用Express、Koa等框架甚至没有使用任何前端框架。应用仅包含index.html、style.css和一个极简的server.js。目的是消除一切与核心目标无关的复杂性让应用本身简单到不会成为学习障碍。容器化层Docker。它是实现环境一致性的基石。通过一个Dockerfile我们定义了应用运行所需的完整环境Node.js运行时、依赖、代码。这确保了应用在任何地方你的笔记本、GitHub的构建服务器、Azure的生产环境的行为都是一致的。镜像仓库Azure Container Registry。你可以把它理解为Azure平台内的私有Docker Hub。它是我们流水线的中间站GitHub Actions构建的镜像被推送到这里然后ACI从这里拉取镜像并运行。使用ACR而非公共仓库保证了镜像的安全性和部署速度同在Azure网络内。计算平台Azure Container Instances。如前所述它是我们的“服务器”。我们无需关心虚拟机、操作系统更新或运行时配置。ACI按秒计费用完即删成本极低非常适合实验。自动化引擎GitHub Actions。它是整个流水线的大脑和执行者。我们通过一个YAML文件定义了一系列步骤检出代码、登录Azure、构建镜像、部署容器。GitHub提供了免费的额度足以支撑个人项目的CI/CD需求。这个架构的精妙之处在于每一个环节都是云原生的标准组件并且它们之间的集成是“原生”的。例如GitHub Actions有官方的Azure登录ActionACR支持通过Azure CLI直接进行安全的镜像构建。这种设计使得整个流水线既健壮又易于理解和维护。2.2 安全与权限管理设计在自动化流程中安全是首要考虑。我们不能将敏感凭证如Azure订阅的访问密钥硬编码在代码或配置文件里尤其是公开的仓库。本项目的安全设计遵循了最小权限原则和秘密管理最佳实践服务主体我们在Azure Active Directory中创建一个专门用于GitHub Actions的“服务主体”。你可以把它理解为一个机器人账户。我们只为这个账户授予对特定资源组而非整个订阅的“贡献者”角色。这样即使凭证泄露攻击者的权限也被限制在很小的范围内。GitHub Secrets创建服务主体后我们会得到一组密钥Client ID, Client Secret, Tenant ID。这些密钥被以加密形式存储在GitHub仓库的“Secrets”中。在GitHub Actions工作流运行时这些秘密会被安全地注入到环境变量中供登录步骤使用。你的工作流YAML文件里永远不会出现明文的密钥。ACR访问控制在部署到ACI时ACI需要从ACR拉取镜像。我们同样使用服务主体的凭证进行认证而不是为ACR单独创建管理员账号。这实现了统一的身份管理。注意在真实项目中对于生产环境可以考虑使用Azure Key Vault来集中管理密钥并通过GitHub Actions的azure/get-keyvault-secretsAction来动态获取实现更高级别的秘密轮换和管理。3. 环境准备与项目初始化3.1 本地开发环境配置在开始编写任何代码之前我们需要确保本地环境就绪。这里假设你使用的是macOS或Linux系统Windows用户建议使用WSL2以获得最佳体验。安装Docker Desktop这是容器化的基础。前往Docker官网下载并安装Docker Desktop。安装后启动它确保右下角状态为“Running”。在终端运行docker --version和docker run hello-world来验证安装成功。Docker Desktop包含了完整的Docker引擎、CLI以及一个轻量级的Kubernetes集群本项目用不到是我们进行本地构建和测试的利器。安装Azure CLI这是与Azure资源交互的命令行工具。你可以通过包管理器安装如macOS的brew install azure-cli或从微软官方下载安装包。安装后运行az --version验证。接下来需要进行登录运行az login这会打开浏览器让你用你的Azure账户进行认证。成功登录后你的订阅信息就被CLI管理起来了。准备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 langen head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleAzure CI/CD 演示/title link relstylesheet hrefstyle.css link relpreconnect hrefhttps://fonts.googleapis.com link relpreconnect hrefhttps://fonts.gstatic.com crossorigin link hrefhttps://fonts.googleapis.com/css2?familyInter:wght300;400;600displayswap relstylesheet /head body div classcontainer header classheader h1 自动化部署成功/h1 p classsubtitle你的代码已通过 GitHub Actions 自动部署到 Azure Container Instances。/p /header main classcontent div classcard h2当前版本/h2 div classversion-badgev1.0.0/div p这个版本号仅存在于 codeindex.html/code 文件中。修改它并推送即可触发一次全新的自动化部署。/p /div div classcard h2技术栈/h2 ul classtech-stack liNode.js 静态服务器/li liDocker 容器化/li liAzure Container Registry (ACR)/li liAzure Container Instances (ACI)/li liGitHub Actions (CI/CD)/li /ul /div div classcard h2下一步尝试/h2 ol classnext-steps li打开 codeindex.html/code将版本号改为 codev2.0.0/code。/li li执行 codegit add ./code, codegit commit -m Bump to v2.0.0/code, codegit push/code。/li li前往 GitHub 仓库的 strongActions/strong 标签页观看流水线自动运行。/li li约2分钟后刷新此页面观察版本号是否已更新。/li /ol /div /main footer classfooter p这是一个用于演示现代 CI/CD 工作流的教学项目。容器化与自动化让部署变得简单可靠。/p /footer /div /body /htmlstyle.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 --onlyproduction # 第二阶段复制应用代码 # 再次使用轻量级镜像减小最终镜像体积 FROM node:20-alpine # 设置运行时的工作目录 WORKDIR /usr/src/app # 从上一阶段builder复制已安装的node_modules COPY --frombuilder /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的设计考量多阶段构建我们使用了两个FROM指令。第一阶段builder专门用于安装依赖。第二阶段是最终的运行时镜像。我们只从第一阶段复制了node_modules而没有复制package.json等文件。这样做的好处是最终的镜像不包含构建工具和源代码之外的任何文件体积更小安全性更高减少了攻击面。基础镜像选择node:20-alpine。Alpine Linux是一个极简的Linux发行版镜像体积通常只有5MB左右而Node.js官方基于Alpine的镜像也比基于Debian等发行版的镜像小很多。对于生产环境小体积意味着更快的拉取速度和更少的安全漏洞。依赖安装优化使用npm ci而不是npm install。npm ci严格根据package-lock.json安装依赖能确保依赖树的一致性并且安装速度更快。--onlyproduction参数确保不安装devDependencies进一步减小镜像。层缓存策略Docker构建是分层的每一行指令都会产生一个层。我们将变化频率最低的指令如COPY package*.json ./放在前面变化频率最高的指令如COPY应用代码放在后面。这样当我们只修改了应用代码而package.json未变时Docker可以复用之前已构建好的node_modules层极大加速构建过程。EXPOSE与CMDEXPOSE 3000是元数据告诉用户这个容器打算监听3000端口。CMD定义了容器启动时的默认命令。我们使用数组格式[node, server.js]这能确保信号如SIGTERM能正确传递给Node.js进程。4.2 本地构建与运行测试在编写完Dockerfile后务必在本地进行测试这是保证后续CI/CD流程顺利的关键。构建镜像在项目根目录Dockerfile所在目录打开终端执行docker build -t cicd-demo-app:local .-t参数为镜像打上标签cicd-demo-app:local.表示使用当前目录作为构建上下文。观察输出你会看到Docker一步步执行Dockerfile中的指令。首次构建会下载基础镜像需要一些时间。运行容器镜像构建成功后运行它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: 要运行的镜像标签。验证打开浏览器访问http://localhost:8080。你应该能看到和之前本地运行Node.js时一模一样的页面。这证明了容器化成功——应用在一个与宿主机隔离的、标准化的环境中正常运行了。查看日志与清理# 查看容器日志 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_GROUPrg-cicd-demo-$(date %s) # 定义容器注册表名称全局唯一只能包含小写字母和数字 ACR_NAMEacrdemo$(openssl rand -hex 3) # 选择区域建议选择离你近的如 eastus, westus2, westeurope LOCATIONeastus # 获取当前订阅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容器注册表ACRSKU选择Basic适合开发测试成本最低 az acr create \ --resource-group $RESOURCE_GROUP \ --name $ACR_NAME \ --sku Basic \ --admin-enabled false # 禁用管理员账户使用更安全的服务主体认证创建ACR可能需要一两分钟。--admin-enabled false是一个安全最佳实践它禁用了ACR自带的用户名/密码管理员账户强制我们使用Azure Active DirectoryAAD进行身份验证例如接下来要创建的服务主体。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 SecretAZURE_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打开你的GitHub仓库页面。点击顶部导航栏的Settings。在左侧边栏找到Secrets and variables-Actions。点击New repository secret。分别创建四个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/checkoutv4 # 步骤2登录到Azure。使用我们存储在Secrets中的服务主体凭证。 - name: Login to Azure uses: azure/loginv2 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 PORT80 \ --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 工作流步骤深度解析触发机制on指令定义了工作流何时运行。push到main分支是自动触发workflow_dispatch提供了手动触发按钮这在调试时非常有用。环境变量在env部分定义的变量可以在所有步骤中使用。这里我们动态生成了资源组名称rg-cicd-demo-${{ github.run_id }}利用GitHub Actions的运行ID确保每次运行创建的资源组都是唯一的避免了冲突也便于清理。github.sha和github.run_number是GitHub提供的上下文变量分别代表触发工作流的提交SHA和当前工作流的运行编号。Azure登录我们使用了官方的azure/loginv2Action。这里演示了两种传递凭证的方式。更简洁的方式是将之前服务主体输出的整个JSON对象保存为一个名为AZURE_CREDENTIALS的GitHub Secret然后直接使用creds参数引用。这比管理四个单独的secret更方便。ACR构建az acr build命令是关键优化点。它不是在GitHub的Runner上执行docker build而是将构建上下文你的代码上传到Azure在Azure云服务中执行构建。这样做的好处是无需Docker in DockerGitHub Runner不需要安装或运行Docker守护进程简化了Runner环境。性能与网络构建在Azure内部完成镜像直接推送到同区域的ACR速度极快且不消耗GitHub Runner的出站带宽。安全构建密钥和中间层不会暴露在Runner上。我们为镜像打了两个标签${{ github.sha }}唯一的提交哈希用于精确版本追踪和回滚和latest指向最新构建的镜像。ACI部署部署步骤使用了条件判断。它先尝试检查同名的ACI容器组是否存在。如果存在则执行az container update来更新其镜像实现原地升级如果不存在则执行az container create创建新的容器组。这是一种更健壮的部署逻辑。--dns-name-label: 为容器实例生成一个公共可访问的FQDN完全限定域名。我们附加了run_number使其唯一。--cpu和--memory: 指定容器的计算资源。1个vCPU和1.5GB内存对于这个简单应用绰绰有余也是ACI免费层如果可用或低成本层的典型配置。--environment-variables PORT80: 将容器内的环境变量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 工作流优化与进阶技巧基础流水线已经跑通但我们可以让它更健壮、更专业。使用单个JSON Secret如前所述将服务主体的完整JSON输出保存为一个名为AZURE_CREDENTIALS的Secret然后在工作流中直接使用creds: ${{ secrets.AZURE_CREDENTIALS }}这样更简洁安全。添加构建缓存虽然az acr build在云端进行但我们可以通过配置缓存来加速后续构建。修改构建步骤添加--platform和缓存参数如果适用。镜像扫描与安全在推送镜像前可以集成安全扫描工具。Azure Defender for container registriesACR高级版功能或开源工具如Trivy可以集成到工作流中在构建后对镜像进行漏洞扫描如果发现高危漏洞则失败。多环境部署为开发dev、预发布staging、生产prod设置不同的资源组和ACR通过GitHub环境Environments和分支规则来管理。例如推送到develop分支部署到开发环境打标签时部署到生产环境。添加健康检查在az container create命令中添加--protocol TCP --port 80的liveness probe和readiness probe让ACI能够判断容器是否健康并进行自动恢复。使用Azure Key Vault管理密钥将数据库连接字符串等应用机密存储在Azure Key Vault中让ACI在启动时从Key Vault拉取而不是写在代码或环境变量里。8.2 常见问题与排查实录在实践过程中你可能会遇到以下问题。这里记录了排查思路和解决方法问题1GitHub Actions工作流在“Login to Azure”步骤失败。错误信息Error: Credentials could not be authenticated.排查检查GitHub Secrets中的四个值Client ID, Secret, Tenant ID, Subscription ID是否与服务主体创建时的输出完全一致尤其是Secret确保没有多余的空格或换行。服务主体可能已过期或被删除。使用az ad sp list --display-name 你的服务主体名称检查其状态。可以尝试重新创建服务主体并更新Secrets。确保服务主体确实被赋予了目标资源组的“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.排查检查YAML文件中env.ACR_NAME的值是否正确是否与你通过CLI创建的ACR名称完全一致区分大小写。检查服务主体是否有权限访问该ACR。除了资源组的Contributor角色可能还需要ACR特定的角色如AcrPush。可以运行az role assignment create --assignee clientId --scope /subscriptions/subId/resourceGroups/rgName/providers/Microsoft.ContainerRegistry/registries/acrName --role AcrPush来授予推送权限。问题3容器部署成功但无法通过浏览器访问连接被拒绝/超时。排查确认协议ACI默认是HTTP。确保浏览器访问的是http://开头的地址而不是https://。检查端口我们的应用在容器内监听process.env.PORT || 3000而我们在az container create中设置了环境变量PORT80并指定了--ports 80。所以容器内监听80ACI对外暴露80这是正确的。如果应用在容器内仍监听3000则无法访问。确保环境变量传递成功。查看容器日志使用Azure CLIaz container logs --resource-group rgName --name containerName查看容器内部的应用日志确认Node.js服务器是否成功启动是否有错误。检查ACI状态az container show --resource-group rgName --name containerName查看provisioningState是否为SucceededipAddress和fqdn字段是否正常。问题4更新镜像后访问网站发现还是旧版本。排查浏览器缓存这是最常见的原因。使用CtrlF5强制刷新浏览器或打开浏览器开发者工具的“网络”选项卡勾选“禁用缓存”。ACI更新延迟az container update命令发出后ACI需要时间拉取新镜像并重启容器。等待1-2分钟再试。DNS缓存公共DNS记录可能有TTL生存时间。可以尝试使用其他设备或网络访问或者通过容器的IP地址直接访问从az container show的输出中获取ipAddress.ip。确认镜像标签确保工作流中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工作流的核心逻辑——认证、构建、推送、部署——都将保持不变。变化的只是最后一步部署的目标平台和命令。这才是这个实验最大的价值它为你提供了一个坚实、可扩展的自动化部署基础模板。