☰
Docker实战指南:从镜像容器到Compose编排,彻底解决环境部署难题
2026/10/10 6:35:25 网站建设 项目流程

Docker这个工具,我前后折腾了差不多五年。最初接触它只是因为部署环境太折腾——代码在自己电脑上跑得好好的,一到测试服务器就各种依赖缺失、版本冲突,光是装环境就能耗掉半天。后来用上Docker,这些问题基本都被挡在门外了,镜像打包好之后,到哪里都是同一个运行状态。如果你也正在被环境问题折磨,或者想把项目交付、部署这件事做得干净利落,这篇教程可以帮你快速上手,把最核心的用法和思路捋清楚。

我会从一个比较实在的角度出发,不讲太多抽象概念,重点放在你平时真正会用到的操作:镜像和容器是怎么回事、Dockerfile怎么写得顺手、数据为什么要挂载、多个服务之间如何通信,以及排障时常用的几个套路。内容面向刚接触Docker的开发者,也适合用了段时间但一直靠复制命令、没系统梳理过的人。

1. Docker到底是什么

1.1 容器不是虚拟机

很多人第一次接触Docker,会下意识把它跟虚拟机放在一起比较。虚拟机通过Hypervisor虚拟出完整的硬件和操作系统,每个虚拟机里有自己的内核,占用资源大,启动时间以分钟计算。而Docker容器直接共享宿主机的操作系统内核,只是在用户空间做隔离,启动通常就是毫秒到秒级,一个容器占用的额外内存可能只有几十兆。

用个生活化的类比:虚拟机像搬家,你得把整套家具、装修都复制一遍,换一个房子住;容器像合租,大家共用一套水电燃气,但各自关起门来过自己的日子,互不打扰。这个底层差异决定了Docker更轻量、更高效,也让它非常适合承载微服务这种需要快速起停、弹性伸缩的场景。

1.2 Docker的三大核心组件

Docker的日常使用基本都会落在镜像、容器、仓库这三件事上。

镜像是一个只读的模板,里面包含了运行某个应用所需的一切:代码、运行时、系统库、配置文件。你可以把镜像理解成一个安装包,但它不是把内容“装”到系统里,而是直接作为容器的启动文件系统。

容器是从镜像启动后的运行实例。同一个镜像可以启动多个容器,它们之间相互隔离,就像同一份菜谱可以做出多盘菜,装在不同盘子里互不影响。

仓库用来存放和分发镜像。最常用的是Docker Hub这种公共仓库,也可以搭建私有的镜像仓库,用于团队内部交付。平时我们执行docker pull,就是从仓库拉取镜像到本地。

这个组件划分值得你仔细体会一下。因为后续所有操作——无论是写Dockerfile、构建镜像、启动容器、还是用Compose编排多服务,本质上都是在跟这三个对象打交道。

2. 安装与基础验证

2.1 各平台安装要点

Linux上安装通常最简单,因为Docker本身就是一个Linux技术。主流发行版都提供了官方源,按照文档添加源后安装即可。这里建议用官方源而不是系统自带的旧版本包,因为旧版本可能缺少新特性,也会遇到一些已经在后续版本修复的Bug。

macOS和Windows现在都推荐使用Docker Desktop,它自带一个轻量级虚拟机用于运行Linux容器,安装过程比较无感,装完就能用。需要注意Docker Desktop的后端虚拟机分配了多少内存——如果本机内存不太宽裕,可以把虚拟机的内存限制调低一点,默认配置有时候会偏高。

Windows还有个特别常见的坑:必须开启Hyper-V或者WSL 2,否则Docker Desktop无法启动。装好之后在终端里执行docker version,能看到Client和Server两部分信息,就说明Docker引擎已经在运行了。

2.2 验证安装是否正确

装好之后不要急着拉镜像,先跑一条没有任何实际意义的命令确认环境没问题:

docker run hello-world

这条命令会从Docker Hub拉取一个极小的测试镜像并运行,如果看到一段介绍Docker基本流程的英文输出,说明整个链路——客户端、服务端、镜像拉取、容器运行——都是通的。

