Docker

Docker 实用笔记

我把 Docker 当成一套“标准化交付运行环境”的工具:镜像负责描述交付内容,容器负责把镜像真正跑起来,仓库负责分发镜像。它解决的是环境一致性,不是把所有问题都塞进容器。

先把概念分清

概念我的理解常见命令
Dockerfile构建镜像的配方docker build
Image只读模板,由多层文件系统组成docker image ls
Container镜像启动后的隔离进程,额外带一层可写层docker run
Registry存放和分发镜像的服务docker pulldocker push
Volume由 Docker 管理的持久化数据docker volume
Bind mount把宿主机指定路径挂进容器--mount type=bind
Network容器之间及容器与外界通信的边界docker network
Compose用 YAML 描述一组有关联的容器docker compose

最容易混淆的是镜像和容器:镜像像安装包,容器像正在运行的程序。删除容器不会删除镜像;删除镜像也不会删除已经放在 volume 里的业务数据。

容器不是虚拟机

虚拟机通常带完整 Guest OS 和独立内核,容器共享宿主机内核,通过 namespace 隔离进程、网络和挂载点等资源,再通过 cgroup 做资源限制。

Docker CLI → Docker daemon → containerd → runc → 宿主机上的隔离进程

容器与虚拟机的层次对比

这带来两个直接结论:

  • 容器启动快、额外开销小,但隔离边界通常不等同于虚拟机。
  • Linux 容器依赖 Linux 内核;Windows 上的 Docker Desktop 通常借助 WSL 2 或轻量虚拟机提供 Linux 内核。

环境检查

代码块BASH · 4 行收起展开
docker version
docker info
docker context ls
docker run --rm hello-world

docker version 会分别显示 Client 和 Server。只有 Client 没有 Server,通常不是命令没装,而是 Docker daemon / Docker Desktop 没启动、当前 context 不对,或者当前用户没有访问 daemon 的权限。

hello-world 会走完拉取镜像、创建容器、启动进程和自动清理,适合做最小闭环验证。

docker run 到底做了什么

代码块BASH · 6 行收起展开
docker run -d \
  --name web \
  -p 8080:80 \
  -e APP_ENV=dev \
  --restart unless-stopped \
  nginx:alpine

背后依次是:本地没有镜像时先 pull,基于镜像 create 容器,准备可写层、网络和挂载,最后 start 主进程。

参数作用
-d后台运行
--name指定可读的容器名
-p 8080:80宿主机 8080 映射到容器 80
-e K=V注入环境变量
--env-file .env从文件批量加载环境变量
-v source:target挂载的简写形式
--mount ...更明确的挂载写法
--rm进程退出后自动删除容器
--restart设置 daemon 重启后的恢复策略
--cpus--memory限制 CPU 和内存
--entrypoint临时覆盖镜像入口

端口映射始终是 宿主机端口:容器端口。应用在容器里还必须监听 0.0.0.0;如果只监听 127.0.0.1,即使 -p 写对了,宿主机也可能访问不到。

生命周期与日常调试

代码块BASH · 15 行收起展开
docker ps
docker ps -a
docker start web
docker stop web
docker restart web
docker rm web

docker logs -f --tail 100 --timestamps web
docker exec -it web sh
docker inspect web
docker stats
docker top web
docker port web
docker diff web
docker cp web:/etc/nginx/nginx.conf ./nginx.conf

docker stop 会先给主进程发送 SIGTERM,等待宽限期后才发送 SIGKILL。应用应该正确处理 SIGTERM,完成停止接收请求、关闭连接和刷新缓冲区等收尾动作。

容器是否存活取决于 PID 1。主进程退出,容器就退出。不要靠 tail -f /dev/null 伪装服务存活,应该让真正的服务进程以前台方式运行。

我的排查习惯是先看 ps -alogs,再看 inspect,最后才 exec 进去。直接进容器手改文件只能救急,因为修改不在 Dockerfile 中,重建容器就会丢失。

代码块BASH · 3 行收起展开
docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' web
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' web
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'

镜像、标签和摘要

