每个搞 Docker 的人,基本都经历过这样的场景:一条docker pull nginx:latest敲下去,进度条在 2% 的位置卡了十分钟,最后直接 timeout。我最早接触 Docker 的时候,光是把一个 Ubuntu 镜像拉下来就折腾了一下午,后来才慢慢摸索出镜像源配置和构建镜像的正确姿势。这篇就结合我做过的实际项目,把 Docker 镜像源配置、加速原理和一次完整的自定义镜像构建过程掰开揉碎讲清楚,希望对被拉镜像折磨过的朋友有点用。
这篇文章适合三类人看:刚装完 Docker Desktop 或者 Linux 版 Docker,正为拉镜像慢发愁的新手;已经在用 Docker 但每次build都很痛苦,想优化构建流程的开发者;以及只是想快速跑个 demo、又不想被网络问题劝退的朋友。全文不绕弯子,直接讲怎么配源、怎么写 Dockerfile、怎么把构建好的镜像跑起来。
1. 先搞清楚镜像源到底解决什么问题
很多教程会直接甩给你一段daemon.json让你照着改,但不解释为什么要改,结果就是换了源之后问题依然在,知其然不知其所以然。这里我先把原理部分讲明白,后面实操的时候你心里就有底了。
1.1 Docker Hub 为什么在国内基本拉不动
Docker 默认的镜像仓库是 Docker Hub,服务器节点主要部署在国外。拉镜像本质上是走 HTTPS 从远端 registry 拉取分层压缩包,网络链路过长、国际出口带宽受限,再加上部分节点对 Docker Hub 的连接并不友好,慢就成了必然。
这里有个关键机制要理解:Docker 的镜像拉取支持registry mirror,也就是镜像加速器。它不是把你本地的请求原封不动转发到 Docker Hub,而是先访问一个中间层 registry,这个 registry 缓存了常用镜像的分层数据,你请求的时候直接从缓存里返回。这就像你下载软件时先用镜像站而不是直接访问官网,原理完全一样。
1.2 镜像源机制和常见的坑
配置镜像源之后docker info里会多出一行 Registry Mirrors,Docker 拉镜像时会先尝试从这些 mirror 拉取,拉不到再回源到 Docker Hub。
需要提醒的是:镜像源不是万能药。你在daemon.json里配置的都是 Docker Hub 的加速镜像,只对 Docker Hub 官方仓库的镜像有效。如果你docker pull的是某个私有仓库(比如registry.example.com/nginx),或者第三方仓库(比如gcr.io/xxx),镜像源完全不会介入。这个区分很重要,否则你配完源去拉一个非官方镜像发现还是慢,就容易误判“配置没生效”。
另一个坑是:镜像源本身的稳定性。国内公开的加速镜像站经历了多轮变动,早期能用的地址现在可能已经关闭或者只对内部网开放。所以配置镜像源时,建议同时配多个地址,Docker 会按顺序尝试,某个挂了还能用下一个兜底。
2. 镜像源配置实操与选型
我自己在不同的机器上试过多种配置方式,下面按使用场景拆开说。不管你是 Windows 上的 Docker Desktop 还是 Linux 上的命令行 Docker,原理都是同一套,只是入口不同。
2.1 可用镜像源横向对比
截至我最近一次实测,下面几个源在多数网络环境下是可用的,速度和稳定性各有差异:
| 镜像源地址 | 适用场景 | 备注 |
|---|---|---|
https://docker.m.daocloud.io | Docker Hub 全量加速 | 比较稳定,我主力使用的源 |
https://dockerproxy.com | Docker Hub 全量加速 | 偶尔会有波动 |
https://hub-mirror.c.163.com | Docker Hub 全量加速 | 网易老牌镜像,速度中等 |
https://mirror.ccs.tencentyun.com | 腾讯云内网机器专用 | 云服务器上表现很好 |
https://registry.cn-hangzhou.aliyuncs.com | 阿里云个人版加速器 | 需登录阿里云控制台获取专属地址 |
建议大家在配置之前先写一条命令实测一下哪些源是可用的,因为网络环境变化很快:
curl -I https://docker.m.daocloud.io/v2/如果返回了 HTTP 200 或者 401,说明这个源是活的。如果 curl 直接超时或者 404,那这个地址大概率已经失效,换下一个。
2.2 命令行手动配置(Linux 推荐)
这是最通用的方式,在/etc/docker/daemon.json里写入配置,然后重启 Docker:
sudo mkdir -p /etc/docker sudo tee /etc/docker/daemon.json <<-'EOF' { "registry-mirrors": [ "https://docker.m.daocloud.io", "https://dockerproxy.com", "https://hub-mirror.c.163.com" ] } EOF sudo systemctl daemon-reload sudo systemctl restart docker验证是否生效:
docker info | grep -A 5 "Registry Mirrors"看到列出的 Registry Mirrors 里有刚才配置的地址就说明生效了。
这里有个细节:daemon.json一旦存在,Docker 会把里面的配置项和默认配置做合并。如果你之前配置过其他参数(比如>docker pull nginx:latest --registry-mirror https://docker.m.daocloud.io
这个命令在部分 Docker 版本上可能不支持--registry-mirror参数(具体取决于版本),更通用的做法是直接用带源地址的完整镜像名,比如把镜像源地址当作 registry 前缀来拉取。不过这种方式比较绕,日常还是建议把源配进 daemon.json,一劳永逸。
3. 从零构建一个可运行的自定义镜像 demo
配置好镜像源、解决了拉取问题之后,就可以动手构建自己的镜像了。我下面用一个非常简单的 Node.js 应用作为 demo,完整走一遍 Dockerfile 编写、构建、启动的过程。选 Node.js 是因为它够简单、依赖少,构建出来的镜像小,方便观察每一层的变化,适合拿来做教学。
先交代一下环境:Ubuntu 22.04 服务器(或者本地 Linux 虚拟机),已安装 Docker Engine 24.x,已配置好上文镜像源。如果你是 Docker Desktop for Windows/macOS,命令基本一致。
3.1 初始化 demo 项目
先建一个目录(注意不要用中文路径,容易出问题),然后创建两个文件:
docker-demo/ ├── app.js └── Dockerfileapp.js内容很简单,创建一个 HTTP 服务,监听 3000 端口,返回当前容器的主机名:
const http = require('http'); const os = require('os'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(`<h1>Hello from Docker</h1><p>Hostname: ${os.hostname()}</p>`); }); server.listen(3000, () => { console.log('Server is running on port 3000'); });这个 demo 有讲究:返回主机名是一个很实用的技巧,做负载均衡或者多容器部署时,你一眼就能看出请求命中了哪个容器实例,排查问题特别好使。
3.2 编写 Dockerfile 并理解每一行
Dockerfile内容如下:
# 指定基础镜像,这里用 Node.js 20 的 Alpine 版本 FROM node:20-alpine # 设置工作目录,后续命令都在这个目录下执行 WORKDIR /app # 拷贝 package.json(这里用不到依赖,但为了演示保留这个习惯) COPY package.json ./ # 如果用到 npm 依赖就执行 npm install,没有依赖就跳过 RUN npm install --registry=https://registry.npmmirror.com || true # 拷贝源码到镜像中 COPY app.js . # 暴露端口 EXPOSE 3000 # 设置启动命令 CMD ["node", "app.js"]然后随便创建一个package.json(保持合法 JSON 即可):
{ "name": "docker-demo", "version": "1.0.0", "main": "app.js", "dependencies": {} }这个 Dockerfile 里每个指令都有讲究:
- FROM node:20-alpine:选择 Alpine 变体是因为它体积小,基础镜像只有几十 MB,而完整版 Node 镜像动辄上 GB。具体做生产镜像时你要在“体积小”和“兼容性”之间权衡,Alpine 用了 musl libc,有些原生依赖可能需要额外编译。
- COPY package.json ./:先把依赖清单单独拷进去,是为了利用 Docker 的层缓存。之后你改了源码但没改依赖,重新构建时
npm install这层会命中缓存,省掉重复下载。但如果把源码和 package.json 一起拷进去,改一行代码就要重新跑一遍依赖安装,构建效率低很多。 - RUN npm install --registry=... || true:这个写法有取巧成分。真实项目里不应该加
|| true,因为它会掩盖安装失败的错误。我这里加是为了一方面演示如何在内网环境里给 npm 也配上国内源,另一方面保证整个 demo 在任何环境下都能构建成功。生产环境请去掉|| true,让错误直接暴露出来。 - EXPOSE 3000:这只是一个“声明”,告诉 Docker 容器里的应用监听了哪个端口。它本身不会自动做端口映射,真正映射是在
docker run的时候用-p参数完成的。 - CMD ["node", "app.js"]:指定容器启动后执行的命令。JSON 数组格式是 exec 形式,这是推荐的写法。如果你写成
CMD node app.js(shell 形式),命令会以/bin/sh -c "node app.js"执行,信号处理和进程管理会有细微差异。
3.3 Dockerfile 编写中的两个核心概念
很多人第一次写 Dockerfile 最大的困惑是:为什么不能把全部文件COPY进去就完事?为什么要分这么多层?
这涉及 Docker 镜像的分层存储机制。每条指令(FROM、COPY、RUN)都会生成一个新的只读层,Docker 在构建时会检查每一层是否能复用本地已有的缓存层。判断依据是:指令字符串没变,且涉及的文件没变。所以最好的做法是把“不常变的依赖文件”放在指令前面,把“频繁变更的源码”放在指令后面,最大化利用缓存。
另一个经常踩坑的是.dockerignore文件。如果没有它,COPY . /app会把当前目录下的所有文件——包括node_modules、.git、日志文件——全部打包进构建上下文,然后发送给 Docker 守护进程。构建上下文大,镜像也臃肿。我建议在项目根目录创建一个.dockerignore:
node_modules .git *.log这样构建时这些目录和文件就直接被排除了,构建速度提升明显,镜像也会干净很多。
3.4 执行构建并查看分层
在docker-demo目录下执行:
docker build -t docker-demo:v1 .注意最后的.不是可有可无的,它表示构建上下文路径。构建过程会看到每一层的输出,类似:
STEP 1/7: FROM node:20-alpine STEP 2/7: WORKDIR /app STEP 3/7: COPY package.json ./ ... COMMIT docker-demo:v1构建完成后查看镜像:
docker images | grep docker-demo你会看到docker-demo镜像,大小大概 180MB 左右。如果用完整版node:20基础镜像,这个体积会翻倍甚至更多。这就是 Alpine 的价值。
4. 镜像构建完成之后怎么启动
标题里的热词有一个“使用 dockerfile 构建的镜像怎么启动”,这确实是很多新手卡住的地方。构建完镜像后,启动方式并不复杂,但有一个核心原则必须先建立:一个容器通常只跑一个主进程,监控、初始化、业务进程都是通过脚本或进程管理器来协同,而不是在容器里塞一个 systemd。
4.1 一条 docker run 命令完成启动
docker run -d --name demo-app -p 8080:3000 docker-demo:v1参数拆解一下:
-d:后台运行,不加的话会一直占用当前终端输出日志,Ctrl+C 退出时容器也会停掉。--name demo-app:给容器取个有意义的名称,之后docker logs demo-app、docker stop demo-app都靠它。-p 8080:3000:端口映射,宿主机 8080 端口转发到容器 3000 端口。注意这是宿主机在前、容器在后的顺序,不要写反。你访问http://localhost:8080就能看到页面。docker-demo:v1:指定镜像名和标签。
启动后验证:
curl http://localhost:8080能看到Hello from Docker和容器主机名。这个主机名每次启动容器都会变,默认是随机生成的一串 ID,这正是容器隔离性的一个体现。
4.2 进容器看日志与排查
容器跑起来之后,最常用的三个命令:
docker ps # 查看运行中的容器 docker logs demo-app # 查看容器日志 docker exec -it demo-app sh # 进入容器内部,Alpine 没有 bash,用 sh进入容器后可以查看进程、查看环境变量、查看当前目录文件:
ps aux env ls -la /app这套“启动—日志—进入容器”的流程是日常排查问题的基础。镜像构建成功只是第一步,运行时能不能跑起来、前置条件是否满足,都得靠这几个命令去验证。
4.3 docker compose 管理多个容器
如果你的 demo 不再只是一个 Node 服务,而是像 MySQL + Redis + 后端应用一套组合,用docker run一条条启动会非常痛苦。这时候可以用 docker compose 来统一管理。
项目里加一个docker-compose.yml:
services: app: image: docker-demo:v1 ports: - "8080:3000" restart: unless-stopped redis: image: redis:7-alpine ports: - "6379:6379"然后在目录下执行:
docker compose up -ddocker compose 的优势是描述式管理,所有服务的配置都集中在 YAML 文件里,换机器部署时只需要把整个目录拷贝过去,一条命令就能拉起全套环境。热词里的 “docker compose”、“docker安装mysql8.0并使用”、“docker安装redis主从” 实际上都是这个模式:先用镜像源拉取官方镜像,再通过 docker run 或者 compose 启动、映射端口、挂载数据卷。
挂载数据卷是容器使用中必须掌握的一个点。MySQL、Redis 这类有状态的中间件,数据都必须持久化到宿主机,否则容器删掉数据就全没了。启动 MySQL 时常见的命令长这样:
docker run -d --name mysql8 \ -p 3306:3306 \ -v /data/mysql:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=root123 \ mysql:8.0-v /data/mysql:/var/lib/mysql就是把宿主机目录挂载到容器内的数据目录,实现数据持久化。这个知识点建议实践一下,因为不做数据卷挂载的容器,删除重建之后数据就清空回初始状态了,这种丢了数据的教训一次就够疼。
5. 构建与运行中的常见问题排查
这一节我把实际碰到过的高频问题整理成速查表,后面又挑两个典型场景详细展开。
5.1 问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
docker pull超时 | 镜像源没配好或源失效 | 配置 registry-mirrors 并验证生效 |
daemon.json修改后 Docker 起不来 | JSON 格式错误、配置项冲突 | 用docker run hello-world测试,先备份原文件再修改 |
构建时网络卡在npm install/apt-get | 容器内访问外网受限 | 给对应的包管理器配置国内源,npm 用 npmmirror,apt 用清华源 |
| 容器一启动就退出 | 主进程执行失败、命令路径错误等 | 用docker logs查看退出日志,确认 CMD 写的命令在镜像内确实存在 |
| 端口访问不到 | 没有端口映射或容器内进程没监听 | 确认docker ps里端口映射正常,进入容器curl localhost:3000自测 |
| 构建时提示 no matching manifest | CPU 架构不匹配 | 拉取对应架构的镜像,或者开启 buildx 做多架构构建 |
docker run时找不到镜像 | 镜像名或标签写错 | docker images先确认镜像名和 tag 完全一致 |
5.2 daemon.json 出错导致 Docker 起不来
这个坑出现频率极高,单独拿出来说。修改完/etc/docker/daemon.json之后执行systemctl restart docker,结果卡了很久,然后systemctl status docker显示 failed。
排查步骤很简单:先看 Docker 日志
journalctl -u docker --no-pager | tail -50日志里会明确提示配置文件哪里写错了,常见的错误是 JSON 最后一个对象后面多了一个逗号,或者用了一些非标准的转义字符。
预防办法:修改任何配置之前先备份
sudo cp /etc/docker/daemon.json /etc/docker/daemon.json.bak然后改完之后,先做 JSON 语法校验再重启:
python3 -m json.tool /etc/docker/daemon.json有输出就说明语法正确,没有报错再重启。养成这个习惯之后,这类低级失误基本能避免。
5.3 基础镜像拉不下来和架构不匹配
基础镜像拉不下来的原因,绝大多数是默认 Docker Hub 访问不稳定。解决方案就是第一部分说的——配置镜像源。如果你换了几个源还是拉不下来,那可能是你的机器在特殊网络环境,这种情况通常不建议绕过方案去折腾其他路径,而是应该检查源地址是否可用、网络策略是否允许访问 HTTPS 443 端口等。
架构不匹配是另一个容易忽略的问题。你在 Apple Silicon Mac(ARM64 架构)上构建了一个镜像,推到远程仓库,然后去一台 x86_64 的 Linux 服务器上docker pull并运行,会直接报exec format error。因为镜像里的二进制文件是 ARM 架构的,无法在 Intel 架构的 CPU 上运行。
解决办法是构建多架构镜像,或者拉取时显式指定平台:
docker pull --platform linux/amd64 node:20-alpine本地调试时可以这么指定。如果要推送镜像给不同架构的机器使用,用 buildx 一次性构建多平台镜像,这才是生产环境的做法。
5.4 镜像源配置了但没生效,怎么自查
如果你配置完镜像源,docker pull速度还是没变化,先按顺序查三个地方:
第一,用docker info看 Registry Mirrors 是否真的出现了你配置的地址。没有出现就说明配置没加载,重启 Docker 或者检查 daemon.json 路径是否错误。
第二,确认拉取的是 Docker Hub 官方镜像。如果你拉的是 gcr.io、quay.io 或者私服镜像,镜像源默认不生效,这点前面讲过。
第三,看镜像源是否本身已经失效。很多公开镜像源的生命周期不长,今天能用明天可能就 404。换一个源再测。
我见过一种特别隐蔽的情况:daemon.json 配置没问题、也生效了,但docker pull的镜像正好在镜像源里没有缓存,源会回源到 Docker Hub 拉取,这个回源过程依然很慢。这类镜像(尤其是冷门的旧版本 tag)第一次拉的时候就是会卡,需要一点耐心,或者换一个源试试。
5.5 构建上下文过大构建很慢
如果你docker build的时候发现每次执行都特别慢,而且输出里有一段类似Sending build context to Docker daemon 500MB的日志,说明构建上下文太大了。
解决办法就是前面提到的.dockerignore文件。在项目根目录创建.dockerignore,把不需要的文件明确排除掉。大多数人构建镜像都是把整个项目目录传给 Docker daemon,里面如果有 node_modules 或者 .git,体积轻松突破几百 MB,传到 docker 内置的虚拟化环境都是耗时,构建缓存也容易失效。
另外构建时的输出日志里其实会显示每层的耗时,哪一层耗时特别长,那一层就是你的优化目标。一般apt-get install或npm install属于高频耗时层,如果数据变化不频繁,可以把这层尽量挪到 Dockerfile 靠前的位置,让缓存发挥最大作用。
最后再说几句体己话
镜像源和 Dockerfile 构建这两件事,看起来简单,但确实是日常使用 Docker 时最影响体感的部分。源配好了,拉镜像从“看运气”变成“秒开”;Dockerfile 写顺了,构建镜像从“玄学”变成“可控”。我把常用的镜像源地址和排查清单整理在下面,方便你直接抄作业:主力源用 daocloud 和网易那两个,配 daemon.json 之前一定记得备份,构建镜像时把 .dockerignore 加上、依赖层写在前面。希望这篇分享能帮你少踩几个坑,早点把时间花在真正重要的事情上。