1. 从一次容器化改造的翻车现场说起去年帮一个朋友处理他们团队的一个遗留系统那套东西原本跑在一台物理服务器上部署文档写了整整三页纸从装运行时到配环境变量再到改配置文件每一步都靠人肉执行。他们想用容器化把这套流程固化下来结果第一次尝试就翻车了——镜像构建出来1.8个G启动之后日志里全是找不到配置文件的报错最离谱的是数据库连接串居然指向了构建机器上的本地地址。这个场景我见过太多次了。很多人对Docker的理解停留在把应用打包成镜像然后跑起来这个层面但真正动手的时候才发现镜像分层、存储驱动、网络模式、数据卷挂载这些概念一个都绕不开。你如果只是照着教程敲几条命令遇到问题根本不知道怎么排查。所以这篇内容我想把Docker的核心概念和基本组成彻底讲清楚。不是那种教科书式的概念罗列而是从实际使用中会遇到的问题出发把每个概念背后的设计意图和实际影响讲透。不管你是刚接触容器的新手还是已经用过一段时间但总觉得理解不够深入的开发者应该都能从中找到对自己有用的东西。2. 镜像与容器一对容易混淆的孪生概念2.1 镜像不是安装包容器也不是运行中的程序很多人第一次接触Docker的时候会下意识地把镜像理解成安装包把容器理解成安装之后运行起来的软件。这个类比在最初几分钟里勉强能用但很快就会把你带偏。镜像的本质是一个只读的、分层的文件系统快照加上一些描述运行时行为的元数据。它里面包含的不仅仅是你的应用程序还有操作系统的基础文件、运行时环境、依赖库、环境变量、启动命令等等。你可以把它想象成一栋精装修房子的完整设计图纸加上所有建材的清单——它描述了房子应该长什么样但它本身不是房子。容器则是镜像的一个运行实例。当你执行docker run的时候Docker会在镜像的只读层之上叠加一个可写层然后启动一个隔离的进程空间让里面的进程以为自己在一台独立的机器上。这个可写层是临时的容器销毁之后就没了。所以容器更像是在那栋设计图纸的基础上真正盖起来的一栋房子你可以在里面住、可以改动内部装修但房子拆了之后你的改动也就消失了。这个区别在实际操作中非常关键。我见过有同学在容器里装了一堆工具、改了一堆配置然后docker commit保存成新镜像结果镜像体积膨胀了好几倍而且完全无法追溯到底改了什么。正确的做法应该是把这些改动写进Dockerfile重新构建镜像。2.2 分层机制为什么你的镜像越建越大镜像分层是Docker最核心的设计之一但也是最容易被忽视的。每一条Dockerfile指令除了少数几条都会创建一个新的镜像层每一层都是上一层的增量修改。这个机制带来的直接好处是层可以被复用和缓存。比如你有十个微服务都基于同一个基础镜像那这个基础镜像在磁盘上只存一份在拉取的时候也只拉一次。但如果你不注意Dockerfile的写法就会导致缓存频繁失效构建时间越来越长镜像体积越来越大。我拿一个真实的例子来说明。假设你有一个Node.js项目的Dockerfile是这样写的FROM node:18 WORKDIR /app COPY . . RUN npm install CMD [node, server.js]这个写法的问题在于每次你修改任何源代码文件COPY . .这一层的缓存就会失效紧接着npm install也要重新执行一遍。哪怕你只是改了一个注释也要重新下载所有依赖。正确的做法是把依赖安装和源代码拷贝分开FROM node:18 WORKDIR /app COPY package.json package-lock.json ./ RUN npm install COPY . . CMD [node, server.js]这样只要你没有修改package.json或package-lock.jsonnpm install这一层就会一直命中缓存。这个优化在依赖安装耗时较长的项目里效果非常明显构建时间可以从几分钟缩短到几秒钟。还有一个常见的坑是清理动作必须和产生垃圾的动作在同一层。比如你在某一层下载了一个压缩包并解压然后在下一层删除了压缩包这个压缩包实际上仍然存在于镜像中因为每一层都是只读的删除操作只是在新的层里标记了一下。正确的做法是在同一条RUN指令里完成下载、解压和清理RUN curl -o /tmp/tool.tar.gz https://example.com/tool.tar.gz \ tar -xzf /tmp/tool.tar.gz -C /usr/local/bin \ rm /tmp/tool.tar.gz2.3 容器生命周期从创建到销毁的完整链路容器的生命周期比大多数人想象的要复杂。一个容器从创建到最终被清理会经历多个状态created、running、paused、stopped、exited。每个状态之间的转换都有对应的命令和实际意义。当你执行docker run的时候Docker实际上做了这么几件事首先检查本地有没有指定的镜像没有的话从仓库拉取然后创建一个可写层挂载到镜像的只读层之上接着创建一套隔离的命名空间namespace和控制组cgroup最后启动容器内指定的进程。这里有一个非常重要的细节容器的主进程就是容器本身。如果主进程退出了容器就进入了exited状态。很多人写Dockerfile的时候用CMD service nginx start这种写法结果容器启动之后立刻就退出了。原因是service nginx start这个命令执行完就结束了它并不是一个持续运行的进程。正确的写法应该是CMD [nginx, -g, daemon off;]让nginx在前台运行。这个知识点在实际操作中极其重要。我见过太多人因为这个问题卡住明明容器启动命令没报错但docker ps就是看不到运行中的容器用docker ps -a才发现状态是exited。这时候用docker logs看一下日志通常就能发现主进程已经执行完毕退出了。3. 存储与网络容器化落地的两大拦路虎3.1 数据卷让数据活过容器的生命周期容器里的可写层是临时的这个特性对于无状态服务来说没问题但对于数据库、消息队列、文件存储这类有状态服务来说就是灾难。你肯定不希望每次重启容器之后数据库里的数据就全没了。Docker提供了三种方式来持久化数据绑定挂载bind mount、数据卷volume、临时文件系统tmpfs mount。这三种方式各有适用场景选错了会带来很多麻烦。绑定挂载是把宿主机的某个目录直接映射到容器里。它的优点是直观你在宿主机上能直接看到和修改文件。但缺点也很明显依赖宿主机的目录结构可移植性差权限问题经常让人头疼特别是容器内进程以非root用户运行时经常会遇到写不进去的情况。数据卷是Docker管理的存储区域默认位于宿主机的/var/lib/docker/volumes/目录下。它的优点是Docker完全接管了生命周期管理你不需要关心具体存在哪里可移植性好在不同环境之间迁移更方便。缺点是你在宿主机上不能直接方便地访问这些文件需要通过docker volume inspect找到具体路径。我的建议是生产环境优先用数据卷开发环境可以用绑定挂载方便调试。数据库这类对I/O要求高的服务如果条件允许最好还是用绑定挂载到高性能的存储设备上或者直接使用云服务商提供的块存储。还有一个容易踩的坑是数据卷的权限问题。比如你运行一个MySQL容器数据卷挂载到/var/lib/mysql但容器内的MySQL进程是以mysql用户运行的如果数据卷的属主是rootMySQL就写不进去。解决办法是在启动容器之前先调整好宿主机目录的权限或者在Dockerfile里提前创建好对应的用户和目录。3.2 网络模式容器之间怎么互相说话Docker默认会为每个容器分配一个独立的网络命名空间这意味着容器有自己的网络栈、IP地址、路由表。但容器之间怎么通信、容器怎么访问外网、外网怎么访问容器这些问题就需要通过网络模式来解决了。Docker提供了几种网络模式最常用的有bridge、host、none和container。默认情况下容器使用bridge模式Docker会创建一个虚拟网桥所有容器都连接到这个网桥上通过IP地址互相通信。但这里有一个非常关键的问题默认bridge网络下的容器只能通过IP互相访问不能通过容器名解析。这意味着你没法在代码里写db:3306这样的地址只能写具体的IP。而容器的IP在每次重启后都可能变化这显然不可接受。解决办法是创建自定义bridge网络。在自定义网络里Docker内置了DNS服务容器之间可以直接用容器名或者网络别名互相访问。这个特性在实际项目中非常实用你可以在docker-compose.yml里定义服务名然后各个服务之间直接用服务名通信。version: 3 services: web: build: . ports: - 8080:8080 depends_on: - db db: image: postgres:15 environment: POSTGRES_PASSWORD: example在这个配置里web服务可以直接用db作为主机名来连接数据库不需要关心db容器的具体IP是什么。host模式则是让容器直接使用宿主机的网络栈容器里的进程看到的网络和宿主机完全一样。这种模式性能最好因为没有NAT转换的开销但缺点是端口冲突问题严重而且失去了网络隔离带来的安全性。通常只在性能敏感的场景下使用比如某些高频交易系统或者网络性能测试工具。3.3 端口映射的底层逻辑docker run -p 8080:80这条命令看起来很简单但背后的机制值得说清楚。它的作用是把宿主机的8080端口映射到容器的80端口外部访问宿主机的8080端口时流量会被转发到容器的80端口。这个转发是通过iptables的DNAT规则实现的。当你指定了端口映射Docker会在宿主机的iptables里添加一条规则把目标端口是8080的包的目标地址改成容器的IP和80端口。这个过程对用户是透明的但理解它有助于排查网络问题。一个常见的坑是端口映射只监听在特定网卡上。默认情况下-p 8080:80会监听在所有网卡上0.0.0.0这意味着同一网络中的其他机器也能访问。如果你只想让本机访问应该写成-p 127.0.0.1:8080:80。这个细节在安全敏感的环境里非常重要我见过因为没注意这个配置导致内部服务意外暴露的案例。另外端口映射和防火墙的关系也经常让人困惑。即使你在宿主机上配置了防火墙规则Docker的iptables规则可能会绕过这些规则。因为Docker直接操作iptables的nat表和filter表它的规则优先级通常高于你手动添加的规则。如果你发现防火墙规则不生效大概率是这个原因。4. Dockerfile把部署知识固化成代码4.1 基础镜像选择别一上来就用ubuntu很多人写Dockerfile的时候第一行就写FROM ubuntu:latest然后开始apt-get install一堆东西。这个做法不是不行但通常不是最优解。基础镜像的选择直接决定了最终镜像的体积、安全性和构建速度。Ubuntu基础镜像大概70多MB加上你安装的各种依赖很容易就超过500MB。而如果选择Alpine作为基础镜像基础体积只有5MB左右即使加上依赖也通常能控制在100MB以内。但Alpine也不是银弹。它使用的是musl libc而不是glibc某些依赖glibc的二进制程序可能无法直接运行。而且Alpine的包管理器和Ubuntu不同有些软件包的名称和版本也不一样。如果你的应用对glibc有强依赖或者需要用到某些特定的系统库可能还是得用Debian或Ubuntu作为基础镜像。我的经验法则是优先选择官方提供的语言运行时镜像比如node:18-alpine、python:3.11-slim、openjdk:17-slim。这些镜像已经帮你做好了基础优化体积和兼容性之间取得了比较好的平衡。只有在官方镜像不满足需求的时候才考虑自己从更基础的系统镜像开始构建。还有一个细节是镜像标签的选择。尽量不要用latest标签因为它会随着时间变化今天构建的镜像和明天构建的镜像可能基于不同的基础版本。应该使用具体的版本号比如node:18.17.0-alpine3.18这样可以保证构建的可重复性。4.2 多阶段构建把编译环境和运行环境分开多阶段构建是我认为Dockerfile里最实用的特性之一。它的核心思想是在一个Dockerfile里定义多个构建阶段前面的阶段用于编译和打包后面的阶段只拷贝编译产物从而大幅减小最终镜像的体积。以一个Go项目为例如果不使用多阶段构建你的Dockerfile可能是这样的FROM golang:1.21 WORKDIR /app COPY . . RUN go build -o server . CMD [./server]这个镜像里包含了完整的Go工具链体积大概在800MB以上。但实际上运行时只需要那个编译好的二进制文件根本不需要Go编译器。使用多阶段构建之后FROM golang:1.21 AS builder WORKDIR /app COPY . . RUN CGO_ENABLED0 go build -o server . FROM alpine:3.18 COPY --frombuilder /app/server /usr/local/bin/server CMD [server]最终镜像只有十几MB因为里面只有编译好的二进制文件和Alpine的基础系统。这个优化效果非常显著特别是在需要频繁拉取镜像的场景下能节省大量的网络传输时间和存储空间。多阶段构建还有一个好处是减少了攻击面。最终镜像里没有编译器、没有包管理器、没有源代码攻击者即使拿到了容器权限能利用的工具也非常有限。4.3 构建缓存让重复构建快起来Docker的构建缓存机制是基于层来工作的。如果某一层的指令和上下文没有变化Docker就会直接使用缓存跳过重新执行。理解这个机制对于优化构建速度至关重要。缓存的失效规则是一旦某一层的缓存失效它之后的所有层都会失效。所以Dockerfile里指令的顺序非常重要。变化频率低的指令应该放在前面变化频率高的指令应该放在后面。除了前面提到的依赖安装和源代码拷贝要分开之外还有一些细节值得注意。比如RUN apt-get update和RUN apt-get install最好写在同一条指令里否则可能会出现缓存了update的结果但install时软件源已经变化的情况导致安装失败。RUN apt-get update apt-get install -y \ curl \ git \ rm -rf /var/lib/apt/lists/*这里把update、install和清理放在同一条RUN指令里既保证了缓存的正确性又避免了在镜像里留下不必要的包列表文件。还有一个技巧是使用.dockerignore文件。默认情况下COPY . .会把构建上下文中的所有文件都发送给Docker守护进程包括.git目录、node_modules、日志文件等等。这些文件不仅会拖慢构建速度还可能导致缓存频繁失效。在.dockerignore里排除掉这些不需要的文件能明显提升构建效率。5. 那些文档里不会写的实操经验5.1 容器时区问题为什么日志时间总是差8小时这个问题几乎每个在国内做容器化的人都会遇到。容器默认使用UTC时区而我们的业务系统通常需要东八区时间。结果就是日志里的时间戳总是比实际时间少8小时排查问题时非常容易产生误导。解决办法有几种。最简单的是在运行容器时通过环境变量指定时区docker run -e TZAsia/Shanghai myapp但这种方式依赖于应用本身支持通过TZ环境变量来设置时区。有些应用不认这个变量那就需要在Dockerfile里直接修改系统时区RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone需要注意的是Alpine镜像里默认没有tzdata包需要先安装RUN apk add --no-cache tzdata \ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone \ apk del tzdata这里安装tzdata之后又删除了它是为了减小镜像体积。时区文件已经拷贝到/etc/localtime了tzdata包本身就不需要了。5.2 容器内进程的PID 1问题在Linux系统里PID为1的进程有特殊的地位它负责回收孤儿进程、处理信号等。在容器里你的主进程就是PID 1但大多数应用程序并没有设计成以PID 1的身份运行这会导致两个典型问题。第一个问题是信号处理。当你执行docker stop的时候Docker会向容器内的PID 1进程发送SIGTERM信号等待一段时间后如果进程还没退出就发送SIGKILL。如果你的应用没有正确处理SIGTERM就会导致容器需要等待超时才能停止拖慢部署速度。第二个问题是僵尸进程回收。如果容器内有进程创建了子进程但自己没有回收这些子进程就会变成僵尸进程。在正常的Linux系统里init进程会负责回收这些僵尸进程但容器里的PID 1如果不做这件事僵尸进程就会一直堆积。解决办法是使用--init参数Docker会注入一个轻量级的init进程作为PID 1你的应用进程则作为它的子进程运行。这个init进程会正确处理信号转发和僵尸进程回收。docker run --init myapp或者在docker-compose.yml里配置services: web: build: . init: true这个参数我建议默认加上成本几乎为零但能避免很多奇怪的问题。5.3 镜像体积的排查与优化当你发现镜像体积异常大的时候怎么排查是哪一层的问题docker history命令可以列出镜像的每一层及其大小docker history myapp:latest这个命令会显示每一层对应的Dockerfile指令和该层的大小。如果发现某一层特别大就重点检查那条指令做了什么。常见的体积问题来源包括在镜像里留下了下载的压缩包、安装了不必要的开发工具、缓存文件没有清理、把构建上下文里的大文件拷贝进去了。针对这些问题对应的优化手段就是前面提到的同层清理、多阶段构建、使用.dockerignore等。还有一个工具叫dive可以交互式地分析镜像的每一层查看每层里具体有哪些文件、哪些文件被删除了但仍然占用空间。这个工具在排查复杂的镜像体积问题时非常有用。5.4 容器日志的收集与轮转Docker默认会把容器的标准输出和标准错误收集起来存储在宿主机的/var/lib/docker/containers/目录下。如果不做限制这些日志文件会一直增长最终把磁盘撑满。配置日志轮转的方式是在启动容器时指定日志驱动和选项docker run --log-opt max-size10m --log-opt max-file3 myapp这样每个日志文件最大10MB最多保留3个总共不超过30MB。对于大多数应用来说这个配置是够用的。如果需要对日志进行更复杂的处理比如发送到集中式日志系统可以考虑使用fluentd或journald等日志驱动。在docker-compose.yml里也可以全局配置services: web: build: . logging: driver: json-file options: max-size: 10m max-file: 3这个配置我建议在所有生产环境的容器上都加上否则迟早会遇到磁盘被日志写满的问题。6. 从单容器到多容器编排的起点当你开始把应用拆分成多个容器的时候就会遇到容器之间如何协调的问题。比如web容器需要在db容器启动之后再启动需要在db容器就绪之后再连接。这些依赖关系如果靠手动管理很快就会变得不可维护。docker-compose是Docker官方提供的单机编排工具它通过一个YAML文件来定义多个容器之间的关系。虽然现在Kubernetes是更主流的选择但在开发和测试环境里docker-compose的简洁性仍然是无可替代的。一个典型的docker-compose.yml结构包括服务定义、网络配置、数据卷配置和环境变量管理。服务之间可以通过服务名互相访问depends_on可以控制启动顺序healthcheck可以定义健康检查规则。version: 3.8 services: web: build: . ports: - 8080:8080 environment: - DATABASE_URLpostgres://user:passdb:5432/mydb depends_on: db: condition: service_healthy db: image: postgres:15 environment: - POSTGRES_USERuser - POSTGRES_PASSWORDpass - POSTGRES_DBmydb volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U user] interval: 5s timeout: 5s retries: 5 volumes: pgdata:这里depends_on配合condition: service_healthy的写法确保了web容器在db容器健康检查通过之后才会启动。这个细节在实际使用中非常重要否则web容器可能会在db还没准备好的时候就尝试连接导致启动失败。从单容器到多容器编排本质上是从怎么把应用跑起来到怎么让多个应用协调工作的思维转变。这个转变过程中网络配置、数据共享、启动顺序、健康检查这些概念会变得越来越重要。把docker-compose用熟之后再迁移到Kubernetes会顺畅很多因为很多概念是相通的。我在实际使用中的体会是Docker的学习曲线在前半段比较陡因为概念多且相互关联但一旦把镜像、容器、网络、存储这四个核心概念理解透了后面的事情就顺理成章了。最怕的是一知半解就开始用遇到问题只会搜命令不知道背后的原理那样踩的坑会越来越多。
阅读完成 · 觉得有帮助?