ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GitLab CI/CD 前端自动化部署:从零搭建完整流水线

GitLab CI/CD 前端自动化部署:从零搭建完整流水线 前端项目真正从开发到上线最容易被忽略的其实是最后那几步。我见过不少团队代码写得很规整技术栈也很新结果每次上线都是同一个人下午四点半开始手动打包、压缩、传服务器、清 nginx 缓存……速度快不快完全取决于这个人今天状态好不好。上了一台新服务器或者换了交接人那更是灾难。这篇文章就完整记录我是怎么把前端项目的 GitLab 自动化部署链路从零搭起来的核心就三件事把“人肉上线”变成“提交代码自动触发”把每次发版信息留在流水线里让任何一台机器、任何一个人点一下按钮就能复现同一次构建。1. 前端的发布流程为什么需要一只“看不见的手”1.1 手动部署的三个真实痛点先说个我自己的例子项目早期每次发版都是“打包三连”——本地跑npm run build等两分钟然后把整个 dist 目录用 scp 甩到服务器上。听着挺简单对吧真正跑起来之后你会发现三个非常现实的问题。第一不可重复。本地机器和服务器、和同事的机器构建出来的产物经常有细微差别。node 版本不同、npm 版本不同、环境变量没对齐都会导致“在我电脑上是好的传上去就有问题”。我们甚至碰到过因为某台电脑的.npmrc里配了公司私有源结果构建产物里混进了只有内网才能访问的资源引用上线后外网用户死活加载不出来。第二人肉流程天然依赖某个“神”。谁手里有服务器密码、谁知道发布步骤、谁记得要清缓存这个人一请假整个上线就卡住了。更可怕的是这种知识往往只存在于某个人的聊天记录或者本地便签里别人问起来得到的答复多半是“你等我看看”。第三没有审计。上线之后出了问题想查看“服务器上当前跑的是哪次 commit、构建时间是什么时候”得靠回忆。如果上线前后动过好几次甚至没人能说清现场是个什么状态。这些痛点和“自动化”本身有多高级没关系它们逼着你思考一件事发布这件事能不能变成一条固定流程让人只是触发它、观察它而不是亲手把每一步做一遍。1.2 自动化部署解决的不只是速度问题很多人以为上 CI/CD 就是为了“快”其实快只是副产品。它真正解决的是可重复、可追溯和可回滚。打一个最直观的比方手动部署就像每天手工抄报表抄得再快也可能抄错行CI/CD 则像是打卡系统规则写在系统里谁打没打卡、几点打的、打了什么位置全有记录。放到 GitLab 这套体系里每次 merge 或 push 触发流水线后日志里会清清楚楚地记录这次构建是哪个 job、由哪个 commit 触发、构建产物 hash 是多少、部署到了哪台服务器。之后不管是指着某次提交说“我就要这个版本”还是凌晨两点排查线上事故这些记录都是救命线索。所以这篇文章面向的读者很明确手上有 GitLab 仓库、前端的构建产物还是靠人工传服务器或者刚接触 CI/CD 一脸懵想把项目完整接进去的前端同学。下文所有步骤和配置都是我实际在项目里跑过的可以直接参考。2. 先把地基打好GitLab 和 Runner 的安装与连接2.1 快速部署一个 GitLab 服务Docker 方式如果你是直接用公司的 GitLab那这一步可以跳过。但很多人是在自己的服务器上搭环境练手这时候最省心的方案是用 Docker 起一个社区版实例。社区版的 CI/CD 功能是完整的跟企业版在基础的 pipeline 使用上没有本质区别。下面是我实际用过的一份 docker-compose 配置端口做了常见调整避免跟宿主机上已有的服务冲突。version: 3.8 services: gitlab: image: gitlab/gitlab-ce:latest container_name: gitlab restart: always hostname: gitlab.example.com ports: - 8080:80 - 8443:443 volumes: - /srv/gitlab/config:/etc/gitlab - /srv/gitlab/logs:/var/log/gitlab - /srv/gitlab/data:/var/opt/gitlab shm_size: 256m这里有两个容易踩的细节。一是端口映射。很多人习惯把容器内的 80 直接映射到宿主机的 80但如果宿主机上已经跑了 nginx 或者 Apache端口冲突会导致 GitLab 起不来。所以我用了8080:80访问地址就是http://gitlab.example.com:8080。二是hostname和external_url的关系。GitLab 很多内部操作比如生成 clone 地址、回调 URL是根据“它认为自己的地址”来生成的。如果你只是局域网访问最好在 gitlab.rb 里显式设置external_url http://gitlab.example.com:8080改完这个配置需要执行gitlab-ctl reconfigure。这一步不做后面极有可能出现仓库 clone 地址变成容器内部 IP 之类的怪问题。版本方面想多说一句。GitLab 老版本和新工具链的兼容性问题非常多后面排查部分我会讲到。我自己现在部署新实例一律用gitlab-ce:latest或明确的 16.x、17.x 标签坚决不用 14 以下的版本——不是炫新是新版工具IDE 插件、CLI默认使用的 API 在旧版上根本跑不通。2.2 Runner流水线的实际执行者GitLab CI/CD 的架构是“GitLab 平台 独立的 Runner 进程”。GitLab 本身只负责定义流水线、展示日志真正跑npm run build、执行rsync的是 Runner。它需要单独装不能省略。Runner 可以装在和 GitLab 同一台机器也可以装在单独的机器上。个人项目练手阶段直接同机装最简单。用 Docker 方式装 Runner 也很快但注意一个关键点Runner 注册时选择的 executor 决定了每个 job 怎么执行。docker executor是最常用的模式——每个 job 都启一个独立的容器来干活环境互不隔离。注册命令大概是这样gitlab-runner register \ --url http://gitlab.example.com:8080 \ --registration-token 项目或实例的token \ --executor docker \ --docker-image node:20-alpine \ --description node-runner \ --tag-list web,node,frontend注册完成后到 GitLab 的管理后台Admin Area - CI/CD - Runners看一眼能看到这台 Runner 是绿色的在线状态就可以继续往下走了。这里需要解释一下tag-list的作用。GitLab 的 job 可以通过tags字段锁定在特定 Runner 上跑比如构建任务要求那个 Runner 能访问内网、或者只有它装了特定证书。你给 Runner 打的 tag 和 job 里的 tags 必须形成匹配关系否则 job 会一直卡在 pending 状态。我习惯给 Runner 打上web,node,frontend这样语义化的标签后面配置.gitlab-ci.yml时一眼就能认出每个 job 归谁执行。2.3 个人访问令牌与 CI/CD 变量GitLab 有几种令牌注册 Runner 用的 registration token、用户登录用的 Personal Access Token、管道专用的 CI_JOB_TOKEN、部署用的 Deploy Token。日常个人使用有 Personal Access Token 就够了它的权限范围可以精确到api、read_repository、write_repository等。创建位置在个人头像 - Preferences - Access Tokens勾上你需要的 scope生成后立刻复制保存——这个令牌只显示一次页面刷新就没了丢了只能删掉重建。令牌不是给你每次都手动填的它的主要使用场景是配 IDE 插件、GitLab CLI以及某些 CI job 需要操作 Git 仓库时使用。真正在流水线里用的敏感信息应该放到项目的Settings - CI/CD - Variables里。我梳理了一个前端项目部署最常用的变量清单供你参考变量名用途是否需要 MaskedSSH_PRIVATE_KEY部署阶段 SSH 登录服务器的私钥是SERVER_HOST目标服务器 IP 或域名建议SERVER_USERSSH 登录用户建议SERVER_PATH服务器上部署目录否DEV_SERVER_HOST / DEV_SERVER_PATH测试环境专用变量否PROD_SERVER_HOST / PROD_SERVER_PATH生产环境专用变量否SSH 私钥的生成和配置是部署环节里最容易出错的一步下面单开一节细讲。3. .gitlab-ci.yml前端流水线的核心配置3.1 pipeline 的四个阶段怎么划分GitLab CI 里的 job 要按照 stage 来组织stage 之间是串行的同一个 stage 里的 job 可以并行。前端项目我常用的划分是四个阶段install、lint、build、deploy。install 抽出来单设一个 stage 是有讲究的。前端依赖安装特别吃 IO如果每个 job 都各自跑一遍npm install流水线会慢得让人想摔键盘。把它单独拉出来再配合 artifacts 把node_modules传给后面的 job整个流程只会安装一次。lint 和 build 可以在 install 完成后并行跑省时间。deploy 永远排在最后确保只有构建产物成功产出之后才会往服务器上推。四个阶段对应到流水线的实际流程就是安装依赖 - 静态检查 - 打包 - 发布。这个顺序不是拍脑袋定的它保证了一个最朴素的原则绝不带着垃圾构建产物去部署。代码风格有问题、测试没过build 都不会触发更碰不到服务器。3.2 一份可直接改的前端 CI 配置下面这份配置是我目前前端项目在用的简化版技术栈是 Vue/React 都通用只要构建命令是npm run build几乎可以照搬。stages: - install - lint - build - deploy variables: NODE_ENV: production NPM_CONFIG_REGISTRY: https://registry.npmmirror.com cache: key: $CI_COMMIT_REF_SLUG paths: - node_modules/ before_script: - node -v - npm -v install: stage: install image: node:20-alpine script: - npm ci artifacts: paths: - node_modules/ expire_in: 1 day lint: stage: lint image: node:20-alpine script: - npm run lint dependencies: - install build: stage: build image: node:20-alpine script: - npm run build artifacts: paths: - dist/ expire_in: 7 days dependencies: - install deploy: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client rsync - mkdir -p ~/.ssh - echo $SSH_PRIVATE_KEY ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H $SERVER_HOST ~/.ssh/known_hosts script: - rsync -avz --delete dist/ $SERVER_USER$SERVER_HOST:$SERVER_PATH rules: - if: $CI_COMMIT_BRANCH main environment: name: production逐段拆开说。image 字段每个 job 都指定了一个基础镜像。node 镜像负责前端构建deploy 阶段的 alpine 镜像体积小、起得快只需要装 openssh-client 和 rsync 就能做文件传输。这样做的本质是“职责分离”构建环境不需要部署工具部署环境不需要 Node.js。before_script每个 job 都先打印 node 和 npm 版本作用只有一个——排查问题的时候第一眼确认运行环境这个习惯能省掉大量无谓的“环境不一致”争吵。npm ci vs npm install我强烈建议用npm ci它严格按照 package-lock.json 安装速度快、可复现CI 环境里它就是比npm install稳。前者如果 lock 文件与 package.json 不匹配会直接报错这反而是件好事说明你的依赖声明有问题。artifacts这是 GitLab CI 里最容易被误解的概念之一。它不是“缓存”而是 job 产出的“正式结果”。install job 把 node_modules 打包传给后续 jobbuild job 再把 dist 目录传给 deploy job。artifacts 默认会保留到流水线结束后方便你下载查看expire_in控制保留时间node_modules 留 1 天就够dist 留 7 天。部署这类敏感操作完成后dist 的 artifacts 其实也可以删但留着没坏处回滚排查时能直接用。deploy 阶段的 rules这里我用了一个条件判断只有 main 分支的提交才执行部署。这样测试环境的需求可以复制同样的配置把分支名换成 dev 即可后面章节会专门展开多环境。3.3 部署脚本里的安全细节deploy 阶段是整个配置中最“危险”的部分因为它拿到了服务器的 SSH 权限。我见过太多人直接在 .gitlab-ci.yml 里写死服务器用户名密码这是绝对要避免的做法。三个安全细节值得强调。第一私钥不能直接埋在 yml 里必须通过$SSH_PRIVATE_KEY引用项目变量。GitLab 的变量支持 Masked 属性勾选后在日志里会被打码但注意不是所有字符串都能被 mask比如太短的字符串就不行所以私钥这类长串文本没问题。第二写入私钥后必须立刻chmod 600否则 SSH 会直接拒绝使用权限过宽的文件并提示“Permissions 0644 for id_rsa are too open”。这是 Cl 环境里很容易踩的坑看着是权限问题其实已经帮你挡了一次危险操作。第三rsync 用--delete参数时要谨慎。它保证了本地 dist 和服务器部署目录的完全一致旧的构建产物会被清掉这个特性非常有用但如果路径配错比如指到服务器上的根目录或另一个重要目录会把你直接清懵。所以 SERVER_PATH 变量最好写成明确的绝对路径比如/var/www/html/my-project然后在部署完成后的第一步就验证目录内容。另外提一个被很多人忽略的细节ssh-keyscan会绕过首次连接的 host key 确认过程。这在 CI 环境里是必要的因为非交互式 shell 无法手动输入 yes但我建议把StrictHostKeyChecking保持默认 yes而不是图省事写-o StrictHostKeyCheckingno。用ssh-keyscan -H提前写入 known_hosts 既安全又省事不会让服务器在不知不觉中被中间人攻击。4. 首跑流水线的典型报错从现象到根因的排查记录自动化部署第一次跑通之前一定会遇到几个报错。这里我把最常见的四条完整排查过程记录下来每条都从现象讲起最后给出根因和解决方式。4.1 IDE/CLI 登录失败版本兼容的乌龙第一个典型报错其实发生在写 pipeline 之前。我刚开始用 GitLab 时在 IDEA 里配置完 GitLab 账号一点登录就弹出一个提示Login failed. GitLab versions older than 14.0 are not supported。我当时的直觉是 token 有问题于是重新生成了令牌、反复检查 URL全都无效。冷静下来之后才发现方向错了。这个报错的关键信息不是“token 不正确”而是“版本不满足要求”。新版 IDE 插件和 GitLab CLI 调用的都是 GitLab 新版 API老版本 GitLab低于 14.0并没有提供这些 REST 接口或者返回的数据结构和预期对不上于是客户端索性拒绝登录。解决方式有两条路可选升级 GitLab或者换用支持旧版的客户端。如果 GitLab 是你自己部署的强烈建议升级官方升级文档写得很清楚注意低版本要按版本号逐步升级不能直接跳到最新否则数据库迁移会出问题升级前一定要备份/etc/gitlab目录和数据库。如果是公司里由别人运维的 GitLab你暂时无权升级那就先用 SSH 方式登录 IDE或者改用 Git 命令行提交代码绕开新版 API 的依赖。4.2 Runner 注册成功却一直 Offline / Job 永远 Pending第二个高频问题是我把 Runner 装好、注册成功之后发现 GitLab 后台里 Runner 一直灰色触发的 job 永远是 pending不执行。排查链路是这样的先在 Runner 机器上执行gitlab-runner status和gitlab-runner verify确认本地进程是活的然后从 Runner 机器上curl -I http://gitlab.example.com:8080确认能访问 GitLab 的 Web 服务接着检查防火墙如果 Runner 和 GitLab 之间隔了防火墙必须放行对应的端口。这一套走完最常见的原因其实不在网络而在tag 不匹配。job 里如果写了tags: [web, node]而 Runner 注册时只打了node标签GitLab 就会认为没有可用的 Runnerjob 一直 pending。解决方式很简单要么把 job 里的 tags 去掉让它跑在任何可用的 Runner 上要么保证 Runner 的标签覆盖了 job 的要求。这类问题最好通过看 Runner 日志来定位命令是docker logs gitlab-runner或journalctl -u gitlab-runner -f日志里会明确写出类似 “Job was not executed because it would use a masked runner” 之类的信息比瞎猜快得多。4.3 SSH 免密部署失败与 known_hosts 问题第三个经典报错出现在 deploy 阶段日志里最常见的两句话是Host key verification failed和Permission denied (publickey)。先说第一句。服务器首次连接时会要求确认 host keyCI 环境是非交互式这个确认过程会直接失败于是部署就卡在 ssh 连接这一步。我的处理方式是在 before_script 里用ssh-keyscan -H $SERVER_HOST把服务器指纹写入 known_hosts。这样既绕开了交互确认也保留了 host key 校验属于正规做法而非绕过后门。再说第二句。SSH 免密依赖私钥但 CI 里的私钥往往是从本地 Windows 机器上复制过来的格式可能是-----BEGIN RSA PRIVATE KEY-----的老版本格式OpenSSH 的新版本默认不再接受。遇到这种报错先在本地执行ssh-keygen -p -m PEM -f key_file转成 PEM 格式或者干脆重新生成一对 OpenSSH 格式密钥然后把公钥追加到服务器的~/.ssh/authorized_keys里。这里有个细节很多人在本地一直用的用户名和服务器上的用户名不一致例如本地是admin服务器上实际用户是deploy导致连接时身份不对。排查时先手动在服务器上执行一次ssh-keygen -t ed25519 -C gitlab-ci并在 authorized_keys 里确认一下密钥内容能省掉很多不必要的折腾。4.4 分支保护导致的写权限冲突最后一个报错发生在“想跑自动化发布但流水线想做的事被 GitLab 拦住了”的场景。GitLab 默认对 main/master 分支启用了分支保护Developer 角色的用户默认是无法直接 push 到受保护分支的——这其实是很多人问的“GitLab developer 可以提交代码到 master 吗”的标准答案可以 merge request但不能直接 push。CI 里的 job 默认用的是当前用户的身份和权限。如果你的部署脚本里有“生成版本号、打 tag、推送到远程仓库”之类的操作但当前用户是 Developer就会被 403 拦下来。我以前在 build 阶段加了一步自动生成版本文件并推回仓库第一次上线就遇到了这个报错。解决方式有三种按推荐程度排序。第一种是不要在 CI 里“写回”仓库改为在构建产物里多放一个带时间戳和 commit hash 的文件部署时服务器侧读取即可第二种是去项目设置里调整受保护分支的允许角色把Allowed to merge和Allowed to push设置放开到指定角色第三种是给 CI 单独创建一个 Deploy Token 或 Project Access Token并把它的权限控制在最小范围只在需要写仓库的 job 里引用这个 token。需要特别提醒不要把这种权限冲突理解成“GitLab 故意添乱”。它本质上是在保护一个重要分支不被随意覆盖。正式的团队协作里维护这个保护机制反而对发布安全有利。5. 前端部署的进阶细节多环境、缓存策略和上线后的小技巧流水线能跑通只是万里长征第一步。接下来这几个细节是真正决定这套自动化部署能不能在日常团队协作里长期用下去的关键。5.1 一套配置跑通 dev / staging / prod多数前端团队有多个环境如果每个环境都写一份 .gitlab-ci.yml那就是给自己埋了一堆重复代码的坑。GitLab 支持同一份配置里通过rules区分不同分支、不同变量。做法很简单把不同环境的差异都收敛到变量上流水线本身只有一份。deploy: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client rsync - mkdir -p ~/.ssh - echo $SSH_PRIVATE_KEY ~/.ssh/id_rsa - chmod 600 ~/.ssh/id_rsa - ssh-keyscan -H $SERVER_HOST ~/.ssh/known_hosts script: - rsync -avz --delete dist/ $SERVER_USER$SERVER_HOST:$SERVER_PATH rules: - if: $CI_COMMIT_BRANCH dev variables: SERVER_HOST: $DEV_SERVER_HOST SERVER_USER: $DEV_SERVER_USER SERVER_PATH: $DEV_SERVER_PATH - if: $CI_COMMIT_BRANCH main variables: SERVER_HOST: $PROD_SERVER_HOST SERVER_USER: $PROD_SERVER_USER SERVER_PATH: $PROD_SERVER_PATH很多新手看到variables里套变量会觉得没必要直接写死不就行了但一旦你的服务器 IP 换了或者测试环境和生产环境分开管理写死的结果就是去改流水线配置、触发一次不必要的新任务、还冒着改错影响生产部署的风险。用变量引用变量环境相关的东西全部收敛到 GitLab 项目设置里这才是可维护的做法。同时在 deploy job 里配上environment: name: production这类字段GitLab 的“环境”页面就能自动记录每一次部署的时间和 commit回滚时直接用 UI 都比查日志方便。5.2 CI 缓存与构建速度的平衡前端构建最耗时的就是npm install。为了省时间我给 install job 设置了 cachecache: key: $CI_COMMIT_REF_SLUG paths: - node_modules/cache 专门缓存node_modules目录key 按分支区分避免不同分支之间相互污染。这里解释一下 cache 和 artifacts 的区别cache 是“依赖的中间缓存”可以被多次任务复用artifacts 是“当前任务的正式产出”传给下游任务。很多人把这两个概念混在一起导致配置出了问题还很迷惑。用一句话总结npm i 之后 node_modules 可以被缓存build 之后 dist 应该被当作 artifacts 传递。缓存也不是越大越好。node_modules里混进了临时文件、调试包会让缓存的命中率变低坏缓存还会引发各种诡异的报错。我的习惯是缓存只保留关键路径同时给缓存设置一个合理的时间不要让它无限期躺在 Runner 的存储里。5.3 版本号与前端强制刷新前端上线遇到的最尴尬问题之一就是“新版已经发布了但用户浏览器还在用旧版”。原因很多SPA 的 index.html 被本地缓存、静态资源带 hash 但入口文件没过期、用户开着页面一直不刷新……被动等用户刷新不是办法不如让页面自己“知道”该刷新了。我在 build 阶段会在public目录下生成一个version.json内容大概是这样的echo {\buildTime\:\$(date %Y-%m-%dT%H:%M:%S)\,\commit\:\$CI_COMMIT_SHORT_SHA\} public/version.json前端在主应用启动时轮询这个文件对比本地缓存的版本号发现不一致就提示用户刷新页面。核心逻辑大概是let cachedVersion null; async function checkVersion() { const res await fetch(/version.json?t Date.now()); const data await res.json(); if (cachedVersion data.commit ! cachedVersion) { // 提示刷新 window.location.reload(); } cachedVersion data.commit; } setInterval(checkVersion, 60000);注意在 fetch 时加上tDate.now()查询参数避免浏览器对 version.json 本身产生缓存。配合 nginx 对 HTML 文件设置Cache-Control: no-cache这个方案能有效减少“明明上线了用户还是旧版”的投诉。它当然不是万能的长连接场景下还是要靠更完善的推送机制但对绝大多数普通 Web 应用来说性价比已经很高了。这个思路很契合热搜词里提到的“通过版本号的变更让前端强制刷新页面”本质上是给前端装一个“版本感知器”。5.4 发布后的回滚姿势最后聊一个上线前就要想好的问题回滚。CI/CD 并不是不会出问题反而因为自动化程度高一旦出问题影响面可能更大。一条错误的 nginx 配置、一个错误的构建开关都可能让线上瞬间不可用。所以部署方案一定要把回滚设计进去而不是出事了才想起“刚才那个版本在哪”。我的做法是在服务器上保留最近几个历史版本的 dist 目录按时间戳命名比如releases/20261123153021/然后用一个软链接current指向当前版本nginx 配置直接指向current目录root /data/app/webapp/current;发布流程变成rsync 新版本到新的时间戳目录 - 更新current软链 - reload nginx。一旦新版本出问题只要把软链指回上一个版本目录、reload nginx就完成回滚了整个过程用不了十秒。这个思路比“重新从历史 pipeline 里下载旧 artifacts 再部署”要快得多也更安全。有的团队会嫌维护多个版本目录占磁盘但前端构建产物一般就几 MB 到几十 MB多留三五个版本完全可接受。磁盘在这时候是真便宜换取的上线安全感是真贵。这套自动化部署链路搭完我最大的感受是以前上线全靠默契和胆量现在上线全靠流程和日志。最后一次调整完部署脚本我在流水线日志里看到 deploy job 亮起绿钩的那一瞬间心里那块石头才算真正落了地。最后再分享一个我坚持到现在的习惯——deploy 阶段脚本第一行永远是set -e然后第二行 echo 出当前的 commit hash 和部署时间。别人看日志时能一眼知道服务器上跑的是哪次提交这比任何文档都管用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进