代码块BASH · 6 行收起展开
docker image ls
docker pull nginx:1.27-alpine
docker tag myapp:local registry.example.com/team/myapp:1.4.0
docker push registry.example.com/team/myapp:1.4.0
docker image inspect nginx:1.27-alpine
docker history nginx:1.27-alpine

标签是可以被重新指向的名字,digest 才是内容地址。开发环境用标签方便,生产部署需要可复现时应固定明确版本,进一步可以固定 digest。尽量不要依赖 latest,它既不代表最新,也不能说明实际版本。

多架构机器上遇到 exec format error,优先检查镜像平台:

代码块BASH · 2 行收起展开
docker image inspect --format '{{.Os}}/{{.Architecture}}' myapp:latest
docker buildx build --platform linux/amd64,linux/arm64 -t example/myapp:1.0 --push .

Dockerfile:把构建过程写进仓库

一个 Java 应用的多阶段构建示例:

# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src

COPY mvnw pom.xml ./
COPY .mvn .mvn
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q -DskipTests dependency:go-offline

COPY src src
RUN --mount=type=cache,target=/root/.m2 ./mvnw -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 appuser
COPY --from=build /src/target/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT [java, -jar, /app/app.jar]
docker build -t myapp:dev .
docker run --rm -p 8080:8080 myapp:dev

我写 Dockerfile 时会检查这些点:

  • 基础镜像使用明确版本,并选择可信来源。
  • 先复制依赖描述文件,再复制频繁变化的源码,提高缓存命中率。
  • 构建环境和运行环境分开,最终镜像不携带 Maven、编译器和源码。
  • 能用普通用户运行就不使用 root。
  • COPY 范围尽量精确,不把密钥、Git 历史和本地缓存带进镜像。
  • 配置通过运行时注入,不为每个环境重新构建一份镜像。

.dockerignore

代码块PLAINTEXT · 7 行收起展开
.git
.idea
.vscode
target
node_modules
.env
*.log

.dockerignore 控制发送给 build context 的内容。它不仅影响镜像大小,也影响上传速度、缓存和敏感信息暴露。

RUNCMDENTRYPOINT

  • RUN:构建镜像时执行,结果写入镜像层。
  • ENTRYPOINT:定义容器的主程序。
  • CMD:提供默认命令或默认参数,运行时容易覆盖。

推荐 exec form:

代码块DOCKERFILE · 2 行收起展开
ENTRYPOINT [java, -jar, /app/app.jar]
CMD [--spring.profiles.active=prod]

JSON 数组形式不会额外套一层 shell,信号传递和参数边界更可靠。如果确实需要变量展开、管道或 &&,再显式使用 shell。

构建缓存

Dockerfile 从上往下计算缓存。一层失效,后面的相关层通常都要重新执行,所以稳定步骤放前面、变化频繁的源码放后面。缓存只能加速构建,不能代替可复现的依赖锁定。

代码块BASH · 2 行收起展开
docker build --progress=plain -t myapp:dev .
docker builder du

数据:volume、bind mount、tmpfs

Volume

代码块BASH · 8 行收起展开
docker volume create pgdata
docker run -d --name db \
  -e POSTGRES_PASSWORD=change-me \
  --mount type=volume,src=pgdata,dst=/var/lib/postgresql/data \
  postgres:17

docker volume ls
docker volume inspect pgdata

适合数据库、队列等需要跨容器重建保留的数据。删除容器不会自动删除命名 volume。

Bind mount

代码块BASH · 4 行收起展开
docker run --rm \
  --mount type=bind,src=$(pwd),dst=/workspace \
  -w /workspace \
  node:22 node --version

适合开发时挂载源码或明确的宿主机配置。它依赖宿主机路径和权限,可移植性比 volume 差。只读配置可以加 readonly

tmpfs

代码块BASH · 1 行收起展开
docker run --rm --tmpfs /tmp:rw,noexec,nosuid,size=64m myapp:dev

数据只放内存,不写入容器可写层,适合临时文件和部分敏感短期数据,容器停止后就消失。

Volume 不等于备份

volume 只能保证数据脱离容器存在。数据库备份优先使用数据库自己的逻辑备份或一致性快照工具:

代码块BASH · 1 行收起展开
docker exec db pg_dump -U postgres appdb > appdb.sql

