折腾过Docker的人基本都绕不开这三个词:镜像、容器、仓库。我最初接触Docker的时候,看到各种教程里频繁出现“docker pull 镜像”、“docker run 容器”、“docker push 到仓库”,说实话是一脸懵的。但后来把这几个概念想通了,发现Docker并没有想象中那么玄乎,它本质上就是一套“打包-运输-运行”的标准化流程。这篇文章不搞复杂理论,就用最直白的语言把这三大核心概念彻底拆开,再结合我实际踩过的坑、日常部署中遇到的真实场景,让你三分钟内建立起对Docker的完整认知框架。
写这篇文章的初衷也很简单:我见过太多新手在安装完Docker之后,卡在“镜像和容器到底啥区别”“启动容器之后怎么访问”“为什么我commit了个镜像却push不上去”这类问题上。这些问题一旦想明白,后面学Docker Compose、K8s都会顺畅很多。所以这篇文章适合刚装好Docker、准备认真用起来的开发者,也适合那些用Docker部署过几个服务但一直“知其然不知其所以然”的人。
1. 镜像:像做菜用的“模具”,只读不可改
1.1 镜像不是一台虚拟机,而是一套只读模板
理解镜像最简单的方式,是想象你在做月饼:模具是固定的,不管你用模具做出多少个月饼,模具本身不会变。Docker镜像就是这个模具,它把所有需要的文件、依赖、配置、代码全部打包成一个只读模板。你用这个模板创建出来的“月饼”就是容器。
这个只读特性非常关键。它意味着镜像本身永远不会被运行中的程序改动,程序运行产生的新文件、修改的配置,都会写入容器自己的可写层。我见过有人把镜像当成虚拟机来用,进入容器后改了配置文件,然后一重启容器发现配置又变回去了,折腾了半天才意识到要重新构建镜像或者使用数据卷。这个坑在初学阶段几乎人人都会踩。
严格来说,镜像是由一层一层的只读文件系统叠加而成的,每一层对应Dockerfile里的一条指令。比如:
FROM centos:7 RUN yum install -y net-tools COPY app.jar /opt/app.jar CMD ["java", "-jar", "/opt/app.jar"]这个Dockerfile会生成三层:基础操作系统层、安装工具层、拷贝应用层。每一层都相当于是“在上一层基础上新增了什么”的记录。这样做的好处是,多个镜像可以共享底层相同的基础层,比如你拉取了十个不同的CentOS镜像,底层公共层只会在硬盘里存一份,大量节省磁盘空间和网络带宽。
1.2 分层机制:像Git提交记录一样层层叠加
镜像分层这个概念很多人容易忽略,但它是理解镜像体积、构建缓存、镜像命名的钥匙。Docker镜像的每一层都是只读的,它们像乐高积木一样堆叠在一起。当你修改镜像时,实际上不是在原层上修改,而是新增一层覆盖上去。这跟Git的提交记录很像:每一次提交都是基于上一次状态的增量,历史记录永远保留。
我在构建镜像时最常用的一个技巧就是利用分层缓存。Docker在构建镜像时,如果某一行Dockerfile指令对应的层之前已经构建过且没有变化,就会直接使用缓存,秒完成。所以合理的Dockerfile写法是把变动频率低的内容放在前面、变动频繁的内容放在后面。比如先COPY requirements.txt再执行依赖安装,最后才COPY源码。如果顺序反了,只要源码改一行,依赖安装那一步就得重新跑一遍,白白浪费几分钟。
查看分层信息可以直接用:
docker history 镜像名:标签你会看到每一层的ID、创建时间、占用的存储体积。这在排查“镜像为什么这么大”的时候特别有用。
1.3 镜像从哪来:三个来源
镜像的获取途径无非三种:从仓库拉取、用Dockerfile构建、从容器提交。
从仓库拉取最常见,一条命令搞定:
docker pull nginx:latest用Dockerfile构建适合自己定制镜像,比如打包Java应用、Node应用:
docker build -t my-app:1.0 .从容器提交这种方式我一般不推荐在生产环境用。它就是把容器的当前状态整个打包成一个镜像:
docker commit 容器ID my-app-backup:20260601这种做法的弊端在于镜像层会膨胀,而且你完全说不清楚这个镜像里到底包含什么,不具备可复现性。我只有在临时需要备份容器现场的时候才用commit,常规项目一律用Dockerfile。
2. 容器:镜像跑起来之后的“活体”
2.1 容器的本质是一个隔离的进程
如果说镜像是模具,那容器就是用模具做出来的月饼——它是镜像的可运行实例。但更准确地说法应该是:容器是一个被隔离的进程。它运行在宿主机上,但通过Linux Namespaces技术拥有自己独立的文件系统、网络、进程号、用户视角。
很多新手以为容器里跑的就是一个完整的操作系统,其实不是。容器里只有一个进程,以及这个进程运行时需要的文件和依赖。你进入容器执行ps命令,看到的进程列表非常简单,因为容器看不到宿主机的其他进程。这就是容器和虚拟机的核心差异:虚拟机跑的是完整客户机操作系统,容器跑的是共用宿主机内核的隔离进程。
我通常用一句话给团队新人解释:虚拟机是你租了一间带全套家具的房子,容器是你住在一个大房子里但你的房间是独立的,你可以按自己的喜好布置房间,但你和大房子里其他人共用水电和承重墙。
2.2 容器的生命周期与状态管理
容器的生命周期其实很好记:创建、启动、停止、销毁。对应命令分别是docker create / docker start / docker stop / docker rm。
常用命令:
# 用nginx镜像创建并后台启动一个容器,命名为web1,映射80端口 docker run -d --name web1 -p 80:80 nginx:latest # 查看正在运行的容器 docker ps # 查看所有容器,包括已停止的 docker ps -a # 进入容器内部 docker exec -it web1 bash # 停止容器 docker stop web1 # 删除容器 docker rm web1这里有个经验要分享:docker run是create和start的合并操作,但容器启动后进程崩溃退出,容器状态会变成Exited。排查问题时第一件事就是docker ps -a,因为docker ps默认只显示运行中的容器,一个个排查如果没有这个习惯,经常会“找不到容器去哪了”。
2.3 容器资源隔离与限制
容器虽然共享宿主机内核,但可以通过Cgroups技术做资源隔离和限制。你可以给容器规定它最多能用多少CPU、多少内存、多少磁盘IO。这在多容器共存的物理机上尤其重要,不设限制的容器可能会把宿主机资源吃光,直接影响其他服务。
常用资源限制参数:
# 限制内存最大512M,CPU最多用1.5个核心 docker run -d --name app --memory 512m --cpus 1.5 my-app:1.0我实际部署过一台服务器同时跑MySQL、Redis、Nginx和几个Java应用的场景,如果不做资源限制,某个Java应用出现内存泄漏,宿主机可能直接OOM崩掉。加了限制之后,最坏情况只是这个容器被杀死,其他服务不受影响。这也是容器相对裸进程的一大优势。
需要说明的是,资源隔离不等于安全隔离。容器仍共享宿主机内核,如果某个容器获得了root权限并且存在内核漏洞,理论上可能穿透隔离边界。所以不要把不可信的东西随便丢进容器里,更不要随意给容器加--privileged参数。
2.4 容器的可写层与数据持久化
容器启动后会获得一个可写层,所有运行时产生的文件、日志都写在这里。但这里隐藏着一个大坑:容器一旦被删除,这个可写层连同里面的数据会一起消失。操作日志、数据库文件、上传的附件,全部没了。
解决这个问题靠数据卷(Volume)和挂载目录:
# 把宿主机的/data/mysql目录挂载到容器的/var/lib/mysql目录 docker run -d --name mysql -v /data/mysql:/var/lib/mysql mysql:8.0挂载数据目录是我每次部署数据库类容器必须做的事,没有例外。我的习惯是宿主机上每个服务固定一个目录,比如/data/mysql、/data/redis、/data/app,这样即使容器被误删重建,数据也还在,备份也方便。
3. 仓库:存放和分发镜像的“码头”
3.1 仓库到底在解决什么问题
镜像打包好了,如果只在本地用,那直接docker images就能管理。但现实中的需求是团队协作、多机部署、环境迁移,这时候就需要一个公共或私有的“码头”来存放镜像,让不同机器都能下载使用。这个码头就是仓库。
仓库的概念其实用过Maven的人应该秒懂。Maven仓库存放的是jar包,Docker仓库存放的是镜像。远程中央仓库(比如Maven Central)对应Docker Hub,私有Nexus仓库对应Docker Registry。我在用Maven配置私服的时候,核心诉求是加速依赖下载、统一内部构件版本;用Docker私有仓库的诉求几乎一模一样——加速镜像分发、保存自研镜像、绕过公网的不确定性。
3.2 镜像命名规则与标签
要熟练使用仓库,必须理解镜像名的组成。一个完整的Docker镜像名是:仓库地址/命名空间/镜像名:标签。比如:
docker pull registry.cn-hangzhou.aliyuncs.com/acs/mysql:8.0这个镜像名拆开后含义是:阿里云杭州仓库(registry.cn-hangzhou.aliyuncs.com)、命名空间acs、镜像名mysql、标签8.0。如果不带仓库地址,默认去Docker Hub拉取。如果不写标签,默认取latest。
标签是版本管理的核心。我一开始图省事,所有镜像都打latest,结果后来越来越混乱,完全不知道线上跑的是哪个版本的镜像。后来强制自己每个镜像都带版本标签,比如my-app:1.0.0、my-app:1.1.0,回滚的时候直接切换标签就行,非常爽快。
3.3 push与pull的完整操作流程
推送镜像到仓库的操作流程是固定的:
# 1. 给镜像打上仓库标签 docker tag my-app:1.0.0 registry.example.com/myteam/my-app:1.0.0 # 2. 登录仓库 docker login registry.example.com # 3. 推送 docker push registry.example.com/myteam/my-app:1.0.0拉取的时候只要仓库可访问,就直接docker pull。这里容易踩的坑是:本地构建的镜像没有打仓库地址前缀,push的时候会得到denied: requested access to the resource is denied。原因在于Docker默认把不带仓库地址的镜像名当成Docker Hub的官方库名。所以push之前一定要先tag,把仓库地址补全。
如果团队内网络条件有限,又想快速分发镜像,可以自己搭一个Registry。一条命令就能跑起来:
docker run -d --name registry -p 5000:5000 registry:2然后本地推送到这个地址就行。这个方案在局域网内分享镜像非常实用,配合Nginx做HTTPS或放在内网网段即可。我有一次在机房内网迁移服务,几百台机器要部署同一套环境,直接在内网搭了个Registry,批量docker pull,比U盘拷贝效率不知道高了多少倍。
3.4 镜像加速与仓库配置经验
国内拉取Docker Hub镜像经常速度感人,这里不讨论任何绕过网络限制的手段,但配置镜像加速器是合规且普遍的做法。常见思路是在Docker配置文件中设置registry-mirrors:
{ "registry-mirrors": ["https://docker.1ms.run"] }配置完成后重启Docker服务即可生效:
sudo systemctl restart docker拉取前可以先确认镜像源是否可用,我一般用docker info查看Registry Mirrors配置,再找一个镜像测试拉取速度。配置之后如果还慢,换个镜像源地址再试,不同地区不同网络环境下效果差异很大。
4. 一条线串起三者:镜像、容器、仓库如何协作
4.1 完整工作流:拉取、运行、修改、推送
把三个概念放在一条流水线里,整个协作关系就非常清楚了:
# 第一步:从仓库拉取镜像 docker pull nginx:1.24 # 第二步:基于镜像创建并运行容器 docker run -d --name web -p 8080:80 nginx:1.24 # 第三步:进入容器做一些自定义修改 docker exec -it web bash # 比如修改nginx配置、安装工具等,在容器内部操作 # 第四步:把修改后的容器提交为新镜像 docker commit web my-nginx:1.0 # 第五步:给新镜像打标签并推送到仓库 docker tag my-nginx:1.0 registry.example.com/myteam/nginx:1.0 docker push registry.example.com/myteam/nginx:1.0这个工作流里,镜像和容器的关系就像“类和实例”:镜像是类定义,容器是实例化出来的对象。你可以从一个镜像创建无数个容器,每个容器都是独立运行的,互不干扰。仓库则是这个体系的“版本控制系统+分发网络”,保证你拿到的一定是这个版本号对应的那套代码、依赖和配置。
4.2 部署Java应用的完整示例
纸上谈兵不够,我用一个最常见的Java应用部署场景把整个过程走一遍。假设你有一个Spring Boot项目的jar包,想把项目容器化部署。
第一步,写Dockerfile:
FROM openjdk:8-jdk-alpine COPY app.jar /opt/app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/opt/app.jar"]第二步,构建镜像:
docker build -t bookstore:1.0 .第三步,运行容器并验证:
docker run -d --name bookstore -p 8080:8080 bookstore:1.0 docker ps curl http://localhost:8080/health第四步,把镜像推送到仓库:
docker tag bookstore:1.0 registry.example.com/apps/bookstore:1.0 docker push registry.example.com/apps/bookstore:1.0第五步,在另一台机器上部署:
docker pull registry.example.com/apps/bookstore:1.0 docker run -d --name bookstore -p 8080:8080 registry.example.com/apps/bookstore:1.0看到没有,从开发机到生产机的迁移,全程只需要镜像名加上版本号。这就是仓库存在的价值:它让“这份代码+这份运行环境”变成一个有版本、可分发、可追溯的产物。
4.3 镜像安全与容器安全
镜像和容器是Docker里最容易出现安全问题的两个位置。镜像安全的核心在源头:拉取的镜像是不是可靠的。之前出现过恶意镜像事件,有人往镜像里植入挖矿程序或盗取数据的脚本,一旦拉到本地运行,宿主机会被拖下水。
我的习惯是只从官方仓库或信誉良好的私有仓库拉取镜像,拉取前用docker image inspect查看镜像的创建时间、Entrypoint、暴露的端口,重点检查是否有异常脚本。自建镜像时尽量使用精简基础镜像,比如alpine,减少攻击面。
容器安全的核心在运行时:不随便加--privileged、不把容器端口裸奔到公网、定期更新镜像补丁内核漏洞。容器隔离不等于绝对安全,这一点必须时刻记在心里。
5. 实操过程中最容易踩的坑与排查技巧
5.1 Docker Desktop突然启动失败
Windows上使用Docker Desktop,最常见的报错之一就是“virtualization support not detected”或“Docker Desktop failed to start because virtualization support is not detected”。这个问题的本质是宿主机的虚拟化功能没有开启。
排查步骤,先确认BIOS里Intel VT-x或AMD-V已经开启,再去Windows功能里确认“虚拟机平台”和“适用于Linux的Windows子系统”都被勾选。如果两项都确认了仍然失败,用管理员权限打开PowerShell执行:
bcdedit /set hypervisorlaunchtype auto然后重启电脑。大概率问题就解决了。还有一个小概率情况是电脑上装了其他虚拟机软件,比如VMware、VirtualBox,和Hyper-V产生了冲突,这就需要考虑切换Docker Desktop的后端,或者关掉冲突的虚拟化软件。
5.2 容器运行MySQL却连不上
我用Docker安装MySQL踩过两次比较深的坑,一次是容器跑起来了但宿主机连接超时,另一次是时区不对。连接超时的典型原因是指定了localhost却发现宿主机端口映射没问题但容器内部MySQL服务没起来。
排查思路不用乱猜,先看日志:
docker logs mysql如果日志显示ready for connections,说明服务本身没问题,接着排查端口映射:
docker ps --format "table {{.Names}}\t{{.Ports}}"确认宿主机端口有没有正确映射到容器3306端口。如果映射了还是连不上,再排查防火墙和MySQL的用户授权问题。MySQL容器默认只允许root在localhost登录,远程连接需要额外创建用户并授权,这是非常容易忽略的点。
5.3 容器里启动sshd失败
有些场景需要在容器内跑sshd方便调试,比如直接拿CentOS 7.9容器当跳板机或者测试环境使用。但我在容器里启动sshd时遇到过一次典型的失败:sshd: no hostkeys found。
这个报错的原因很简单,容器镜像默认没有生成SSH host key,需要先手动生成:
ssh-keygen -A service sshd start如果是CentOS容器,还需要确认sshd服务的启动方式。容器环境没有systemd的进程一号,用systemctl start sshd多半会报错,因为容器里根本没有init系统,正确做法是直接执行/usr/sbin/sshd -D或者把sshd的启动命令写进容器的入口脚本里。这个坑我记忆犹新,当时卡了半个多小时才反应过来容器里的“服务管理”和宿主机不是一回事。
5.4 容器“没了”但容器里还有数据要抢救
如果误删了容器,先别慌,被删除的容器如果还在宿主机磁盘上留下写层数据,可以使用docker commit抢救。但更常见的场景是容器还在但数据没挂载到宿主机路径,比如通过docker exec进容器修改了配置、生成了文件,容器没删,数据就还在。确认容器ID后:
docker cp 容器ID:/etc/nginx/nginx.conf ./nginx.conf.bak把关键文件复制出来备份。我每次在容器里做临时修改前,都习惯先docker cp备份原文件,这个习惯帮我避免过多次“改崩了但不知道原文件长什么样”的尴尬。
5.5 想让容器直接用宿主机网络怎么办
有几次部署服务时,我不想让容器走端口映射那套逻辑,而是让容器直接使用宿主机的网络环境。比如宝塔面板里装了某些容器应用,希望它们跳过NAT,直接用宿主机IP对外提供服务。这个需求不用绕弯子,Docker原生就支持host网络模式:
docker run -d --network host --name web nginx:latesthost模式下,容器不分配独立IP,直接共享宿主机的网络栈。它的好处是没有了端口映射这一层性能损耗,通过localhhost就能直接访问容器内的服务。坏处是端口冲突极其直接:容器里监听一个端口,宿主机上就不能同时监听同一个端口,否则立刻报address already in use。
还需要注意,host网络模式在Docker Desktop的Mac和Windows版本上表现和Linux不一样,那两个平台本质上还是跑在一台虚拟Linux机器里,所谓“宿主机”其实是虚拟机。所以host模式最合适的场景是Linux服务器上部署,追求极简网络链路的场合,Windows/macOS上老老实实用bridge模式加端口映射就好。
5.6 容器时间不对,日志时间差了8小时
容器默认时区是UTC,导致运行Java应用时日志时间比北京时间少8个小时,排查问题时非常容易造成误解。之前遇到过客户反馈“凌晨2点系统自动任务没执行”,我一看日志时间,全部显示为前一天18点,当时就意识到是时区问题。
解决办法是在运行容器时加上时区挂载:
docker run -d --name app \ -v /etc/localtime:/etc/localtime:ro \ -v /etc/timezone:/etc/timezone:ro \ my-app:1.0或者在Dockerfile里直接设置时区:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime这个事看似不起眼,但对依赖定时任务、财务日切、报表统计的业务来说,时区错乱带来的后果相当严重。做容器化改造时,我上来的第一件事就是把时区问题确认清楚,省得后面排查别的故障时被日志时间干扰判断。
5.7 容器资源占用过高排查
容器把宿主机CPU或内存吃满的情况,我遇到过不止一次。排查思路通常是这样的:先用docker stats查看每个容器的实时资源占用,找出嫌疑对象;再用docker top 容器ID查看容器内进程的宿主视角PID;然后配合top或htop按CPU排序,确认具体进程。
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"定位之后再决定是调优应用、加资源限制、还是直接重启容器。注意,docker stats显示的CPU百分比是相对宿主机整体而言的,如果一台机器只有2核,一个容器跑到100%,它基本就把一台物理机吃没了。这时候只有提前设置了--cpus和--memory限制,才能避免服务之间互相拖累。
最后分享一点我个人的使用心得
镜像、容器、仓库这三个概念搞明白之后,Docker这条船的舵就算握住了。我自己的习惯是:每用到一个新镜像,先不看文档,直接用docker inspect看它开了哪些端口、挂载了哪些路径、默认执行什么命令,用这种方式“反推”镜像设计者的意图。很多镜像的常见坑都能从inspect结果里提前看出端倪。比如官方MySQL镜像自带的环境变量、官方Redis镜像默认无密码、Nginx镜像的默认网站根目录路径,这些信息全都在docker inspect的输出里躺着。
如果让我给刚入门的读者一条最实际的建议:不要急着背命令,先拿一个nginx镜像反复做“拉取镜像-启动容器-修改配置-提交镜像-推送到私有仓库”这个闭环,跑通一遍之后,你对镜像、容器、仓库三个概念的体感会完全不一样。光看文章永远只是知道,亲手把这条链路跑通,那才是真正的会了。