第一次用 Docker 的时候,我以为它就是个”轻量级虚拟机”。后来发现完全不是那么回事—容器不是虚拟机,镜像是分层文件系统,volume 是数据持久化关键。这一路踩了不少坑,写出来给大家参考。

核心概念:先搞明白这三个词

镜像(Image)

镜像是只读模板,类似于安装光盘。你基于镜像启动容器,就像用光盘安装系统。

镜像是由一层层只读层组成。每一层对应 Dockerfile 里的一条指令。

# Dockerfile
FROM node:18-alpine          # 层1:基础镜像
WORKDIR /app                 # 层2:设置工作目录
COPY package*.json ./        # 层3:复制依赖声明
RUN npm install              # 层4:安装依赖
COPY . .                     # 层5:复制应用代码
CMD ["npm", "start"]         # 启动命令(不是层,是元数据)

每一层都是增量。如果你只改了应用代码(层5),那么层1-4 可以从缓存里拿,不用重新构建。这就是为什么要把 COPY package*.jsonRUN npm install 放在 COPY . . 前面的原因—依赖不变话,安装的那个层就可以复用。

容器(Container)

容器是镜像运行实例。你可以把它理解成一个”隔离的进程”。

容器和虚拟机核心区别:

  • 虚拟机模拟硬件,每个 VM 有独立操作系统
  • 容器共享宿主机操作系统内核,只是通过 namespace 和 cgroup 做隔离

这也意味着:容器比虚拟机轻量得多,启动快(秒级),占用资源少。但代价是:隔离性不如虚拟机,安全性依赖内核 namespace/cgroup 实现。

我一般把容器当成”一次性运行环境”:

# 启动一个临时容器,退出后自动删除
docker run --rm -it node:18-alpine sh

# 在容器里随便折腾,不会影响宿主机
# 退出后容器自动清理

仓库(Registry)

仓库是存放镜像地方。Docker Hub 是最知名公共仓库,但你也可以搭建私有仓库。

# 从 Docker Hub 拉取镜像
docker pull nginx:1.25

# 给镜像打标签(准备推送到私有仓库)
docker tag my-app:1.0 registry.example.com/my-app:1.0

# 推送到私有仓库
docker push registry.example.com/my-app:1.0

我们团队用 Harbor 搭建了私有仓库,存储内部服务镜像。这样做好处是:

  • 镜像不会暴露到公网
  • 拉取速度快(内网带宽)
  • 可以设置访问控制

常用命令实战

镜像操作

# 列出本地镜像
docker images

# 删除镜像(如果有容器在用它,先删容器)
docker rmi <image-id>

# 清理所有未使用的镜像(慎用)
docker image prune -a

# 查看镜像的分层历史
docker history <image-name>

docker history 很有用,能帮你分析镜像为什么这么大。我曾经看到一个同事镜像有 1.2GB,用 docker history 一看,发现他在镜像里放了整个 node_modules(包括 devDependencies),还有一个 300MB 的测试数据文件。

容器操作

# 启动容器(后台运行)
docker run -d --name my-nginx -p 8080:80 nginx

# 查看运行中的容器
docker ps

# 查看所有容器(包括停止的)
docker ps -a

# 查看容器日志
docker logs -f my-nginx

# 进入容器内部(调试用)
docker exec -it my-nginx sh

# 停止容器
docker stop my-nginx

# 删除容器
docker rm my-nginx

-f 参数让 docker logs 持续输出,类似 tail -f。调试的时候经常用。

docker exec -it <container> sh 是进入容器方式。如果容器里没有 sh,试试 bash 或者 ash(alpine 镜像用 ash)。

清理垃圾

Docker 用久了会占很多磁盘空间。我的一般清理流程:

# 删除所有停止的容器
docker container prune

# 删除所有未使用的镜像
docker image prune -a

# 删除所有未使用的 volume
docker volume prune

# 一键清理所有未使用资源(容器、镜像、网络、volume)
docker system prune -a

docker system df 可以查看磁盘占用情况,定期清理是个好习惯。

Dockerfile 编写技巧

基础镜像怎么选

