
从零到上线Node.js 项目的完整部署流程包含 Docker 和 CICD这个话题说起来挺大但拆开看其实就是三件事把代码跑起来、把跑起来的过程固定下来、把固定的过程自动化。我最早接手公司一个 Node.js 服务时用的还是最原始的“SSH 上去 git pull npm install 手动重启”三连本地明明跑得好好的上服务器就各种版本对不上某次凌晨线上出故障我连当时跑的是哪次提交的代码都说不清。后来我把部署流程完整重做了一遍用 Docker 解决环境一致性问题用 CICD 把部署动作自动化整个上线过程从“高危操作”变成了“push 一下就完事”。这篇文章就是我从零完整跑通的总结包含踩过的坑和最终沉淀下来的方案适合刚接触后端部署的开发者也适合团队还在人肉发布、想接入自动化流水线的情况。1. 部署思路与方案选型1.1 为什么 Node.js 裸机部署会翻车很多刚接触 Node.js 的朋友都会问部署不就是把代码传到服务器然后 node index.js 吗没错最小可行确实是这样但生产环境不是本地开发你会遇到几个很现实的问题。第一个是环境一致性。本地开发用的是 Node 20.11但服务器上装的是 Ubuntu 自带源的 Node 18甚至可能是 16。有些新 API 在旧版本里不支持代码一上线就崩。更别提部分依赖需要编译原生模块比如 bcrypt、sharp 这一类服务器上有没有 python3、make、g 全是未知数。我经历过最典型的一次翻车本地 npm install 好好的到服务器上编译 bcrypt 直接报 node-gyp rebuild failed折腾半天发现是服务器缺了 python3-dev。第二个是版本切换。同一台服务器上如果跑了多个 Node 服务每个项目依赖的 Node 版本还不一样就得靠 nvm 来回切换。切着切着忘了切回去下一个项目部署时直接用了错误版本排查起来极其痛苦。第三个是进程管理。裸跑 node app.js进程一崩就没了没有人帮你拉起来。你可能需要 pm2但你又要维护 pm2 的进程列表、开机自启、日志切割。每来一台新服务器这些都要重复一遍。Docker 出现之后我发现这些问题大半都能被容器化解决Node 版本直接用官方镜像锁死依赖在构建阶段装好进程崩溃了由 Docker 自动拉起换服务器也不需要重装环境。1.2 CICD 解决的是人肉部署的重复劳动再说 CICD。在接入自动化流水线之前我的部署流程是本地开发完git push 到仓库然后 SSH 登录服务器git pullnpm installbuild重启进程。听起来不复杂但每个手动步骤都有犯错的机会忘记 pull 导致部署了旧代码npm install 赶上网络波动装到一半失败build 之后忘重启环境变量少配一个线上直接 500。更难受的是手动操作不可复制、不可审计。某次线上出问题你根本说不清线上到底跑的是哪次提交的代码、什么时间部署的、环境变量是哪些。赶上发布高峰期多个服务同时要发版一个人 SSH 到几台机器上敲命令效率低还容易出错。CICD 要做的就是把“代码提交到服务上线”这段路径自动化推送到指定分支后自动拉代码、自动装依赖、自动构建镜像、自动推送到镜像仓库再自动登录服务器拉取新镜像并重启容器。整个过程你不碰服务器只管 push剩下的交给流水线。上线时间、版本号、状态全都有记录要回滚也只需要让流水线再跑一次旧版本。一句话总结我的选型逻辑Docker 管“环境交付的一致性”CICD 管“部署动作的自动化”两者配合后部署就从一个不可重复的人工步骤变成了可追踪、可回滚的标准化动作。2. 环境准备从零搭好一台能用的服务器2.1 安装 Node.js 20 LTS别用 apt 默认源先在服务器上装好 Node.js。注意不要直接 apt install nodejsUbuntu 默认源里的 Node 版本通常很旧而且 apt 的 nodejs 包经常不带 npm。正确做法是使用 NodeSource 提供的二进制仓库cd ~ curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs装完验证一下node -v npm -v如果网络连不上 NodeSource备选方案是从官网下载 Node 官方预编译二进制包解压到 /usr/localwget https://nodejs.org/dist/v20.11.1/node-v20.11.1-linux-x64.tar.xz sudo tar -xJf node-v20.11.1-linux-x64.tar.xz -C /usr/local sudo ln -s /usr/local/node-v20.11.1-linux-x64/bin/node /usr/local/bin/node sudo ln -s /usr/local/node-v20.11.1-linux-x64/bin/npm /usr/local/bin/npm注意如果服务器上之前装过 apt 版 nodejs建议先卸载干净。否则 /usr/bin/node 和 /usr/local/bin/node 同时存在版本会乱掉shell 里执行 node 走的还是旧版。这里有个容易忽略的细节生产环境要锁定 LTS 版本比如 20.11.x不要装最新的奇数版本如 23.x。LTS 意味着社区长期维护、稳定可靠。而且这个版本要与你后续 Docker 镜像里用的 Node 版本保持一致本地开发和服务器运行环境才能对得上。本地开发机要装 Node.js 时我一般用 nvm因为本地经常切换项目nvm 最灵活curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20 nvm alias default 20nvm 给每个项目一个独立的 Node 版本环境本地多项目并行时非常实用。但服务器上因为主要靠 Docker 跑应用系统自带的 Node 版本反而变成次要的只需要保证基础工具可用即可。2.2 安装 Docker 与 docker-compose含 Permission denied 处理Docker 安装同样不推荐用 Ubuntu 自带源直接按官方文档操作最稳妥sudo apt-get update sudo apt-get install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod ar /etc/apt/keyrings/docker.gpg echo \ deb [arch$(dpkg --print-architecture) signed-by/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release echo $VERSION_CODENAME) stable | \ sudo tee /etc/apt/sources.list.d/docker.list /dev/null sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin安装完别急着用先把当前用户加进 docker 组避免每次都敲 sudosudo usermod -aG docker $USER newgrp docker这里有一个新手必踩的坑装完 Docker 直接跑 docker ps会看到 permission denied while trying to connect to the Docker daemon socket。原因就两个当前用户不在 docker 组里或者 Docker 服务没启动。解法就是加组 确保服务起来执行sudo systemctl enable --now docker。顺便说一下 docker compose 的版本问题。Ubuntu 通过 docker-compose-plugin 装完以后你用的是docker compose中间有空格而不是过去的docker-compose。老命令是 Python 写的独立工具现在官方已经推荐直接用 docker compose 子命令我后面给出的所有编排文件都按新语法来。2.3 项目代码准备与本地冒烟测试环境准备好以后把自己要部署的 Node.js 项目推到 Git 仓库。以一个常见的 Express 应用为例项目根目录结构大概是my-node-app/ ├── src/ │ ├── app.js │ └── routes/ ├── package.json ├── .env.example └── .gitignore在写 Dockerfile 之前务必先在本地跑一次完整验证npm install、npm test如果有、npm start确认业务功能正常。这样做的意义是把“部署问题”和“业务代码问题”分离——如果本地都跑不起来后面容器和流水线的排查会变成一团乱麻。我习惯在本地把 .env.example 复制成 .env 并填入测试环境变量跑通后再进入容器化阶段。这个习惯看起来不起眼但能省掉大量“线上报错其实是环境变量没配好”的调试时间。3. Docker 化把 Node.js 应用装进镜像3.1 Dockerfile 编写要点多阶段构建怎么用接下来写 Dockerfile这是容器化的核心文件。我建议用多阶段构建一个阶段负责装依赖另一个阶段负责运行这样镜像体积能小很多。尤其对 Node.js 这种自带 node_modules 的项目效果非常明显。一个基础的多阶段 Dockerfile 长这样# 第一阶段安装依赖和构建 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction # 第二阶段运行阶段 FROM node:20-alpine ENV NODE_ENVproduction WORKDIR /app COPY --frombuilder /app/node_modules ./node_modules COPY . . EXPOSE 3000 USER node CMD [node, src/app.js]几个容易踩坑的细节第一用npm ci而不是npm install。npm ci 会严格按 package-lock.json 安装依赖保证构建装的依赖版本与本地开发完全一致而且速度比 npm install 快不少。如果项目里没有 package-lock.json先本地跑一次 npm install 生成锁文件并提交到 Git。第二.dockerignore一定要写。把 node_modules、.git、.env 等文件从镜像构建上下文中排除否则COPY . .会把本地 node_modules 打进去既污染镜像又让镜像体积暴涨。更危险的是如果 .env 没被排除真实密钥就进了镜像后续部署等于把密码写在脸上。我常用的 .dockerignore 长这样node_modules .git .env *.log Dockerfile .dockerignore第三关于 USER node。Node 官方镜像里自带一个叫 node 的非 root 用户如果不切换容器内进程就是以 root 身份运行存在安全风险。改成 USER node 让容器以非 root 运行是老生常谈但很多人忽略的安全项。第四如果项目需要编译原生模块比如 bcrypt、sharp基础镜像建议不要用 alpine。alpine 的 musl libc 与原生模块的兼容性偶尔会出现诡异问题我实际遇到过 bcrypt 在 alpine 上编译成功但运行时反复报错的情况换成node:20-bookworm-slim后直接解决。3.2 用 docker-compose 编排环境变量与数据卷一般项目不会只有一个应用容器至少还会有个反向代理或者数据库。所以我会直接用 docker-compose.yml 把全套服务编排起来下面是一个最小可用的写法services: app: build: context: . dockerfile: Dockerfile restart: always ports: - 3000:3000 environment: - NODE_ENVproduction - PORT3000 env_file: - .env depends_on: - db db: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE} volumes: - db-data:/var/lib/mysql ports: - 3306:3306 volumes: db-data:几个要点环境变量不写死在 compose 文件里用 env_file 从 .env 引入。compose 会自动读取项目目录下的 .env 文件进行变量替换不同环境的配置差异就隔离在各自的 .env 里。数据库数据要挂到 volume 里否则容器一删数据就没了。上面例子里的db-data:/var/lib/mysql就是数据卷挂载这是我在实际项目里吃过亏的地方——当初图省事没挂 volume一次容器重建直接清空了所有数据。restart: always表示容器意外退出后 Docker 会自动拉起它。这不能完全代替进程守护方案但对大多数中小项目来说已经足够可靠配合健康检查机制基本能覆盖日常故障场景。3.3 本地构建镜像与验证容器运行在把镜像推给别人之前自己先要在本地构建并跑起来验证一次docker compose build docker compose up -d docker compose ps docker logs -f app第一次构建通常会比较慢因为要从 Docker Hub 拉取 node:20-alpine 基础镜像。国内服务器如果镜像拉取慢可以在 /etc/docker/daemon.json 里配置 registry mirror{ registry-mirrors: [https://docker.m.daocloud.io] }然后重启 dockersudo systemctl restart docker。启动以后要验证两件事。一是容器是否正常启动通过docker compose ps看状态是不是 Updocker logs有没有报错。二是访问测试如果是 web 服务curl 一下容器映射的端口比如curl http://localhost:3000/api/health看到预期响应就说明容器内部服务正常工作。本地验证通过之后还要配置镜像仓库。我用得比较多的是阿里云容器镜像服务 ACR 或者 Docker Hub关键是把这个测试通过的镜像打上 tag 并 push 到仓库后面 CICD 流程就从仓库拉这个镜像。命令大概是这样docker tag my-node-app:latest my-registry.aliyuncs.com/namespace/my-node-app:1.0.0 docker push my-registry.aliyuncs.com/namespace/my-node-app:1.0.0注意用 tag 里的版本号来跟踪构建不要用 latest 当生产版本号。latest 在本地调试用用没问题但生产环境的镜像一旦刷成 latest你根本说不清线上跑的是哪个版本回滚也无从谈起。4. 搭建 CICD 流水线从 push 到上线全自动4.1 CICD 的核心设计思路如果只在本地构建镜像并手动 push那还没充分体现自动化。完整 CICD 流水线要解决三个问题触发、构建、部署。触发方式一般是监听 Git 仓库的分支 push 事件或 tag 推送事件。推送 main 分支可以触发生产流水线推送 dev 分支触发测试流水线。构建阶段负责把代码拿到一个干净环境里重新执行 npm ci Docker build push 镜像。部署阶段则是登录服务器从镜像仓库拉取新镜像更新容器版本并重启。不同平台语法不一样但核心思想一致代码提交 → 流水线自动跑 → 构建镜像 → 推送仓库 → 服务器更新。这里我用最主流的 GitLab CI 和 GitHub Actions 分别给一套参考配置。4.2 用 GitLab CI 配置完整的构建部署流程如果项目托管在自建 GitLab 上我会在项目根目录放一个.gitlab-ci.yml。参考配置如下stages: - build - deploy build_image: stage: build image: docker:24 services: - docker:24-dind script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG only: - tags deploy_prod: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client script: - eval $(ssh-agent -s) - echo $SSH_PRIVATE_KEY | ssh-add - - ssh -o StrictHostKeyCheckingno rootyour-server-ip docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG docker stop my-node-app || true docker rm my-node-app || true docker run -d --restart always --name my-node-app -p 3000:3000 \ -e NODE_ENVproduction \ $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG only: - tags这里有几个使用技巧要重点说明。变量管理不要在 .gitlab-ci.yml 里写死密码、密钥。GitLab 项目设置里有 CI/CD Variables 页面可以配置受保护变量生产服务器的 SSH 私钥就放那里流水线里通过$SSH_PRIVATE_KEY引用。一定要勾上 Protected并限定触发分支防止开发者从任意分支触发流水线并拿到敏感变量。镜像 tag 策略上面代码用$CI_COMMIT_TAG做镜像 tag意思是打 tag 才触发部署。你 push 一个 v1.2.0 的 tag流水线就构建image:v1.2.0并部署到生产这个 tag 同时也是版本号后续回滚直接复用。如果不习惯打 tag也可以改用$CI_COMMIT_SHORT_SHA用提交 hash 做版本号。SSH 部署动作的顺序先 docker pull再 stop/rm 旧容器最后 docker run。|| true的作用是避免第一次部署时旧容器不存在导致命令失败。更好的方案是用 docker compose 管理但为了展示核心逻辑示例里用的 docker run。实际复杂项目我把部署脚本抽成独立的 deploy.sh 维护流水线里只负责执行脚本职责更清晰。4.3 用 GitHub Actions 配置参考如果项目托管在 GitHub配置逻辑几乎一样区别只在配置文件位置和语法放在.github/workflows/deploy.ymlname: Deploy Node.js App on: push: tags: - v* jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkoutv4 - name: Set up Docker Buildx uses: docker/setup-buildx-actionv3 - name: Login to Docker Registry uses: docker/login-actionv3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push uses: docker/build-push-actionv5 with: push: true tags: ${{ secrets.IMAGE_REPO }}:${{ github.ref_name }} - name: Deploy to server uses: appleboy/ssh-actionv1.0.3 with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | docker pull ${{ secrets.IMAGE_REPO }}:${{ github.ref_name }} docker stop my-node-app || true docker rm my-node-app || true docker run -d --restart always --name my-node-app -p 3000:3000 \ -e NODE_ENVproduction \ ${{ secrets.IMAGE_REPO }}:${{ github.ref_name }}github.ref_name在触发来源是 tag 时就是 tag 名比如 v1.0.0用这个给镜像打版本号非常方便。GitHub Actions 的好处是免费、配置直观、Marketplace 有现成 action 可用缺点是企业私有仓库会有分钟数限制但小团队和个人项目基本不需要担心。4.4 环境变量与敏感信息管理的几条经验CICD 里最容易被忽视也最危险的是敏感信息管理。我见过有人把数据库密码写在 .gitlab-ci.yml 或 workflow 文件里这是极其糟糕的实践等于把凭据暴露给所有能读仓库的成员。正确做法分几层。第一层是平台提供的 Secret 机制。GitLab CI/CD Variables 和 GitHub Secrets 都能保存加密变量流水线里通过变量名引用。日志里这些变量会被自动打码但不能 100% 信任自己也不要顺手 echo 出来。第二层是环境配置与镜像分离。生产环境的 .env 文件直接放在服务器上用 docker compose 的 env_file 加载不进 Git 仓库也不打镜像。这样即使镜像内容被泄露也不包含数据库密码等关键配置。第三层是定期轮换关键信息。数据库密码、SSH 私钥等虽然麻烦但一旦发现疑似泄露可以快速切断风险而不是坐等被利用。5. 上线之后监控、日志与常见问题排查5.1 上线初期最容易踩的坑清单我把自己和身边同事实际踩过的高频问题整理成一张速查表可以帮你省去不少排障时间。现象可能原因排查办法容器启动后立刻退出进程没在前台运行CMD 写错docker logs 看日志确认 CMD 是否为 node src/app.js端口无法访问服务器安全组/防火墙没放行先 curl localhost 验证再检查云厂商安全组规则镜像构建很慢基础镜像拉取慢配置 registry mirror或错峰拉取镜像Permission denied当前用户不在 docker 组usermod -aG docker重新登录连接不上数据库compose 环境变量没配置检查 .env 文件与容器内 environment 是否一致容器一直重启应用启动时报错docker logs 查看具体错误通常是环境变量缺失代码更新后没生效镜像没有重新构建确认 CICD 构建阶段是否跑完并 push 新镜像这张表里最高频的一类问题是环境变量占比超过一半。比如生产环境连的数据库地址写的是 127.0.0.1但容器里 127.0.0.1 指向容器自身而不是宿主机正确写法应该是宿主机在 docker 网络里的地址或者直接用 compose 里的服务名。这类问题光看日志往往会觉得很诡异但其实都是从环境变量来源开始的排查时先确认容器内的环境变量对不对再去看代码逻辑。5.2 日志与监控的轻量做法部署上线后不建议马上就上整套庞大监控体系。对中小项目来说先把日志和探活跑通就够。日志方面容器环境下用 docker logs 看单容器日志已经足够日常排障。但如果服务多建议把容器日志输出到宿主机目录通过挂载映射出来volumes: - ./logs:/app/logs更稳妥的是在 compose 里限制日志大小防止日志无限堆积把磁盘打爆logging: driver: json-file options: max-size: 10m max-file: 5这个配置我强烈建议加上否则半年不看服务器磁盘可能被 JSON 日志文件塞满。监控方面我第一选择是 Uptime Kuma 这类简单的探活工具定期检查你的 /health 接口异常就发通知。先把这套简单方案跑起来再考虑 Prometheus Grafana 那套大而全的体系避免一上来就被监控系统本身搞晕。5.3 回滚与发布节奏控制CICD 带来的直接好处是发布和回滚变得非常可控。发布时流水线构建并部署新 tag服务器上跑的确实是新版本。如果线上出问题回滚就是“再用旧 tag 跑一遍流水线”这么简单。拿上一个版本号重新触发构建或者如果保留了旧镜像直接在服务器上 docker run 旧 tag 即可。这背后的思路是每个发布版本都是一个不可变镜像而不是直接改容器里的代码。不可变的好处是你可以明确知道线上跑的是哪个镜像、哪个 tag、对应哪次提交排障时不会被“可能改过的容器”干扰。从我的经验来看发布节奏控制同样重要。不要在周五下午直接发生产尽量选流量低谷期发完后至少留半小时观察日志和探活结果。把发布当成严肃操作来对待而不是随手一推。最后再分享一个我个人的习惯无论是 Dockerfile、compose 文件还是 CICD 配置都当作文档来维护每次改动都写注释并跟着代码一起提交到 Git。这些配置文件里那些看起来不起眼的|| true和变量引用几个月后回来看很可能想不起来当初为什么这么写。注释虽然不能帮你减少踩坑但至少能让你少走很多回头路。这套从裸机到容器化再到 CICD 的流程花了我整整一周才完整跑通但跑通之后每次发布只需要打一个 tag剩下的全是自动的这种感觉值得你亲自体验一次。