模块 03

Docker 容器

把「在我电脑上能跑」变成「在任何服务器上都能跑」。重点是理解镜像分层,它决定了构建速度和镜像体积。

8 个章节 含 Dockerfile 实战 含 Compose 编排

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 inspectOOMKilled 字段确认。

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"]
💡

为什么用 execsh -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
  • COPYADD 会校验文件内容哈希,与文件修改时间无关。

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 裁剪 JDKJRE 可降至 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 logsdocker inspect {{.State.ExitCode}}启动命令报错、配置文件缺失、路径不对
退出码 137docker 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
  • 说明 CMDENTRYPOINT 的区别与配合方式
  • exec 或 exec 形式入口解决 SIGTERM 收不到的问题
  • 用命名卷持久化数据库数据,并完成一次备份恢复
  • 创建自定义网络,让两个容器通过服务名互相访问
  • 用 Compose 拉起 web + db + redis 三件套,并配置 healthcheck
  • 定位一次退出码 137 的问题并说明原因
  • 为容器设置日志轮转,避免磁盘被写满
  • 用 Trivy 扫描镜像并获得一份漏洞报告
🎯

当你能稳定产出小体积、非 root、可优雅停止的镜像后,就可以进入 Kubernetes 模块,把这些镜像放到集群里跑。