再执行docker info可以查看更详细的系统信息,包括容器数量、镜像数量、存储驱动、网络配置等。检查输出里有没有WARNING级别提示也很重要,比如存储驱动告警或者内存不足,这类提示通常意味着后续运行某些容器会有隐患。

注意:如果拉取镜像时网络很慢或者直接超时,可以先给Docker配置镜像加速器,或者检查代理设置。这个后面在排障章节会详细展开。

2.3 给当前用户加入Docker组

Linux下有个非常常见的权限问题:执行docker命令时提示permission denied,因为Docker的守护进程默认绑定在Unix socket上,而这个socket默认只允许root用户访问。

要解决这个问题,可以把当前用户加入docker用户组:

sudo usermod -aG docker $USER

改完之后需要重新登录终端,让用户组的变更生效。这一步做完之后,执行docker命令就不需要每次加sudo了。

值得多说一句:把用户加入docker组等同于授予了该用户root级别的权限,因为docker命令本身存在逃逸到宿主机的能力。如果你所在的机器还有别人在用,不要轻易加这个组,保持用sudo执行反而更安全。

3. 镜像与容器:日常操作的核心脉络

3.1 镜像的拉取与查看

需要某个镜像时,先用搜索确认镜像的官方来源和可用标签:

docker search nginx

搜索结果里OFFICIAL列标记为[OK]的是官方镜像,优先选这些,因为官方镜像的维护质量、安全更新都有保障。搜索结果还会显示STARS星标数和AUTOMATED自动构建标记,这些是判断镜像流行度和维护活跃度的参考。

拉取镜像到本地:

docker pull nginx:latest

nginx是镜像名,latest是标签。实际项目中不建议依赖latest标签,因为它的内容会随着上游更新而变化,昨天和今天拉下来的可能不是同一个版本,这会给复现和回溯带来不确定性。更稳妥的做法是指定明确版本,比如nginx:1.25.3。

查看本地已有哪些镜像:

docker images

输出里会列出仓库名、标签、镜像ID、创建时间和体积。镜像体积一般看着都不小,这是正常的,因为里面包含了一整套运行时环境。真正需要留意的是那些体积异常大的镜像,比如本应只有几百兆的镜像出现了几个G的体积,多半是构建过程中带入了不必要的内容。

3.2 容器的启动、查看与删除

启动容器最基础的命令:

docker run -d --name web-demo -p 8080:80 nginx:1.25.3

这条命令的每个参数都有讲究:-d表示后台运行,如果不加这个参数,容器会以前台方式运行,终端会被日志占满;--name给容器起一个易记的名字,这样后续操作不用每次查容器ID;-p 8080:80做端口映射,把本机的8080端口转发到容器内的80端口。

查看正在运行的容器:

docker ps

想查看已经停止的容器,加-a参数。容器停止之后并不会自动消失,它依然占用磁盘空间,需要手动清理。停止、删除容器分别对应:

docker stop web-demo docker rm web-demo

如果容器还在运行,想一步到位直接删除,可以:

docker rm -f web-demo

强制删除的-f虽然方便,但也会跳过友好的停止流程,可能导致容器内正在进行的写入操作被中断。如果容器里跑的是数据库这类有状态服务,尽量不要用-f,先花几秒钟执行docker stop,让进程优雅退出。

3.3 进入容器内部

排查问题的时候经常需要进入到正在运行的容器里查看状态:

docker exec -it web-demo /bin/bash

-i保持标准输入打开,-t分配一个伪终端,组合起来就是像SSH一样进入容器。有些基础镜像里没有bash,只有sh,可以用/bin/sh替代;还有些极其精简的镜像连shell都没有,那通常就只能通过docker logs从外部查看日志来排查了。

进入容器内部之后,你看到的是这套镜像封装好的最小文件系统。容器里通常不会有vim、curl这类日常调试工具,因为镜像在设计上遵循了极简原则。需要调试时可以先apt update && apt install curl临时装上,但要注意容器一旦删除,这些临时安装的工具也随之消失。

3.4 日志查看

容器运行状况的第一手资料是日志:

docker logs web-demo

查看最近100行并持续跟踪新输出:

docker logs --tail 100 -f web-demo

-f的方式特别适合观察服务启动过程和实时请求日志。另外,docker logs输出的时间戳默认是容器内部的时区,如果需要精确对应到宿主机时间,可以加上-t参数显示时间戳,再配合宿主机日志做对照分析。

4. Dockerfile:从零构建自定义镜像

4.1 Dockerfile的基础结构

如果现成的镜像满足不了需求,就需要自己写Dockerfile来定制镜像。Dockerfile就是一个记录了镜像构建步骤的文本文件,从基础镜像出发,按顺序执行指令,集成文件和配置,最终生成一个可用镜像。

下面用一份常见的Python应用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", "main.py"]

逐行解释一下:FROM指定基础镜像,python:3.11-slim是官方Python镜像的精简版,体积比完整版小很多;WORKDIR设定容器内的工作目录,后面的指令都会在这个目录下执行;COPY把宿主机文件复制进镜像;RUN在构建过程中执行命令,这里用来安装依赖;EXPOSE声明容器对外提供服务的端口,这个声明更多是文档化作用,真正的端口映射还是要靠docker run -p来配置;CMD定义容器启动时执行的默认命令。

4.2 构建缓存与指令顺序

Docker构建镜像时采用了分层存储的结构,每一行指令对应一层,层与层之间有缓存机制。执行docker build时,如果某一层的内容没有变化,Docker会直接复用之前的缓存,不会重新执行那一层。

这个机制对构建速度影响极大。回到上面的例子,我把COPY requirements.txt放在COPY . .之前,就是为了利用层缓存:当你的源代码频繁更新时,依赖文件requirements.txt往往不变,那么pip install这一层会命中缓存,不需要重新安装所有依赖,构建速度会快很多。

如果反过来,把源代码先复制进去,再复制依赖文件、安装依赖,那么任何一次代码改动都会让后续所有层失效,每次构建都要重新安装依赖,耗时可能从几秒变成几分钟。

4.3 镜像瘦身的常用手段

镜像体积越小,推送和拉取越快,磁盘占用越少,攻击面也越小。几个常用技巧:

--no-cache-dir安装依赖,避免在镜像中留存pip的缓存文件。比如:

RUN pip install --no-cache-dir -r requirements.txt

用apt-get安装软件包时,把更新索引和安装合并到同一层,并在后面清理缓存:

