Docker入门实战:从镜像到容器,一篇搞懂核心操作与避坑指南
1. 为什么我劝你尽早把Docker用起来刚接触容器化那会儿我也觉得这东西离自己挺远——不就是个轻量级虚拟机吗我本地跑得好好的为什么要多套一层直到有一次帮朋友处理一个线上环境的问题同一个服务在开发机上跑得飞起部署到服务器上就各种报错排查了大半天才发现是系统里某个依赖库的版本对不上。那一刻我才真正意识到环境不一致这件事消耗的时间远比学Docker本身要多得多。Docker解决的核心问题其实就一句话把应用和它依赖的运行环境打包在一起让它在任何地方都能以同样的方式跑起来。你写好的服务、装好的依赖、配好的环境变量全部封装进一个镜像里换台机器直接拉起来就能用不用再对着“在我电脑上明明是好的”这句话干瞪眼。这篇笔记面向的是完全没碰过Docker、或者只听过名字但没实际动过手的朋友。我会从最基础的概念讲起把安装、镜像、容器、数据卷、网络、Dockerfile这些核心环节全部走一遍每个命令都配上我实际踩过的坑和验证过的参数。看完之后你至少能做到自己写一个Dockerfile把项目打包成镜像用容器跑起来数据能持久化多个容器之间能互相通信。这些就是日常开发里最高频的操作剩下的都是在这个基础上的延伸。我个人的习惯是学任何工具都先把它跑起来再回头理解原理。所以下面不会一上来堆概念而是边操作边解释你跟着敲一遍自然就懂了。2. 先把地基打牢核心概念一次讲透2.1 镜像、容器、仓库到底是什么关系很多人第一次接触Docker会被这几个词绕晕我用一个生活化的类比帮你理清楚。镜像Image就像是一张光盘或者一个安装包它是只读的、静态的。里面包含了运行某个程序所需要的一切代码、运行时、系统工具、库文件、配置文件。镜像本身不会运行它只是一个模板。容器Container是镜像运行起来的实例。你把光盘放进播放器里播放光盘是镜像正在播放的这个过程就是容器。容器是可读写的你可以在里面装东西、改配置但这些改动默认只存在于这个容器里不会影响镜像本身。仓库Registry是存放镜像的地方最常用的公共仓库就是Docker Hub。你可以把它理解成一个应用商店里面有几百万个别人做好的镜像你直接拉下来就能用。你也可以把自己的镜像推上去方便在其他机器上拉取。这三者的关系用一句话概括从仓库拉镜像用镜像起容器。日常操作基本就是围绕这条链路展开的。注意容器里的改动如果不做特殊处理容器一删就全没了。这一点新手特别容易踩坑后面讲数据卷的时候会详细说怎么解决。2.2 和虚拟机到底差在哪这是被问得最多的问题。我用一张表把关键差异列出来你一看就明白。对比维度虚拟机Docker容器虚拟化层级硬件级虚拟出完整的操作系统操作系统级共享宿主机内核启动速度通常几十秒到几分钟通常几百毫秒到几秒资源占用每个虚拟机需要独立的操作系统占用大共享内核占用小隔离性强隔离安全性更高进程级隔离相对弱一些镜像大小通常几个GB起步可以做到几十MB适用场景需要完整操作系统隔离的场景微服务、快速部署、开发环境统一关键差异在于共享内核这四个字。虚拟机是在硬件层面虚拟出一整套硬件然后在上面的操作系统里跑应用容器是直接跑在宿主机的内核上通过命名空间和控制组做隔离。所以容器启动快、占用小但隔离性不如虚拟机彻底。实际工作中两者并不是替代关系。很多团队的做法是底层用虚拟机做资源池上层用容器跑服务。你不需要纠结选哪个先把容器用起来体会到它的便利之后自然就知道什么场景该用什么了。2.3 安装环节的几个关键选择安装本身不复杂但有几个点值得提前说清楚免得你装完了发现不对劲。Linux上的安装官方提供了一键脚本但我不建议直接跑脚本。原因是你不知道它到底改了哪些配置出了问题不好排查。我更推荐用包管理器一步步来以常见的发行版为例大致流程是先卸载旧版本然后添加官方软件源再安装指定版本。具体命令各发行版略有差异核心思路就是通过包管理器安装方便后续升级和卸载。Windows和macOS上的安装直接下载桌面版客户端就行。它会帮你把底层环境都配好开箱即用。但有一点要注意桌面版默认使用的资源是有限的如果你要跑比较吃内存的服务记得在设置里调整CPU和内存的分配。安装完成后用下面这条命令验证docker version如果能看到客户端和服务端的版本信息说明安装成功。如果只显示客户端信息提示连不上服务端那多半是服务没启动检查一下服务状态即可。提示安装完成后建议把当前用户加入docker用户组这样就不用每条命令都加sudo了。具体操作是把自己加到docker组里然后重新登录一次让权限生效。这一步能省掉很多日常操作的麻烦。3. 镜像操作从拉取到管理3.1 拉取镜像的正确姿势拉取镜像用docker pull命令格式是docker pull 镜像名:标签。标签不写的话默认是latest但我强烈建议你永远显式指定标签。为什么因为latest是一个浮动标签今天拉到的和明天拉到的可能不是同一个版本。生产环境里用浮动标签等于给自己埋雷。我一般会指定具体的版本号比如nginx:1.25这种确保每次拉到的都是同一个东西。docker pull nginx:1.25拉取的过程会分层下载每一层下载完会显示Pull complete。这里有个细节镜像是由多层组成的相同的基础层在不同镜像之间是共享的。所以你拉第二个基于同样基础镜像的镜像时那些已经存在的层不会重复下载速度会快很多。如果你觉得从公共仓库拉取速度慢可以配置镜像加速器。各大云服务商都提供加速地址配置方法是在Docker的配置文件里加上registry-mirrors字段然后重启服务。这个配置是一次性的配好之后所有拉取操作都会走加速。3.2 查看和管理本地镜像拉下来的镜像存在本地用下面这条命令查看docker images输出会包含仓库名、标签、镜像ID、创建时间和大小。镜像ID是一串十六进制字符你在操作镜像时可以用ID的前几位来指代不用写全。删除镜像用docker rmi后面跟镜像ID或者镜像名:标签。如果这个镜像正在被某个容器使用删除会失败。这时候你有两个选择先删掉依赖它的容器或者加-f参数强制删除。我建议用前者强制删除容易留下悬空镜像时间长了占空间。说到悬空镜像那些没有标签、显示为none的镜像就是。它们通常是构建过程中产生的中间层或者被新版本覆盖后留下的旧层。清理它们用docker image prune这个命令会删除所有悬空镜像。如果想连没有被任何容器使用的镜像一起清理加-a参数。但用-a之前想清楚别把还需要用的镜像误删了。3.3 镜像的导入导出有时候你需要把镜像从一台机器搬到另一台机器而两台机器之间网络不通或者目标机器不方便从仓库拉取。这时候可以用导入导出的方式。导出用docker savedocker save -o nginx.tar nginx:1.25这会把镜像保存成一个tar文件。传到目标机器后用docker load导入docker load -i nginx.tar导入后镜像就出现在本地镜像列表里了和从仓库拉下来的一模一样。注意docker save和docker export是两回事。前者导出的是镜像包含所有层和元数据后者导出的是容器的文件系统快照会丢失镜像的历史层信息。日常搬运用save和load就够了。4. 容器操作让镜像真正跑起来4.1 启动容器的常用参数启动容器用docker run这是最核心也最复杂的命令。我把它拆成几个部分来讲。最基本的用法是docker run 镜像名这会启动一个容器并执行镜像里定义的默认命令。但实际使用中我们几乎总是要加各种参数。下面这几个是我用得最多的docker run -d --name my-nginx -p 8080:80 nginx:1.25逐个解释-d表示后台运行不加的话容器会在前台运行占着你的终端--name给容器起个名字方便后续操作不起名字的话Docker会随机分配一个-p 8080:80把宿主机的8080端口映射到容器的80端口这样你访问宿主机的8080就能访问到容器里的服务。端口映射的格式是宿主机端口:容器端口别搞反了。我见过有人写成80:8080然后纳闷为什么访问不了。还有一个常用参数是-it通常一起用表示分配一个交互式终端。比如你想进到容器里看看docker run -it --name my-shell ubuntu:22.04 /bin/bash这会启动一个ubuntu容器并直接进入bash。退出的时候输入exit容器就停止了。4.2 容器的生命周期管理容器有几种状态运行中、已停止、已暂停。对应的操作命令如下操作命令说明启动docker start 容器名启动已停止的容器停止docker stop 容器名优雅停止给进程发送信号强制停止docker kill 容器名直接杀掉不给缓冲时间重启docker restart 容器名先停再启暂停docker pause 容器名冻结进程恢复docker unpause 容器名解冻进程删除docker rm 容器名删除已停止的容器stop和kill的区别值得说一下。stop会先给容器里的主进程发送一个终止信号让它有机会做一些清理工作等待一段时间后才强制杀掉。kill则是直接发强制终止信号。日常操作优先用stop只有在进程卡死、stop没反应的时候才用kill。删除容器时如果容器还在运行需要先停止再删除或者加-f强制删除。批量清理所有已停止的容器docker container prune这个命令会删除所有处于停止状态的容器用之前确认一下没有还需要保留的。4.3 进入容器和查看日志容器跑起来之后你经常需要进去看看情况。有两种方式第一种是docker exec在运行中的容器里执行命令docker exec -it my-nginx /bin/bash这会打开一个新的终端会话你可以在里面操作退出后容器继续运行。这是最常用的方式。第二种是docker attach连接到容器的主进程docker attach my-nginx这种方式有个坑你退出的时候容器的主进程也会收到信号可能导致容器停止。所以除非你明确知道自己在做什么否则优先用exec。查看日志用docker logsdocker logs -f --tail 100 my-nginx-f表示持续输出类似tail -f--tail 100表示只看最后100行。不加--tail的话会把所有日志都打出来如果容器跑了很久日志量会很大刷屏刷到你看不清。提示如果容器的主进程把日志输出到文件而不是标准输出docker logs是看不到的。所以自己构建镜像时尽量让应用把日志打到标准输出和标准错误这样用docker logs就能统一查看。5. 数据持久化别让数据随容器一起消失5.1 为什么需要数据卷前面说过容器里的改动默认只存在于容器内部容器一删就全没了。这对无状态服务来说没问题但对数据库、文件存储这类有状态服务来说就是灾难。数据卷Volume就是解决这个问题的。它把宿主机的某个目录或者Docker管理的某个存储区域挂载到容器里容器往这个目录写数据实际上是写到了宿主机上。容器删了数据还在。Docker里有两种主要的挂载方式数据卷和绑定挂载。数据卷由Docker管理存在/var/lib/docker/volumes/下面你不需要关心具体路径绑定挂载则是把宿主机的任意目录挂到容器里路径由你指定。5.2 数据卷的创建和使用创建数据卷docker volume create my-data查看所有数据卷docker volume ls使用数据卷启动容器docker run -d --name my-db -v my-data:/var/lib/mysql mysql:8.0-v my-data:/var/lib/mysql表示把名为my-data的数据卷挂载到容器的/var/lib/mysql目录。这样MySQL的数据就存在数据卷里了容器删了数据还在。绑定挂载的写法类似只是把数据卷名换成宿主机路径docker run -d --name my-app -v /home/user/app:/app my-app:1.0这会把宿主机的/home/user/app目录挂到容器的/app目录。你在宿主机上改文件容器里立刻能看到开发调试的时候特别方便。注意绑定挂载时如果宿主机目录不存在Docker会自动创建一个空目录。但如果你挂载的是一个文件而不是目录宿主机上这个文件必须已经存在否则Docker会把它当成目录创建导致挂载出错。5.3 数据卷的备份和恢复数据卷本身不方便直接拷贝但可以通过临时容器来操作。备份的思路是启动一个临时容器把数据卷和宿主机的备份目录都挂进去然后在容器里打包。docker run --rm -v my-data:/data -v $(pwd):/backup ubuntu:22.04 tar czf /backup/my-data.tar.gz -C /data .这条命令启动一个ubuntu容器把my-data卷挂到/data把当前目录挂到/backup然后在容器里把/data打包到/backup。--rm表示容器用完就删。恢复的时候反过来docker run --rm -v my-data:/data -v $(pwd):/backup ubuntu:22.04 tar xzf /backup/my-data.tar.gz -C /data这套操作我实际用过好几次换机器迁移数据的时候很管用。关键点是临时容器用完就删不会留下垃圾。6. 网络配置让容器之间能说上话6.1 默认网络模式解析Docker安装后会创建三种默认网络bridge、host和none。bridge是默认模式。每个容器分配一个独立的IP通过Docker创建的虚拟网桥和宿主机通信。容器之间可以通过IP互相访问但默认不能用容器名互相解析。host模式下容器直接使用宿主机的网络栈没有独立的IP。好处是性能好坏处是端口冲突的风险高而且失去了网络隔离。none模式就是不给容器配网络只有回环接口。一般用不到除非你有特殊需求。查看网络列表docker network ls6.2 自定义网络的实战价值默认的bridge网络有个很大的限制容器之间不能用名字互相访问。你只能通过IP但IP是动态分配的容器重启后可能就变了。这在多容器协作的场景下很不方便。解决办法是创建自定义网络docker network create my-net然后在启动容器时指定加入这个网络docker run -d --name app1 --network my-net my-app:1.0 docker run -d --name app2 --network my-net my-app:1.0加入同一个自定义网络的容器可以直接用容器名互相访问。比如app1里访问app2直接用app2:端口就行Docker内置的DNS会帮你解析。这个特性在实际项目中非常实用省去了手动维护IP映射的麻烦。6.3 端口映射的几种场景端口映射的格式前面提过这里补充几种常见场景。映射到指定端口-p 8080:80宿主机的8080对应容器的80。映射到随机端口-p 80只写容器端口宿主机端口由Docker随机分配。用docker port 容器名可以查看实际分配到了哪个端口。映射多个端口-p 8080:80 -p 8443:443需要几个就写几个。限制绑定地址-p 127.0.0.1:8080:80只允许本机访问外部访问不了。这个在开发环境里很有用避免服务被外部意外访问到。提示端口映射是在启动容器时指定的容器启动后不能修改。如果端口写错了只能删掉容器重新创建。所以启动前想清楚端口规划别等跑起来了才发现冲突。7. Dockerfile把自己的项目打包成镜像7.1 Dockerfile的基本结构Dockerfile是一个文本文件里面是一条条的指令每条指令构建一层。Docker按顺序执行这些指令最终生成一个镜像。一个典型的Dockerfile长这样FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD [python, app.py]逐行解释FROM指定基础镜像WORKDIR设置工作目录后续的命令都在这个目录下执行COPY把文件从构建上下文复制到镜像里RUN在构建时执行命令EXPOSE声明容器运行时监听的端口这只是一个声明实际映射还是靠-p参数CMD指定容器启动时执行的命令。7.2 构建指令的细节和坑FROM选择基础镜像时优先选体积小的版本。比如python:3.11-slim就比python:3.11小很多因为去掉了很多不常用的工具。但要注意slim版本可能缺少一些编译工具如果你的依赖需要编译可能得用完整版或者手动装编译工具。COPY和ADD的区别COPY就是单纯复制文件ADD除了复制还支持自动解压tar文件和从URL下载。但官方建议优先用COPY因为ADD的行为不够直观容易出意外。RUN指令的合并每条RUN都会产生一层层数太多会让镜像变大。所以多个命令尽量用连起来写在一行RUN apt-get update apt-get install -y curl rm -rf /var/lib/apt/lists/*这样只产生一层而且清理了apt的缓存镜像会小很多。CMD和ENTRYPOINT的区别CMD提供默认命令如果docker run时指定了命令CMD会被覆盖ENTRYPOINT则是固定的入口docker run后面的参数会作为ENTRYPOINT的参数传进去。简单说CMD容易被覆盖ENTRYPOINT不容易。7.3 构建上下文和.dockerignore执行docker build的时候当前目录或者指定的目录就是构建上下文。Docker会把整个上下文打包发给守护进程所以上下文越小构建越快。如果上下文里有很多不需要的文件比如.git目录、node_modules、日志文件会拖慢构建速度。解决办法是创建一个.dockerignore文件把不需要的文件排除掉.git node_modules *.log __pycache__这个文件的作用类似.gitignore但针对的是Docker构建。我见过有人构建一个简单的Python镜像因为上下文里有个几百MB的数据文件每次构建都要等好几分钟。加上.dockerignore之后几秒钟就完事了。7.4 多阶段构建的实战应用多阶段构建是Dockerfile里非常实用的一个特性特别适合编译型语言。思路是在一个阶段里编译然后把编译产物复制到另一个更小的阶段里运行。以Go为例FROM golang:1.21 AS builder WORKDIR /build COPY . . RUN go build -o myapp . FROM alpine:3.18 WORKDIR /app COPY --frombuilder /build/myapp . CMD [./myapp]第一个阶段用完整的Go环境编译出二进制文件第二个阶段用极小的alpine镜像只把编译好的二进制复制过来。最终镜像可能只有几十MB而如果直接用Go镜像可能有好几百MB。这个技巧在实际项目中非常有用尤其是对镜像体积有要求的时候。我经手的一个服务用多阶段构建把镜像从800MB压到了50MB部署速度提升非常明显。8. 常见问题排查与避坑经验8.1 容器启动失败怎么查容器启动失败是最常见的问题。排查思路分三步第一步看容器状态。docker ps -a能看到所有容器的状态如果是Exited说明启动后很快就退出了。第二步看日志。docker logs 容器名能看到容器的输出大部分启动失败的原因都会在这里显示。比如配置文件写错了、端口被占用了、依赖的服务连不上日志里都会有线索。第三步如果日志里没有有用信息可以覆盖默认命令让容器启动后执行一个不会退出的命令然后进去手动排查docker run -it --entrypoint /bin/bash 镜像名这样容器会停在bash里你可以手动执行原来的启动命令看看到底哪里报错。8.2 端口冲突和网络不通端口冲突的报错信息很明确Bind for 0.0.0.0:8080 failed: port is already allocated。意思是宿主机的8080端口已经被占用了。解决办法是换一个端口或者找到占用端口的进程把它停掉。网络不通的情况稍微复杂一些。如果是从宿主机访问容器服务检查端口映射是否正确docker port 容器名可以查看映射关系。如果是容器之间互相访问检查它们是否在同一个自定义网络里用docker network inspect 网络名可以看到网络里有哪些容器。还有一个容易忽略的点容器里的服务如果只监听127.0.0.1外部是访问不到的。必须监听0.0.0.0才行。这个坑我踩过服务在容器里跑得好好的就是外面访问不了查了半天才发现是监听地址的问题。8.3 磁盘空间被占满Docker用久了磁盘空间会被镜像、容器、数据卷慢慢吃掉。定期清理是个好习惯。查看磁盘占用docker system df这会显示镜像、容器、数据卷、构建缓存各自占了多少空间。清理命令有几个层级docker system prune这个命令会清理停止的容器、没有被使用的网络、悬空镜像和构建缓存。加-a参数会连没有被使用的镜像一起清理。加--volumes会连没有被使用的数据卷一起清理。注意docker system prune --volumes会删除没有被任何容器使用的数据卷。如果你的数据卷是手动创建、暂时没有容器在用但里面存着重要数据这个命令会把它删掉。用之前一定确认清楚。8.4 常见问题速查表问题现象可能原因排查方法容器启动后立即退出主进程执行完就结束了docker logs看输出端口映射不生效端口写反了或服务监听地址不对docker port检查映射确认服务监听0.0.0.0容器之间ping不通不在同一网络docker network inspect检查镜像拉取慢网络问题配置镜像加速器构建镜像很慢上下文太大加.dockerignore数据卷挂载后权限报错容器内用户和宿主机用户UID不一致调整目录权限或指定用户磁盘空间不足镜像和容器堆积docker system df查看prune清理9. 我个人的几条实操心得最后分享几个我在实际使用中总结出来的习惯都是踩过坑之后养成的。第一永远给容器起名字。不起名字的话Docker会分配一个随机字符串过两天你根本记不住哪个容器是干什么的。起名字花不了几秒钟但能省掉很多“这个容器是啥来着”的困惑。第二镜像标签不要用latest。前面说过latest是浮动的今天和明天可能不是同一个版本。用具体版本号出了问题能快速定位到是哪个版本引入的。第三数据一定要挂出来。不管是数据库还是应用产生的文件只要是需要保留的统统挂到数据卷或者宿主机目录。容器应该是无状态的随时可以删掉重建。第四Dockerfile里少用RUN。能合并的合并能清理的清理。镜像小一点拉取和部署都快一点。这个习惯在项目初期可能感觉不到好处但服务多了之后差距很明显。第五定期清理。我一般每周跑一次docker system prune把不用的东西清掉。磁盘空间这东西你不主动管理它就会悄悄被吃光。这些习惯看起来都是小事但积累起来能省掉很多麻烦。Docker本身不难难的是养成规范使用的意识。先把基础操作跑通然后在实际项目中慢慢体会这些细节的价值比一上来就啃文档要有效得多。