# 不推荐:用 latest 标签
FROM node:latest

# 推荐:用具体版本
FROM node:18.19.0

# 更好:用 alpine 版本(体积小,安全性高)
FROM node:18.19.0-alpine

latest 标签的问题在于:它今天可能是 18,明天可能是 20,你的构建就可能莫名其妙地挂了。所以生产环境锁定版本。

Alpine 版本的镜像体积小(node 大约 40MB,而 node 大约 350MB),但有个坑:alpine 用的是 musl libc,而不是大多数 Linux 发行版 glibc。某些原生模块(native addon)可能在 alpine 上编译失败。

如果你遇到过 Error: could not detect abi for version,多半是这个问题。解决方案:

  1. 换用非 alpine 的基础镜像
  2. 或者在 Dockerfile 里安装编译依赖(build-basepython3make 等)

多阶段构建:减小镜像体积

这是我见过最容易忽略技巧。

不好的写法

FROM node:18-alpine

WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

EXPOSE 3000
CMD ["npm", "start"]

这样写出来镜像会包含:

  • 源代码
  • 所有 devDependencies(测试框架、linter、构建工具等)
  • 构建中间产物

最终镜像可能有几百 MB。

多阶段构建

# 阶段1:构建
FROM node:18-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production && npm ci --only=development

COPY . .
RUN npm run build

# 阶段2:运行
FROM node:18-alpine

WORKDIR /app

COPY --from=builder /app/package*.json ./
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
CMD ["node", "dist/index.js"]

两个阶段:

  • builder:负责安装所有依赖、编译代码
  • 最终镜像:只复制运行需要东西(编译后代码 + 生产依赖)

这样出来镜像不包含源代码、不包含 devDependencies、不包含构建工具,体积能小很多。

我曾经把一个 Express 应用的镜像从 800MB 缩减到 120MB,就是用多阶段构建做到。

.dockerignore 很重要

.dockerignore 类似于 .gitignore,告诉 Docker 哪些文件不用复制到镜像里。

# .dockerignore
node_modules
.git
.gitignore
*.md
.env
.DS_Store
coverage
.nyc_output

如果没这个文件,COPY . . 会把 node_modules 也复制进去—然后你安装依赖那一步(RUN npm install)就等于白做了,而且还会让镜像变得巨大。

减少层数,但不是越少越好

Dockerfile 里的每条指令(除了 FROMWORKDIREXPOSE 等元数据指令)都会产生一个新层。

有人为了”减少层数”,把多个 RUN 合并成一行:

