Docker 容器
把「在我电脑上能跑」变成「在任何服务器上都能跑」。重点是理解镜像分层,它决定了构建速度和镜像体积。
01容器与虚拟机的区别
先建立正确的心理模型,避免把容器当小虚拟机用。
| 维度 | 虚拟机 | 容器 |
|---|---|---|
| 隔离层 | Hypervisor + 独立 Guest OS | 共享宿主机内核,靠 namespace/cgroup 隔离 |
| 启动速度 | 秒级到分钟级 | 毫秒级到秒级 |
| 资源开销 | 每个实例独占内存与磁盘 | 几乎无额外开销,按进程计 |
| 镜像体积 | GB 级 | 可做到 MB 级 |
| 内核版本 | 各自独立 | 受宿主机内核约束 |
容器本质是被限制和隔离的进程。它依赖宿主机内核的三个能力:
Namespaces
隔离视图:PID、网络、挂载点、主机名、用户等,让进程「以为」自己独占系统。
Cgroups
限制资源:CPU、内存、IO 配额,防止单个容器拖垮宿主机。
UnionFS
联合文件系统:多层只读层叠加,写操作在上层,实现镜像复用。
因为共享内核,容器内不要跑与宿主机内核强耦合的程序(如自定义内核模块、复杂防火墙规则)。需要强隔离时用 Kata Containers、gVisor 这类安全容器方案。
安装与验证
# 官方脚本(生产建议按官方文档配置 yum/apt 源)
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
docker version
docker info # 查看存储驱动、cgroup 版本、运行容器数
# 免 sudo(需重新登录生效)
sudo usermod -aG docker $USER
02镜像与分层
理解分层的最大收益:构建缓存命中和镜像体积可控。
镜像由一系列只读层组成,每条 Dockerfile 指令产生一层。容器启动时在所有只读层之上加一个可写层,容器内所有修改都发生在这一层——容器删除,可写层随之消失,这正是「容器的数据默认不持久」的原因。
docker image ls # 列出本地镜像
docker image inspect nginx:1.25 | less # 查看分层、环境变量、入口命令
docker history nginx:1.25 # 逐层查看每层大小与构建指令
# 层层分析镜像体积(排查为什么这么大)
docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" myapp:1.0
镜像命名与标签规范
registry.example.com/team/app:1.4.2-20260912
└─ 仓库地址 ─────┘ └─ 项目 ─┘ └── 标签 ──┘
没有仓库地址时默认 docker.io/library/
标签不写默认 latest —— 生产禁用 latest!
永远不要在流水线中使用 :latest。它不指向固定内容,回滚时无法确定上一版本是什么。用语义化版本或 Git Commit SHA 作为标签。
仓库操作
docker login registry.example.com
docker tag myapp:1.0.0 registry.example.com/team/myapp:1.0.0
docker push registry.example.com/team/myapp:1.0.0
docker pull registry.example.com/team/myapp:1.0.0
# 保存与加载(无网络环境迁移镜像)
docker save myapp:1.0.0 -o myapp.tar
docker load -i myapp.tar
# 从运行中的容器反推镜像(仅用于应急,不推荐作为交付方式)
docker commit <container> myapp:debug
03容器常用命令
按「创建 → 查看 → 进入 → 清理」的生命周期记忆。
运行
docker run -d --name web \
-p 8080:80 \ # 宿主机端口:容器端口
-e TZ=Asia/Shanghai \ # 环境变量
-v /data/web:/usr/share/nginx/html:ro \ # 挂载,ro 只读
--restart unless-stopped \ # 重启策略
--memory 512m --cpus 1.0 \ # 资源限制
nginx:1.25
# 交互式临时容器,退出即删除
docker run --rm -it alpine:3.19 sh
# 注意:镜像里的 CMD 会被 run 后面的命令覆盖,ENTRYPOINT 不会
| 重启策略 | 行为 |
|---|---|
no(默认) | 不重启 |
on-failure[:N] | 非 0 退出时重启,可限制次数 |
always | 总是重启,包括手动 stop 后重启守护进程 |
unless-stopped | 总是重启,但手动 stop 后不再拉起(推荐) |
查看与调试
docker ps -a # 含已退出的容器
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
docker logs -f --tail 100 web # 跟踪日志最后 100 行
docker logs --since 30m web
docker inspect web --format '{{.State.Status}} {{.NetworkSettings.IPAddress}}'
docker exec -it web bash # 进入运行中的容器(排查首选)
docker exec web cat /etc/nginx/nginx.conf
docker stats # 实时资源占用,类似 top
docker top web # 查看容器内进程
docker cp web:/etc/nginx/nginx.conf ./nginx.conf.bak
# 排查「容器起来就退出」:先看退出码
docker inspect web --format '{{.State.ExitCode}} {{.State.Error}}'
清理
docker stop web && docker rm web
docker rm -f web # 强制删除运行中的容器
docker rmi myapp:1.0.0 # 删除镜像(需先无容器引用)
docker system df # 查看磁盘占用构成
docker system prune # 清理未使用的容器、网络、悬挂镜像
docker system prune -a --volumes # 更彻底,会删数据卷,生产环境谨慎!
容器内 exit code 137 通常表示被 SIGKILL 杀死,最常见原因是内存超限被 OOM Killer 干掉。用 docker inspect 看 OOMKilled 字段确认。
04Dockerfile 最佳实践
一份合格的 Dockerfile,既快又小,还安全。
核心语法
| 指令 | 作用 | 要点 |
|---|---|---|
FROM | 基础镜像 | 用具体版本;多阶段构建可多个 |
WORKDIR | 设置工作目录 | 自动创建,优于 RUN cd |
COPY | 复制文件 | 比 ADD 语义清晰;ADD 会自动解压 tar |
RUN | 构建时执行 | 多个命令用 && 串联并清理缓存 |
ENV | 环境变量 | 构建与运行时都生效 |
ARG | 构建参数 | 仅构建期有效,勿放密钥 |
EXPOSE | 声明端口 | 仅文档作用,仍须 -p 映射 |
CMD | 默认启动命令 | 可被 docker run 覆盖 |
ENTRYPOINT | 入口命令 | 配合 CMD 传默认参数 |
USER | 运行用户 | 非 root 运行,安全必备 |
HEALTHCHECK | 健康检查 | K8s 中也常用对应探针替代 |
反面教材
FROM openjdk:latest
COPY . /app
WORKDIR /app
RUN mvn clean package
EXPOSE 8080
CMD java -jar target/app.jar
# 问题:
# 1. latest 标签不可复现
# 2. COPY . 会把 .git、target 一起塞进去,且任何文件变动都让缓存失效
# 3. jar 被复制两次(先 COPY 再构建)
# 4. 构建工具与运行时混在同一镜像,体积巨大
# 5. root 运行
推荐写法:分层构建 + 多阶段
# ---------- 构建阶段 ----------
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先只复制 pom,利用缓存:依赖不变则不重复下载
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
# ---------- 运行阶段 ----------
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 时区与基础工具
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
# 非 root 用户
RUN groupadd -r app && useradd -r -g app app
# 只复制产物,不带 Maven 与源码
COPY --from=builder /build/target/app.jar app.jar
RUN chown -R app:app /app
USER app
EXPOSE 8080
# 容器内存感知 + 优雅退出
ENTRYPOINT ["sh", "-c", "exec java \
-XX:MaxRAMPercentage=75 \
-XX:+UseG1GC \
-XX:+ExitOnOutOfMemoryError \
-jar app.jar"]
为什么用 exec?sh -c "java ..." 时容器 PID 1 是 shell,docker stop 发出的 SIGTERM 不会被 Java 进程收到,导致只能等到超时被强杀。加 exec 让 Java 进程替换 shell 成为 PID 1,才能优雅关闭。
或用 ENTRYPOINT ["java", "-jar", "app.jar"] 的 exec 形式,天然没有这个问题。
构建与 .dockerignore
# 构建并打标签
docker build -t myapp:1.0.0 .
# 指定 Dockerfile 与构建参数
docker build -f docker/Dockerfile.prod --build-arg VERSION=1.0.0 -t myapp:1.0.0 .
# 不使用缓存(依赖更新异常时)
docker build --no-cache -t myapp:1.0.0 .
# .dockerignore
.git
.gitignore
.idea
*.iml
target
node_modules
*.log
*.md
.env
docker-compose*.yml
构建上下文会整个打包发给 Docker 守护进程。.dockerignore 缺失时,一个 node_modules 就能让构建从 10 秒变成 5 分钟。
构建缓存的三条规则
- 某条指令的输入未变化时,直接复用缓存;一旦某层失效,其后的所有层都会重新构建。
- 因此要把「变化频率低」的指令放前面:先
COPY pom.xml装依赖,再COPY src。 COPY与ADD会校验文件内容哈希,与文件修改时间无关。
05数据卷与网络
数据要活过容器,服务之间要能互相找到。
存储的三种方式
| 方式 | 语法 | 适用场景 |
|---|---|---|
| 命名卷 | -v mydata:/var/lib/mysql | 生产首选,由 Docker 管理,跨平台一致 |
| 绑定挂载 | -v /host/path:/container/path | 开发时挂配置与代码,依赖宿主机目录结构 |
| tmpfs | --tmpfs /tmp | 敏感数据、纯临时文件,只存内存 |
# 创建并查看卷
docker volume create pgdata
docker volume ls
docker volume inspect pgdata # 查看宿主机上的实际路径
docker volume rm pgdata # 无容器使用时才能删
# 用临时容器备份卷数据
docker run --rm -v pgdata:/src -v $(pwd):/backup alpine \
tar czf /backup/pgdata-$(date +%F).tar.gz -C /src .
docker rm -v 会连带删除匿名卷;docker system prune --volumes 会删掉所有未被使用的卷。执行前用 docker volume ls 确认,生产数据务必有独立备份。
网络模式
| 模式 | 说明 | 使用场景 |
|---|---|---|
bridge(默认) | 容器接入 docker0 网桥,通过 NAT 出网 | 绝大多数单机场景 |
host | 直接使用宿主机网络栈,无 NAT 开销 | 追求极致性能、端口固定 |
none | 无网络,只有 lo | 离线批处理、安全加固 |
container:<name> | 共享另一容器的网络命名空间 | Sidecar 模式复用网络 |
| 自定义 bridge | 用户自定义网络,内置 DNS 解析 | 推荐,容器间用名字互访 |
# 创建自定义网络
docker network create --subnet 172.28.0.0/16 app-net
docker network ls
# 同一自定义网络内可直接用容器名通信
docker run -d --name db --network app-net postgres:16
docker run -d --name api --network app-net myapp:1.0.0
docker exec -it api ping db # 名字即可解析
# 把运行中的容器接入另一网络
docker network connect app-net web
docker network disconnect app-net web
# 查看网络详情与容器归属
docker network inspect app-net
默认的 bridge 网络不支持按容器名解析,只能靠 --link(已废弃)或 IP。养成创建自定义网络的习惯,服务发现会简单很多。
06Compose 多容器编排
用一份声明式文件描述「一整套应用」,一条命令拉起。
# docker-compose.yml
services:
web:
build:
context: .
args:
VERSION: "1.0.0"
image: myapp:1.0.0
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
DB_HOST: db # 直接用服务名
DB_PASSWORD: ${DB_PASSWORD} # 从 .env 读取,避免明文提交
depends_on:
db:
condition: service_healthy # 等 db 健康后再启动
networks: [backend]
restart: unless-stopped
deploy:
resources:
limits:
cpus: "1.0"
memory: 1g
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 15s
timeout: 3s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: appdb
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks: [backend]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 5
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
networks: [backend]
volumes:
pgdata:
redisdata:
networks:
backend:
driver: bridge
# 前台启动,看到所有日志
docker compose up
# 后台启动并在需要时重新构建
docker compose up -d --build
# 查看状态与服务日志
docker compose ps
docker compose logs -f web
# 在服务容器里执行命令
docker compose exec web sh
# 扩缩容(不带 ports 映射的服务才能多副本)
docker compose up -d --scale web=3
# 停止并清理(-v 会删除卷,慎用)
docker compose down
docker compose down -v
# 校验配置文件语法
docker compose config
用 .env 存放密码等敏感值,并把 .env 加入 .gitignore。提交一个 .env.example 作为模板给团队参考。
07瘦身、安全与排障
镜像越小,拉取越快、攻击面越小,这条链路直接影响到 K8s 的发布速度。
镜像瘦身手段
| 手段 | 效果 | 说明 |
|---|---|---|
| 多阶段构建 | 去除构建工具 | 只复制产物,Maven/源码不进最终镜像 |
| alpine / slim 基础镜像 | 数百 MB → 数十 MB | 注意 musl libc 兼容性 |
| distroless / scratch | 最小攻击面 | 无 shell,排障需用 kubectl debug 侧车 |
| 合并 RUN 并清理缓存 | 减少层数 | && apt-get clean && rm -rf /var/lib/apt/lists/* |
| jdeps + jlink 裁剪 JDK | JRE 可降至 40MB 内 | 只保留用到的模块 |
| 分层的 JAR(spring-boot layertools) | 提升缓存命中 | 依赖层不随业务代码变化 |
# 分析镜像层大小,找出体积大头
docker history --no-trunc --human myapp:1.0.0
# 用 dive 交互式查看每层内容(需另装)
dive myapp:1.0.0
安全基线
- 基础镜像固定版本号,不使用
latest - 用
USER指定非 root 用户运行 - 不把密钥、证书写进镜像层(应用
--env或 Secret 注入) - 运行时加
--read-only与--cap-drop=ALL - 定期用 Trivy / Grype 扫描 CVE
- 限制资源:
--memory、--cpus、--pids-limit
# 加固后的运行方式
docker run -d --name web \
--read-only --tmpfs /tmp \ # 根文件系统只读,临时目录给内存盘
--cap-drop=ALL --cap-add=NET_BIND_SERVICE \
--security-opt no-new-privileges \
--pids-limit 200 \
--memory 512m --cpus 1.0 \
-v /data/web:/usr/share/nginx/html:ro \
myapp:1.0.0
# 漏洞扫描
trivy image --severity HIGH,CRITICAL myapp:1.0.0
高频故障对照表
| 现象 | 排查命令 | 常见原因 |
|---|---|---|
| 容器启动即退出 | docker logs、docker inspect {{.State.ExitCode}} | 启动命令报错、配置文件缺失、路径不对 |
| 退出码 137 | docker inspect 看 OOMKilled | 内存超限、被强杀 |
| 端口占用 | ss -tulnp | grep 8080 | 宿主机已有进程、端口映射冲突 |
| 容器内访问不到外网 | docker exec -it x ping 8.8.8.8 | 未开启 IP 转发、DNS 配置错误 |
| 容器间连不上 | docker network inspect | 不在同一网络、用了默认 bridge |
| 磁盘被写满 | docker system df | 日志无限增长、悬空镜像过多 |
| 时间不对 | docker exec x date | 未设置 TZ 或未挂载 /etc/localtime |
# 限制日志大小,从根源避免磁盘写满
sudo tee /etc/docker/daemon.json <<'EOF'
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "5" },
"data-root": "/data/docker"
}
EOF
sudo systemctl restart docker
08自测清单
勾选表示已亲手验证过。
- 说出容器与虚拟机的三点本质区别
- 解释镜像分层为什么能加快构建和拉取
- 把 Java 应用构建成 < 200MB 的镜像
- 用
.dockerignore把构建上下文从几百 MB 降到几 MB - 说明
CMD与ENTRYPOINT的区别与配合方式 - 用
exec或 exec 形式入口解决 SIGTERM 收不到的问题 - 用命名卷持久化数据库数据,并完成一次备份恢复
- 创建自定义网络,让两个容器通过服务名互相访问
- 用 Compose 拉起 web + db + redis 三件套,并配置 healthcheck
- 定位一次退出码 137 的问题并说明原因
- 为容器设置日志轮转,避免磁盘被写满
- 用 Trivy 扫描镜像并获得一份漏洞报告
当你能稳定产出小体积、非 root、可优雅停止的镜像后,就可以进入 Kubernetes 模块,把这些镜像放到集群里跑。