直接复制正在写入的数据目录,可能得到无法恢复的一组文件。备份是否有效,最终要靠实际恢复验证。

网络:先想清楚“localhost 是谁”

代码块BASH · 3 行收起展开
docker network create app-net
docker run -d --name db --network app-net postgres:17
docker run -d --name api --network app-net -p 8080:8080 myapi:dev

api 容器里:

  • localhost 指向 api 容器自己。
  • 数据库地址应该写服务名 db:5432,不是 localhost:5432
  • 访问宿主机服务时,Docker Desktop 通常可用 host.docker.internal
  • 从宿主机访问容器,用发布出来的端口,例如 localhost:8080

同一个自定义 bridge 网络里的容器可以通过容器名或网络别名解析。EXPOSE 8080 只是镜像元数据,不会自动开放端口;真正发布端口靠 -p 或 Compose 的 ports

代码块BASH · 3 行收起展开
docker network ls
docker network inspect app-net
docker exec api getent hosts db

Docker Compose:把一组服务写成项目

compose.yaml:

代码块YAML · 30 行收起展开
services:
  api:
    build: .
    ports:
      - 8080:8080
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/app
      SPRING_DATASOURCE_USERNAME: app
      SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: [CMD-SHELL, pg_isready -U app -d app]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

把密码放在不提交的 .env 中。常用命令:

代码块BASH · 8 行收起展开
docker compose config
docker compose up -d --build
docker compose ps
docker compose logs -f --tail 100 api
docker compose exec api sh
docker compose restart api
docker compose down
docker compose down -v

docker compose config 会展开变量和合并配置,启动前值得先看一眼。输出可能含已展开的敏感值,不要直接贴进日志或 issue。

docker compose down 默认保留命名 volume;down -v 会把 volume 一起删除,数据库数据也会消失,不能顺手执行。

普通 depends_on 只表达依赖关系,不保证数据库已经接受连接。更稳妥的是依赖服务提供 healthcheck,应用自身也实现带上限的重试和退避。健康检查是观测信号,不能代替应用容错。

多份配置要看最终合并结果:

代码块BASH · 2 行收起展开
docker compose -f compose.yaml -f compose.dev.yaml config
docker compose -f compose.yaml -f compose.dev.yaml up -d

环境变量和秘密

环境变量适合普通配置和开发环境,但不是保险箱:容器配置、进程环境、错误日志都可能暴露值。

我的底线是:

  • 不把真实 .env、token、私钥写进镜像或 Git。
  • 不用 Dockerfile 的 ARG / ENV 传构建密钥,构建历史和缓存可能留下痕迹。
  • 构建时需要私有仓库凭据,优先使用 BuildKit secret mount。
  • 生产环境使用平台提供的 secret 管理能力,并限制读取权限。
  • 日志、docker inspect 输出和故障截图都要检查脱敏。
RUN --mount=type=secret,id=maven_settings,target=/root/.m2/settings.xml \
    ./mvnw -DskipTests package
docker build --secret id=maven_settings,src='$HOME/.m2/settings.xml' -t myapp:dev .

资源限制和退出码

代码块BASH · 5 行收起展开
docker run -d --name api \
  --cpus 1.5 \
  --memory 768m \
  --memory-swap 768m \
  myapi:dev

没有限制不代表资源无限,只是容器会与宿主机其他进程竞争。Java 应用还要一起考虑堆、直接内存、线程栈和 metaspace,不能把 -Xmx 直接顶到容器上限。

退出码常见含义
0主进程正常结束
1应用主动报错退出
126命令存在但不可执行,常见于权限问题
127命令不存在或 PATH 不对
137收到 SIGKILL,常见于 OOM 或强制停止
143收到 SIGTERM 后退出

退出码只是线索。例如 137 还要结合 OOMKilled、宿主机日志和资源曲线判断,不能直接断言一定是内存泄漏。

代码块BASH · 1 行收起展开
docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{.State.Error}}' api

排错顺序

容器刚启动就退出

代码块BASH · 4 行收起展开
docker ps -a
docker logs --tail 200 <container>
docker inspect -f '{{json .State}}' <container>
docker run --rm -it --entrypoint sh myapp:dev

