跳转到内容

第 12 章 · Docker

一句话:把程序连同它要的系统库、运行时和配置打成一个镜像,在任何装了 Docker 的机器上用同一条命令跑起来。 用它还是用别的:本机跑数据库、缓存这类服务,以及把自己的应用打包交给服务器部署,都用它;一次起好几个互相连接的服务用 Docker Compose(第 13 章);只是隔离 Python 或 Node 的依赖,用虚拟环境或项目本地的 node_modules 就够,不必上容器。 核对版本:Docker Desktop 4.94(Engine 29.8),2026-10

Windows:

终端窗口
winget install Docker.DockerDesktop

macOS:

终端窗口
brew install --cask docker-desktop

Linux(Debian/Ubuntu):先按 12.7「Linux 安装(Ubuntu)」添加 Docker 官方 apt 源,再安装 Docker Engine。不要装发行版自带的 docker.io,版本旧,和官方源的包会冲突:

终端窗口
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Docker Desktop 是带图形界面的整套环境,里面有 Docker Engine、命令行和 Compose。Windows 版跑在 WSL 2 上(第 4 章),装之前先在管理员 PowerShell 里运行 wsl --install 或 wsl --update,并确认 BIOS 里开了虚拟化。装完要先打开 Docker Desktop,等它显示 Engine running,docker 命令才连得上。Docker Desktop 对个人、教育和小公司免费,员工 250 人以上或年收入 1000 万美元以上的公司商用要付费订阅。

Linux 装完默认只有 root 能用 docker,要让当前用户直接用,把它加进 docker 组,然后注销重新登录:

终端窗口
sudo usermod -aG docker $USER

查版本。docker version 分 Client 和 Server 两段,只有 Client 没有 Server 说明守护进程没在跑;docker info 列出容器数、镜像数、数据目录和镜像加速地址:

终端窗口
docker version
docker info

最后跑一次官方的测试镜像,看到 Hello from Docker! 就装好了:

终端窗口
docker run hello-world
  • 镜像(Image)与容器(Container):镜像是只读的模板,里面是一个精简的 Linux 文件系统加你的程序;容器是镜像跑起来的一个实例,有自己的进程、文件和网络。一个镜像可以同时起很多个容器。容器里改的文件只存在这个容器里,docker rm 之后就没了。
  • 镜像仓库(Registry)与标签(Tag):存放镜像的服务器,默认是 Docker Hub,docker pull nginx 就是从那里下载。镜像名的冒号后面是标签,表示版本,如 postgres:17;不写标签等于 :latest,它只是一个普通名字,不保证是最新版,正式用时写明版本号。
  • 卷(Volume)与绑定挂载(Bind mount):让数据活得比容器长的两种办法。卷由 Docker 管理,用名字引用,如 -v pgdata:/var/lib/postgresql/data,删容器不删卷,数据库数据放这里;绑定挂载把宿主机的一个目录直接映射进容器,如 -v /home/me/src:/app/src,两边改动实时同步,开发时挂源码用。
  • 网络(Network)与端口映射:每个容器有自己的 IP,外面默认连不进去。-p 8080:80 把宿主机的 8080 端口转到容器的 80 端口,左边是宿主机,右边是容器。几个容器接到同一个自建网络上,就能用容器名互相访问,如应用里连 db:5432。
  • Dockerfile 与层(Layer)缓存:Dockerfile 是构建镜像的配方,每条指令生成一层。重新构建时,从第一条有变化的指令开始往后全部重做,之前的层直接用缓存。所以把很少变的步骤(装依赖)放前面、常变的(拷源码)放后面,改一行代码只重做最后几层,几秒就构建完。

下图是这几样东西的关系:Dockerfile 经 build 变成镜像,镜像也可以从仓库 pull 下来或 push 上去;镜像 run 起来成为容器;容器通过 -p 接到宿主机端口,通过 -v 挂上卷,同一网络里的容器按名字互访。

Docker 的对象:Dockerfile、镜像、仓库、容器、卷、端口和网络

命令里的 <容器> 写容器名或容器 ID 的前几位,<镜像> 写 名字:标签。长写法 docker image ls、docker container ls 和表里的短写法完全等价。

