一、容器技术的前世今生
容器技术的概念可以追溯到 1979 年 Unix V7 的 chroot 系统调用,它实现了文件系统的隔离。2000 年 FreeBSD 推出了 Jail,将隔离扩展到了进程树、网络和用户权限。2006 年 Google 贡献了 Cgroups(Control Groups),实现了资源限制和审计。2008 年 Linux 内核引入了 Namespaces,提供了进程、网络、挂载点等维度的隔离能力。
LXC(LinuX Containers)在 2008 年诞生,是第一个真正意义上完整的 Linux 容器实现,整合了 Cgroups 和 Namespaces。但 LXC 操作复杂,缺乏标准化的工具链和镜像管理机制。
2013 年,dotCloud 公司(后更名为 Docker Inc.)开源了 Docker。它在 LXC 基础上做了三件事:用 Union File System 实现分层镜像存储,统一了镜像格式和分发协议,提供了简单易用的命令行工具和 Docker Hub 公共仓库。这三件事让容器技术从少数运维专家的玩具变成了大众开发者的日常工具。
容器并非 Docker 发明的,它的隔离哲学和虚拟机有根本区别。想了解 Namespace 与 cgroups 如何组合出隔离、为什么容器共享内核、以及哪些场景容器并不合适,可以阅读什么是容器?。本文不再重复这些原理,聚焦工程实践。
二、Dockerfile 最佳实践
Dockerfile 是镜像的构建脚本,写得好不好直接决定镜像体积、构建速度和安全基线。下面是实战中真正影响产出的几个点。
2.1 利用构建缓存:指令顺序就是性能
Docker 构建是逐层缓存的,某条指令的输入没变就复用缓存,一旦某层失效,它之后的所有层都会重建。这条规则决定了 Dockerfile 的指令顺序。
一个常见错误是过早复制全部源码:
# 不推荐:源码任何改动都导致 npm ci 重新执行FROM node:20-alpineWORKDIR /appCOPY . .RUN npm ciCMD ["node", "server.js"]COPY . . 把整个项目复制进去,只要你改了一行业务代码,npm ci 这一层就失效,依赖重新安装。正确的做法是先把依赖描述文件复制进去,安装依赖单独成层,再复制业务代码:
# 推荐:依赖层只随 package.json 变化FROM node:20-alpineWORKDIR /appCOPY package*.json ./RUN npm ci --only=productionCOPY . .CMD ["node", "server.js"]这样改业务代码时,依赖安装层命中缓存,构建从 COPY . . 开始,几秒完成。对 Go 项目同理,先 COPY go.* ./ 再 go mod download,最后才 COPY . .。
配合 .dockerignore 排除 node_modules、.git、日志文件,避免无关变更使缓存失效,也避免把本地调试产物打进镜像。
2.2 合并 RUN 指令:少一层是一层
每条 RUN 产生一层,层数越多镜像越大。把相关的安装命令用 && 串起来,并在结尾清理缓存:
# 不推荐:四层,且 apt 缓存留在镜像里RUN apt-get updateRUN apt-get install -y curlRUN apt-get install -y vimRUN apt-get clean
# 推荐:一层完成,清理缓存RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl \ vim && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*--no-install-recommends 跳过推荐包,rm -rf /var/lib/apt/lists/* 清掉包索引,这两个动作对镜像体积的影响往往比想象中大。
2.3 多阶段构建:把编译器和源码挡在镜像之外
把编译环境和运行环境塞进同一个镜像,是镜像体积失控的主因。多阶段构建用 AS 给阶段命名,运行阶段只 COPY --from 拷贝编译产物:
# 构建阶段:完整的 Go SDK,数百 MBFROM golang:1.21-alpine AS builderWORKDIR /appCOPY go.* ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .
# 运行阶段:只有二进制和必要证书FROM alpine:3.19RUN apk --no-cache add ca-certificates tzdataWORKDIR /appCOPY --from=builder /server .USER nobodyENTRYPOINT ["/server"]单阶段构建出的镜像可能 800MB,多阶段后能压到 15MB 左右。体积小的意义不只是省带宽,更在于攻击面:运行镜像里没有编译器、没有源码、没有构建工具,攻击者能利用的东西少得多。
-ldflags="-s -w" 去掉调试符号和 DWARF 信息,对 Go 二进制度数级别的缩减。CGO_ENABLED=0 生成静态二进制,运行阶段甚至可以用 FROM scratch 这种空镜像。
2.4 CMD 与 ENTRYPOINT 的配合
这两个指令容易混淆,关键在于 docker run 后面跟的参数如何处理:
CMD:可被docker run参数整体覆盖ENTRYPOINT:docker run参数作为追加参数拼在后面
FROM alpineCMD ["echo", "hello"]# docker run myimage → echo hello# docker run myimage echo world → echo world(CMD 被覆盖)FROM alpineENTRYPOINT ["echo"]CMD ["hello"]# docker run myimage → echo hello# docker run myimage world → echo hello world(world 追加)实战中更常用的是 ENTRYPOINT 定命令、CMD 定默认参数的组合:把容器设计成”带默认行为的可执行程序”,调用方既可以直接跑,也可以传参覆盖默认行为。注意都用 JSON 数组格式(exec 形式),不要用 shell 形式(CMD echo hello),后者会经 shell 解析,信号传递和参数处理都更难控制。
2.5 安全基线
生产镜像至少做到三条:非 root 运行、最小基础镜像、只读根文件系统。
FROM node:20-alpineRUN addgroup -g 1000 -S appgroup && \ adduser -u 1000 -S appuser -G appgroupWORKDIR /appCOPY --chown=appuser:appgroup . .USER appuserCMD ["node", "server.js"]# 运行时配合只读根文件系统和权限收敛docker run -d \ --read-only \ --tmpfs /tmp \ --cap-drop ALL --cap-add CHOWN \ my-app:latestUSER appuser 确保即使容器被入侵,攻击者也没有 root 权限。--read-only 让根文件系统不可写,攻击者无法写入后门或篡改二进制,需要写的临时目录用 --tmpfs 单独挂载。--cap-drop ALL 收回所有 Linux capabilities,只按需加回。
最小基础镜像(alpine、distroless)的意义不只是体积,更少的包意味着更少的 CVE。可以用 docker scout cves <image> 扫描镜像漏洞。
三、Docker Compose 服务编排
单机跑几个容器,docker run 参数会越来越长,端口、卷、环境变量、依赖关系混在一起难以维护。Compose 用一个 YAML 描述多容器应用,是本地开发和中小规模部署的事实标准。
3.1 用 depends_on 表达启动顺序
一个典型的 Web 应用通常由 Web、App、数据库、缓存组成,App 依赖数据库就绪。depends_on 可以声明这种依赖:
services: app: build: ./app depends_on: db: condition: service_healthy cache: condition: service_started environment: - DATABASE_URL=postgres://user:pass@db:5432/mydb - REDIS_URL=redis://cache:6379
db: image: postgres:15-alpine environment: POSTGRES_USER: user POSTGRES_PASSWORD: pass POSTGRES_DB: mydb volumes: - postgres-data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U user -d mydb"] interval: 10s timeout: 5s retries: 5
cache: image: redis:7-alpine volumes: - redis-data:/data
volumes: postgres-data: redis-data:这里有个坑值得单独说:depends_on 只控制启动顺序,不等于就绪。service_started 只表示容器进程拉起来了,不代表应用能接收请求。数据库容器启动后,PostgreSQL 进程还需要几秒完成初始化,这段时间 App 去连库会失败。
解决办法是给被依赖服务配 healthcheck,依赖方用 condition: service_healthy,Compose 会等健康检查通过才启动依赖方。上面的 pg_isready 就是 PostgreSQL 自带的就绪探测命令。
3.2 多环境配置:override 与 -f 组合
Compose 支持多文件叠加。默认会自动加载 docker-compose.yml 和 docker-compose.override.yml,后者常用于开发环境的本地覆盖。生产环境用 -f 显式指定多个文件叠加:
# 开发环境(自动合并 docker-compose.yml 和 override)docker compose up -d
# 生产环境docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# 只启动部分服务docker compose up -d db cache基础文件放通用配置,环境文件只放差异:
# docker-compose.prod.yml:只覆盖生产相关的部分services: app: image: registry.example.com/app:${VERSION:-latest} deploy: replicas: 3 resources: limits: cpus: "1" memory: 512M healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/health"] interval: 30s timeout: 10s retries: 3注意生产文件里 app 用 image 拉取已构建镜像而非 build 本地构建,配合 ${VERSION:-latest} 注入版本号,保证运行的是 CI 产出的那个镜像而不是本地随手构建的。
3.3 环境变量与 .env
敏感信息不要硬编码进 YAML。用 .env 文件管理,Compose 启动时自动读取同目录下的 .env:
DB_URL=postgres://user:pass@db:5432/mydbREDIS_URL=redis://cache:6379VERSION=1.2.3services: app: image: my-app:${VERSION} environment: - DATABASE_URL=${DB_URL}.env 必须加入 .gitignore,仓库里只提交 .env.example 作为模板。命令行也能临时覆盖:VERSION=1.2.4 docker compose up -d。
四、实战案例
4.1 前后端一体镜像
一个前端 SPA 加 Nginx 的典型场景:构建阶段编译前端,运行阶段只放静态文件和 Nginx 配置。
# app/DockerfileFROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM nginx:1.25-alpineCOPY --from=builder /app/dist /usr/share/nginx/htmlCOPY nginx/nginx.conf /etc/nginx/nginx.confEXPOSE 80CMD ["nginx", "-g", "daemon off;"]services: web: build: context: . dockerfile: app/Dockerfile ports: - "80:80" restart: unless-stopped要点是构建产物(/app/dist)从 builder 阶段拷出,运行镜像里没有 Node.js、没有源码、没有 node_modules,只有 Nginx 和编译后的静态文件。
4.2 本地开发环境
本地开发讲究的是”改代码即时生效”,这和生产的”构建一次跑到底”是两套逻辑。用卷挂载源码 + 覆盖启动命令实现热重载:
services: app: build: context: . target: development volumes: - .:/app # 源码挂载进容器,改代码即时生效 - /app/node_modules # 屏蔽宿主机的 node_modules,用容器内的 ports: - "3000:3000" environment: - NODE_ENV=development - CHOKIDAR_USEPOLLING=true # WSL2/macOS 下文件监听需要轮询 command: npm run dev
db: image: postgres:15-alpine ports: - "5432:5432" environment: POSTGRES_USER: dev POSTGRES_PASSWORD: dev POSTGRES_DB: myapp volumes: - dev-db:/var/lib/postgresql/data
redis: image: redis:7-alpine ports: - "6379:6379"
volumes: dev-db:- .:/app 把当前目录挂进容器,编辑器改的代码立刻反映到容器里,配合 npm run dev 的热重载实现不用重启就生效。/app/node_modules 这个匿名卷是个技巧:它遮蔽了挂载进来的宿主机 node_modules,保证容器用的是自己 npm install 装的版本,避免宿主机和容器 Node 版本不一致导致的原生模块不兼容。
CHOKIDAR_USEPOLLING=true 是 WSL2 和 macOS 卷挂载场景下文件监听经常失效的 workaround,不开启会出现在容器里改了代码但不触发热重载的问题。
4.3 CI/CD 流水线
GitLab CI 里用 Docker-in-Docker 构建并推送镜像的典型片段:
stages: - build - test - deploy
variables: IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
build: 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 $IMAGE_TAG . - docker push $IMAGE_TAG
test: stage: test image: $IMAGE_TAG script: - npm test
deploy: stage: deploy image: docker:24 script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker pull $IMAGE_TAG - docker tag $IMAGE_TAG $CI_REGISTRY_IMAGE:latest - docker push $CI_REGISTRY_IMAGE:latest only: - main用 commit SHA 作为镜像 tag,保证每次提交对应一个不可变镜像,测试阶段直接用这个镜像跑,部署阶段再额外打一个 latest tag 供生产拉取。only: - main 限制只有主分支才部署,避免特性分支误发生产。
五、常见问题排查
5.1 容器退出码的含义
docker ps -a 看到容器退出了,先查退出码:
docker inspect <container_id> --format '{{.State.ExitCode}}'docker logs <container_id>常见退出码:
| 退出码 | 含义 |
|---|---|
| 0 | 正常退出 |
| 1 | 应用错误(代码异常、配置错误) |
| 137 | 被 SIGKILL 杀死,常见原因是 OOM 或超时强制终止 |
| 139 | 段错误(内存访问违规) |
| 143 | 被 SIGTERM 终止(正常停止流程) |
137 是最需要警惕的。如果容器没设内存限制或设得太小,宿主机内存紧张时内核的 OOM Killer 会杀掉容器进程,退出码就是 137。确认是不是 OOM:
# 查看宿主机最近的 OOM 记录dmesg | grep -i "killed process"看到对应进程被 OOM Kill,就需要调大 --memory 或排查应用内存泄漏。
5.2 网络不通的排查路径
容器间网络不通,按从内到外的顺序排查:
# 1. 进容器测 DNS(自定义网络才支持容器名解析)docker exec -it <container> shnslookup db # 解析不了说明不在同一自定义网络
# 2. 测连通性ping -c 3 <对端IP>curl -v http://<对端>:<端口>
# 3. 查 NAT 规则(bridge 模式对外通信靠 NAT)iptables -t nat -L -n
# 4. 重建异常的网络docker network prune最常见的根因是两个容器不在同一个自定义网络。默认 bridge 网络里容器之间只能用 IP 通信,不支持容器名解析;自定义 bridge 网络才内置 DNS,能用 ping db 这种名字访问。所以微服务场景一律用自定义网络而非默认 bridge。
5.3 磁盘被镜像和日志撑满
Docker 用久了磁盘报警,多半是镜像层、停止的容器、无主卷和容器日志累积。先看占用分布:
docker system df -v清理未使用的资源:
# 清理停止的容器、无主网络、悬空镜像、构建缓存docker system prune
# 连未使用的镜像一起清(确认无误后)docker system prune -a
# 连无主卷一起清(危险,确认无数据卷在用)docker system prune -a --volumes容器日志是另一个隐形杀手。默认的 json-file 日志驱动不限制大小,一个跑很久的服务日志能写到几十 GB。治本的办法是在 /etc/docker/daemon.json 里限制日志大小:
{ "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" }}这样每个容器最多保留 3 个 100MB 的日志文件,轮转覆盖。改完 systemctl restart docker 生效,已运行的容器需要重建才会应用新配置。
如果数据目录所在的盘本身就小,可以把整个 Docker 数据目录迁到大盘:
sudo systemctl stop docker
# 修改 /etc/docker/daemon.json# { "data-root": "/mnt/docker-data" }
sudo rsync -aP /var/lib/docker/ /mnt/docker-data/sudo systemctl start docker迁移完确认 docker info 里的 Docker Root Dir 已指向新路径,且容器都正常起来后,再删除旧的 /var/lib/docker。
参考资料
- Docker 官方文档 - 完整命令参考和概念说明,查命令细节时首选
- Dockerfile 最佳实践 - 官方镜像构建指南
- Docker Compose 文档 - 服务编排配置参考
- Docker 安全最佳实践 - 容器安全加固指南
- OCI 容器规范 - 开放容器行业标准
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