重点看主进程、启动参数、环境变量、文件权限和退出码。distroless 或 scratch 镜像里本来就没有 shell,不能把 sh: not found 当成应用坏了。

端口访问不到

我的检查顺序:

  1. docker ps 是否还在运行。
  2. docker port 是否真的发布端口。
  3. 映射方向是否为 宿主机:容器
  4. 应用是否监听 0.0.0.0 和正确的容器端口。
  5. 容器内健康检查是否成功。
  6. 宿主机防火墙、云安全组、反向代理是否放行。

容器之间连不上

代码块BASH · 2 行收起展开
docker inspect -f '{{json .NetworkSettings.Networks}}' api
docker inspect -f '{{json .NetworkSettings.Networks}}' db

确认两者是否在同一网络、连接地址是否使用服务名、目标进程是否监听正确接口。Compose 中最常见的错误是把数据库地址写成 localhost

磁盘越来越满

代码块BASH · 5 行收起展开
docker system df
docker system df -v
docker builder prune
docker image prune
docker container prune

先看空间由镜像、容器、volume 还是 build cache 占用,再决定清什么。docker system prune -a --volumes 影响范围很大,尤其可能删除仍有价值的数据卷,不作为日常第一选择。

长期服务还要配置日志轮转:

代码块YAML · 7 行收起展开
services:
  api:
    logging:
      driver: json-file
      options:
        max-size: '10m'
        max-file: '3'

Windows 挂载异常或很慢

  • 确认 Docker Desktop 文件共享权限和 WSL 集成。
  • Linux 工具链处理大量小文件时,项目尽量放在 WSL 的 Linux 文件系统,减少跨边界访问。
  • 检查 CRLF、可执行位、文件名大小写和脚本 shebang。
  • PowerShell、CMD、Git Bash、WSL 对引号和 $(pwd) 的处理不同,命令不能原样混用。

安全基线

  • 默认使用非 root 用户,必要时设置只读根文件系统。
  • 不使用 --privileged,不随意挂载 /var/run/docker.sock
  • 只添加真正需要的 Linux capabilities。
  • 不把宿主机根目录、SSH 目录等高权限路径挂进不可信容器。
  • 镜像来源可追踪,定期扫描系统包和应用依赖漏洞。
  • 对外只发布必要端口;数据库通常不需要映射到公网。
  • 配置 CPU、内存、进程数和日志上限,避免单个服务拖垮宿主机。

偏严格的运行示例:

代码块BASH · 8 行收起展开
docker run --rm \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --memory 512m \
  --cpus 1 \
  myapp:dev

这些限制需要配合应用实际写目录逐项验证,不是参数越多就越安全。

发布前检查

  • 镜像能从空缓存成功构建。
  • .dockerignore 没有把密钥和无关大文件送进 build context。
  • 镜像使用明确版本,最终层里没有构建工具和源码残留。
  • 容器不以 root 运行,入口进程能处理停止信号。
  • 配置与镜像分离,敏感值不出现在 Git、镜像历史和日志中。
  • 数据使用明确的 volume,并有真正验证过的备份与恢复流程。
  • 容器间使用服务名通信,对外只发布必要端口。
  • 有 healthcheck、应用级重试、资源上限和日志轮转。
  • docker compose config 的最终结果符合预期。
  • 已验证重建容器后服务和数据都能恢复,而不只是原容器“目前能跑”。

命令速查

代码块BASH · 26 行收起展开
# 状态
docker ps -a
docker images
docker system df

# 构建与运行
docker build -t app:dev .
docker run --rm -p 8080:8080 app:dev

# 日志与调试
docker logs -f --tail 100 app
docker exec -it app sh
docker inspect app
docker stats

# Compose
docker compose config
docker compose up -d --build
docker compose ps
docker compose logs -f
docker compose down

# 清理:先确认范围
docker container prune
docker image prune
docker builder prune

最后我会用一句话检查自己的方案:容器可以随时删掉重建,镜像能够重复构建,配置能够重新注入,数据能够独立恢复。 这四件事都成立,Docker 才真正帮我降低了交付成本。

延伸阅读