去年有个开发同学跟我抱怨:在自己电脑上跑得好好的项目,一到同事机器上就崩,环境变量对不上、依赖版本冲突、系统库缺失,折腾一整天最后发现是Node版本不对。这个问题在团队里太常见了。而Docker就是解决这类问题最直接的工具——它把你的应用连同运行环境一起打包成一个"集装箱",不管在谁的机器上,都能保证运行结果一致。
我最早接触Docker是在部署微服务的时候。那时候一套环境要装数据库、消息队列、缓存、网关,光安装配置就能写好几页文档,换台服务器又要重来一遍。用Docker之后,这些服务都是现成的镜像,拉下来就能跑,几分钟全部搞定。如果你还没接触过Docker,或者用了几次但感觉概念模糊、命令记不清,这篇就是给你准备的:从最核心的镜像、容器、数据卷、网络这些概念讲起,配合实际操作,把原理和踩坑点一次性说清楚。内容不需要你有任何基础,只要跟着思路走,就能把Docker真正用起来。
1. Docker到底是什么:先搞懂核心概念
1.1 容器和虚拟机的本质区别
很多人第一次理解Docker,都喜欢把它类比成虚拟机。确实,容器和虚拟机都实现了"环境隔离",但它们的底层原理完全不同。
虚拟机跑的是完整操作系统。你在宿主机上安装VMware之类的软件,它会模拟出一套虚拟硬件,然后在上面装一个完整的Guest OS。一个虚拟机动不动就要几个GB的磁盘空间,启动要几十秒甚至几分钟,因为引导一个操作系统需要走完整的启动流程。
容器则完全不同。Docker容器直接复用宿主机的操作系统内核,没有Guest OS这一层。容器里运行的是一个或者几个进程,这些进程共享宿主机内核,但通过Linux命名空间做了隔离——进程、网络、文件系统各看各的,互不干扰。所以容器非常轻量,镜像大小可能是几十MB到几百MB,启动速度以毫秒计,一台服务器上跑几十个容器很轻松。
打个比方:虚拟机是租毛胚房,你拎包入住还需要自己配齐家具家电(装操作系统);容器是精装公寓,里面东西都是配好的,你进门就能住。但注意,正因为容器共享内核,它无法像虚拟机那样隔离物理资源(比如CPU核心硬划分),也不能运行一个完整的内核态操作系统。如果你需要跑一个Windows容器,宿主必须是Windows;要跑Linux容器,宿主必须是Linux(或者通过兼容层)。开发场景下,你99%用的都是Linux容器。
1.2 镜像和容器的关系
理解了容器,就必须理解"镜像"这个概念。Docker镜像是一个只读的模板,里面包含了应用代码、运行时、依赖库、配置文件,甚至连环境变量都定义好了。镜像由一层一层的文件系统组成,每一层都是只读的。
容器呢?容器是镜像运行起来的实例。可以说容器 = 镜像 + 可写层。每次启动容器时,Docker会在镜像的基础上叠加一个临时可写层,所有运行时产生的文件修改都写在这个层里,镜像本身完全不受影响。这也带来一个重要推论:容器一旦被删除,所有未保存的修改都会丢失。我们后面会专门讲数据卷来解决这个问题。
用代码来类比:镜像就像定义好的类(Class),容器就是实例化出的对象(Object)。同一个镜像可以启动无数个容器,每个容器都是独立运行的,互不干扰。
1.3 为什么开发环境要用Docker
Docker的价值我总结成三方面:环境一致、快速交付、资源隔离。
环境一致是最直接的好处。你在Ubuntu上开发,用CentOS部署,以前要担心glibc版本、系统库差异。现在把这些统统打包进镜像,开发、测试、生产用同一个镜像,跑出来的行为就是一致的,省掉大量"我这里好的啊"这类扯皮。
快速交付体现在两个层面。第一是交付速度,以前部署一个应用要跟着手册一步步装依赖,现在只需要一键拉镜像启动;第二是扩容速度,某个服务压力大了,直接多复制几个容器跑起来,放到负载均衡后面,几分钟就能完成横向扩展。
资源隔离则是说的稳定性。不同的应用容器之间资源彼此隔离,一个容器写满磁盘或者占满CPU,不会直接影响宿主机上其他服务。虽然我们后面会讲Docker的默认配置并不做严格限制,但至少进程级别是隔离的。
2. 装好环境,把第一个容器跑起来
2.1 安装Docker的三种方式
Docker的安装方式分系统自带、脚本安装和二进制安装三类。我强烈建议Linux用户用官方推荐的yum/apt源安装,不要用脚本一键装,脚本装的前提是你已经配置好了官方源。
以Debian/Ubuntu为例,常规操作是:
sudo apt update sudo apt install docker.io docker-compose sudo systemctl enable --now dockerCentOS/RHEL系列:
sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo sudo yum install -y docker-ce docker-ce-cli containerd.io sudo systemctl enable --now dockermacOS和Windows用户直接安装Docker Desktop即可,Docker Desktop自带图形界面和命令行工具,但需要注意Windows下需要启用WSL2,macOS下对Intel和Apple Silicon芯片都有对应的安装包。
安装完跑一下docker version,看到client和server都有输出说明正常。有个小细节:docker version是看版本兼容,docker info会展示守护进程的健康状态和存储驱动。如果你发现只有client没有server,多半是docker.service没启动,sudo systemctl start docker可以解决。
2.2 跑一个nginx容器实战
装好之后,第一步应该跑一个简单的服务验证环境。我习惯用nginx做例子,因为它自带默认页面,看到效果最直观。
docker run -d -p 8080:80 --name myweb nginx:alpine这条命令拆开看,docker run是启动容器,-d表示后台运行,-p 8080:80表示把宿主机的8080端口映射到容器的80端口,--name myweb给容器起名字,最后nginx:alpine是镜像名和标签。
第一次执行会自动拉取镜像,看到Status: Downloaded newer image后,浏览器访问http://localhost:8080,能看到欢迎页就算成功了。
这里我想特别强调一下-d参数。如果你不加-d,容器会以前台方式运行,日志直接打到终端上,Ctrl+C会终止容器。加了-d之后容器在后台运行,日志通过docker logs myweb查看。开发调试时我喜欢前台跑,方便观察输出;生产部署必须后台跑。
2.3 镜像加速与国内网络下的优化
拉取官方镜像在国外服务器上很快,但在国内网络环境下,直接从Docker Hub拉取经常慢得像蜗牛,动不动就超时。这种情况下需要配置镜像加速器。所谓的镜像加速器,本质是在Docker客户端配置一个registry mirror,让Docker从离你更近的镜像仓库拉取。
Linux下编辑/etc/docker/daemon.json:
{ "registry-mirrors": ["https://mirror.example.com"] }改完重启Docker服务:
sudo systemctl daemon-reload sudo systemctl restart docker你需要根据自己所在网络选择可用的加速地址。某些加速源可能失效,建议提前测试。拉取镜像时如果提示manifest unknown或者超时,先用docker pull指明确切标签试一遍,再检查网络。另外有条件的话,自建镜像仓库管理私有镜像,也是团队实践的常见方案。
3. 镜像操作的实用细节
3.1 拉取、查看、删除镜像
Docker镜像的常见命令不多,但每个都有讲究。
拉取镜像用docker pull:
docker pull ubuntu:22.04 docker pull nginx:latest注意标签不一定都必须带,但"latest"其实不是严格意义上的"最新版",它只是某个镜像默认标签,在不同仓库甚至可能是陈旧版本。生产环境我建议永远指定具体版本,比如nginx:1.25.3,这样可复现性最好。
查看本机镜像用docker images,它展示仓库名、标签、镜像ID、创建时间和大小。有个经验是镜像大小显示的可能是虚悬大小(包括共享层),docker system df可以看真实磁盘占用。
删除镜像用docker rmi 镜像ID或者名称。删除之前需要确保没有正在运行的容器使用它,否则会报冲突。如果删除时提示镜像被多个容器引用,可以先清理容器再删镜像。批量清理没用的镜像,用docker image prune,它会删除所有悬空镜像(dangling镜像),就是那些没有标签、没有容器的镜像,通常在重新构建后产生。
3.2 Dockerfile: 从零构建自己的镜像
如果说pull镜像是使用别人的自行车,那Dockerfile就是自己造车。一个最基本的Dockerfile长这样:
FROM node:18-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["node", "app.js"]每一行都是核心。FROM选择基础镜像,alpine版本体积小、安全性好,适合作为运行环境。WORKDIR设置工作目录,相当于cd。COPY package*.json ./先把依赖清单拷进去,随后RUN npm install安装依赖。这里有个性能优化点:把依赖的复制和安装放在代码复制之前,是因为Docker构建有缓存,只要前面的层级没有变化,后面的层会复用缓存,这样只有源码变化时不会重新安装依赖。
EXPOSE声明容器对外端口,但注意它只是声明,并不会真正映射端口,实际映射还是在docker run -p时做的。CMD是容器启动时执行的命令,一个Dockerfile只能有一个CMD(多个的话只有最后一个生效),如果run时命令行指定了命令,CDM会被覆盖。
构建命令是:
docker build -t myapp:v1 .最后的点表示上下文路径。整个目录都会作为构建上下文发给Docker守护进程,所以注意.dockerignore文件,把node_modules、.git这类无关内容排除掉,否则构建上下文太大,构建就会变得很慢。
3.3 镜像分层与缓存机制
我第一次看到Docker镜像的存储机制时,最惊讶的是分层。每个指令都会产生一个层,一层叠一层,最终拼成一个完整的文件系统。拉取镜像的时候,Docker会对比每一层的哈希,已有层直接复用,只有新层才下载。这就是为什么多个镜像共用基础镜像时,磁盘空间并没有翻倍增长。
构建缓存机制就是从这来的。你在基础镜像是相同的前提下,构建一个新版本应用的镜像,只有从改变的那条指令开始的层才会重建,之前的层直接用缓存。但缓存也有个坑:如果RUN后面执行的命令不稳定(比如每次都下载最新代码),缓存会导致你构建的镜像并不是最新代码。这种情况下给命令加上--no-cache参数强制不缓存,或者更优雅地用ARG定义"缓存键",代码变了就传一个新值。
4. 容器生命周期管理
4.1 启动、停止、重启、删除容器
容器就像进程,有生命周期。常用命令一览:
docker start 容器名 # 启动已存在的容器 docker stop 容器名 # 优雅停止,默认等10秒结束进程 docker restart 容器名 # 重启 docker rm 容器名 # 删除容器(需先停止) docker ps # 查看运行中容器 docker ps -a # 查看全部容器,包括已停止的docker stop默认等待10秒,然后发送SIGKILL强制停止。容器里的进程如果不监听SIGTERM信号,可能来不及做清理工作。所以生产环境建议在应用内处理SIGTERM,优雅地保存状态、关闭连接。
还有一个细节:docker rm只能删除停止状态的容器,如果容器还在运行会报错。如果你想一次性删除包括正在运行的,用docker rm -f,但生产环境中要慎用,强制删除可能导致数据不一致。
批量清理某些容器时,可以用docker ps -q结合过滤条件。比如停掉所有以"demo"开头的容器:
docker stop $(docker ps -q --filter "name=demo")4.2 进入容器与日志查看
进入一个正在运行的容器,最常用的命令是docker exec -it 容器名 /bin/bash。-it表示以交互模式打开一个终端。如果是alpine这类精简镜像,没有bash也有可能,改成/bin/sh即可。
进入容器后,你就在容器的文件系统里了,可以做排查、改配置。但请记住:改的东西只在当前容器的可写层里,一旦容器重建就没了。要改配置还是走"修改代码+Dockerfile重新构建"这条路,否则相当于白干。
日志查看是日常排障的高频操作。docker logs 容器名默认显示全部日志,-f参数跟随输出,相当于tail -f。在docker-compose环境中,不同服务的日志会混合输出,加上--tail=20只看最近20行会更清爽。
我自己碰到过一个问题:在docker logs中看不到应用日志,原因是应用把日志写到了文件而不是标准输出。Docker本身只收集容器的stdout/stderr,所以要让应用直接打日志到标准输出,或者把日志文件重定向成软链到stdout。不然日志抓不到,排查问题就变得很被动。
4.3 资源限制与清理技巧
默认情况下,容器能使用宿主机的全部资源。这听起来挺好,但实际生产中一个失控的容器可能吃光所有CPU或内存,造成"故障域"扩大。Docker提供了限流参数:
docker run -d --name app --memory=512m --cpus=0.5 nginx--memory=512m限制容器最多用512MB内存,--cpus=0.5表示最多使用半个CPU核。还可以用--pids-limit限制容器内进程数,防止fork炸弹。
磁盘清理是另一个高频痛点。时间一长,docker system df会告诉你镜像、容器、数据卷、构建缓存占了多少空间。我习惯每周跑一次:
docker system prune -a --volumes这个命令会把未使用的镜像、已停止的容器、无用的数据卷和构建缓存全部清掉。注意--volumes会删除无引用的数据卷,如果里面有重要数据就被清没了,所以生产环境要先确认再执行。安全点的做法是手动docker volume ls看一下有哪些卷,该备份的备份好。
5. 数据卷与挂载:让数据持久化
5.1 为什么容器里数据会丢
前面提到过,容器的可写层是临时的。容器删除后,可写层连同里面所有文件一起消失。这意味着如果你直接在容器里写数据库文件,容器一删,数据就全没了。因此运行数据库、消息队列或者需要保留用户上传文件的场景,必须用数据卷。
数据卷的本质是在宿主机上建立一块持久化存储区域,容器往里面写的数据直接落在宿主机磁盘上,容器删掉,数据依然在,再启动一个新容器挂载同一块数据卷,数据又回来了。
5.2 数据卷的三种用法
第一种是匿名数据卷。启动时指定-v /data,Docker会创建一个随机名字的卷挂载到容器的/data目录。这种方式可以做到数据不丢,但卷的名字难记,不方便管理,一般用来保存临时数据。
第二种是具名数据卷。-v mydata:/data,这个mydata就是一个持久化卷,即使容器删了,卷还在。以后启动新容器时用同样的名字挂载就能复用数据。数据库容器用这个方式最省心。
第三种是绑定挂载(bind mount)。-v /host/path:/container/path,直接把宿主机的一个路径挂载到容器里。这种方式最透明,宿主机上的文件和容器内是同一个文件,修改直接互相同步。开发环境调试代码时最爽,我改一行代码,容器里立刻生效。
用-v其实还有一种形式--mount,新版Docker推荐用--mount更语义化:
docker run -d \ --name db \ --mount type=volume,source=mydata,target=/var/lib/postgresql/data \ postgres:15注意:挂载路径如果和镜像内已有目录冲突,宿主机目录内容会覆盖容器目录内容,也就是说镜像里原有的配置会被隐藏。这是新手最容易踩的坑。
5.3 挂载场景选择
我一般的使用规则是:数据库、队列使用具名卷,保证数据持久化;开发调试代码使用绑定挂载,改了代码立刻生效;需要共享配置时用bind mount把网盘或git同步的目录挂进去。
另外有几个细节必须提醒:
- 数据库容器第一次启动时,会初始化数据文件,这个初始化过程可能比较慢,如果你立刻去查数据目录看到是空的,不要慌,等几秒再看。
- Windows和macOS上绑定挂载存在文件权限差异。某些情况下在Mac上挂载后容器内看到的是只读权限,需要自行处理权限位。
- 容器内运行的用户和宿主机运行用户可能不一致,导致绑定挂载目录的读写权限出现"Permission denied"。解决办法是在Dockerfile里用
USER指定和宿主用户一致的UID,或者在启动时加--user $(id -u):$(id -g)。
6. 容器网络:让多个容器协作
6.1 网络模式的基础认知
Docker的默认网络模式叫bridge。每个容器通过虚拟网卡连接到docker0这个虚拟网桥上,彼此可以通过IP相互访问。容器对外访问互联网,通过NAT完成。
除了bridge,还有host模式、none模式和container模式。host模式下容器直接使用宿主机的网络栈,没有端口映射这一说,性能最好,但失去了网络隔离,一般用来跑对性能极度敏感的网络服务。none模式完全不配置网络,适合离线任务。container模式是让容器共享另一个容器的网络命名空间,常用于sidecar模式(比如通过sidecar转发流量)。
大部分人用默认bridge模式就足够了。需要理解的关键点在于:在同一台宿主机上,bridge模式下每个容器默认分配一个IP,这个IP在容器重新创建后是会变化的。所以不要让应用之间通过IP互相访问,而应该通过服务名。
6.2 自定义网络与服务发现
为了让容器通过名字互相访问,最好创建自定义网络:
docker network create mynet docker run -d --name app1 --network mynet nginx docker run -d --name app2 --network mynet ubuntu:22.04 sleep infinity在mynet网络里,app1可以直接用app2这个主机名访问app2,Docker内置的DNS会自动解析。这是docker-compose里服务互相调用的基础,也是微服务在不同容器间通信的最省心办法。
有个小坑:bridge默认网络不支持容器名自动解析,只有自定义网络才支持。你如果在默认bridge网络里用名字访问另一个容器,装插件或者配自定义别名才可行。所以哪怕只有一个服务,我也喜欢建个自定义网络再挂进去。
6.3 端口映射的坑
端口映射是容器对外暴露的关键配置。-p 8080:80表示把宿主机的8080端口映射到容器内的80端口。映射成功后,访问宿主机IP:8080就能到达容器。
它有几个容易踩的坑:
第一,宿主机端口冲突。如果你已经有一个服务占用了8080,再映射就会报Bind for 0.0.0.0:8080 failed: port is already allocated。解决办法要么换宿主端口,要么先停掉冲突进程。检查端口占用可以用sudo lsof -i:8080或netstat -tlnp。
第二,默认绑定的是0.0.0.0,这意味着宿主机所有网卡上的8080都能访问到这个容器。如果你只想让本机访问,或者只给内网某台机器访问,应该显式指定IP,比如-p 127.0.0.1:8080:80,这样外网就访问不到了。
第三,多个容器同时监听同一服务端口是可以的,只要宿主端口不冲突。比如三个nginx容器分别映射 8081、8082、8083 到内部的80端口,这个一点问题没有。在docker-compose中,不要给同一个服务定义多个宿主端口,除非这是多个独立服务实例。
7. Docker Compose:一键编排多容器
7.1 为什么需要Compose
当你的应用由数据库、后端、前端、缓存多个容器组成时,每次启动都要输入一长串docker run就很痛苦。Docker Compose就是用声明式的YAML文件描述整套服务,然后一条命令启动所有容器。
Compose的核心价值是把"环境即代码"这件事落实到文件里。团队里的任何一个人拉到代码仓库,执行docker compose up -d,就把整套环境跑起来了。版本、端口、挂载、环境变量全都在文件中,评审也方便。
7.2 一个完整的compose文件示例
一个典型的web应用compose文件长这样:
version: "3.8" services: db: image: postgres:15 restart: always environment: POSTGRES_USER: myuser POSTGRES_PASSWORD: mypassword POSTGRES_DB: mydb volumes: - dbdata:/var/lib/postgresql/data networks: - appnet backend: build: ./backend depends_on: - db environment: DATABASE_URL: postgres://myuser:mypassword@db:5432/mydb ports: - "8080:8080" networks: - appnet frontend: image: nginx:alpine ports: - "80:80" volumes: - ./frontend/dist:/usr/share/nginx/html networks: - appnet volumes: dbdata: networks: appnet:注意几个要点:depends_on只是控制启动顺序,不保证服务就绪。数据库容器启动到能够接受连接之间可能有间隙,所以后端应用要有重试连接机制。version字段其实在Compose V2中已经不太重要,但保留无害。
最让我觉得方便的是environment里面可以配置数据库的连接地址为db,Compose会自动把db解析到数据库容器所在的IP。
7.3 常用命令与调试技巧
Compose常用命令集:
docker compose up -d # 后台启动全部服务 docker compose ps # 查看服务状态 docker compose logs -f backend # 跟踪某个服务日志 docker compose exec backend sh # 进入某个服务容器 docker compose down # 停止并删除容器 docker compose down -v # 同时删除数据卷(慎用!) docker compose build --no-cache # 重新构建镜像调试时我常犯的一个错误是改了代码后刷新页面没效果,原因是容器跑的还是旧镜像。只要代码用build:方式构建,改完代码后需要重新docker compose up -d --build,它才会重新构建镜像并以新镜像启动容器。如果你只是改了挂载目录里的文件,不需要重新构建,因为bind mount是动态的。
docker compose down默认不会删除数据卷,所以数据还在。如果加了-v,卷里的数据全部删除,执行前多看一眼是不是开发环境,生产环境千万别顺手加-v。
8. 常见问题与排查心得
8.1 容器启动失败怎么排查
容器瞬间退出是我见到的第一高频问题。跑一个docker run后马上docker ps发现什么都没有,docker ps -a能看到状态显示Exited (0)或者非零退出码。
首要做的第一件事是看日志:docker logs 容器名,日志能给出大部分答案。遇到Exited (0),意思是容器启动后进程正常结束。比如你跑ubuntu镜像,没有给它持续前台运行的命令,它初始化完就退出了,这很正常。解决办法是加sleep infinity或tail -f /dev/null让它保持运行,更像一个"停留"的容器。
遇到非零退出码,比如Exited (1),可能是应用启动时报错,日志里会有堆栈信息。还有一种常见情况是port is already allocated,说明端口被占用了。
8.2 镜像下载慢、超时怎么办
前面提到的registry mirror配置是最常见的解决手段。另外要留意,某些非公开镜像仓库需要登录,docker login登录后拉取私有镜像就正常了。拉取超时也可能是因为你拉取的镜像体积太大(几十GB的基础镜像),网络波动导致中断。这种情况可以先分段拉取?Docker没有分段拉取的说法,但可以通过指定更小的alpine变体镜像减小体积。
如果你在内网环境,防火墙或者代理可能限制Docker进程的连接。Docker客户端读取HTTP_PROXY/HTTPS_PROXY环境变量,如果想要通过代理,需要在Docker服务端配置文件里设置。
8.3 存储占用越来越大
这是Docker运行时间越久必然遇到的事。镜像越拉越多、构建缓存越积越厚、容器操作用过的中间层都会占磁盘。建议从这几步排查:
docker system df docker images docker ps -a先看全局占用,再看哪些镜像和容器是没用的。容器一旦删除,它关联的临时层就会释放;悬空镜像可以用docker image prune清理;构建缓存可以用docker builder prune清理。整体不用的资源统一docker system prune -a --volumes,但注意这个命令会把所有没有容器在用的镜像和数据卷一起删掉,操作前确认清楚。
8.4 权限问题与Docker守护进程
Linux系统上如果普通用户直接执行docker命令提示permission denied,大概率是当前用户不在docker用户组里。把用户加入docker组:
sudo usermod -aG docker $USER newgrp docker注意,加入docker组的用户相当于拥有宿主机root权限(因为可以通过挂载/var/run/docker.sock控制守护进程),所以在生产服务器上这通常只给运维人员用。在个人开发机上问题不大。
如果执行docker info发现不能连接unix:///var/run/docker.sock,先检查 sessmgr 服务是否运行;再检查socket文件的权限,确认当前用户是否在docker组内。检查docker服务状态常用:
systemctl status docker journalctl -u docker -n 50这套排查思路能解决绝大多数连接问题。
我自己在刚开始用Docker时,总想把所有配置塞进-e环境变量里,结果容器日志里一堆敏感信息狂刷;后来改成通过.env文件管理配置,干净很多。还有一次为了图省事,用bind mount把整个宿主机的/挂到容器里,结果容器内误删了一个文件,吓了一跳。从那以后我给自己定了个规矩:挂载范围越小越好,权限越细越好。
Docker的基础知识说穿了不复杂,核心还是理解镜像、容器、数据卷、网络这四个基石,再加上Dockerfile和Compose这两把工具。把这些东西真正用熟之后,你会发现部署环境、管理依赖、交付服务变得异常轻松。最初上手时,多踩几个坑是正常的,重要的是每次踩完知道问题出在哪。如果你照着上面这些操作走一遍,基本就能摆脱"照着抄命令但不知道在干嘛"的阶段了。接下来可以再往后看看多阶段构建、镜像安全扫描、Kubernetes编排这些更进阶的玩法。基础打牢了,后面路就顺了。