Docker
Docker 实用笔记
我把 Docker 当成一套“标准化交付运行环境”的工具:镜像负责描述交付内容,容器负责把镜像真正跑起来,仓库负责分发镜像。它解决的是环境一致性,不是把所有问题都塞进容器。
先把概念分清
| 概念 | 我的理解 | 常见命令 |
|---|---|---|
| Dockerfile | 构建镜像的配方 | docker build |
| Image | 只读模板,由多层文件系统组成 | docker image ls |
| Container | 镜像启动后的隔离进程,额外带一层可写层 | docker run |
| Registry | 存放和分发镜像的服务 | docker pull、docker 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 内核。
环境检查
代码块收起展开
docker version
docker info
docker context ls
docker run --rm hello-worlddocker version 会分别显示 Client 和 Server。只有 Client 没有 Server,通常不是命令没装,而是 Docker daemon / Docker Desktop 没启动、当前 context 不对,或者当前用户没有访问 daemon 的权限。
hello-world 会走完拉取镜像、创建容器、启动进程和自动清理,适合做最小闭环验证。
docker run 到底做了什么
代码块收起展开
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 写对了,宿主机也可能访问不到。
生命周期与日常调试
代码块收起展开
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.confdocker stop 会先给主进程发送 SIGTERM,等待宽限期后才发送 SIGKILL。应用应该正确处理 SIGTERM,完成停止接收请求、关闭连接和刷新缓冲区等收尾动作。
容器是否存活取决于 PID 1。主进程退出,容器就退出。不要靠 tail -f /dev/null 伪装服务存活,应该让真正的服务进程以前台方式运行。
我的排查习惯是先看 ps -a 和 logs,再看 inspect,最后才 exec 进去。直接进容器手改文件只能救急,因为修改不在 Dockerfile 中,重建容器就会丢失。
代码块收起展开
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}}'镜像、标签和摘要
代码块收起展开
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,优先检查镜像平台:
代码块收起展开
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
代码块收起展开
.git
.idea
.vscode
target
node_modules
.env
*.log.dockerignore 控制发送给 build context 的内容。它不仅影响镜像大小,也影响上传速度、缓存和敏感信息暴露。
RUN、CMD、ENTRYPOINT
RUN:构建镜像时执行,结果写入镜像层。ENTRYPOINT:定义容器的主程序。CMD:提供默认命令或默认参数,运行时容易覆盖。
推荐 exec form:
代码块收起展开
ENTRYPOINT [java, -jar, /app/app.jar]
CMD [--spring.profiles.active=prod]JSON 数组形式不会额外套一层 shell,信号传递和参数边界更可靠。如果确实需要变量展开、管道或 &&,再显式使用 shell。
构建缓存
Dockerfile 从上往下计算缓存。一层失效,后面的相关层通常都要重新执行,所以稳定步骤放前面、变化频繁的源码放后面。缓存只能加速构建,不能代替可复现的依赖锁定。
代码块收起展开
docker build --progress=plain -t myapp:dev .
docker builder du数据:volume、bind mount、tmpfs
Volume
代码块收起展开
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
代码块收起展开
docker run --rm \
--mount type=bind,src=$(pwd),dst=/workspace \
-w /workspace \
node:22 node --version适合开发时挂载源码或明确的宿主机配置。它依赖宿主机路径和权限,可移植性比 volume 差。只读配置可以加 readonly。
tmpfs
代码块收起展开
docker run --rm --tmpfs /tmp:rw,noexec,nosuid,size=64m myapp:dev数据只放内存,不写入容器可写层,适合临时文件和部分敏感短期数据,容器停止后就消失。
Volume 不等于备份
volume 只能保证数据脱离容器存在。数据库备份优先使用数据库自己的逻辑备份或一致性快照工具:
代码块收起展开
docker exec db pg_dump -U postgres appdb > appdb.sql直接复制正在写入的数据目录,可能得到无法恢复的一组文件。备份是否有效,最终要靠实际恢复验证。
网络:先想清楚“localhost 是谁”
代码块收起展开
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。
代码块收起展开
docker network ls
docker network inspect app-net
docker exec api getent hosts dbDocker Compose:把一组服务写成项目
compose.yaml:
代码块收起展开
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 中。常用命令:
代码块收起展开
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 -vdocker compose config 会展开变量和合并配置,启动前值得先看一眼。输出可能含已展开的敏感值,不要直接贴进日志或 issue。
docker compose down 默认保留命名 volume;down -v 会把 volume 一起删除,数据库数据也会消失,不能顺手执行。
普通 depends_on 只表达依赖关系,不保证数据库已经接受连接。更稳妥的是依赖服务提供 healthcheck,应用自身也实现带上限的重试和退避。健康检查是观测信号,不能代替应用容错。
多份配置要看最终合并结果:
代码块收起展开
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 packagedocker build --secret id=maven_settings,src='$HOME/.m2/settings.xml' -t myapp:dev .资源限制和退出码
代码块收起展开
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、宿主机日志和资源曲线判断,不能直接断言一定是内存泄漏。
代码块收起展开
docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} error={{.State.Error}}' api排错顺序
容器刚启动就退出
代码块收起展开
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 当成应用坏了。
端口访问不到
我的检查顺序:
docker ps是否还在运行。docker port是否真的发布端口。- 映射方向是否为
宿主机:容器。 - 应用是否监听
0.0.0.0和正确的容器端口。 - 容器内健康检查是否成功。
- 宿主机防火墙、云安全组、反向代理是否放行。
容器之间连不上
代码块收起展开
docker inspect -f '{{json .NetworkSettings.Networks}}' api
docker inspect -f '{{json .NetworkSettings.Networks}}' db确认两者是否在同一网络、连接地址是否使用服务名、目标进程是否监听正确接口。Compose 中最常见的错误是把数据库地址写成 localhost。
磁盘越来越满
代码块收起展开
docker system df
docker system df -v
docker builder prune
docker image prune
docker container prune先看空间由镜像、容器、volume 还是 build cache 占用,再决定清什么。docker system prune -a --volumes 影响范围很大,尤其可能删除仍有价值的数据卷,不作为日常第一选择。
长期服务还要配置日志轮转:
代码块收起展开
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、内存、进程数和日志上限,避免单个服务拖垮宿主机。
偏严格的运行示例:
代码块收起展开
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的最终结果符合预期。- 已验证重建容器后服务和数据都能恢复,而不只是原容器“目前能跑”。
命令速查
代码块收起展开
# 状态
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 才真正帮我降低了交付成本。