
先说个我上周刚经历的事一个朋友在服务器上部署他的 Node.js 项目本地跑得好好的代码到服务器上一会儿连不上数据库一会儿环境变量读不到折腾两天最后还是回退到他电脑上继续手工维护。这种事我见太多了本质就一句话本地环境和生产环境之间的差距永远比你想象的大。这也是为什么我一直坚持用 Docker 来打包 Node.js 服务再用 CI/CD 把发布过程自动化。这篇文章我打算把我自己在真实项目里跑通的完整流程摊开讲一遍从 Node.js 环境准备、Docker 镜像构建、Docker Compose 编排到 GitLab CI / GitHub Actions 自动部署上线以及踩过的坑和解决办法。运行环境是 Ubuntu 22.04 Node.js 20 LTS Docker 24 Docker Compose v2。照着走你完全可以从零把一个 Node.js 项目推到线上。1. 部署方案选型为什么 Node.js 项目非要用 Docker 不可1.1 本地跑得通、线上就崩的经典困境先梳理一下不用 Docker 直接用裸机部署你会遇到哪些问题。最典型的是版本错位。你本地装了 Node.js 20服务器上可能还是自带的 Node 16某些语法和 API 在旧版本上直接就不工作或者表现出非常奇怪的行为。我记得之前有个项目用了node:fetch全局接口本地一切正常线上一直报fetch is not defined查了半天发现服务器上的 Node 版本是 14根本不含 fetch。你只要有过一次这种经历就会明白版本一致性管理的价值有多大。其次是依赖安装的差异。npm install会在/node_modules里编译一些原生模块。如果在 Windows 或 macOS 上装好的 node_modules 直接拷到 Linux 服务器上大概率跑不起来因为很多原生模块需要针对目标平台重新编译。就算你在服务器上重新执行npm install也需要服务器提前装好 Python、Make、GCC 等编译链否则某些模块直接报node-gyp错误。环境千差万别每台机器都去人工核对一遍是巨大且容易出错的工作量。然后还有进程管理问题。裸机部署你得用 pm2、systemd 或 forever 去守护进程设置启动脚本、开机自启、日志轮转、崩溃重启。这些本身不复杂但每台服务器都要手动配置一次配置细节稍有不同就会成为后续运维的隐患。1.2 Docker 到底解决了什么Docker 的核心思路是把应用及其运行环境完整地打包成一个镜像。镜像里面有操作系统层、运行时、依赖、代码、环境变量配置所有这些做成一个不可变的标准包。以后不管在哪台机器上运行同一个镜像得到的运行环境都是一模一样的。我打个比方以前部署 Node.js 项目就像你去朋友家做饭你得先确认他家的燃气灶能不能用、锅是哪一口、调料缺不缺用 Docker 部署就像你把整个厨房——灶具、锅、调料、食材——装进一个集装箱到了任何地方只要打开箱子就能做出一模一样的菜。另外它还顺带解决了多项目共存的问题。同一台服务器你可能会同时跑 Node.js 应用、MySQL、Redis、Nginx。直接装在系统里端口冲突、依赖冲突、版本升级互相影响会很头疼。容器之间是隔离的每个容器有自己的文件系统和网络命名空间冲突的可能性大幅降低。1.3 为什么不直接用 pm2 裸部署不要误会pm2 本身是个好工具我现在也会在容器里用它作为进程管理层。但单独把 pm2 用在裸机上部署应用和 Docker 并不在同一个维度pm2 解决的是进程守护和负载均衡它不解决环境一致性问题Docker 解决的是环境标准化和部署单元化。所以更合理的做法是用 Docker 做环境打包用 pm2 在容器内管理 Node 进程的崩溃重启和日志输出。这样既保证了镜像的一致性又不放弃进程级的状态管理。1.4 本文的部署架构我在这篇文章里采用的架构如下服务器一台 Ubuntu 22.04 云主机2核4G够跑小型 Node.js 服务。Node.js 版本20 LTSIron。应用容器化多阶段构建 Dockerfile生产环境只保留运行所需的最小依赖。编排工具Docker Compose管理应用、PostgreSQL、Redis、Nginx 这些服务。CI/CD以 GitLab CI 为主进行讲解同时给出 GitHub Actions 的等价写法。域名与 HTTPSNginx 反代 Lets Encrypt 证书自动续期。后面的内容全部围绕这套架构展开。2. 基础环境准备Node.js 与 Docker 的安装细节在开始构建镜像之前你得先把本地和服务器两套环境准备好。这一节我按 Linux 服务器为主、Windows 本地为辅的思路来写因为最终项目是跑在 Linux 服务器上的搞清楚服务器端的安装才是重点。2.1 Ubuntu 上安装 Node.js 20 LTSUbuntu 22.04 自带的 Node.js 版本是 12.x非常老肯定不能满足 20 LTS 的需求。推荐用 NodeSource 官方源安装干净、可靠、不容易踩坑。服务器上执行以下命令curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs安装完确认版本node -v npm -v我这里强烈建议用 nvm 而非直接系统安装来管理 Node.js 版本尤其是本地开发环境。为什么因为你未来可能要同时维护多个项目有的项目要 Node 16有的要 Node 20直接装系统版本会导致频繁切换非常麻烦。nvm 允许你按项目目录切换 Node 版本方便得多。命令如下curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # 重开终端后 nvm install 20 nvm use 20 nvm alias default 20Windows 本地装 Node.js 则简单很多直接去官网下载 LTS 版 MSI 安装包一路下一步即可。装完用node -v验证。2.2 服务器安装 Docker EngineDocker 在 Linux 服务器上装的是 Docker Engine不是 Docker Desktop。Docker Desktop 是给 macOS / Windows 用的带图形界面的版本。在 Ubuntu 上可以用官方脚本安装方便且不会遗漏依赖curl -fsSL https://get.docker.com | sudo sh脚本会自动配置 apt 源并安装 docker-ce、docker-ce-cli、containerd.io、docker-compose-plugin 等组件。安装完启动服务并设置开机自启sudo systemctl enable docker sudo systemctl start docker验证一下docker --version docker compose versiondocker compose version正常输出就表示 Docker Compose v2 插件已经装好了。很多人会额外去装一个独立的 docker-compose 二进制其实没必要v2 插件已经是标准形态了。2.3 解决 permission denied 问题这一步是很多新手在服务器上必然遇到的一个坑。用docker ps会报错permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock这个错误几乎百分之百是权限问题当前用户不在docker用户组里。默认情况下docker命令需要 root 或者 docker 组用户才能执行。解决办法是把自己加入 docker 组sudo usermod -aG docker $USER改完需要退出 SSH 重登一次让组权限生效。重登后再执行docker ps就不会再报权限问题了。如果重登后还是不行可以先sudo systemctl restart docker个别环境组刷新不及时重启一下服务可以缓解。2.4 Windows 本地安装 Docker Desktop 的注意点本地开发时 Windows 用户装 Docker Desktop最常遇到的问题是启动时报错Docker Desktop failed to start because virtualisation support wasnt detected这是因为 Windows 的虚拟化相关功能没有打开。检查以下几点在 BIOS 中确认 Intel VT-x 或 AMD SVM 已启用依次打开控制面板 → 程序和功能 → 启用或关闭 Windows 功能勾选Hyper-V和适用于 Linux 的 Windows 子系统WSL2安装 WSL2 内核更新包然后运行wsl --set-default-version 2。搞完这些 Docker Desktop 一般就能正常启动了。另外Docker Desktop 设置里建议把资源限制调高一点默认 2GB 内存对 Node.js 开发来说够用但如果要跑多容器就加到 4GB。2.5 准备一个测试项目后面所有操作需要一个真实项目才能演示。这里先搭一个极简的 Express 应用作为示例。mkdir node-deploy-demo cd node-deploy-demo npm init -y npm install express创建app.jsconst express require(express); const app express(); const port process.env.PORT || 3000; app.get(/health, (req, res) { res.json({ status: ok, timestamp: Date.now() }); }); app.get(/, (req, res) { res.send(Hello from Node.js Docker Deployment); }); app.listen(port, () { console.log(Server listening on port ${port}); });这个项目非常小但它足够演示构建、部署、健康检查、CI/CD 的全流程。后面每个环节我都以它为例。3. 编写 Dockerfile把 Node.js 应用装进容器Dockerfile 是整个容器化过程的核心。这一节我除了给出最终可直接用的 Dockerfile还会把每一条指令背后的理由讲清楚。3.1 基础镜像的选择node:20-alpine 为什么是首选Dockerfile 第一行决定基础镜像。官方 Node.js 镜像主要有三种镜像标签体积特点node:20较大基于完整 Linux 发行版包含大量工具node:20-slim中等精简版去掉了大多数非必需工具node:20-alpine很小基于 Alpine Linux体积仅有几十 MB我选 Alpine。因为 Alpine 基于 musl libc 和 BusyBox构建出来的镜像非常小生产环境部署时拉取速度快占用的磁盘空间也少。但需要注意Alpine 的兼容性不总是一样的个别原生 npm 模块在 musl 下需要额外编译依赖。如果你的项目里用了某些依赖大量原生模块的库比如含node-gyp的包构建镜像时需要在 Alpine 里安装python3、make、g等编译工具。如果你不想处理这些兼容性问题直接用node:20-slim会省心一些体积比 Alpine 大一些但依然可控。这里我按 Alpine 来写因为你跑通一次之后收益是最明显的。3.2 多阶段构建开发和生产的镜像隔离多阶段构建是 Dockerfile 的最佳实践核心思想是在第一个阶段安装全部依赖并编译在第二个阶段只拷贝最终需要的文件。这样生产镜像不会带上开发阶段的源码、缓存和编译工具链。回到我们的例子上。第一阶段安装生产依赖第二阶段只拷贝 package.json、package-lock.json 和 node_modules最后用精简镜像运行。# 阶段一安装依赖 FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --onlyproduction # 阶段二构建生产镜像 FROM node:20-alpine ENV NODE_ENVproduction WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . USER node EXPOSE 3000 CMD [node, app.js]这里有几个关键点值得展开npm ci而不是npm install。npm ci严格按照 package-lock.json 安装速度更快、结果可重现在 CI/CD 和 Docker 构建场景下是唯一正确选择。npm install会更新锁文件导致不同时间构建出来的依赖不一致。先拷贝 package.json 再拷贝源码。这是一个缓存优化技巧。Docker 构建的每一层都有缓存只要指令输入没变就会复用缓存。把 package.json 单独拷贝、先安装依赖那么即使你改了业务代码只要依赖没变Docker 都会直接复用缓存层构建速度会快非常多。USER node用非 root 用户运行。容器默认以 root 运行一旦应用被攻破攻击者拿到的是容器内的 root 权限。Node.js 官方镜像里内置了node用户切换过去可以降低安全风险。很多生产环境安全检查会强制要求这一点。ENV NODE_ENVproduction。绝不能让 Express 以 development 模式运行在生产环境性能差异极其明显。Express 在 production 模式下会启用视图缓存、减少日志输出整体吞吐量高很多。3.3 .dockerignore 的重要性这个文件很多人会漏掉但它极其重要。没有.dockerignore的话本地的node_modules会被打进构建上下文导致镜像体积爆炸。更严重的是如果本地 node_modules 是 Windows/macOS 上编译出来的原生模块被打进 Linux 镜像后根本跑不起来。在项目根目录创建.dockerignorenode_modules .git .gitignore .env npm-debug.log Dockerfile .dockerignore注意.env的排除。当容器在服务器上运行时环境变量不应该从镜像里带进去而应该通过运行时注入后面会讲 Compose 的env_file机制。把.env排除在镜像之外是个安全习惯避免密钥意外打包进镜像仓库。3.4 构建镜像并验证在项目根目录执行docker build -t node-deploy-demo:latest .查看镜像体积docker images | grep node-deploy-demo我一般会看到node-deploy-demo最终体积在 180MB 左右Alpine 基础上加上 node_modules 和源码。如果直接用非多阶段构建体积轻松超过 1GB差距是很明显的。运行容器验证docker run -d -p 3000:3000 --name demo node-deploy-demo:latest curl http://localhost:3000/health返回{status:ok,timestamp:...}即表示容器内的应用正常工作了。4. Docker Compose 编排应用 数据库 Nginx 的完整栈单个容器能跑应用但真实项目往往不止一个服务。数据库、缓存、反向代理这些都要编排起来。Docker Compose 的价值就是让你用一份 YAML 文件声明整个应用栈一条命令启动或停止全部服务。4.1 为什么必须上 Compose可以把 Compose 理解成容器组的启动脚本通信总线。没有 Compose你需要手动管理多个容器的网络、依赖顺序、数据卷。有了 Compose服务之间的网络自动打通服务名就是容器 DNS 名数据库地址直接写db:5432就行免去查容器 IP 的麻烦。同时Compose 统一管理数据卷数据库数据不会因为容器删除而丢失。Compose 的另一个作用是在一台服务器上部署多个环境。比如开发环境用 dev.yaml生产环境用 prod.yaml共用一个 Compose 文件加不同的覆盖配置管理和切换成本会低很多。4.2 编写 docker-compose.yaml在我们的项目里假设应用依赖 PostgreSQL 和 Redis并通过 Nginx 对外提供 HTTPS 访问。完整的 Compose 文件如下version: 3.8 services: app: build: context: . dockerfile: Dockerfile image: node-deploy-demo:latest restart: unless-stopped environment: NODE_ENV: production PORT: 3000 DB_HOST: db DB_PORT: 5432 DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} REDIS_HOST: redis REDIS_PORT: 6379 depends_on: db: condition: service_healthy redis: condition: service_healthy networks: - app-network healthcheck: test: [CMD, node, -e, fetch(http://localhost:3000/health).then(r{process.exit(r.ok?0:1)}).catch(()process.exit(1))] interval: 30s timeout: 5s retries: 3 start_period: 20s db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_NAME} volumes: - pgdata:/var/lib/postgresql/data networks: - app-network healthcheck: test: [CMD-SHELL, pg_isready -U ${DB_USER} -d ${DB_NAME}] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine restart: unless-stopped volumes: - redisdata:/data networks: - app-network healthcheck: test: [CMD, redis-cli, ping] interval: 10s timeout: 5s retries: 5 nginx: image: nginx:alpine restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./certbot/conf:/etc/letsencrypt:ro depends_on: - app networks: - app-network volumes: pgdata: redisdata: networks: app-network: driver: bridge这份文件做了几件关键事情每件都要讲清楚。4.3 数据卷持久化PostgreSQL 和 Redis 的数据如果不挂卷容器一删数据就全没了。数据卷是 Docker 在宿主机上管理的持久化存储声明方式volumes: pgdata: redisdata:然后在服务里通过pgdata:/var/lib/postgresql/data这种形式挂载。把数据库数据放在容器外以后无论怎么重建容器、升级镜像数据都还在。很多人部署数据库时没有做这一步等到升级时容器被删除业务数据全部丢光那个教训非常深刻。4.4 健康检查与依赖顺序Compose 的depends_on默认只保证启动顺序不保证服务可用。比如数据库容器起来了但 PostgreSQL 初始化可能还需要几秒。如果应用立即连接数据库会报连接拒绝。解决办法就是健康检查。数据库和 Redis 都定义了healthcheck然后在 app 服务的depends_on里加上condition: service_healthy。Compose 会等待依赖服务通过健康检查后再启动应用。这是生产环境的标准做法一开始就养成习惯后面零故障。4.5 环境变量管理不要硬编码Compose 文件里我用了${DB_USER}这种写法来自.env文件。在项目目录创建.envDB_USERappuser DB_PASSWORDstrongpassword123 DB_NAMEappdb这里有个容易搞错的安全问题Compose 读取.env文件这个行为只发生在执行docker compose命令的目录下而.env文件本身不要提交到 Git必须在.gitignore里排除。生产服务器上的.env只能用 scp 或 CI/CD 部署时远程写入。启动应用docker compose up -d --build检查状态docker compose ps看到所有服务都是running且healthy就说明整条链路已经通起来了。5. CI/CD 流水线从 git push 到自动上线的完整链路到这一步你手动部署已经没问题了但每次改动代码都要 SSH 上服务器重新构建镜像费时且容易出错。CI/CD 的价值是把这个过程变成全自动代码推送到仓库 → 流水线自动构建镜像 → 自动运行测试 → 自动部署到服务器。5.1 CI/CD 流程整体设计我们的流水线分三个阶段构建阶段安装依赖、跑测试。镜像阶段登录镜像仓库构建并推送 Docker 镜像。部署阶段SSH 登录服务器拉取镜像并重启服务。这里有个决策点镜像仓库用什么。小项目可以直接用 Docker Hub 的私有仓库或者腾讯云/阿里云的容器镜像服务CCR/ACR推拉速度更快。因为目标是部署到国内云服务器用国内镜像仓库的拉取速度会好很多。下面的例子我用通用写法仓库地址用registry.example.com/node-deploy-demo占位。5.2 GitLab CI 的完整配置创建.gitlab-ci.ymlstages: - build - deploy variables: IMAGE_NAME: registry.example.com/node-deploy-demo build: stage: build image: docker:24 services: - docker:24-dind before_script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY script: - docker build -t $IMAGE_NAME:$CI_COMMIT_SHA . - docker push $IMAGE_NAME:$CI_COMMIT_SHA only: - main deploy: stage: deploy image: alpine:3.18 before_script: - apk add --no-cache openssh-client - eval $(ssh-agent -s) - echo $SSH_PRIVATE_KEY | ssh-add - - mkdir -p ~/.ssh - echo $SSH_KNOWN_HOSTS ~/.ssh/known_hosts script: - ssh $DEPLOY_USER$SERVER_IP docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY docker pull $IMAGE_NAME:$CI_COMMIT_SHA cd /opt/node-deploy-demo export IMAGE_TAG$CI_COMMIT_SHA docker compose up -d --build only: - main在 GitLab 项目的 Settings → CI/CD → Variables 里配置这些变量SSH_PRIVATE_KEY # 部署用私钥 SSH_KNOWN_HOSTS # ssh-keyscan 获取的服务器公钥指纹 DEPLOY_USER # 如 deploy SERVER_IP # 如 1.2.3.4 CI_REGISTRY_USER # GitLab 内置变量通常是 gitlab-ci-token CI_REGISTRY_PASSWORD # GitLab 内置变量自动生成部署脚本里的核心是 SSH 远程执行命令。注意几个坑不要把私钥写在 CI 文件里必须用变量注入。硬编码私钥等于把服务器大门钥匙贴在大街上。ssh-keyscan server_ip known_hosts先做一次否则首次 SSH 会卡在确认主机指纹的交互提示CI 一直挂起。同一个 Job 里有多条远程命令时用连接确保一条失败则整体失败不要让后续命令继续执行。5.3 GitHub Actions 的等价写法如果你用 GitHub 管理代码配置思路是完全一样的。创建.github/workflows/deploy.ymlname: Build and Deploy on: push: branches: [main] 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 Registry run: echo ${{ secrets.REGISTRY_PASSWORD }} | docker login registry.example.com -u ${{ secrets.REGISTRY_USER }} --password-stdin - name: Build and Push run: | docker build -t registry.example.com/node-deploy-demo:$GITHUB_SHA . docker push registry.example.com/node-deploy-demo:$GITHUB_SHA - name: Deploy via SSH uses: appleboy/ssh-actionv1.0.3 with: host: ${{ secrets.SERVER_IP }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | docker login -u ${{ secrets.REGISTRY_USER }} -p ${{ secrets.REGISTRY_PASSWORD }} registry.example.com docker pull registry.example.com/node-deploy-demo:$GITHUB_SHA cd /opt/node-deploy-demo export IMAGE_TAG$GITHUB_SHA docker compose up -dGitHub Actions 的 Secrets 配置位置在仓库 Settings → Secrets and variables → Actions。appleboy/ssh-action 这个第三方 action 已经非常成熟稳定我一直在用没必要自己写原生的 SSH 调用。5.4 镜像标签策略为什么不用 latest很多新手喜欢把镜像都打成latest但这个习惯在 CI/CD 里非常糟糕。latest是个漂移标签无法准确反映当前线上跑的是哪个版本。部署后一旦出现问题你甚至不知道自己的回滚目标是什么。我采用的策略是用 Git commit SHA 作为镜像标签。每次提交生成的镜像都是唯一的可以精确定位到代码版本。部署失败时回滚操作只需要把 Compose 文件里的IMAGE_TAG改回上一次的 SHA重新docker compose up -d即可。服务器上执行 CI 部署时Compose 需要知道当前该用哪个镜像。做法是在 compose.yaml 里不写死 tag通过环境变量注入app: image: node-deploy-demo:${IMAGE_TAG:-latest} # 其他配置不变CI 里用export IMAGE_TAG$CI_COMMIT_SHA覆盖默认值。这一套设计让回滚变得极简去服务器看一下上次用的 tag 是多少改回来再执行一次 Compose 即可。5.5 部署后的验证流水线跑完之后还需要一个自动或手动的验证环节。至少请求一下健康检查接口curl -f https://yourdomain.com/health如果返回 200部署成功。如果不通第一时间看容器状态docker compose ps docker compose logs -f app --tail100这个验证步骤也建议加进 CI 里部署失败时让流水线报红而不是等监控报警了才发现。6. 上线后的日常维护与踩坑实录部署上线只是开始真正的考验在之后的维护。这一节我把高频遇到的坑和运维要点集中梳理一遍。6.1 两个必踩的经典坑坑一Docker API 权限问题反复出现前面提到过permission denied while trying to connect to the Docker daemon socket。除了用户组问题CI/CD 场景下也可能遇到。如果你在 CI Runner 里直接调用宿主机 Docker而 CI Runner 用户不在 docker 组同样会报这个错。解决办法给 CI Runner 用户加入 docker 组或者通过 TCP 暴露 Docker API。生产环境务必用前者不要轻易打开 Docker 的 TCP 端口极其危险。坑二容器内时区不对默认情况下Docker 容器使用的是 UTC 时区。你代码里new Date()打印出来的时间会和服务器本地时间差好几个小时。如果你的业务有定时任务或日志时间戳依赖这个问题会带来很大的困扰。简单的解决办法是在 Compose 的 app 服务里设置environment: TZ: Asia/Shanghai也可以构建镜像时设置ENV TZAsia/Shanghai日志和定时任务统一走北京时间排查问题的时候会节省大量时间。6.2 日志管理别让日志把磁盘塞满Docker 默认使用 json-file 日志驱动每个容器的 stdout/stderr 都会写到宿主机文件里且默认不轮转。一个长时间运行的应用日志文件可以轻松涨到几十GB把磁盘塞满后容器直接挂掉。建议在 Compose 里每个服务都加上日志限制logging: driver: json-file options: max-size: 10m max-file: 3这样每个容器最多保留 30MB 日志。如果日志需要长期留存或分析再引入 ELK / Loki 之类的日志采集方案。但对于大多数中小项目限制本地日志大小是第一优先级的存活策略。6.3 镜像与容器清理周期长期部署后宿主机上会堆积大量旧的镜像和停止的容器。特别是每次 CI/CD 都会推送一个新的镜像 tag如果不清理磁盘空间迟早用光。我一般定期执行docker system prune -f --all --volumes这个命令会清掉所有停止的容器、未使用的网络、悬空镜像和未使用的数据卷。如果数据卷里有你要保留的数据不要带--volumes参数直接清容器和镜像docker system prune -f只清理悬空镜像的温和版本docker image prune -f注意如果服务器上跑着多个环境清理之前先看清楚哪些镜像还在使用中别把正在用的旧 tag 删了。docker ps -a和docker images先检查一遍再处理。6.4 回滚操作的具体步骤无论自动化流程做得多好总有上线失误的可能。回滚操作要做到像部署一样简单。服务器上 Compose 项目目录里重新指定上次的镜像 tagcd /opt/node-deploy-demo export IMAGE_TAG上一次成功的commit-sha docker compose up -d这条命令会根据新的 tag 重建应用容器而数据库、Redis、Nginx 不受影响。整个过程在几秒内完成不需要重新构建镜像因为镜像已经存在在本地或仓库里。这里有个前提数据库迁移的兼容性。如果上线时执行了不兼容的数据库 schema 变更回滚应用代码但数据库已经回不去了这种情况需要额外设计数据库迁移的回滚方案。建议在部署前把数据库迁移脚本做好并且迁移脚本尽量向前兼容先加列再删列而不是一次到位。6.5 监控告警最小可行方案不用一上来就上 Prometheus Grafana 全家桶。对中小项目我建议先做以下几件低成本的事情服务存活检测用 UptimeRobot 或类似服务每5分钟请求一次/health挂了就通知。容器状态检查每天定时执行docker compose ps确认所有服务没处于 restart 状态。可以在 cron 里加一行异常就发邮件通知。磁盘空间监控检查/分区使用率超过85%自动告警。最简单的方式是 cron 脚本查df -h。这些做完了你的部署链路才算真正闭环。不需要一开始就搭一个重型监控系统等业务量起来了再逐步演进。6.6 HTTPS 证书自动续期如果你用 Nginx 做反代证书续期是绕不开的问题。Lets Encrypt 的证书每三个月要续期一次。手动续期几次后你就会开始烦所以一定要自动化。在 Compose 之外我会加一个 cron 任务sudo crontab -e # 添加一行每天凌晨检查并续期 0 3 * * * docker run --rm -v /opt/certbot/conf:/etc/letsencrypt -v /opt/certbot/www:/var/www/certbot certbot/certbot renew --webroot -w /var/www/certbot --quiet docker exec nginx nginx -s reload续期成功后再让 Nginx reload 加载新证书。整个过程全自动我很多时候都已经忘了证书这回事直到看到浏览器上的安全锁标志才想起来它的存在。7. 这份流程跑通之后的体会整个流程走下来我自己最大的感受是部署这件事以前是项目上线前突击一次的脏活现在变成了日常开发流程里很自然的一个环节。代码写完、推上去、自动测试、自动构建、自动上线一气呵成。就算哪次上线出了问题回滚就是这么一条命令的事不会再有手忙脚乱连不上服务器的紧张感。作为实用建议我最后再说几点不要一次性把流程做完美。先手动部署通一遍理解每一步在干什么再上 CI/CD。跳过手动直接自动化只会让你在问题出现时手足无措。每个服务都要加健康检查。这是整个架构里性价比最高的一个设计。镜像 tag 永远不要只用 latest。固定到提交哈希你会感谢这个决定的。安全底线不要妥协。私钥不写进配置文件、不开 Docker TCP 端口、用非 root 用户跑应用这些都是底线问题省不得。如果你正在为本地能跑、线上必崩的部署问题发愁照着这篇文章把环境准备、Dockerfile、Docker Compose、CI/CD 四件事搭起来大概率能一次性跑通。剩下的运维细节等遇到问题再逐步完善也不迟。