第一次用 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*.json 和 RUN 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,多半是这个问题。解决方案:
- 换用非 alpine 的基础镜像
- 或者在 Dockerfile 里安装编译依赖(
build-base、python3、make等)
多阶段构建:减小镜像体积
这是我见过最容易忽略技巧。
不好的写法:
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 里的每条指令(除了 FROM、WORKDIR、EXPOSE 等元数据指令)都会产生一个新层。
有人为了”减少层数”,把多个 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 update和apt-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 作为主机名(而不是 localhost 或 127.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
这样你可以用同一份基础配置,不同环境只覆盖需要改部分。
本地开发环境容器化
为什么要把开发环境也容器化
几个理由:
- “在我机器上能跑”问题:新同事入职,不用花一下午配环境,直接
docker-compose up就能跑起来 - 版本一致性:所有人用同一个 Node 版本、同一个 PostgreSQL 版本,避免”我的是 14 版,你的是 15 版”这种问题
- 隔离性:不同项目依赖不互相干扰
实际案例:全栈应用开发环境
# 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"]
这样配置之后,新同事只需要:
- 安装 Docker Desktop
git clone项目docker-compose -f docker-compose.dev.yml up
就能跑起来整套开发环境,包括数据库和 Redis。
热重载怎么实现
关键是 volume 挂载。上面配置里 volumes: - .:/app 把本地源码目录挂载到容器里 /app,这样你在本地修改会实时反映到容器里。
但这有个前提:你的应用支持热重载。Node.js 可以用 nodemon 或者 ts-node-dev;前端项目用 vite 或 webpack-dev-server 都自带热重载。
如果是编译型语言(Go、Rust、Java),热重载就没这么简单了。一般需要:
- 在容器里监听文件变化
- 文件变化时重新编译
- 重启应用
或者用 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 强杀。
解决方案:
- 直接用可执行文件作为
CMD或ENTRYPOINT - 如果必须用 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 时遇到过什么坑?欢迎分享,让后来者少走弯路。
种下你的想法
在花园里留下一条评论,和这篇文章一起生长。