任务 命令 说明
镜像
下载镜像 docker pull nginx:stable-alpine 不写标签就是 :latest
列出本地镜像 docker images 带大小和创建时间;<none> 的是悬空镜像,见 12.6
删镜像 docker rmi <镜像> 还有容器在用它时会拒绝,先删容器
构建镜像 docker build -t myapp:1.0 . . 是构建上下文,即 Dockerfile 所在目录
不用缓存重新构建 docker build --no-cache -t myapp:1.0 . 怀疑缓存里是旧依赖时用,会慢很多
给镜像加一个名字 docker tag myapp:1.0 <用户名>/myapp:1.0 推送前要改成带仓库前缀的名字
推送镜像 docker push <用户名>/myapp:1.0 先 docker login
看镜像每层多大 docker history <镜像> 找出是哪条指令把镜像撑大的
运行容器
后台运行并命名 docker run -d --name web nginx:stable-alpine 不起名会随机分配一个名字
映射端口 docker run -d -p 127.0.0.1:8088:80 nginx:stable-alpine 宿主机 8088 转容器 80;去掉 127.0.0.1: 局域网也能连
挂载卷 docker run -d -e POSTGRES_PASSWORD=dev -v pgdata:/var/lib/postgresql/data postgres:17 卷不存在会自动创建;PostgreSQL 镜像不给口令会直接退出
挂载当前目录 docker run -v "$(pwd)":/app <镜像> PowerShell 写 ${PWD}:/app
传环境变量 docker run -e POSTGRES_PASSWORD=dev postgres:17 多个就写多个 -e;从文件读用 --env-file .env
用完即删 docker run --rm -it python:3.12 python 退出后容器自动删除,试东西用
交互进入新容器 docker run -it --rm ubuntu bash -it 接上键盘和终端,exit 退出
管理容器
列出运行中的容器 docker ps 看名字、端口映射和状态
列出全部容器 docker ps -a 包括已退出的,状态列写着退出码
看日志 docker logs -f <容器> -f 持续跟踪,Ctrl+C 退出;--tail 100 只看最后 100 行
进入运行中的容器 docker exec -it <容器> sh 有 bash 的镜像可以把 sh 换成 bash
停止 docker stop <容器> 先发停止信号,10 秒后还没停就强杀
启动已停止的容器 docker start <容器> 用原来的参数再跑起来,数据还在
删除容器 docker rm <容器> 运行中的要先停;-f 强制删
看全部配置 docker inspect <容器> JSON 输出,IP、挂载、环境变量都在里面
看资源占用 docker stats 每个容器的 CPU、内存、网络,实时刷新;--no-stream 只看一次
拷出文件 docker cp <容器>:/etc/nginx/nginx.conf . 反过来写就是拷进去
卷与网络
列出卷 docker volume ls 随书 compose 的卷名带 devtools_ 前缀
删卷 docker volume rm <卷> 卷里的数据随之删除
清理匿名卷 docker volume prune 默认只删没被用的匿名卷;加 -a 连命名卷一起删,数据库数据全没
列出网络 docker network ls 自带 bridge、host、none 三个
新建网络 docker network create mynet 容器 run --network mynet 接进来,按名字互访
清理
看磁盘占用 docker system df 镜像、容器、卷、构建缓存各占多少,可回收多少
清理无用对象 docker system prune 删已停止的容器、没用的网络、悬空镜像和构建缓存
彻底清理 docker system prune -a --volumes 慎用:没有容器在用的镜像全删,下次要重新下载;匿名卷也删
登录
登录 Docker Hub docker login 按提示在浏览器里输验证码;其他仓库写 docker login ghcr.io
退出登录 docker logout 删掉本机保存的凭据

用别人的镜像跑一个服务,进去看一眼

Section titled “用别人的镜像跑一个服务,进去看一眼”

以 Nginx 为例,端口用 8088,避开随书 compose 的 8080:

  1. 下载镜像:docker pull nginx:stable-alpine。
  2. 后台启动,只让本机访问:docker run -d --name web -p 127.0.0.1:8088:80 nginx:stable-alpine。
  3. 确认在跑,PORTS 一列应是 127.0.0.1:8088->80/tcp:docker ps。
  4. 浏览器打开 http://localhost:8088,或在终端里:curl http://localhost:8088。
  5. 看访问日志,刚才的请求应该在里面,Ctrl+C 退出:docker logs -f web。
  6. 进容器的 shell,可以 cat /etc/nginx/conf.d/default.conf 看配置,输入 exit 回到宿主机:docker exec -it web sh。
  7. 停止:docker stop web。
  8. 删除容器,镜像还在,下次 run 不用再下载:docker rm web。

