首页 / 资讯中心 / 文章详情

Docker核心原理与实战:镜像、容器、Compose及生产排错全解析

Docker核心原理与实战:镜像、容器、Compose及生产排错全解析 ★ FEATURED ARTICLE
1. 镜像、容器、仓库这三个词理解对了就赢了一半但凡接触过一点 Docker基本都听过镜像、容器、仓库三件套。但我在实际带项目的时候发现很多人是背住了名词没建立模型——最典型的表现就是把 Docker 当成一台轻量虚拟机来用在里面装全套环境跑一个常驻系统容器一停就等于关机。这个认知偏差不纠正后面学再多命令也是空中楼阁。1.1 镜像不是安装包是一层一层叠出来的只读模板第一次用 Docker往往是从docker pull ubuntu开始的。拉下来一看体积好几百兆很多人就以为是一个完整的 Ubuntu 系统搬进来了。这是最大的误解。镜像是分层layer叠加的结果。构建一个镜像时每一条会改变文件系统的指令都会产生一个新层例如RUN apt-get install、COPY而每一层只记录相对上一层的差异。比如你在 Ubuntu 基础镜像上装了一个 Nginx新增的只有 Nginx 相关的文件和包管理缓存并不会把整个根文件系统复制一份。多个容器共享同一个基础镜像时这些公共层在磁盘上只有一份这就是为什么能用一条docker run ubuntu秒起一个容器而不是像虚拟机一样经历完整的系统引导。理解分层还有一个实际用处排查镜像为什么这么大。用docker history 镜像名可以看到每一层的大小和创建指令用docker image inspect 镜像名可以查看层列表。我在优化镜像体积时第一步永远是看 history定位是哪一层引入了体积暴涨的文件然后针对性地做清理。提示docker pull下载的是多个层的压缩包本地保存的是解压后的层目录。所以拉下来的镜像体积和磁盘上实际占用不一定完全一致要看docker system df才有准确数字。1.2 容器是镜像的一次运行实例但它仍然只是进程镜像本身是个静态文件。真正让它跑起来是docker run创建了一个容器Docker 会在镜像最上面加一层可写层container layer然后启动一个用户态进程把这个进程隔离在一个独立的命名空间里。这句话值得再读一遍容器里跑的是进程不是操作系统。它有自己的 PID 空间容器里的 PID 1 不等于宿主机的 PID 1、独立的网络栈和挂载点但它和宿主机共享同一个内核。所以你在容器里执行uname -r看到的还是宿主机内核版本这也是容器比虚拟机轻的根本原因。这个模型解释了许多翻车现象容器启动后立刻退出往往是因为镜像里配置的启动命令是一个前台进程而它执行完就结束了容器也随之退出容器里kill 1可以干掉整个容器因为 PID 1 就是这个容器的主进程修改容器里的文件容器删除后修改就丢了除非把改动提交成新镜像或挂载数据卷。1.3 仓库与人分享tag 和 digest 才是精确定位镜像靠仓库分发。默认使用的就是 Docker Hub一条docker pull nginx:1.25里nginx是仓库名1.25是 tag省略前缀时默认从官方仓库拉取。维护生产环境的人我建议多盯一眼digest。同一个nginx:1.25tag镜像内容可能被仓库维护者重新推送过导致不同时间拉下来的镜像并不完全一样。真正不可变的是nginx:1.25sha256:xxxx这种按 digest 引用。如果你们的发布流程对一致性要求极高可以构建时记录 digest回滚时按 digest 拉取而不是对着一个 tag 赌运气。2. 跑通第一个容器之前先搞懂命令背后的执行路径与守护进程很多人学 Docker 是记命令docker run -d -p 8080:80 nginx跑通了就觉得自己会了。可一旦端口不通、容器没起来、日志打不出来就全抓瞎。我建议先搞清楚这条命令从输入到回车后台到底发生了什么。2.1 从安装到守护进程别跳过 daemon 配置Linux 上主流做法是通过发行版软件源安装 docker-ce 和 containerd然后把docker服务设为开机自启。装完之后不要急着拉镜像先检查一个文件/etc/docker/daemon.json。这是 Docker daemon 的配置文件很多疑难杂症都在这里埋着。常用配置项包括配置项作用建议>docker run -d \ --name web \ -p 8080:80 \ --memory512m \ --cpus1 \ --restartunless-stopped \ nginx--restartunless-stopped常被我称作基础保命策略进程崩了Docker 自动拉起但手动 stop 的容器不会复活。--memory和--cpus是硬限制配合docker stats观察资源水位才算是对容器有了掌控力。3. Dockerfile 是一份可追溯的构建清单优化点决定交付质量用docker commit把一个改好的容器提交成镜像这种做法在校验阶段偶尔用可以但绝不能成为常规手段。镜像交付必须是声明式的、可复现的Dockerfile 就是把环境构建的每一步变成显式记录。3.1 基础镜像不是越全越好选基础镜像的原则很简单够用就好。同样的应用需要直接写FROM ubuntu没问题但更推荐从alpine、debian:slim这类精简镜像入手。不要觉得系统越全越保险容器里没有 sshd、没有 vim、没有一堆工具恰恰说明攻击面小、交付体积小。同时避免使用latest。latest是移动靶这次构建和三个月后构建拉到的可能完全是两个镜像。我习惯写明确版本alpine:3.20、debian:12-slim甚至锁定 digest。看起来是小事但在复现问题、回滚版本时它能帮你省下大量时间。3.2 指令顺序与层缓存机制把变动频率低的放在前面Docker 构建时会逐层检查缓存如果当前指令和上一层内容都没变就会复用层缓存而不是重新执行。利用这个机制我把依赖安装放在源码拷贝之前FROM node:20-alpine WORKDIR /app # 先拷贝依赖清单命中缓存时无需重复安装依赖 COPY package.json package-lock.json ./ RUN npm ci # 最后拷贝源码源码改动不会破坏前面的缓存 COPY src ./src CMD [node, src/index.js]如果反着写每条COPY src都会导致后续所有层缓存失效每次构建都得重新npm ci构建时间直接从秒级变成分钟级。这里需要理解一个反直觉的点指令不会因为上一个指令内容完全没变而自动跳过它必须结合层缓存一起判断如果你心里没底就记住先稳定后变动的排列顺序。3.3 多阶段构建把编译环境和运行环境分开多阶段构建的核心价值是构建过程中需要的工具链编译器、依赖包很大但运行期根本用不上。与其费劲在一个镜像里删删减减不如把构建和运行分成两个阶段。以 Go 服务为例# 构建阶段 FROM golang:1.22-alpine AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 go build -o app ./cmd/server # 运行阶段 FROM alpine:3.20 WORKDIR /app COPY --frombuilder /build/app ./app RUN addgroup -S appuser adduser -S appuser -G appuser USER appuser EXPOSE 8080 CMD [./app]最终镜像里只有编译好的二进制和运行时依赖没有一丁点编译源码或中间产物。很多人觉得多阶段构建复杂实际操作一遍看到体积对比和启动体验后就再也不想用单阶段方案了。3.4 ENTRYPOINT 和 CMD 的关系以及 PID 1 的信号问题这两者的关系是命令行参数会覆盖 CMD但不会覆盖 ENTRYPOINT。最稳妥的用法是ENTRYPOINT 写固定的主程序CMD 写默认参数比如ENTRYPOINT [/usr/local/bin/app] CMD [--config/etc/app/config.yaml]用户通过docker run追加参数时可以轻松替换 CMD 部分而不影响主程序。容器里的 PID 1 还有一个经典问题信号转发。默认情况下发往容器的 SIGTERM 信号不会自动转发给 PID 1如果主进程没有处理信号docker stop就会变成SIGKILL进程可能丢失未落盘的数据。小应用可以挂tini或dumb-init这类轻量 init 进程把这层信号处理补上。这不影响正常开发但对优雅停机有要求的服务是必须考虑的。4. 数据与网络容器重启不可怕数据丢了才可怕容器是一次性的这句话的潜台词是删除容器时容器可写层里的数据会一起消失。对无状态服务来说删了重建是好事但对数据库、应用上传文件、日志这种有状态数据必须主动管理。4.1 三种挂载方式匿名卷、命名卷、bind mountdocker run -v /data会创建一个匿名卷但容器删掉后匿名卷还存在只是很难再和业务对应起来容易变成僵尸卷。我推荐至少使用命名卷docker volume create pgdata docker run -d -v pgdata:/var/lib/postgresql/data postgres:16命名卷放在/var/lib/docker/volumes下容器可以换数据始终在卷里。备份时可以docker run --rm -v pgdata:/data -v $(pwd):/backup alpine tar czf /backup/pgdata.tar.gz /data直接在数据层做封存。还有一种 bind mount把宿主机目录直接映射进容器比如开发时热加载源码、把宿主机日志目录挂给容器。好处是看得见、改得方便坏处是宿主机目录的权限问题容易引发容器内读取异常。我的原则是开发调试用 bind mount生产数据用命名卷。4.2 卷权限问题postgres 起不来的常见元凶一个非常典型的问题docker run postgres直接报权限错误因为 postgres 镜像默认用postgres用户写数据目录而 bind mount 目录的所有权可能是 root。解决办法不是chmod 777这是给权限问题打补丁启动后还有隐患而是在创建卷时指定正确的属主或者用支持 user namespace remap 的方式。排查时先看日志报 permission denied 就检查目录属主和 SELinux/AppArmor 策略。4.3 网络模型不止 bridge容器之间怎么通信默认的 bridge 网络里容器之间通过 IP 可以互通但 IP 会变化。想让容器通过服务名稳定访问最好的办法是创建自定义网络docker network create app-net docker run -d --name web --network app-net nginx docker run -d --name api --network app-net my-api在自定义网络里api容器可以直接用web这个名字访问web容器的端口Docker 内置 DNS 做了服务名解析。这个特性在 compose 项目里尤其重要。host 模式适合对网络性能极度敏感的场景比如部分网络转发服务但缺点很明显没有隔离网络栈不能用-p做端口映射。none 模式则完全没有网络适合拿来跑一些只处理本机文件的临时任务。生产环境默认还是优先自定义 bridge。4.4 网络排查三板斧遇到容器之间访问不通我一般按这个顺序排查docker network inspect app-net确认两个容器确实在同一网络进入任意容器docker exec -it api sh用getent hosts web看能不能解析到服务名确认目标容器监听端口、防火墙规则容器内和宿主机都要看。很多网络不通最后都栽在第 1 步容器明明在同一宿主机但一个在自定义网络、一个在默认 bridgeIP 互通规则完全不同。先查网络归属比盲改防火墙靠谱。5. Compose 把一堆 docker run收拢成一份声明式配置但细节决定成败当服务数量超过五六个逐个敲docker run已经不可接受了。Compose 用一份 YAML 文件把镜像、端口、卷、网络、环境变量全部定义好一条docker compose up -d全部拉起。但声明式不代表自动正确细节仍然要抠。5.1 文件骨架与常用指令一个最简骨架services: web: image: nginx:1.25 ports: - 8080:80 networks: - app-net volumes: - ./html:/usr/share/nginx/html:ro depends_on: - api api: image: my-api:1.0.0 environment: - DB_HOSTdb networks: - app-net networks: app-net: {} volumes: dbdata: {}注意我在这里加了一个networks段。很多初学者的 compose 文件根本没定义网络所有服务会被塞进默认网络服务名解析在现代版本里依然可用但显式定义网络后网络参数、IP 规划、通行策略都更可控我是建议从一开始就写明白的。5.2 depends_on 不等于服务已经就绪健康检查才是depends_on只是控制启动顺序它并不能保证依赖服务已经可接受流量。最经典的坑api 容器启动时连接数据库数据库容器虽然启动了但 PostgreSQL 初始化还没完成api 重试几次失败就崩溃退出了。解决办法是加健康检查services: db: image: postgres:16 healthcheck: test: [CMD-SHELL, pg_isready -U postgres] interval: 5s timeout: 5s retries: 10 start_period: 10s api: image: my-api:1.0.0 depends_on: db: condition: service_healthy这一条配置能救很多人的命。start_period更是关键它给容器一段启动宽限期期间健康检查失败不计入 retries避免数据库还在高负荷启动就误判不健康。5.3 环境变量与配置文件分离Compose 写死环境变量是安全反模式。正确做法是用${VAR:-default}语法引用宿主机环境变量或者用env_file加载独立的变量文件并把这个文件加入版本忽略列表不提交到代码仓库。我的习惯compose.yaml声明结构不含密钥.env本地方便覆盖的变量如端口secrets/敏感信息单独管理生产环境走密钥服务。docker compose config这个命令很多人不用但它能把你写的 YAML 插值解析成最终配置调试变量覆盖问题一流。6. 生产环境里最容易翻车的五个场景我把排查链路写出来了标题里那句拿捏的死死的最终要落到生产环境中能冷静处理问题。下面五个翻车场景是按出镜频率排的每一个我都真实见过或者踩过。6.1 容器启动后立即退出现象docker ps -a显示容器状态Exited (1)。排查链路docker logs 容器名看最后几条日志。大多数时候错误信息已经写出来了检查启动命令是否是前台运行。很多基础镜像需要nginx -g daemon off;如果你通过docker run ... nginx直接跑默认 CMD它可能进入了后台模式然后立即退出检查 ENTRYPOINT/CMD 拼接是否正确命令路径是否存在、权限是否够若日志为空尝试临时覆盖entrypoint: [/bin/sh]进容器检查环境变量和挂载目录。这个场景八成的根因是没有前台运行。6.2 端口冲突与诡异访问不通现象docker run -p 8080:80报 bind: address already in use或者容器起来了但访问不了。排查链路ss -lntp | grep 8080查宿主机端口占用被其他进程占着就换端口docker port 容器名查看实际映射有些编排工具会改写端口规则如果外部访问不通但容器内curl 127.0.0.1:80正常检查宿主机的防火墙和云安全组规则。这个属于把 Docker 跑通了但网络策略没跟上属于高频遗漏点。6.3 日志文件撑爆磁盘现象磁盘告警看du发现/var/lib/docker/containers/*/*-json.log堆到几十 GB。根因默认json-file日志驱动不设轮转容器标准输出持续堆积。解决{ log-driver: json-file, log-opts: { max-size: 10m, max-file: 5 } }改完 daemon.json 重启后对存量容器需要重建才会生效。更严重的设计问题是大量应用把日志打到 stdout没有结构化日志链路我建议在 daemon 层面做好轮转兜底应用层面再按规范输出到指定文件或直接送日志中心两层都不能省。6.4 时区与编码不对现象容器内date的时间和宿主机差了 8 小时或者日志中文乱码。排查链路基础镜像默认 UTC 时区设置环境变量TZAsia/Shanghai是最省事的方式涉及 Java 应用注意user.timezone之类的参数覆盖编码问题通常与缺失 locale 有关Alpine 镜像里尽量补musl-locales或改用 debian slim 系不要盲目去挂载宿主机的/etc/localtime在容器里挂宿主机文件会引入意外耦合优先用环境变量解决。6.5 docker system prune 不能无脑跑docker system prune -a会清理所有未使用的镜像和停止的容器看起来是磁盘救星但它会连带删除所有未被容器使用的命名卷吗新版本里默认不会删卷但如果你手滑加了--volumes或用了某些图形工具的一键清理后果可能是数据库持久化数据全没了。我的建议生产环境的清理操作必须单独写命令。只清理悬空镜像用docker image prune清理停止容器用docker container prune卷清理必须逐个人工确认。宁可磁盘多占用一点也不要拿数据开玩笑。7. 从会用命令到拿捏原理namespace 和 cgroup 的关键认知到这一步Docker 的命令、文件、网络、Compose 都通了再往前走一步就是理解它凭什么能隔离。这部分不用深入内核源码只需要建立两个关键认知。7.1 namespace让进程以为自己是唯一Linux namespace 是容器隔离的基础。进程创建时Docker 会为它分配新的 namespacePID namespace容器里的进程看到自己是 PID 1看不到宿主机其他进程Network namespace容器有独立的网卡、路由表、防火墙规则Mount namespace容器只能看到自己挂载的文件系统视图UTS namespace容器有自己的 hostnameIPC namespace隔离进程间通信资源。类比一下镜像分层是文件系统视图的叠加namespace 是进程视图的隔离。容器不是另一个机器而是同一台 Linux 里一个自成体系的进程组。7.2 cgroup限制和统计资源光隔离还不够还要防一个容器把整台机器吃垮这就是 cgroup 的职责。--memory512m --cpus1最终就是写入 cgroup 对应的控制文件里由内核强制生效。理解 cgroup 对排查有很大帮助容器内存超限被 OOM kill 时dmesg里能看到 cgroup 相关记录磁盘统计异常时/sys/fs/cgroup下的数据比 docker stats 更底细。遇到资源问题不要只会docker stats切换到底层视角看往往能更准确判断是容器内问题还是宿主机竞争。7.3 安全边界容器不等于安全堡垒共享内核意味着容器逃逸一旦发生危害范围就是整个宿主机。所以生产环境我会建议做几件事用USER指定非 root 运行避免容器内 root 直接对应宿主机 root 权限按需关闭容器 Capabilities比如--cap-drop ALL --cap-add NET_BIND_SERVICE文件系统设为只读--read-only必要时再挂 tmpfs 到/tmp镜像里不要留调试工具和生产私钥。这些点不是危言耸听而是在真实环境里反复验证过的底线。Docker 的便利性容易让人忽略它仍然是共享内核的进程隔离把权限收得越紧出事的概率就越低。这篇文章写到这里基本把 Docker 从镜像模型、命令执行、Dockerfile 构建、数据网络、Compose 编排到生产排错和底层原理都过了一遍。我在实际使用中一个很深的体会是学 Docker 不需要把每个命令都背下来真正要建立的是三个模型——镜像分层的存储模型、容器即进程的隔离模型、声明式配置的编排模型。这三个模型在脑子里立住了遇到没见过的问题也能顺着日志和原理推出答案。最后分享一个小技巧日常调试时可以设置全局的环境别名比如把docker ps别名成dps把docker compose logs -f --tail 200别名成dcl。工具效率提升不大但调试时少敲几个字符心情确实会好一点。希望这篇长总结能帮你把 Docker 这一块真正拿捏住。
阅读完成 · 觉得有帮助?
咨询建站