
先交代一下背景。我最近把一个跑了快两年的 Node.js 服务从手动上传 ssh 上去敲命令的原始状态彻底改造成了代码 push 到仓库剩下全自动的 Docker CICD 流程。整个过程中踩了不少坑也总结出一套可以复用的套路正好借这篇文章把完整流程写清楚方便后面自己复盘也给正在做同样事情的朋友一份参考。这篇文章覆盖的是从零到上线的全过程环境怎么准备、Node.js 应用怎么写 Dockerfile、docker-compose 怎么编排、CICD 怎么搭以及上线之后遇到问题怎么排查。不管你是个人开发者还是小团队只要手里有一个 Node.js 服务想规范化部署这篇文章都适合你。1. 部署方案的整体设计思路1.1 先想清楚你的部署到底要解决什么问题很多人在开始做部署的时候第一反应是赶紧装个 Docker把服务跑起来。这个思路不能说错但它跳过了最重要的一步——想清楚部署这件事到底在解决什么问题。我手上那个项目就是个典型例子。一开始服务就一台机器PM2 挂着日志写到文件里每次上线都是本地 build 完 scp 上去然后 ssh 上去 pm2 reload。这套流程在只有一台机器、一个月发布两次的时候没什么问题一旦频率上来就出事了今天这台机器装了什么依赖、明天那句配置是手动改的时间一长没人说得清楚线上环境和本地差了多少。更崩溃的是某次服务器重启之后PM2 进程没自动拉起来服务挂了半天才发现。所以做部署方案第一个要回答的问题是你是想让服务能跑还是想让服务在任何环境里都能以相同的方式跑起来如果是后者Docker 几乎是绕不开的选择。它把应用、依赖、配置、启动命令全部打包成镜像镜像在本地能跑在服务器上就能跑不会再出现我本地是好的啊这种话。第二个要回答的问题是发布这个动作能不能自动化。我见过不少团队容器化做得挺好但发布还是靠人肉去服务器上 docker pull docker compose up。一旦把这个动作交给 CICD收益是肉眼可见的每次发布都有记录、有日志、可回溯回滚也只需要换一个镜像 tag。人工操作能省掉 90%。1.2 为什么选 Docker 而不是直接在宿主机跑直接对比一下两条路线。直接在宿主机上跑 Node.js 应用的传统做法是服务器装 Node.js把代码拉下来npm install然后用 systemd 或者 PM2 管进程。这套方案的优点是简单直接没什么学习成本缺点是环境一致性、依赖隔离、多项目共存、版本切换这些问题都得自己解决。比如你要在同一台服务器上跑两个 Node.js 项目一个用 Node 18一个用 Node 20就得靠 nvm 在用户级别切换跑起来提心吊胆。Docker 的思路完全不一样每个应用连同它自己的运行时、依赖、配置文件一起装进容器容器之间互不影响。Node 20 的服务和 Node 18 的服务可以肩并肩跑在同一台机器上不用任何魔法。这就像你搬家的时候不是把一堆杂物塞进同一辆卡车而是每个房间单独打包成箱子箱子与箱子之间互不干扰到了新家往对应的位置一放就行。还有个容易忽略的好处是可移植性。本地用 Docker 跑起来的服务拿到服务器上跑行为一致测试环境和生产环境用同一套镜像省掉了测试通过上线就崩的经典戏码。这些优势在单机上体现得不明显一旦扩展到多台机器或者迁云价值就完全释放出来了。1.3 CICD 要解决的核心问题CICD 不是一个具体工具而是一种流程设计思路。CI 是持续集成核心是每次代码变更都自动做构建和验证CD 是持续部署核心是通过验证的代码自动发布到目标环境。自己做这套流程的时候我深刻感受到它解决的不是快的问题而是稳定和可预期的问题。手动部署最怕的不是慢是这次和上次不一样——这次忘了装依赖那次配置文件少改了一项。CICD 把部署过程固化成流水线每次执行的都是同一套动作从源到目标完全可复现。另一个容易被忽视的是质量门禁。CI 阶段可以自动跑 lint、跑测试跑挂了就不会往下走相当于在代码进到生产环境之前先筛一遍。这个问题在团队协作中尤其重要但在个人项目里同样适用——你写代码的时候觉得万无一失 commit 的瞬间是个啥状态自己心里其实没底。所以我的最终目标架构很明确代码推到仓库主分支触发流水线流水线里跑测试、构建 Docker 镜像、推到镜像仓库然后连上服务器执行部署脚本。整个过程不需要任何人在服务器上手动敲一条命令。2. 环境准备从裸机到可用的部署环境2.1 在 Ubuntu 上装 Node.js 20用哪个方案不踩坑安装 Node.js 有好几条路apt 直接装、NodeSource 源、nvm 编译安装、下载官方二进制包。我推荐用 NodeSource 的 apt 源或者 nvm。两者适用场景不同看你的需求。如果服务器上只跑一个 Node.js 版本而且不想折腾版本切换用 NodeSource 源最省事curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs装完验证一下node -v npm -v如果开发者机器上需要在多个 Node 版本之间来回切换比如一个项目要 Node 18另一个要 Node 20那就用 nvm。nvm 装完会在用户目录下维护多个版本通过nvm use随时切换不会污染系统全局curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash source ~/.bashrc nvm install 20 nvm use 20这里有个实操经验生产服务器上尽量别用 nvm除非你有极其特殊的理由。nvm 本质上是一个 shell 函数它依赖 login shell 的初始化逻辑如果用它管理生产环境的 Node 版本systemd 服务或者 Docker 构建时很容易出现找不到 node 命令的情况。生产环境用 apt 源就够了版本锁定升级路径清晰。还有一个小坑用 apt 装完 Node.js 之后npm 全局包的安装路径可能需要手动加到 PATH 里否则 npm install -g 出来的命令会提示 command not found。不同 Ubuntu 版本表现不一样装完可以先npm root -g看一下路径有问题再补环境变量。2.2 Docker 安装与配置官方源、镜像源、权限一次搞定Docker 的安装我强烈建议走官方 apt 源不要贪图方便用apt install docker.io。Ubuntu 自带的 docker.io 包版本较旧很多新特性不支持比如 buildx 多平台构建、compose v2 这些都是新版本才有的。官方源的安装流程把这几步走完就可以了sudo apt-get update sudo apt-get install 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 docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin注意最后一行把docker-buildx-plugin和docker-compose-plugin一起装了。buildx 是多阶段构建和 BuildKit 的基础compose plugin 是编排多容器服务的基础。这两个插件如果你用默认源装大概率版本不够全到后面用的时候才发现缺这缺那再回头补装很麻烦。装完 Docker 之后有一个高频坑直接用 docker 命令提示 permission denied。这是因为 Docker daemon 默认只允许 root 用户和 docker 组成员访问 socket普通用户不在组里。解决方案是把当前用户加进 docker 组sudo usermod -aG docker $USER newgrp docker这个操作执行完要重新登录一次才能生效。注意newgrp docker只是临时切换当前会话的组身份如果你在远程终端里操作建议直接退出重登否则很容易疑惑为什么还是没权限。然后是镜像下载问题。我自己的实测经验是不配镜像源的话拉一些常用镜像偶尔会卡到怀疑人生。配置方法是在/etc/docker/daemon.json这个文件不存在就新建里加 registry-mirrors{ registry-mirrors: [https://docker.m.daocloud.io] }改完配置文件之后sudo systemctl restart docker重启生效。镜像源这个东西选谁都可以带 docker 官方前缀的源在这几年变化很快能以实际能用的为准。核心思路是找一个在你网络环境下稳定可用的镜像加速地址写进配置里一劳永逸。Windows 和 macOS 上如果图省事用 Docker Desktop安装之前先确认一下虚拟化是否开启。Windows 上 Docker Desktop 依赖 WSL2 或者 Hyper-V启动失败大概率是 BIOS 里的 virtualization support 没开或者本机装了其他虚拟机软件冲突。这类问题排查起来不难但很消磨耐心所以如果只是在开发阶段用Docker Desktop 可以如果目标是生产部署建议直接练手 Linux 服务器因为最终线上环境大概率就是 Linux。2.3 Docker Compose不是可选是必备单容器跑一个 Node.js 应用没什么难度但真实项目基本都有数据库、缓存、消息队列这些配套服务。如果每个服务都手动 docker run参数一多就乱了而且服务之间的网络关系、启动顺序、数据持久化这些都要自己操心。docker-compose 就是来解决这个问题的用一个 YAML 文件描述一组服务的关系一条命令拉起整个环境。我使用的是docker compose注意是空格不是连字符连字符那个是 Python 版旧工具。新版 compose 是 Docker 官方的 Go 实现功能和性能都好很多。装完上面的 docker-compose-plugin 之后验证一下docker compose version有输出就说明可用。Compose 文件规范现在普遍用 3.x 版本定义 services、volumes、networks 三块核心内容。示例配置我在第 3 节里面会详细展开这里先不赘述。3. Node.js 应用容器化实战3.1 写一个合格的 Dockerfile多阶段构建与镜像瘦身很多 Node.js 项目的 Dockerfile 长这样基础镜像选 node:latest把整个项目拷贝进去npm installnpm run start完事。这种写法跑起来没问题但镜像体积大、构建慢而且把源码、依赖、构建工具全都留在运行环境里既不安全也不利于排查问题。我自己的做法是基于多阶段构建的思路一个阶段做构建另一个阶段做运行运行阶段只保留必要文件。下面是个可直接参考的 Dockerfile 示例# 第一阶段构建 FROM node:20-alpine AS builder WORKDIR /app # 先拷贝依赖清单利用 Docker layer 缓存 COPY package*.json ./ RUN npm ci # 拷贝源码并构建 COPY . . RUN npm run build # 第二阶段运行 FROM node:20-alpine WORKDIR /app ENV NODE_ENVproduction # 只拷贝构建产物和运行时依赖不带走源码和构建工具 COPY --frombuilder /app/package*.json ./ COPY --frombuilder /app/node_modules ./node_modules COPY --frombuilder /app/dist ./dist EXPOSE 3000 USER node CMD [node, dist/main.js]先解释一下这个文件为什么要这么写。第一基础镜像选node:20-alpine而不是node:20。alpine 是专门做了精简的 Linux 发行版镜像体积小很多基础镜像加依赖整体能小一半以上。代价是有些 npm 包需要编译原生模块时需要装编译工具链容易踩 hidden 坑。如果你的项目依赖里有 bcrypt、sharp 这类需要原生编译的包第一次构建时很可能报错多阶段构建第一阶段里补充RUN apk add --no-cache python3 make g就能解决运行阶段不需要因为编译好的原生模块已经拷出来了。第二COPY package*.json ./和RUN npm ci分开写是充分利用 Docker layer 缓存的经典操作。Dockerfile 每一行都会生成一个 layer如果某一行的输入没变构建时就可以直接复用缓存的 layer跳过执行。把 package.json 单独拷贝先执行 npm ci这样只要依赖没有变化后面即使源码改动了依赖安装这步也不用重跑构建速度提升非常明显。第三npm ci 而不是 npm install。npm ci 会严格按照 package-lock.json 安装依赖不会自动修改版本范围保证生产环境复现的和本地开发时完全一致。我强烈建议这个命令用在所有自动化构建场景里。第四USER node这一步很多人忽略。容器默认以 root 身份运行如果应用被攻破攻击者直接就是 root。切换成 node 用户运行是纵深防御里性价比很高的一步。缺点是如果应用需要写日志文件到项目目录得先处理好目录权限这也是很多人不敢切的原因。我的建议是尽量让应用把日志输出到 stdout由 Docker 的日志机制接管就不需要容器内写文件了。Node.js 进程打印到 stdout 和 stderr 的信息会被 docker logs 自动收集这是十二要素应用的推荐做法部署时省心不少。3.2 docker-compose 编排应用 数据库 缓存一次拉起单个 Dockerfile 只解决应用怎么打包的问题真实项目还需要配套的 MySQL、Redis 之类的服务。我用一个 docker-compose.yml 把它们编排在一起。这里给一个我在项目中实际使用的配置骨架services: app: build: context: . dockerfile: Dockerfile container_name: myapp ports: - 3000:3000 environment: NODE_ENV: production DB_HOST: mysql DB_PORT: 3306 DB_USER: appuser DB_PASSWORD: ${DB_PASSWORD} REDIS_HOST: redis REDIS_PORT: 6379 depends_on: mysql: condition: service_healthy redis: condition: service_started restart: unless-stopped networks: - app-network mysql: image: mysql:8.0 container_name: myapp-mysql environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: myapp MYSQL_USER: appuser MYSQL_PASSWORD: ${DB_PASSWORD} volumes: - mysql-data:/var/lib/mysql healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -u, root, -p${MYSQL_ROOT_PASSWORD}] interval: 10s timeout: 5s retries: 5 start_period: 20s restart: unless-stopped networks: - app-network redis: image: redis:7-alpine container_name: myapp-redis volumes: - redis-data:/data restart: unless-stopped networks: - app-network networks: app-network: driver: bridge volumes: mysql-data: redis-data:这里有几个关键点要展开说。网络配置上app、mysql、redis加进同一个 bridge 网络服务之间通过容器名互相访问。容器名在这套配置里就是mysql、redis所以应用里配数据库地址直接写DB_HOSTmysql就行不需要去记 IP。这解决了我们前面热词里提到的容器间网络不通的问题——默认情况下不同 compose 项目或不同网络的容器是互相隔离的只有手动加进同一个网络才能通信。depends_on 的细节值得多说一句。默认的depends_on只是控制容器启动顺序MySQL 容器启动了不代表能接受连接了。MySQL 冷启动要初始化数据目录快则几秒慢则几十秒如果没有健康检查Node.js 应用启动时去连数据库大概率会连接被拒。加了condition: service_healthy之后compose 会等 MySQL 的 healthcheck 通过之后才启动 app 容器从机制上绕开这个经典问题。数据持久化方面MySQL 和 Redis 的数据目录都挂到了命名卷上mysql-data、redis-data。这样容器删了重建数据还在。千万不要把数据直接写在容器可写层里容器一旦删除重建数据就全丢了。还有环境变量管理。我上面的配置里 DB_PASSWORD 和 MYSQL_ROOT_PASSWORD 用了${DB_PASSWORD}这种形式意味着这些值会从 .env 文件读取。compose 会自动加载项目目录下的 .env 文件作为变量来源这样数据库密码、认证密钥这类敏感信息不会硬编码在 compose 文件里也不应该提交进 Git 仓库。.env 文件只留在服务器上。3.3 健康检查、日志与资源限制容器化之后服务是否真的可用是个需要专门定义的事情。进程活着不代表应用健康比如数据库连接池耗尽、某个上游接口超时进程还在但已无法服务用户。healthcheck 就是让 Docker 定时去探测应用的真实状态。对于 Node.js 应用最常用的方式是开一个简单的 health endpoint然后配置 HTTP 探测healthcheck: test: [CMD, wget, -qO-, http://localhost:3000/healthz] interval: 30s timeout: 5s retries: 3 start_period: 10s应用里对应加一个路由返回 200 和一段状态信息即可。这个 endpoint 不要做太重的逻辑比如不需要查数据库否则数据库抖动一次健康检查就挂了反而会影响编排系统的判断。它只需要确认进程能响应请求就行。日志这块容器里 Node.js 应用把日志打到 stdout 之后docker logs 就能看到。compose 里可以限制日志文件的滚动策略避免日志无限膨胀撑爆磁盘logging: driver: json-file options: max-size: 10m max-file: 3资源限制更是个容易被忽略的点。Node.js 应用在容器里如果没有内存限制遇到内存泄漏能把整台机器拖死。compose 里加deploy: resources: limits: cpus: 1.0 memory: 512M reservations: cpus: 0.5 memory: 128M注意这个 deploy 字段在 docker-compose 作为单机编排工具使用时部分生效内存限制是生效的。设置了内存上限之后Node.js 本身也要配合调整--max-old-space-size我给内存 512M 的容器配的是NODE_OPTIONS--max-old-space-size384留出一点余量给其他开销避免容器 OOM 杀掉进程的瞬间V8 堆还没到限制就先触发了系统级别的回收。4. CICD 流水线从 git push 到线上运行4.1 CICD 平台选择和整体流程设计到了这一步本地已经把如何构建、如何编排、如何启动都固化成文件了。接下来要解决的是怎么让这些动作在代码变更的时候自动执行。我自己用的是 GitHub Actions因为项目仓库就在 GitHub不用额外接平台。如果你用的是 GitLab也有内置的 GitLab CIGitee 仓库用 Gitee Go自建 Git 服务可以用 Jenkins 或者 Gitea Drone。这些平台的设计思想是一致的定义一个工作流配置文件平台在特定事件触发时拉起一个临时环境按配置里的步骤执行任务。整体流程我是这么设计的开发者推送代码到 main 分支CICD 平台自动触发 workflow安装依赖、跑 lint、跑单元测试CI 阶段测试通过后构建 Docker 镜像并打上 git commit SHA 的 tagCI 阶段推送镜像到镜像仓库CD 阶段前奏SSH 登录服务器执行部署脚本拉取新镜像重新创建容器CD 阶段这个流程每一步都是上一道工序的产物链条非常清晰。为什么用 commit SHA 打 tag因为同一个 commit 只能构建出同一个镜像回滚的时候只需要告诉服务器跑回哪个 SHA就行完全可回溯。环境变量和密钥是流水线里最容易被搞砸的地方。服务器地址、SSH 私钥、镜像仓库的登录凭证这些都不能写进配置文件里提交到仓库。GitHub Actions 提供了 Secrets 机制在仓库的 Settings → Secrets 里配置workflow 运行时作为变量注入。你可以在 workflow 里通过${{ secrets.SERVER_SSH_KEY }}引用明文不会出现在日志里。4.2 一份可以直接参考的 GitHub Actions 配置下面这个 workflow 是我项目里实际在用的去掉业务相关的细节后你可以直接套用name: Build and Deploy on: push: branches: [main] jobs: build-and-deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkoutv4 - name: Setup Node.js uses: actions/setup-nodev4 with: node-version: 20 cache: npm - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Build Docker image run: | docker build -t registry.cn-hangzhou.aliyuncs.com/your-namespace/myapp:${{ github.sha }} . docker tag registry.cn-hangzhou.aliyuncs.com/your-namespace/myapp:${{ github.sha }} registry.cn-hangzhou.aliyuncs.com/your-namespace/myapp:latest - name: Login and push image env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | echo $REGISTRY_PASSWORD | docker login registry.cn-hangzhou.aliyuncs.com -u $REGISTRY_USERNAME --password-stdin docker push registry.cn-hangzhou.aliyuncs.com/your-namespace/myapp:${{ github.sha }} docker push registry.cn-hangzhou.aliyuncs.com/your-namespace/myapp:latest - name: Deploy to server uses: appleboy/ssh-actionv1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SERVER_SSH_KEY }} script: | cd /opt/myapp export TAG${{ github.sha }} docker compose pull docker compose up -d --no-deps app docker image prune -f拆开说几个关键点。actions/setup-nodev4里配置cache: npm会自动缓存 npm 依赖后续运行的 install 会快很多。这个缓存粒度是跨 workflow 运行共享的只要 package-lock.json 没变就能命中缓存。测试这一步我放在了构建之前。测试挂了就构建也跳过避免把一份有问题的代码构建成镜像推送到线上环境。如果你的项目还有 lint、类型检查可以插在 test 之前。登录镜像仓库这一步我用标准的方式在 workflow 里 docker login。很多镜像仓库支持用 Access Token 当作密码效果和完整密码一样。把 Token 存到 Secrets 里安全性比直接存密码更好密码可以随时改Token 可以单独撤销。服务器部署那一步用了 appleboy/ssh-action它在 workflow 里通过 SSH 连上服务器执行脚本。这是目前 GitHub Actions 里最常用的远程执行方案。执行内容很简单进入项目目录拉取新镜像重建 app 容器清理旧镜像。--no-deps是因为我们只更新应用容器mysql 和 redis 不需要动。4.3 服务器端部署脚本写给永不回家的人上面的 workflow 里有一段脚本直接写在 SSH action 的 script 字段里生产环境我建议把这段逻辑抽成一个独立脚本放到服务器上workflow 里只保留一行bash /opt/myapp/deploy.sh ${{ github.sha }}。服务器上的 deploy.sh 长这样#!/usr/bin/env bash set -euo pipefail cd /opt/myapp TAG${1:-latest} export IMAGE_TAG$TAG echo pull image: $TAG docker compose pull app echo recreate app container docker compose up -d --no-deps app echo cleanup old images docker image prune -f /dev/null 21 || true echo done这个脚本做了三件事拉取新镜像、重建应用容器、清理残留镜像。逻辑上足够简单但有几个细节是实战中必须处理的。首先set -euo pipefail 看着不起眼但缺了它脚本里某个命令悄悄失败后续命令还是会继续执行部署结果不可控。shell 脚本默认是尽量继续执行这在部署场景是最危险的。其次docker compose 默认读取的 .env 是固定的如果我想让 workflow 里传入的 TAG 生效需要在 compose 文件里做一次变量映射。我在 docker-compose.yml 里给 app 服务的镜像字段写成image: registry.cn-hangzhou.aliyuncs.com/your-namespace/myapp:${IMAGE_TAG:-latest}这样 compose 就知道要用哪个 tag。然后docker image prune会清理所有未被容器使用的悬空镜像。因为每次构建都会产生一个新 tag旧的 tag 越来越多不清理的话磁盘迟早被镜像占满。所以必须在完成部署之后再做清理。关于回滚这个脚本的关键优势在于TAG 是参数化的。如果线上服务出了问题我只需要重新跑一次手动部署指定为上一个正常的 tag 就行bash /opt/myapp/deploy.sh 上一个正常的commit-sha不需要回滚代码、不需要重新构建直接拉旧镜像重建容器几分钟内就能回到上一版本。这就是为什么 CICD 的价值不仅是自动化更重要的是给发布加上了后悔药。4.4 蓝绿发布是后话先保证能快速回滚很多人一上来就问蓝绿发布、金丝雀发布我觉得在个人项目和小团队场景里先把基础做好比花式发布重要得多。我的建议是第一阶段先把自动构建 自动部署 快速回滚跑通。这个阶段最大的收益是发布过程可重复、可回溯回滚是拉旧 tag 重建容器简单直接。蓝绿发布是把两个版本同时跑起来流量切换适合要求零停机或者极度谨慎的场景。这个方案需要额外的负载均衡器比如 Nginx 或者云负载均衡复杂度一下子就上来了。我的项目当前用 docker compose 重启会有十几秒的停机时间业务上可以接受就没必要上蓝绿。所以别被高大上的部署方案迷了眼先根据自己的业务容忍度选择合适粒度。基础版方案跑顺之后如果业务确实需要再把蓝绿发布加上去。5. 部署过程中的高频问题与排查实录5.1 Docker 部署高频报错速查这一节把我碰到过、以及身边朋友频繁咨询过的问题整理成速查表方便直接对着排查。permission denied while trying to connect to the Docker daemon socket这个报错的意思是你的用户没有权限访问 Docker 的 Unix socket。Docker daemon 默认监听在/var/run/docker.sock只有 root 和 docker 组的用户能访问。解决方法sudo usermod -aG docker $USER然后重新登录验证docker ps如果重新登录后还是报同样的错检查一下是否真的在新会话里有些终端复用老会话groups输出里没有 docker 就说明没生效。镜像下载慢或者超时这个问题的原因很直白默认拉取的是境外镜像源。解决方法是配置 registry mirror在/etc/docker/daemon.json里加{ registry-mirrors: [https://docker.m.daocloud.io] }改完重启 dockersudo systemctl restart docker这里有个常见误解registry mirror 只对 Docker Hub 官方镜像生效拉取其他仓库的镜像比如 quay.io、ghcr.io加速效果有限这是正常的不是配置有问题。Docker Desktop 启动失败提示 virtualization support not detectedWindows 上 Docker Desktop 依赖 WSL2 或者 Hyper-V启动失败基本都是虚拟化没有被开启。检查思路开机进 BIOS 确认 VT-x/AMD-V 已经打开确认系统里没有其他占用虚拟化能力的虚拟机软件用管理员权限执行bcdedit /set hypervisorlaunchtype auto然后重启。macOS 上如果是 Intel 芯片检查是否安装了最新版 macOS 更新因为系统组件的更新有时候会重置虚拟化相关设置。端口被占用容器启动失败容器启动报 bind: address already in use说明宿主机上那个端口已经被别的进程占了。常见嫌疑对象宿主机上直接跑着的旧版本服务、另一个容器占用了同一端口、Nginx 或 Apache 占用。排查sudo lsof -i :3000找到进程后根据情况清理或者改掉 compose 文件里的端口映射再docker compose up -d。容器起来了但应用没起来docker logs 是空白的这种情况通常是应用启动时崩溃日志还没来得及打印就被 kill 了。优先检查有没有内存限制导致 OOM之前踩过--max-old-space-size配太高容器内存限制又不够Node.js 进程启动即被系统杀掉。再检查健康检查探活路径是否返回了非 2xx 状态探活失败容器也会一直处于 unhealthy 状态看起来像没起来。5.2 Node.js 应用进容器后的典型坑监听地址写错容器外访问不了Node.js 里app.listen(3000)默认监听 127.0.0.1这在宿主机上没问题进了容器就出问题了。容器内的 127.0.0.1 只属于容器自己宿主机访问不到。必须显式监听 0.0.0.0app.listen(3000, 0.0.0.0);这个算是容器化部署里最经典的新手坑应用在容器里跑着docker ps 也显示 UP但就是访问不了。环境变量注入失败应用拿到 undefined如果应用通过process.env.DB_HOST拿配置去看看 compose 文件里 environment 字段是否写对了。一个隐蔽的问题是.env 文件里有中文或特殊字符compose 解析时会出幺蛾子。另一个是有些 Node.js 框架有自己的 .env 加载逻辑比如 NestJS 默认读取项目根目录的 .env 文件而生产环境用的是 compose 的 environment两者优先级可能会有冲突。我的建议是容器化之后统一用 compose 的 environment 注入应用里关掉自动加载 .env 文件的逻辑避免本地好好的线上连不上数据库这种诡异问题。时区问题导致日志时间对不上容器默认时区是 UTC如果应用在日志里记录时间会看到时间比北京时间差 8 小时。解决方式是在 compose 的 environment 里加environment: - TZAsia/Shanghai同时容器内如果用到 cron 之类的定时任务也要保证时区一致。日志不对齐排查问题的时候很痛苦这个细节最好一开始就处理掉。容器间网络不通数据库连不上这个问题在新手阶段高频出现。原因基本都是两个应用容器和数据库容器不在同一个网络里或者数据库服务名写错了。检查方法先用docker compose ps确认服务都启动了然后用docker compose exec app ping mysql验证网络连通性。如果 ping 不通检查一下两个服务是不是都被同一个 compose 文件管理如果要跨 compose 项目共享网络需要先手动创建一个外部网络然后在两个 compose 文件里同时声明使用这个网络docker network create shared-net# 两个 compose 文件里都加 networks: default: external: name: shared-netMySQL 容器健康检查一直失败MySQL 8.0 的官方镜像里健康检查如果命令写错容器会一直处于 unhealthy 状态depends_on 的 service_healthy 条件不满足应用永远启动不了。比较可靠的写法是healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -uroot, -p${MYSQL_ROOT_PASSWORD}] interval: 10s timeout: 5s retries: 5 start_period: 20s注意 start_period 给了 20 秒的宽限期。MySQL 第一次初始化数据目录需要时间这期间健康检查失败是正常的不会触发重启风暴。排查的时候用docker compose ps看 STATUS 列unhealthy 状态的容器一眼就能看出来。我自己在整套流程里最有体感的一件事是先容器化、再自动化这个顺序非常关键。容器化解决的是跑出什么的问题CICD 解决的是怎么把跑出来的东西稳定送到线上的问题。前面的工作没做好流水线建得再漂亮也只是把错误更快地复制到生产环境。最后分享一个操作细节上线之前建议在服务器上先手动跑一遍docker compose up完整看一下 log 输出和健康检查状态再清理掉让 CICD 接管。这一步相当于给整个流程做一个彩排比直接让流水线自动发到生产环境心里踏实得多。等你把这一整套玩顺了后续不管是多一台服务器、接一个 K8s还是引入更复杂的发布策略这也都是你继续往前的坚实基础。