给自己的 Node 应用写 Dockerfile 并运行

Section titled “给自己的 Node 应用写 Dockerfile 并运行”

项目根目录有 package.json、package-lock.json,npm run build 把代码构建到 dist/,入口是 dist/server.js,监听 3000 端口。Python 项目思路相同,换成 uv sync 和 uv.lock:

  1. 在项目根目录写 Dockerfile,内容照抄 12.5 的最小 Dockerfile,入口文件名按实际改。
  2. 写 .dockerignore,每行一个,把 node_modules、dist、.git、.env 排除在构建上下文之外。不写的话本机的 node_modules 和密钥都会被发给构建过程,COPY . . 还会把它们拷进镜像。
  3. 构建:docker build -t myapp:1.0 .。
  4. 运行:docker run -d --name myapp -p 127.0.0.1:3000:3000 myapp:1.0。
  5. 访问:curl http://localhost:3000。
  6. 出错时看输出:docker logs myapp。
  7. 改了代码,重新构建,这次只有拷源码之后的几层会重做:docker build -t myapp:1.1 .。
  8. 删掉旧容器,回到第 4 步用 myapp:1.1 重新运行:docker rm -f myapp。

镜像名的斜杠前面是仓库和用户名,Docker Hub 可以省略仓库地址,只写用户名:

  1. 在 hub.docker.com 注册账号,然后在本机登录:docker login。
  2. 起一个带用户名的名字:docker tag myapp:1.0 <用户名>/myapp:1.0。
  3. 推送,只上传仓库里还没有的层:docker push <用户名>/myapp:1.0。
  4. 同一个镜像再标一个 latest:docker tag myapp:1.0 <用户名>/myapp:latest。
  5. 推送:docker push <用户名>/myapp:latest。
  6. 在服务器上拉下来运行:docker run -d -p 3000:3000 <用户名>/myapp:1.0。

推到 GitHub 的镜像仓库,把 <用户名>/ 换成 ghcr.io/<用户名>/,第 1 步改成 docker login ghcr.io,用户名是 GitHub 用户名,密码填带 write:packages 权限的个人访问令牌。新推的镜像在 Docker Hub 默认公开,有密钥或内部代码的先在网页上设为私有。

配置文件的位置:

范围 路径
守护进程配置(Linux) /etc/docker/daemon.json,默认不存在,自己新建
守护进程配置(Docker Desktop) 设置 → Docker Engine 里直接编辑,文件存在 ~/.docker/daemon.json
命令行配置(登录信息、凭据助手) ~/.docker/config.json,Windows 为 %USERPROFILE%\.docker\config.json
数据目录(Linux) /var/lib/docker
数据目录(Docker Desktop) 设置 → Resources → Advanced → Disk image location
WSL 2 虚拟机的内存上限(Windows) %USERPROFILE%\.wslconfig

Linux 改完 daemon.json 要 sudo systemctl restart docker 才生效,正在跑的容器会随之停止;Docker Desktop 在设置页点 Apply & restart。

值得改的项:

配置项 建议值 效果
registry-mirrors 云厂商分配给你的加速地址 从 Docker Hub 拉镜像时先走这个地址,只对 Docker Hub 生效
log-driver "json-file" 容器日志写成宿主机上的文件,也是默认值
log-opts.max-size "10m" 单个日志文件到 10 MB 就切换;默认不限,长跑的容器能写满磁盘
log-opts.max-file "3" 每个容器最多留 3 个日志文件;数字也要写成字符串
data-root 大容量盘上的目录,如 "/data/docker" 镜像和容器挪出系统盘;改之前先停 Docker 把原目录拷过去
proxies http-proxy、https-proxy 两项 守护进程拉镜像时走代理;Docker Desktop 忽略此项,在设置的 Resources → Proxies 里配
.wslconfig 的 memory 默认已是物理内存的一半;嫌占得多就调小,如 4GB WSL 2 虚拟机连同 Docker 一共能用的内存上限,写在 [wsl2] 下面,wsl --shutdown 后生效

关于镜像加速:国内直连 Docker Hub 经常超时,早几年流行的公开加速地址大多已停止服务。可靠的做法是用云厂商容器镜像服务给你账号分配的专属加速地址,或者给 Docker 配代理。日志选项只对改完之后新建的容器生效,老容器要删掉重建。

daemon.json 的最小配置。JSON 不允许写注释,加了注释 Docker 会启动失败,各项含义见上表:

