接触Docker也有些年头了,带过好几批刚入行的运维和转岗的开发,第一课的内容始终逃不开“Docker简介”这四个字。很多人喜欢一上来就背命令,docker run -d、docker ps、docker logs背得滚瓜烂熟,但真到了线上:容器内存被打满、磁盘被日志撑爆、重启之后数据全没了,才发现根本没把基础概念吃透。这篇文章不打算把官方文档翻译一遍,而是从一个运维老兵的角度,把Docker到底是个什么东西讲清楚,顺便拆解日常操作里最容易被忽略的细节。它适合刚接手容器环境的新人,也适合部署过几个容器但心里还没底的开发者。跟着文章的思路走,半小时内你就能跑起第一个真正能访问的业务容器,而且知道每一步为什么这么干。
1. 先搞懂:Docker到底解决了什么问题
1.1 我最初对Docker的误解
我第一次接触Docker时,脑袋里的概念就是“一个轻量虚拟机”。因为当时看文档,发现它也能装系统、启动服务、分配网络,看起来和虚拟机没什么两样。直到有一次排查线上故障,我才彻底改变认知。
那次故障的起因很老套:开发环境代码跑得好好的,测试环境死活起不来,最后发现是底层依赖库版本不一样,一个动态链接库没装对。传统修法是写一份冗长的部署文档,让运维照着在服务器上一行行执行,只要漏一步,后面就是连环炸。后来我们把应用打包成Docker镜像,整个运行环境跟着镜像走,开发、测试、生产三套环境用同一个镜像启动,问题当场消失。这件事让我明白,Docker的核心价值不是“虚拟一台机器”,而是把应用连同它运行所需的一切,打包成一个可移植、可复制的标准单元。
还有个关键认知必须纠正:Docker容器和宿主机共享内核,它不像虚拟机那样各自维护一套完整操作系统。这意味着隔离是进程级别的,而不是硬件级别的。理解了这个,后面关于启动速度、资源占用、镜像体积的很多问题就都能顺理成章想明白。
1.2 容器和虚拟机的六维对比
很多文章会给你一张很大的对比表,但关键差异其实就六个维度。我把它们整理成一个可以直接用在实际选型里的表:
| 维度 | Docker容器 | 传统虚拟机 |
|---|---|---|
| 隔离级别 | 进程级隔离,共享宿主内核 | 硬件级虚拟化,独立内核 |
| 启动时间 | 秒级甚至毫秒级 | 分钟级 |
| 资源占用 | 只占业务进程所需资源 | 每台虚拟机都要预留完整系统资源 |
| 镜像/磁盘体积 | 几十MB到几百MB | 通常几个GB起步 |
| 性能损耗 | 非常低,接近原生 | 有虚拟化层开销 |
| 迁移性 | 镜像可跨环境复制,一致性高 | 虚机迁移要考虑系统兼容,成本高 |
逐个解释。启动时间的差异最直观:容器启动本质是启动一个进程,内核早就跑在宿主机上了;虚拟机要先把整个系统初始化一遍,从BIOS到内核引导再到初始化服务,快也要一分钟。资源占用的差异也很致命:一台8G内存的服务器,跑三个虚拟机,光系统就吃掉一多半内存;同样配置能跑几十个容器,因为容器本身没有额外系统开销。性能方面,容器里的进程几乎直接操作宿主机CPU和内存,损耗小到可以忽略;虚拟机无论怎么优化,CPU指令翻译、内存页管理都有额外成本。
但虚拟机也不是一无是处,它的隔离更彻底,安全性更高,内核出问题不会影响宿主机。所以正确态度是:跑微服务、写应用、做CI/CD,优先容器;需要隔离操作系统、跑不同内核版本、或者对安全边界要求极高,保留虚拟机。两边不是替代关系,是不同场景下的工具选型。
1.3 镜像与容器:模板与实例的关系
这个类比我讲给每个新人听:镜像就是模板,容器就是根据模板创建出来的运行实例,类似于编程里的“类”和“对象”。类是定义,你要真想用它的属性,必须new一个对象;镜像是只读定义,你要真想跑业务,必须通过它启动容器。
镜像是静态的,里面包含了一层层文件系统、程序代码、依赖库、环境变量和启动命令。容器启动时,Docker会在镜像之上加一层可写层,所有运行时的修改,比如写入日志、临时文件,都落在这层。删除容器,可写层一起消失;但镜像本身还在,随时能再创建新容器。
这种设计带来一个极其实用的能力:同一个镜像,可以在任何安装了Docker的机器上启动出一模一样的容器,不管它底下是哪种Linux发行版,只要内核兼容就行。我实际工作中的常用姿势就是,本地把镜像构建好,推到镜像仓库,生产服务器上一条docker pull拉下来直接run,再也没有“我这能跑你那不能跑”的争论。
2. Docker核心概念一次讲透
2.1 镜像分层:为什么pull镜像能省流量
看docker pull日志的时候,你会发现一个很有意思的现象:明明拉的是一个几百兆的镜像,输出里很多层写着“Already exists”。这就是镜像分层存储的功劳。
Docker镜像不是一整块磁盘文件,而是由若干只读层叠加而成。基础镜像比如ubuntu是最底层,每一条修改指令可能生成新的一层,最后一层层叠起来组成完整文件系统。容器启动时,最上面再叠一个可写层。因为层是共享的,本机已经存在的基础层,换个镜像一样能复用,不用重新下载。比如我机器上早就拉过ubuntu,之后拉基于它构建的其它镜像,底层直接跳过,只把新增层拉下来,时间省一大半。
这个机制同时带出一个经验:删除文件并不让镜像变小。某个层里写入了一个1G的文件,后续层把这个文件删了,文件内容仍留在前面那层里,镜像体积依然很大。想真正瘦身,只能在更早的层里避免引入大文件,或者用多阶段构建从源头控制最终产物。这个坑我见过太多次,新手总觉得“我后面rm掉了,应该体积不大”,结果生产环境拉镜像慢到崩溃。
2.2 容器的生命周期与状态
容器不是一把梭的“运行中/停止”,它有一套完整状态机。理解状态机,排查问题会顺手很多。
| 状态 | 含义 | 常见命令 |
|---|---|---|
| created | 容器已创建但未启动,可写成“配置完成待命” | docker create / docker start |
| running | 容器进程正在运行 | docker run -d / docker start |
| paused | 容器所有进程被暂停,类似挂起 | docker pause / docker unpause |
| exited | 容器进程已退出,退出码分正常、异常 | docker stop / docker run 前台退出 |
| dead | 异常状态,一般需要重建容器 | 少见但可能出现在宿主机异常后 |
我最常被问到的问题是:为什么docker stop之后容器还在列表里?因为它只是“exited”,没有被删除。stop的过程也有讲究:Docker先给容器主进程发送SIGTERM信号,让程序自己优雅退出,如果超出等待时间还没退出,再发SIGKILL强制杀掉。这个等待时间默认10秒,有些应用启动慢、停止也慢,业务里需要调大,否则会出现“服务明明还在处理请求,却被kill”的情况。
实际清理时,新手最容易犯的错是直接docker rm -f 容器名。-f是强杀,所有未落盘的日志、临时状态全没了,属于最后手段。正确做法是docker stop、docker rm分开执行。还有docker restart,它等于stop加start,不会删除容器,常用于重新拉起遇到问题的服务。
2.3 数据卷、网络与仓库:三个绕不开的名词
数据卷。容器默认是“无状态”的,写在可写层的数据跟着容器走,容器一删数据全没。问题在于,数据库、配置文件、日志,这些明明是要持久化的。解决办法是数据卷和绑定挂载。数据卷是Docker管理的目录,用-v volume_name:/container/path挂载,数据落在宿主机的一个特殊目录里;绑定挂载则是直接指定宿主机目录,比如-v /data/mysql:/var/lib/mysql。我个人的经验是:能选数据卷就选数据卷,路径由Docker管理,备份和迁移都方便;绑定挂载更适合需要直接编辑宿主机文件或复用已有数据的场景。
网络。默认bridge网络会给每个容器分配一个内网IP,容器之间能互相访问,但外部访问不到。所以跑一个Web服务,需要-p 8080:80做端口映射,把宿主机的8080端口流量转到容器内的80端口。理解这个映射关系之后,很多“容器启动了但外部访问不了”的问题都能一眼定位:八成是没映射端口,或者宿主机防火墙没放行。
仓库。镜像是死文件,要分发就得有仓库。Docker Hub是默认公共仓库,完整镜像名包括仓库地址、镜像名和标签,比如nginx:1.24-alpine,冒号前后是镜像名和标签。标签默认是latest,但生产环境我强烈建议固定到具体版本,否则某天基础镜像更新,你拉到的版本可能和线上不一致,无声无息制造故障。
3. 上手实操:安装配置与第一组容器
3.1 安装前的环境准备
把概念层面的东西讲清楚,下面直接上手。第一步是准备环境,这不是傻乎乎执行几条命令就行的事,有几个前置检查必须做。
先看内核版本。Docker对Linux内核有最低要求,太老的系统跑不起来。执行uname -r,主流发行版内核都在3.10以上,基本都能满足,但过旧的内核我还是建议先升级系统。接下来检查机器上有没有装过旧版本Docker,早期包名可能是docker、docker-engine,如果不清理干净,新包装上容易起冲突。卸载命令很简单,yum remove或apt remove对应包名,不用纠结残留,设施化安装时官方源会覆盖处理。
然后添加软件源并安装。这里我强调一下:很多发行版自带docker.io之类的包,版本通常很旧,不建议图省事直接用。更规范的做法是添加官方软件源,装docker-ce、docker-ce-cli、containerd.io三个包。CentOS系和Debian系的源地址格式不一样,网络上有大量踩坑记录,关键是把源地址替换成你自己系统对应版本。我一般会写这么一段:
# RHEL/CentOS系示例(仓库地址请按发行版替换) sudo yum install -y yum-utils sudo yum-config-manager --add-repo <官方软件源地址> sudo yum install -y docker-ce docker-ce-cli containerd.io # 启动并设置开机自启 sudo systemctl enable --now dockerDebian系把yum换成apt,源配置方式略有差异,但不影响判断。安装完成后先别急着用,执行systemctl status docker确认状态是running,再执行docker version,把客户端和服务端信息都看一眼。服务端版本异常为空,基本都是daemon没起来,去查journalctl -u docker的日志,九成能定位原因。
3.2 daemon配置:把底子一次打好
很多新手装完Docker直接开跑,根本不碰/etc/docker/daemon.json。这个文件相当于Docker的总开关,有些配置早晚要改,不如一开始就配好。
{ "data-root": "/var/lib/docker", "storage-driver": "overlay2", "log-driver": "json-file", "log-opts": { "max-size": "20m", "max-file": "5" }, "registry-mirrors": [ "https://<你的镜像加速地址>" ], "exec-opts": ["native.cgroupdriver=systemd"] }逐个说。>sudo systemctl daemon-reload sudo systemctl restart docker
重启后用docker info检查配置是否生效,重点看Storage Driver、Logging Driver和Registry Mirrors三处。我第一次改配置时忘写逗号,直接整段JSON解析失败,daemon起不来,这类最基础的错误排查起来也很简单——看日志说“invalid character”就去看JSON的逗号括号。
3.3 用Nginx跑起第一个业务容器
底子打好后,来跑一个真正能访问的容器。我习惯用Nginx做示例,它镜像小、启动快、结果直观。
docker run -d \ --name web-test \ -p 8080:80 \ nginx:1.24-alpine解释一下这条命令。-d表示后台运行,不占着当前终端;--name web-test给容器起名字,后面管理用名字比用一串随机ID方便;-p 8080:80是端口映射,宿主机8080转发到容器80;最后的nginx:1.24-alpine指定镜像和标签,alpine是精简Linux发行版,镜像体积比标准版小很多。跑完之后执行docker ps,可以看到web-test处于Up状态,访问http://宿主机IP:8080就能看到Nginx欢迎页。
如果访问不到,先别怀疑容器,按这个顺序排查:docker ps确认容器在跑;curl 127.0.0.1:8080看本机通不通;再查宿主机防火墙有没有放行8080端口。我遇到过太多回,容器、映射全没问题,最后是安全组策略把端口拦了。
容器运行中,可以开一个终端执行docker logs -f web-test看输出,这是排查问题的利器。想进入容器内部,执行docker exec -it web-test sh,会进到容器的shell里。在里面可以看文件系统、查进程、改配置。测试完毕不要忘了清理:
docker stop web-test docker rm web-testexec和attach要区别开。exec是进入一个正在运行的容器执行新命令,attach是把当前终端直接挂到容器主进程的输出上,很多人一挂上去按Ctrl+C,直接把容器主进程也杀了。所以我平时诊断问题只用exec,尽量别用attach。
4. 从demo到“能扛事”的操作习惯
4.1 重启策略:容器挂了自动拉起来
开发环境随手docker run无所谓,生产环境第一个要考虑的就是:进程挂了谁拉起?Docker提供了重启策略,用参数--restart控制。
| 策略 | 行为 | 适用场景 |
|---|---|---|
| no | 默认值,不自动重启 | 一次性任务 |
| on-failure | 非正常退出时重启 | 短任务、批处理 |
| unless-stopped | 无论退出原因都重启,但手动stop后不再拉起 | 常驻业务服务(推荐) |
| always | 无论退出原因都重启,手动stop后重启Docker时还是会拉起 | 需要强制保活的基础组件 |
我的习惯是:后端API、前端Nginx这种常驻服务用unless-stopped。理由很直接,always太“粘人”,你明明主动停了容器做维护,只要Docker服务一重启,它又自己跑起来,容易造成意外恢复。unless-stopped就灵活得多,它区分“容器被主动停止”和“容器异常退出”,被主动stop之后重启Docker不会拉起,符合运维直觉。
命令是这样:
docker run -d --restart unless-stopped --name api-demo myapp:1.0容器创建之后想改策略,不需要重建,用docker update --restart unless-stopped 容器名搞定。这个命令还能顺带改资源限制,是个被低估的运维工具。
4.2 资源限制:不给容器上缰绳的后果
跑容器最刺激的场面,是某个业务突增流量,容器内存持续走高,最后宿主机OOM,把同机其它服务全带走。所以凡是常驻业务容器,我几乎都会加资源限制。
docker run -d \ --memory 512m \ --cpus 0.5 \ --name api-demo myapp:1.0--memory 512m限制内存上限为512M,超过这个值容器会被内核杀掉;--cpus 0.5限制最多使用半个CPU核心。为什么要限制?容器本质是共享宿主机内核的进程,没有限制时,一个失控容器能吃掉所有硬件资源。我可以负责任地说,绝大多数线上事故都不是业务代码写错了,而是某个容器突然膨胀挤压了其它邻居。
这里有个经验点:限制内存后要看容器的退出状态。内存被打满时,容器通常以137退出,这是被内核OOM Kill的信号。用docker inspect 容器名查看OOMKilled字段,字段为true基本可以确定是内存不足。排查时配合docker stats实时看资源占用,哪个容器是“内存黑洞”一目了然。新人在容器里敲free -h看内存会困惑,因为看到的是宿主机内存,这是共享内核导致的正常现象,资源限制要用Docker层的方法看。
4.3 日志、时区、健康检查:一坑一个准
先说日志。容器内应用默认会把日志输出到stdout/stderr,被Docker的json-file驱动收集,这也是docker logs能直接看到日志的原理。但很多应用默认会同时写目录里的文件,如果没做重定向,日志文件在容器可写层里越滚越大。容器一删,日志跟着丢;平时查日志还麻烦。我的原则很简单:容器内不落日志文件,全走标准输出。应用配置里把日志输出到stdout,再由Docker统一收集,配合之前daemon.json里的大小限制,干净又省心。
然后说时区。新容器默认是UTC时间,和北京时间差8小时,日志时间对不上号,排查问题很痛苦。常规做法是传环境变量:
docker run -d \ -e TZ=Asia/Shanghai \ -v /etc/localtime:/etc/localtime:ro \ --name app-demo myapp:1.0但有些精简镜像连时区数据都没有,光设TZ还不够,要在Dockerfile里装tzdata。实际生产环境,我更推荐Dockerfile里就把时区固定:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ && echo $TZ > /etc/timezone再看健康检查。默认情况下Docker只能判断容器主进程有没有挂,没法判断业务是否真的可用。一个Java进程活着,但对外接口全部超时,docker ps照样显示Up。给容器加健康检查,让Docker定期探活:
docker run -d \ --health-cmd="curl -f http://localhost/health || exit 1" \ --health-interval=10s \ --health-retries=3 \ -p 8080:80 nginx:1.24-alpine执行docker inspect 容器名,State.Health.Status字段会从starting变healthy或unhealthy。这里有个教训:健康检查命令本身要轻量,如果检查逻辑里包含大量计算或阻塞操作,它会拖慢容器,甚至引起异常。还有一个经常踩的坑:容器里没有curl,健康检查命令要写在镜像构建时就装好,否则--health-cmd会执行失败。
4.4 镜像瘦身:把无用文件拦在门外
很多新手构建镜像,直接用ubuntu:latest,然后go install、apt install一套组合拳,构建完镜像体积轻松上G。实际业务里,镜像瘦身带来的收益非常直接:拉取更快、启动更快、磁盘占用更小、攻击面更小。
最常见的手段是多阶段构建。第一阶段放编译工具链,把应用源码编译成二进制;第二阶段只放运行环境和二进制,不携带编译器。我举个例子:
# 第一阶段:构建 FROM golang:1.21 AS builder WORKDIR /app COPY . . RUN CGO_ENABLED=0 go build -o app . # 第二阶段:运行 FROM alpine:3.19 WORKDIR /app COPY --from=builder /app/app /app/app EXPOSE 8080 ENTRYPOINT ["/app/app"]这个思路对任何语言都适用。编译产物只依赖少量运行库,那就只装运行库。还有几个具体技巧:选alpine这类精简基础镜像,能大幅压体积;同一RUN里合并多个操作,减少层级;构建时设置代理或换源,避免下载超时。瘦身不是目标本身,目标是让交付物更稳定、更快、更容易被拉取,本质上是一种运维效率投资。
5. 常见故障与排查命令实录
5.1 端口冲突与容器启动失败
跑docker run -p 8080:80时报“port is already allocated”,说明宿主机的8080端口已经被占用。排查先执行:
ss -lntp | grep 8080看是哪个进程占着端口。如果是被其它容器占用,执行docker ps看容器列表,确认端口映射关系;如果是宿主机进程占用,就要找这个进程的归属。也遇到过一种情况,容器创建失败退出码特别奇怪,这种情况直接看容器日志:
docker logds <容器名>日志往往会把真正的错误原因打出来,比如权限不够、目录不存在、启动命令写错,远比看退出码猜要快。
5.2 镜像拉取失败或超时
网络问题在真实环境里出现的概率不低。报错大多类似“dial tcp: lookup ... no such host”或者“timeout”。第一步先确认网络通不通:ping 镜像仓库域名、curl -I 镜像仓库URL。别忽略域名解析问题,有些服务器的/etc/resolv.conf配置有问题,导致DNS解析异常,这时调系统DNS配置就行。
如果网络没问题,检查docker info里的Registry Mirrors配置是否生效。配置了镜像仓库加速但没生效,基本是配置完没重启Docker,或者JSON写错了。拉镜像超时还有一个被动处理手段:多尝试几次,镜像服务器偶发故障时,重试往往能成功。这里也提醒一句:生产环境尽量提前把镜像拉到本机,而不是业务高峰期现场拉取,一个几百M的大镜像遇到网络抖动,很容易把发布窗口拖垮。
5.3 容器内时间不对
容器内执行date,发现时间和宿主机差8小时,这就是时区问题。按上一节说的方式处理:设置TZ环境变量并确保镜像里有tzdata。如果是在运行的容器临时看时间,可以用docker exec进入容器内执行date -R确认。很多人会试图挂载/etc/localtime进去,但精简镜像可能没有/etc/localtime的完整路径结构,这时挂载也白搭,正确做法是装tzdata后重新构建镜像。
5.4 磁盘被日志和镜像撑爆
df -h看到根分区使用率95%,排查方向先看Docker目录:
docker system df这个命令会输出容器、镜像、数据卷、构建缓存占用的空间明细。如果镜像和构建缓存是大头,执行:
docker system prune它会清理悬空镜像、停止的容器、无用的网络和构建缓存,释放大量空间。如果数据卷很大,必须先确认那是业务数据,不能一刀切删。这里的教训是:日志限制一定要在daemon.json里配好,镜像不要频繁构建而不清理缓存,磁盘告警是最常见的Docker运维问题,别等它发生才想起来管。
5.5 常用查询命令速查
| 场景 | 命令 | 说明 |
|---|---|---|
| 查看运行中的容器 | docker ps | 加-a看所有容器 |
| 查看容器资源占用 | docker stats | 实时刷新,看内存CPU |
| 查看容器详情 | docker inspect 容器名 | JSON格式,查状态、网络、挂载、OOM |
| 实时看日志 | docker logs -f 容器名 | 追踪输出,排查问题首选 |
| 清理无用资源 | docker system prune -a | 注意会删掉不被使用的镜像 |
| 修改容器参数 | docker update | 改重启策略、资源限制 |
6. 下一步:从“会跑容器”到“能管好容器”
6.1 实操练手路径
跑完Nginx,你基本串起了镜像、容器、网络、日志、健康检查这些东西。但“会跑一个容器”距离“能管好容器”还有很长的路。我给新人的建议是别急着学集群,先把单机场景用到极致。搭一套最常用的组合:一个Nginx做反向代理、一个后端服务、一个Redis缓存、一个MySQL数据库,用多个容器跑在同一台机器上,手动把端口、网络、数据卷全部串起来。
这套练习能逼你搞清楚很多问题:容器之间怎么通信?数据怎么持久化?服务启动顺序怎么保证?这些坑踩过一遍,后面上编排工具Compose、集群平台Kubernetes,理解速度快得不是一点半点。我见过太多人连docker inspect都不熟,直接开始看集群教程,最后越学越懵,基础没有深度,上层全是概念。
6.2 最后分享几条个人心得
文章结束前,说几个我这些年沉淀下来的操作习惯。第一,不要在生产随便用latest标签,镜像必须锁定具体版本号,可追溯、可回滚。第二,容器默认是临时的,要把它当成“无状态进程”来用,任何需要持久化的东西,提前想好数据卷方案,不要等数据丢了再追悔。第三,遇到异常先查日志再查配置,docker logs和journalctl -u docker能覆盖八成以上问题。第四,把Docker命令做成团队文档,统一命名规范、端口规范、标签规范,比口头传话可靠得多。
Docker本身不复杂,复杂的是围绕它形成的一整套运维习惯。把这篇文章里提到的概念和操作消化掉,你已经有了一个不算差的起点。剩下的,交给持续踩坑和复盘。