# 不推荐:可读性差
RUN apt-get update && apt-get install -y curl wget && rm -rf /var/lib/apt/lists/*
# 推荐:用换行符保持可读性
RUN apt-get update && \
    apt-get install -y curl wget && \
    rm -rf /var/lib/apt/lists/*

层数太多确实会让镜像变大(每层都会占用空间),但合并指令也要考虑可读性。一般来说:

  • 相关的命令可以合并(比如 apt-get updateapt-get install 必须在一起,不然可能装到过期包)
  • 不相关命令分开写(方便缓存和调试)

ENTRYPOINT vs CMD

这是个经典问题。

# ENTRYPOINT:容器启动时一定会执行的命令
ENTRYPOINT ["node", "app.js"]

# CMD:给 ENTRYPOINT 提供默认参数(可以被 docker run 命令行参数覆盖)
CMD ["--port", "3000"]

实际的启动命令是:node app.js --port 3000

如果你在 docker run 时加了参数:

docker run my-image --port 8080

实际执行是:node app.js --port 8080(CMD 覆盖了)

使用建议

  • 如果容器是”一个具体应用”(比如 nginx、redis),用 ENTRYPOINT
  • 如果容器是”一个可配置工具”(比如 curl 镜像),用 CMD
  • 组合使用:ENTRYPOINT 定义主程序,CMD 定义默认参数

我之前犯过一个错误:在 Dockerfile 里用了 CMD ["npm", "start"],然后在工作里想传环境变量给应用,结果写成 docker run my-image --env NODE_ENV=production—这个 --env 当成了应用参数,而不是 docker 的参数。正确写法是 docker run -e NODE_ENV=production my-image

docker-compose:编排多服务

基础用法

单机运行多个容器,还让它们能互相通信,用 docker-compose 最方便。

# docker-compose.yml
version: "3.8"

services:
  web:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://db:5432/mydb
    depends_on:
      - db
    volumes:
      - ./src:/app/src  # 开发时挂载源码,实现热重载

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydb
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

启动:

# 构建并启动所有服务
docker-compose up -d

# 查看服务状态
docker-compose ps

# 查看某个服务的日志
docker-compose logs web

# 停止并删除容器
docker-compose down

# 停止并删除容器 + 删除 volume(会丢数据,慎用)
docker-compose down -v

服务间通信

在 docker-compose 里,服务名就是 hostname。上面的配置中,web 服务连接数据库时用 db 作为主机名(而不是 localhost127.0.0.1)。

这是因为 docker-compose 会创建一个默认 bridge 网络,服务名通过内置 DNS 解析。

我曾经在这个地方卡了一个小时:ECONNREFUSED 127.0.0.1:5432。原因是我把数据库连接配置写成 localhost 了—在容器里,localhost 指的是容器自己,不是宿主机,其他容器。

开发环境 vs 生产环境

docker-compose 有个好用功能:通过多个文件覆盖配置。

# docker-compose.yml(基础配置)
version: "3.8"
services:
  web:
    build: .
    ports:
      - "3000:3000"

# docker-compose.dev.yml(开发环境覆盖)
services:
  web:
    volumes:
      - ./src:/app/src  # 挂载源码,支持热重载
    environment:
      - NODE_ENV=development

# docker-compose.prod.yml(生产环境覆盖)
services:
  web:
    restart: always
    environment:
      - NODE_ENV=production

启动时指定多个文件:

# 开发环境
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up

# 生产环境
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

这样你可以用同一份基础配置,不同环境只覆盖需要改部分。

本地开发环境容器化

为什么要把开发环境也容器化

几个理由:

  1. “在我机器上能跑”问题:新同事入职,不用花一下午配环境,直接 docker-compose up 就能跑起来
  2. 版本一致性:所有人用同一个 Node 版本、同一个 PostgreSQL 版本,避免”我的是 14 版,你的是 15 版”这种问题
  3. 隔离性:不同项目依赖不互相干扰

实际案例:全栈应用开发环境

# docker-compose.dev.yml
version: "3.8"

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev  # 开发环境专用 Dockerfile
    volumes:
      - .:/app
      - /app/node_modules  # 匿名 volume,避免本地 node_modules 覆盖容器里的
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:password@db:5432/mydb
    command: npm run dev  # 假设用了 nodemon 或类似工具
    depends_on:
      - db
      - redis

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydb
    ports:
      - "5432:5432"  # 暴露到宿主机,方便用 GUI 工具连接
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:
# Dockerfile.dev
FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

EXPOSE 3000

# 开发环境不需要把源码 COPY 进去,因为会用 volume 挂载
# 这里只是个默认命令,可以被 docker-compose 的 command 覆盖
CMD ["npm", "run", "dev"]

这样配置之后,新同事只需要:

  1. 安装 Docker Desktop
  2. git clone 项目
  3. docker-compose -f docker-compose.dev.yml up

就能跑起来整套开发环境,包括数据库和 Redis。

热重载怎么实现

关键是 volume 挂载。上面配置里 volumes: - .:/app 把本地源码目录挂载到容器里 /app,这样你在本地修改会实时反映到容器里。

但这有个前提:你的应用支持热重载。Node.js 可以用 nodemon 或者 ts-node-dev;前端项目用 vitewebpack-dev-server 都自带热重载。

如果是编译型语言(Go、Rust、Java),热重载就没这么简单了。一般需要:

  1. 在容器里监听文件变化
  2. 文件变化时重新编译
  3. 重启应用

或者用 entr 这类工具:

# 在容器里用 entr 实现热重载(Go 示例)
CMD find . -name "*.go" | entr -r go run main.go

常见的坑

坑1:容器里时区不对

默认情况下,Docker 容器用是 UTC 时区。如果你应用对时间敏感(比如定时任务、时间显示),这会是个问题。

解决方案

# 在 Dockerfile 里设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

或者在 docker-compose 里设置:

services:
  app:
    environment:
      - TZ=Asia/Shanghai

坑2:进程管理问题

如果你在容器里跑了一个 shell 脚本,脚本里启动了多个后台进程,然后用 tail -f /dev/null 保活—这是个坏实践。

容器里 PID 1 进程(你的主进程)负责接收信号(比如 SIGTERM),并传递给子进程。如果你用 shell 脚本作为入口,shell 默认不会传递信号,导致容器停止时子进程收不到 SIGTERM,只能被 SIGKILL 强杀。

解决方案

  1. 直接用可执行文件作为 CMDENTRYPOINT
  2. 如果必须用 shell 脚本,用 exec 启动最终进程:
# entrypoint.sh
#!/bin/sh
# 做一些初始化工作...
exec node app.js  # 注意这个 exec,它会替换当前 shell 进程,让 node 成为 PID 1

坑3:文件权限问题

在 Linux 上,容器内创建文件可能属于 root,导致在宿主机上无法删除。

解决方案:在 Dockerfile 里指定用户:

FROM node:18-alpine

# 创建非 root 用户
RUN addgroup -g 1000 appuser && \
    adduser -u 1000 -G appuser -s /bin/sh -D appuser

WORKDIR /app

# 把工作目录的拥有者改成 appuser
COPY --chown=appuser:appuser . .

USER appuser

CMD ["node", "app.js"]

这样容器里创建文件就属于 appuser(uid 1000),跟宿主机当前用户 uid 一致的话,就不会有权限问题。

坑4:镜像构建时无法访问私有仓库

如果你 npm install 需要从私有 npm registry 拉包,或者 go mod download 需要拉私有仓库代码,在 docker build 过程中会失败(因为容器里没有你 SSH key 或 npm token)。

解决方案:用 build argument 传入 token:

# Dockerfile
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
    npm install
# 构建时传入 token
docker build --build-arg NPM_TOKEN=$(cat ~/.npmrc | grep _authToken | cut -d= -f2) .

或者更安全做法:用 docker build --secret

# Dockerfile(需要 Docker BuildKit)
RUN --mount=type=secret,id=npmrc cat /run/secrets/npmrc > .npmrc && \
    npm install
# 构建时传入 secret
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc .

坑5:Windows 和 macOS 的 volume 性能问题

在 macOS 和 Windows 上,Docker Desktop 用虚拟机跑 Docker,volume 挂载涉及到文件系统共享,性能会比较差(尤其是 Node.js 的 node_modules,大量的小文件读写)。

缓解方案

  • 如果是 Node.js 项目,把 node_modules 放在容器内,不要挂载出来(用匿名 volume):
volumes:
  - .:/app
  - /app/node_modules  # 这一行很重要,避免用本地的 node_modules
  • 对于 macOS,可以试试用 Mutagen 或者 Docker Desktop 的 cached 挂载模式:
volumes:
  - .:/app:cached

总结

Docker 的核心价值在于”环境一致性”—开发环境、测试环境、生产环境用同一份镜像,能避免很多”在我机器上能跑”的问题。

但并不是所有场景都适合容器化。如果你在做一个简单静态网站,直接 npm run dev 可能比折腾 Docker 更有效率。容器化适合:

  • 需要多个服务协同(数据库、缓存、消息队列等)
  • 团队多人协作,需要统一环境
  • 需要部署到生产环境,希望环境可复制

提醒一句:不要把容器当虚拟机用。容器是”一次性”,容器里数据随时可能丢失(除非用 volume 持久化)。我见过有人把数据库文件存在容器里,然后重启容器后发现数据全没了—这是没理解容器是 ephemeral(临时的)这一设计理念。


你在使用 Docker 时遇到过什么坑?欢迎分享,让后来者少走弯路。

种下你的想法

在花园里留下一条评论,和这篇文章一起生长。

COMMENTS