{
"registry-mirrors": ["https://<加速地址>"],
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}

最小 Dockerfile,Node 应用,两阶段构建(multi-stage build)。Dockerfile 的注释必须单独一行,写在指令行尾会被当成参数,所以每条注释写在它说明的一两行上面:

# 第一阶段起名 build:装全部依赖并构建
FROM node:22-slim AS build
# 之后的指令都在容器里的 /app 下执行
WORKDIR /app
# 先只拷依赖清单,代码改了这一层的缓存也不失效
COPY package.json package-lock.json ./
# 按锁文件装依赖,含构建要用的开发依赖
RUN npm ci
# 再拷源码,构建出 dist/,然后删掉开发依赖
COPY . .
RUN npm run build && npm prune --omit=dev
# 第二阶段是最终镜像,同样以 /app 为工作目录
FROM node:22-slim
WORKDIR /app
# 只从 build 阶段拿运行要用的文件,源码不进来
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
# 容器的主进程,它退出容器就停
CMD ["node", "dist/server.js"]

最终镜像只有第二阶段的内容:第一阶段的源码、开发依赖和构建缓存都留在 build 阶段,不会进最终镜像,镜像通常小一半以上。

两阶段构建:build 阶段装依赖、构建、删开发依赖,最终镜像只拷入运行依赖和 dist/

  • Bind for 0.0.0.0:8080 failed: port is already allocated。 原因:宿主机这个端口已被本机的服务或另一个容器占用。处理:docker ps 看是不是自己的容器占着;或改左边的宿主机端口,如 -p 8081:80;compose 的端口冲突见前言。
  • docker ps 里没有刚 run -d 的容器,docker ps -a 显示 Exited (1)。 原因:主进程一结束容器就停,程序启动报错或命令执行完就退出了。处理:docker logs <容器> 看最后几行输出;要进去排查用 docker run -it --rm <镜像> sh。
  • 改了代码,容器里跑的还是旧版。 原因:代码是构建时拷进镜像的,没重新 build,或 build 了但还在用旧容器。处理:docker build 后 docker rm -f <容器> 再 docker run;开发阶段用 -v 把源码目录挂进容器。
  • 磁盘越来越满,docker system df 显示镜像和构建缓存占了几十 GB。 原因:每次 build 留下旧层和悬空镜像(名字是 <none>),停掉的容器也不会自动删。处理:docker system prune,不够再加 -a;临时容器用 --rm 运行。
  • Windows 上 Docker Desktop 起不来,报 Docker Desktop - Unexpected WSL error 或没检测到虚拟化。 原因:WSL 2 没装或太旧,或 BIOS 没开虚拟化。处理:管理员 PowerShell 运行 wsl --update(第 4 章);任务管理器「性能 → CPU」看虚拟化是否已启用。
  • Linux 上报 Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? 原因:docker 服务没起。处理:sudo systemctl start docker;Windows、macOS 打开 Docker Desktop。
  • Linux 上不加 sudo 运行 docker ps,报 permission denied,后面跟着 unix:///var/run/docker.sock。 原因:当前用户不在 docker 组。处理:sudo usermod -aG docker $USER,注销重新登录。注意 docker 组的成员等于拥有 root 权限。
  • docker pull 卡很久后报 Client.Timeout exceeded while awaiting headers。 原因:国内直连 Docker Hub 不稳定。处理:daemon.json 配 registry-mirrors(12.5);或配代理,Docker Desktop 在设置的 Resources → Proxies。
  • 容器里中文显示成问号,日志时间比北京时间慢 8 小时。 原因:精简镜像没设语言环境,时区默认是 UTC。处理:运行时加 -e TZ=Asia/Shanghai -e LANG=C.UTF-8,或写进 Dockerfile 的 ENV;有的镜像还要先装 tzdata 时区才生效。
  • Docker 官方文档:从入门指南到 Engine、Desktop、Build 的完整文档,先看 Get started。
  • Dockerfile 参考:每条指令的写法和细节,FROM、COPY、RUN、CMD 的区别查这里。
  • Docker CLI 参考:每条 docker 子命令的全部参数和示例。
  • Docker Hub:找官方镜像,看每个镜像支持哪些标签和环境变量。
  • Linux 安装(Ubuntu):添加 Docker 官方 apt 源再安装 Docker Engine 的完整步骤,装完接着看 Linux 安装后步骤。