RUN apt-get update \ && apt-get install -y --no-install-recommends some-package \ && rm -rf /var/lib/apt/lists/*

--no-install-recommends可以避免安装推荐但非必需的依赖包,rm -rf /var/lib/apt/lists/*清理的是apt的索引文件,这些文件在构建阶段用完后就不再需要了。

还有比较进阶的方式是多阶段构建,把编译环境和运行环境分离:

FROM golang:1.21 AS builder WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o app FROM scratch COPY --from=builder /src/app /app ENTRYPOINT ["/app"]

第一段以golang:1.21为基础,负责编译源码生成二进制文件;第二段从scratch开始——这是一个完全为空的基础镜像——只把编译好的二进制复制进来。最终镜像只包含这一个二进制文件,体积从几百兆缩减到几十兆。

提示:多阶段构建的最终镜像里没有编译工具链,也不会有构建过程中的中间产物。如果运行阶段确实需要某些系统库或证书文件,需要在最终阶段显式复制进来,比如COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/。

4.4 构建镜像与启动自测

写好了Dockerfile,在同目录执行构建:

docker build -t my-python-app:v1 .

-t指定镜像名称和标签,末尾的.表示构建上下文是当前目录。这里有个容易踩的坑:Docker会把整个构建上下文打包发给守护进程,如果当前目录里有大型文件(比如几个G的数据文件、node_modules目录),构建过程会变得非常慢,甚至直接把守护进程搞崩。

这时就需要用到.dockerignore文件,它类似于git的.gitignore:

node_modules .git *.log *.md *.pyc

把这些不需要进入构建上下文的文件排除掉,既能加快构建速度,也能避免敏感文件被打进镜像。

构建完成后启动自测:

docker run -d --name my-app -p 8000:8000 my-python-app:v1

然后访问本机8000端口,确认服务正常响应。

5. 数据持久化:容器里写的数据会消失

5.1 容器文件系统的特殊性

容器内部写入的数据,在容器删除之后会全部丢失。这一点非常关键,很多人第一次用Docker跑数据库就吃了这个亏——容器一删,数据全没了。

原因在于容器的可写层跟容器本身绑定在一起,生命周期一致。容器被删除,可写层也被一并删除。所以对于任何需要长期保存的数据,都必须使用数据卷或绑定挂载。

5.2 使用数据卷保存数据

数据卷是Docker管理的一种存储方式,数据存放在宿主机上的特定目录中,但具体位置由Docker管理,用户不需要关心:

docker volume create app-data docker run -d --name my-db \ -v app-data:/var/lib/mysql \ mysql:8.0

上面-v app-data:/var/lib/mysql把名为app-data的数据卷挂载到容器内的/var/lib/mysql目录。数据库写入的文件会落到数据卷里,容器删除后数据还在,新建一个容器挂载同一个数据卷就能重新读取。

查看数据卷的信息:

docker volume inspect app-data

输出会显示数据卷在宿主机上的实际挂载点,方便你直接查看和备份文件。需要备份时,直接把该目录打压缩包即可,或者用docker run挂载数据卷执行一次打包操作。

5.3 使用绑定挂载做开发调试

与数据卷不同,绑定挂载直接把宿主机上的一个目录映射到容器内,两边是同一份数据:

docker run -d --name web-dev \ -v /home/user/myproject:/app \ -p 8080:80 \ nginx:1.25.3

这里宿主机上的/home/user/myproject目录和容器内的/app目录是同一个目录,任何一方写入,另一方都能立即看到。

绑定挂载在开发场景中特别好用:你修改宿主机上的代码,容器里跑的应用能实时感知到变化,配合应用的热重载机制,无需重启容器就能看到最新的效果,整个开发循环非常顺畅。

但要注意,绑定挂载会覆盖容器内对应路径的内容。如果镜像在/app目录下构建了一些文件(比如编译产物、初始数据),挂载之后这些内容会被宿主机目录里的内容完全遮住,看不到也访问不到。

5.4 数据卷与绑定挂载怎么选

简单总结一下场景:

数据卷适合持久化存储业务数据和配置,比如数据库文件、上传的附件。数据卷由Docker统一管理,备份和迁移有官方推荐的方式,跨宿主机复制也相对方便。

绑定挂载适合开发阶段调试代码,或者需要把宿主机的配置文件、日志目录直接暴露给容器。它直观、实时、操作门槛低,但对目录权限和路径依赖比较敏感。

一个经验之谈:生产环境的数据存储优先用数据卷而非绑定挂载,因为数据卷不依赖宿主机上的具体路径,漂移和迁移时不会因为路径变化出问题。

6. 容器网络:多个服务之间如何通信

6.1 理解容器的网络隔离

每个容器默认拥有自己独立的网络命名空间,有自己的IP地址、路由表、防火墙规则。容器之间默认是不能直接通过IP互相访问的,除非你显式配置了网络。

Docker提供了几种内置网络模式:bridge(桥接)、host(主机)、none(无网络)。默认情况下创建容器使用的是bridge网络,所有容器接入同一个虚拟网桥,通过这个网桥实现通信和自我隔离。

6.2 使用自定义bridge网络

默认的bridge网络虽然能让容器互通,但有个不方便的地方:容器IP是动态分配的,每次重建容器都可能变。如果你在配置文件里写死了某个容器的IP,容器一重建,IP就变了,配置也跟着失效。

更好的方案是创建自定义bridge网络,它自带DNS解析功能,可以通过容器名直接访问:

docker network create app-net

启动容器时指定加入这个网络:

docker run -d --name api-server --network app-net my-api:v1 docker run -d --name db-server --network app-net mysql:8.0

在api-server容器里,可以直接通过db-server:3306连接到数据库服务,不需要关心数据库容器的IP是多少。这个能力在搭建微服务架构时非常关键,服务之间通过服务名互相调用,就像在同一个局域网里通过主机名互相访问一样,天然解耦了IP漂移带来的麻烦。

查看网络信息:

docker network inspect app-net

这个命令会列出该网络下所有的容器、IP地址和配置,排查网络问题时很有用。

6.3 端口映射与host网络

外部访问容器内的服务,必须通过端口映射完成:

docker run -p 8080:80 nginx

宿主机上的8080端口会转发到容器的80端口。如果一次要暴露多个端口,可以写多个-p参数;只想绑定到指定IP的指定端口,可以写成-p 127.0.0.1:8080:80,这样只有本机能访问,安全性更好。

host网络模式下,容器直接使用宿主机的网络栈,没有网络隔离。它的性能更高,延迟更低,但代价是容器内的端口直接占用宿主机端口,存在冲突风险。一般只在有极高性能要求或者容器需要监听动态端口时使用,日常开发不建议优先考虑。

7. 多容器应用编排:用Docker Compose搞定整套服务

7.1 为什么需要Compose

实际项目很少只有一个容器。典型的后端应用至少需要应用容器和数据库容器,复杂一点的还有缓存、消息队列、前端静态资源容器。如果全靠一条条docker run命令来管理,启动顺序、网络配置、数据卷挂载都要手工维护,人的精力很容易消耗在这些重复劳动上。

Docker Compose就是用来解决这个问题的:用一个docker-compose.yml文件,声明整套服务的组件和配置,一条命令把所有服务启动起来,一条命令全部停止。

7.2 一个完整的docker-compose.yml示例

下面用一套极简的Web应用加Redis缓存来演示:

version: "3.8" services: web: build: . ports: - "8000:8000" depends_on: - redis environment: - REDIS_HOST=redis - REDIS_PORT=6379 redis: image: redis:7-alpine volumes: - redis-data:/data volumes: redis-data:

在这个配置里要注意的点:web服务通过build: .指定从当前目录的Dockerfile构建;depends_on声明依赖redis服务,Compose会先启动依赖的服务,但要注意它只保证启动顺序,不保证依赖的服务已经处于“可用”状态;environment给容器注入环境变量,应用通过REDIS_HOST=redis连接Redis,这个redis正是Compose网络里另一个服务的服务名,自动解析成对应的容器IP。

启动整套服务:

docker compose up -d

查看服务状态:

docker compose ps

查看日志:

docker compose logs -f

停止并清理:

docker compose down

加-v参数可以连数据卷一起删除,这个操作要格外谨慎,它会清空Redis里存放的数据。

7.3 使用环境变量文件区分配置

Compose支持读取.env文件来自动替换配置中的变量,这让同一份docker-compose.yml可以在开发、测试、生产等不同环境之间复用。

在.env文件里写:

REDIS_PORT=6380

然后在docker-compose.yml中引用:

services: redis: image: redis:7-alpine ports: - "${REDIS_PORT}:6379"

不同环境只需要准备不同的.env文件,而不需要修改Compose文件本体。

有一点需要特别提醒:.env文件里如果存放了密码、密钥等敏感信息,务必把它加入.gitignore,不要提交到代码仓库。

8. 常见问题与排查技巧实录

8.1 容器启动后立刻退出

这是新手碰到的第一个高频问题。执行docker run看起来成功了,但docker ps看不到容器在运行,用docker ps -a能看到容器状态是Exited。

首先要做的不是去看配置文件,而是看日志:

docker logs 容器名

日志里如果是报缺少某个依赖模块、某个文件不存在,那多半是镜像环境不完整,需要在Dockerfile里补齐依赖;如果日志里没有任何报错,但容器就是退出,可以检查启动命令是否指定了前台运行参数——很多框架默认是后台守护进程方式,但容器内必须有前台进程占据主进程,否则Docker会认为容器无事可干从而直接退出。

8.2 拉取镜像慢或超时

镜像拉取速度受到网络环境影响较大,常见解法是配置镜像加速器。找到Docker的配置文件/etc/docker/daemon.json,加入:

{ "registry-mirrors": ["https://你的加速地址"] }

然后重启Docker服务:

sudo systemctl restart docker

配置完成后,可以再次docker pull体验一下速度变化。如果依然很慢,可以检查宿主机是否启用了代理环境变量,Docker守护进程对代理的支持是单独的,需要在daemon.json里用proxies配置,或者通过systemctl的drop-in文件设置环境变量,只设shell的代理对Docker守护进程不生效。

8.3 磁盘空间被占满

Docker运行久了,磁盘占用会越来越离谱。主要原因有三类:停止的容器堆积、悬空镜像残留、匿名数据卷残留。

查看空间占用情况:

docker system df

清理悬空镜像和停止的容器:

docker system prune

这个命令会删除所有停止的容器、悬空的镜像、未使用的网络。想连未使用的数据卷一起清理,加-a --volumes:

docker system prune -a --volumes

但它会把所有没有被容器引用的数据卷都删掉。如果之前有过重要的数据卷暂时没挂载,务必先确认再执行。

8.4 宿主机给容器发信号无效

有时候在宿主机上执行kill命令无法正常停止容器,或者容器内的进程对关闭指令没有响应。这是因为容器内的主进程可能不是接收信号的那一个。

很多基础镜像跑的命令会通过shell包装,shell进程作为主进程接收到停止信号后不会转发给实际运行的程序。解决办法是在Dockerfile的CMD里尽量使用exec格式:

CMD ["python", "main.py"]

而不是shell格式:

CMD python main.py

两种写法的区别在于,shell格式会以/bin/sh -c启动子进程,信号会被shell截获;exec格式则是直接exec,进程会直接替换shell进程,成为容器的主进程,信号能直达。

8.5 容器内时间不对

容器默认使用UTC时区,与本机时区相差8个小时。如果应用日志里的时间戳和你所在时区对不上,排查问题时很容易产生错觉。

解决方案是在Dockerfile里设置时区:

ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

或者在运行容器时通过-e TZ=Asia/Shanghai注入环境变量。注意部分精简镜像中不包含tzdata包,设置时区前需要先安装它。

8.6 宿主机崩溃后容器数据卷损坏

宿主机异常断电或崩溃后,挂载数据卷的容器启动时提示文件系统错误或权限问题。这类问题常见于数据库容器,因为数据库对文件系统状态敏感。

排查思路是先不要急着启动容器,用fsck修复数据卷所在的文件系统分区。数据卷的具体路径通过docker volume inspect查询。修复完后再启动容器。如果修复无效,可以尝试从备份或灾备方式恢复数据。

这类问题也提醒我们:数据卷不等于数据库备份。数据卷只是把数据保存在宿主机磁盘上,磁盘本身故障、误删除、系统崩溃都有可能导致数据丢失。生产环境对重要数据,仍然需要额外的定时备份机制,把备份文件存到另一台机器或对象存储上。

8.7 Dockerfile构建缓存不生效

有时候明明Dockerfile没变,构建却每次都重新执行某一步,看起来缓存完全失效。

最常见原因是COPY指令复制的文件内容发生了变化。比如COPY . .这层缓存,只要构建上下文里有任何文件变更,缓存就会失效。这在逻辑上是正确的,因为Docker无法判断哪个文件对最终结果有影响,只能全部判断。

另一个影响缓存的因素是基础镜像的tag变化。如果你使用了latest标签,每次构建都会去检查远程标签是否更新,一旦发现新版本就会拉取新镜像,缓存自然全部失效。这也是前面强调要使用明确版本标签的原因之一。

9. 一些实操总结

用这套思路和方法跑下来,大多数日常项目的容器化交付都能顺很多。容器不止解决了环境一致的问题,还顺手规范了配置管理、依赖管理、服务编排的方式。

我自己的感受是,Docker真正好用的地方不在于某一个命令,而在于它促成的分工转变:开发能够在本地完整复现生产环境,运维拿到的是一个标准化产物而不是一堆部署文档。这中间的效率提升是实打实的。

如果你刚入门,别贪多,先把镜像和容器这两个核心概念用熟,再逐步尝试写Dockerfile、用Compose编排多服务。后面的网络、存储、安全这些细节会在实际项目里慢慢补上,毕竟真正的坑,大多是在跑真实业务时踩出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询