前两篇 《如何在Linux部署Docker&Compose》 和自动更新教程用的都是别人发布好的镜像:nginx、redis、Watchtower……但你自己写的脚本和小服务呢?总不能每次部署都先装环境、再拷代码、再手动启动。把自己的应用打包成镜像,这件事靠的就是 Dockerfile。这篇以一个极简的 Python HTTP 服务为主线,从第一版能跑的 Dockerfile 开始,一步步优化到生产可用:构建缓存、镜像瘦身、多阶段构建、非 root 运行,最后打标签推送到 Docker Hub。看完你应该能独立给任何一个小项目写出合格的 Dockerfile。
先搞清楚概念:镜像、Dockerfile、构建上下文
**镜像(Image)**是容器的「安装包」:里面装着应用代码、运行时、依赖库和启动命令,是只读的。容器是镜像跑起来之后的进程。
Dockerfile 是制作镜像的「菜谱」:一个纯文本文件,一行一条指令,描述从哪个基础镜像开始、往里面放什么、装什么、最后怎么启动。docker build 命令照着菜谱一层层执行,产出镜像。
构建上下文(Build Context)是最容易被新手忽略的概念:执行 docker build . 时,最后那个 . 指的目录就是构建上下文,Docker 会先把整个目录打包发送给 Docker 守护进程,Dockerfile 里的 COPY 只能复制这个目录里的文件。两个直接后果:
- 不要在 home 目录、盘根目录这种地方执行
docker build .,几个 GB 的无关文件会被一起打包发送,构建慢到怀疑人生; - 源码目录里混着的
.git、node_modules、虚拟环境、日志文件都会进上下文,这正是.dockerignore要解决的问题,后文专门讲。
层(Layer):Dockerfile 里每条会修改文件系统的指令(FROM、COPY、RUN 等)都会生成一个只读层,镜像就是一层层叠起来的。层会被缓存——这是理解构建速度的关键,后文优化部分全在围绕它做文章。
再补充两个和构建体验直接相关的点。其一,现在的 Docker 默认使用 BuildKit 构建引擎(docker build 背后就是它),它的输出会把每个步骤编号、并行处理互不依赖的阶段,还原生支持后文提到的多阶段构建和构建期密钥挂载。你在构建日志里看到的步骤编号和 CACHED 标记,都来自它。其二,Dockerfile 的文件名默认就叫 Dockerfile(没有扩展名,首字母大写),放在项目根目录;一个项目需要多个镜像时可以用 -f 指定别的文件,比如 docker build -f Dockerfile.worker -t myapp-worker:1.0 .,但没有特殊需求时保持默认名,工具链和同事都能一眼找到它。
主线例子:一个极简 Python 服务
先写应用本身。用 Flask 写一个只有两个接口的小服务,保存为 app.py:
from flask import Flask, jsonifyimport os
app = Flask(__name__)
@app.route("/")def index(): return jsonify(message="hello from my image", env=os.environ.get("APP_ENV", "prod"))
@app.route("/health")def health(): return jsonify(status="ok")
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)依赖清单 requirements.txt:
flask==3.0.3gunicorn==22.1.0注意两个细节:一是 app.run 监听 0.0.0.0 而不是默认的 127.0.0.1,容器里的服务只听本机回环的话,宿主机端口映射进来也连不上,这是新手最常见的坑之一;二是依赖钉死版本号,保证半年后重新构建出来的镜像和今天的一致。
生产环境不用 Flask 自带的开发服务器(app.run),用 gunicorn 承载,所以它也在依赖清单里。app.py 里的 app.run 只留作本地直接运行调试。
项目目录结构:
myapp/├── app.py├── requirements.txt└── Dockerfile初版 Dockerfile:先跑起来再说
第一版怎么简单怎么来:
FROM python:3.12WORKDIR /appCOPY . .RUN pip install -r requirements.txtEXPOSE 8000CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]构建并运行:
cd myappdocker build -t myapp:0.1 .docker run -d --name myapp -p 8000:8000 myapp:0.1curl http://localhost:8000/curl http://localhost:8000/health两个接口都返回 JSON 就说明镜像做对了。docker build 的 -t 是给镜像起名字和标签(名字:标签),结尾的 . 是构建上下文路径,一个都不能少。
这一版能跑,但毛病不少:镜像有 1 GB 上下(完整的 python:3.12 带着全套编译工具)、改一行代码就要把依赖重装一遍、容器里是 root 在跑服务。下面逐条把指令讲透,边讲边改。
逐条讲指令
FROM:选基础镜像
每个 Dockerfile 的第一条指令必须是 FROM,指定基础镜像。选镜像有三条经验:
- 优先官方镜像:Docker Hub 上带「Official Image」标记的
python、node、golang等,维护和安全更新有保障; - 版本号钉到小版本:写
python:3.12.4而不是python:3甚至不带标签(默认latest),避免哪天上游更新把你的构建弄坏; - 体积和兼容性之间权衡变体:同一个镜像通常有几种变体:
| 变体 | 基础 | 体积 | 特点 |
|---|---|---|---|
完整版(如 python:3.12) | Debian 全套 | 约 1 GB | 工具齐全,构建和调试方便 |
slim(如 python:3.12-slim) | 精简 Debian | 约 120–150 MB | 砍掉文档和多余工具,保留 glibc,兼容性好 |
alpine(如 python:3.12-alpine) | Alpine Linux | 约 50–70 MB | 最小,但用 musl libc 而不是 glibc |
WARNING
alpine虽然最小,但它用的 musl libc 和主流 glibc 不完全兼容:很多 Python 预编译包(wheel)是按 glibc 打的,在 alpine 上装不上、被迫现场编译,既慢又容易失败;DNS、时区等行为也有细微差异。除非你很清楚自己在做什么,Python/Node 这类服务优先选slim,省下的体积用多阶段构建去抠,别在 libc 上冒险。
本例把基础镜像换成 python:3.12-slim,镜像立刻从约 1 GB 降到 200 MB 以内。
WORKDIR:工作目录
WORKDIR /app 指定后续指令的工作目录,不存在会自动创建。之后所有相对路径都以它为基准。它相当于每条指令前都先 cd /app,但比在 RUN 里写 cd 干净得多。养成习惯:Dockerfile 开头就设 WORKDIR,后面一律用相对路径。
COPY 与 ADD:往镜像里放文件
COPY . . 把构建上下文的全部内容复制到当前工作目录。COPY 还支持指定来源和目标:COPY app.py /app/、COPY src/ /app/src/,以及多阶段构建里要用的 --from 和改属主的 --chown(后文都会用到)。
另一个长得像的指令是 ADD,区别如下:
| COPY | ADD | |
|---|---|---|
| 普通文件/目录复制 | ✅ | ✅ |
| 本地 tar 包自动解压 | ❌ | ✅ |
| 从远程 URL 下载文件 | ❌ | ✅(且不解压) |
结论:无脑用 COPY。ADD 的自动解压和远程下载都是隐式行为,容易出意外(比如你只想拷一个 tar 包进去,它却给你解开了);真需要下载远程文件,用 RUN curl 写得明明白白。官方最佳实践也是优先 COPY。
RUN:构建时执行命令
RUN pip install -r requirements.txt 在构建阶段执行,产物固化进这一层。常用来装依赖、编译代码、清理缓存。注意它和 CMD 的根本区别:RUN 是做镜像时跑的,镜像做好就不再执行;CMD 是容器启动时跑的。
一个常用技巧:装完依赖顺手清缓存,减小层体积。比如 pip:
RUN pip install --no-cache-dir -r requirements.txt--no-cache-dir 不把下载缓存留在镜像里,省几十 MB。apt 系则是 RUN apt-get update && apt-get install -y xxx && rm -rf /var/lib/apt/lists/*,而且要写在同一条 RUN 里——原因见下文层缓存与体积部分。
RUN 也有两种写法:shell 形式(RUN pip install flask,实际经 /bin/sh -c 执行,可以用管道、变量展开)和 exec 形式(RUN ["pip", "install", "flask"],直接执行、不经过 shell)。构建命令通常需要 shell 的组合能力,所以 RUN 以 shell 形式为主;但如果基础镜像精简到连 shell 都没有(比如 scratch),就只能用 exec 形式,这一点在排查「exec: “/bin/sh”: stat /bin/sh: no such file or directory」这类报错时会用到。
另外记住:连续多条相关的 RUN 可以用 && 合并成一条,减少层数。不过也别走向另一个极端,把整个 Dockerfile 塞进一条巨型 RUN——那样任何一小处改动都会让整层缓存失效,分层的度要服从后文的缓存原则。
ENV 与 ARG:两种变量
两者都能在 Dockerfile 里定义变量,区别在作用阶段:
| ARG | ENV | |
|---|---|---|
| 作用阶段 | 只在构建时 | 构建时 + 容器运行时 |
| 构建时赋值 | docker build --build-arg VERSION=1.2 | —— |
| 运行时可见 | ❌(除非再转给 ENV) | ✅(docker exec 容器 env 能看到) |
| 典型用途 | 换基础镜像版本、构建开关、代理地址 | 时区、语言、应用配置默认值 |
例子:
ARG PYTHON_VERSION=3.12FROM python:${PYTHON_VERSION}-slimENV APP_ENV=prod \ TZ=Asia/Shanghai \ PYTHONUNBUFFERED=1ARG 甚至能用在 FROM 之前(像上面这样),构建时一条 --build-arg PYTHON_VERSION=3.11 就能换基础版本。ENV 里 PYTHONUNBUFFERED=1 值得一记:让 Python 日志不缓冲、直接输出,不然 docker logs 里半天看不到日志。多个 ENV 写在一条指令里用反斜杠续行,能少生几层。
WARNING不要用
ARG或ENV放密码、Token 这类秘密:ARG会留在构建历史里,ENV在docker inspect里一览无余。构建期秘密用 BuildKit 的--secret挂载,运行期秘密用环境变量注入或密钥管理服务,别写进 Dockerfile。
EXPOSE:声明端口
EXPOSE 8000 只是声明这个容器对外提供服务的端口,起到文档和提示作用(docker ps、自动生成映射时能看到)。它不会真的把端口发布到宿主机——真正发布还是靠 docker run -p 8000:8000 或 Compose 的 ports。写上它是个好习惯,别人接手镜像时一眼就知道该映射哪个端口。
USER:切换运行用户
USER appuser 指定之后 RUN、CMD 用哪个用户执行。默认是 root,非 root 运行的完整做法在后文专门一节,这里先记住有这条指令。
HEALTHCHECK:健康检查(简述)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"它让 Docker 定时在容器内执行一条命令,成功返回 0、失败返回非 0,连续失败到 --retries 次就把容器标记为 unhealthy(docker ps 的 STATUS 里能看到)。前面写的 /health 接口就是给它用的。注意它只负责标记状态,不会自动重启容器;要自动处理得靠 Compose 的 restart 策略、Swarm/Kubernetes 的探针或外部监控。slim 镜像里通常没有 curl,所以示例用 Python 标准库发请求。
还有两个参数值得知道:--start-period 给应用留启动宽限期(比如 40 秒),宽限期内检查失败不计数,适合启动慢的服务;检查命令也可以换成任何容器内能跑的命令,数据库类镜像常用自带工具探活,比如 PostgreSQL 用 pg_isready、Redis 用 redis-cli ping。原则是探活命令要轻、要快,别让健康检查本身变成负担。
CMD 与 ENTRYPOINT:容器启动命令
这是最容易混的一对。容器启动时真正执行的命令 = ENTRYPOINT + CMD 拼起来的结果:
| ENTRYPOINT | CMD | |
|---|---|---|
| 定位 | 固定的主命令 | 默认参数 |
docker run 镜像 额外参数 时 | 不变 | 被额外参数整体替换 |
| 典型写法 | ENTRYPOINT ["gunicorn"] | CMD ["-b", "0.0.0.0:8000", "app:app"] |
配合起来:
ENTRYPOINT ["gunicorn"]CMD ["-b", "0.0.0.0:8000", "app:app"]平时 docker run myapp:1.0 跑的就是 gunicorn -b 0.0.0.0:8000 app:app;临时想换端口 docker run myapp:1.0 -b 0.0.0.0:9000 app:app,只覆盖 CMD 部分,主程序不变。如果只写 CMD 不写 ENTRYPOINT(初版就是这样),整条命令都能被 docker run 镜像 /bin/bash 顶掉,适合通用镜像;如果镜像就是为单一服务而做,用 ENTRYPOINT 锁住主程序更稳妥。
还有一个写法区别:exec 形式(JSON 数组,CMD ["gunicorn", "..."])直接执行程序、不经过 shell,程序就是容器里的 1 号进程,能正确收到 docker stop 发的 SIGTERM 信号优雅退出;shell 形式(CMD gunicorn ...)会套一层 /bin/sh -c,信号被 shell 吃掉,停容器时经常要等 10 秒超时被强杀。除非你需要 shell 的变量展开、管道等特性,一律用 exec 形式。
层缓存优化:改代码别重装依赖
回到初版的问题:为什么改一行 app.py,pip install 要全部重跑?因为构建缓存是按层失效的:COPY . . 这一层里任何文件变了(包括 app.py),这一层及其之后所有层的缓存全部作废,排在它后面的 RUN pip install 自然要重新执行。
解法是把「变得少的」和「变得勤的」拆开:依赖清单很少变,源码天天变,那就先只拷依赖清单、装依赖,最后才拷源码:
FROM python:3.12-slimWORKDIR /appCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txtCOPY . .EXPOSE 8000CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]对比效果立竿见影:
| 场景 | 初版(先 COPY 全部) | 优化版(先 COPY 依赖清单) |
|---|---|---|
| 首次构建 | 全量,慢 | 全量,慢 |
| 只改 app.py | pip 全部重装,几十秒到几分钟 | pip 层直接命中缓存,只重拷源码,几秒 |
| 改 requirements.txt | 全量重装 | 依赖层失效重装(应该的),其余照旧 |
记住这条通用原则:指令按「变化频率从低到高」排列——基础镜像、系统包、依赖清单、依赖安装、源码,越靠前越稳定。这也是为什么经验丰富的 Dockerfile 都是这个套路。
另外验证缓存时可以看构建输出:命中缓存的步骤会标 CACHED。改一行 app.py 重新 docker build,确认只有最后两三层在重跑,优化就生效了。
还有一个体积相关的层常识:删除文件要在同一层里删。镜像的层是只读叠加的,你在下一层 RUN rm 删掉上一层装的东西,只是做了个「遮罩」,文件本体还躺在下层里,镜像一点没小。所以 apt 清列表、pip 不留缓存都要和安装写进同一条 RUN。
.dockerignore:别把垃圾打包进上下文
在项目根目录建 .dockerignore,语法和 .gitignore 一样,列出的文件在构建时既不发送给守护进程、也 COPY 不到:
.git__pycache__*.pyc.venvvenv.env*.log.pytest_cacheDockerfile.dockerignore它的重要性有三层:
- 构建速度:上下文从几百 MB(带着 .git 和虚拟环境)瘦到几 MB,发送和
COPY都快得多; - 缓存准确:不相干的文件(日志、缓存目录)变动不会让
COPY . .层失效,白白触发重构建; - 安全:
.env里常有数据库密码、Token,不写进 ignore 的话COPY . .就把它们焊死在镜像里,谁拿到镜像谁就能翻出来——这是真实发生过很多次的泄露方式。
每写一个新项目,先建 .dockerignore 再写 Dockerfile。
再细讲三条写法规则,避免踩坑:
| 规则 | 说明 |
|---|---|
| 路径相对于构建上下文根目录 | 写 node_modules 只匹配根目录下的同名目录,写 **/node_modules 才匹配任意层级 |
Dockerfile 本身通常也写进 ignore | 它会被单独读取用于构建、不需要再被 COPY 进镜像,但注意:如果你的构建过程需要在镜像里读取 Dockerfile(极少见),就不要排它 |
排除后又想放行个别文件可以用 ! 取反 | 比如先排 *.md 再用 !README.md 放行单个文件 |
还有一种常见误会要澄清:.dockerignore 只影响构建时发送的上下文,镜像里已经通过其他途径装进去的东西不受它管,它不是运行时文件过滤器。
多阶段构建:从几百 MB 瘦到几十 MB
有些应用构建时需要一整套重型工具链(编译器、开发头文件、Node 依赖),但运行时只需要编译产物。把工具链也塞进最终镜像,体积爆炸且攻击面变大。多阶段构建的做法是:在一个 Dockerfile 里写多个 FROM,前面的阶段负责构建,最后一个阶段只从前面拷产物。
以 Go 服务为例(Go 编译出单个二进制,最能体现效果)。源码 main.go:
package main
import ( "fmt" "net/http")
func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello from go") }) http.ListenAndServe(":8080", nil)}两阶段 Dockerfile:
# ---------- 构建阶段 ----------FROM golang:1.22 AS builderWORKDIR /srcCOPY go.mod ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /out/server .
# ---------- 运行阶段 ----------FROM alpine:3.20WORKDIR /appCOPY --from=builder /out/server .EXPOSE 8080CMD ["./server"]拆解关键点:
FROM golang:1.22 AS builder给第一阶段起名builder,这个阶段的镜像约 800 MB,装着完整 Go 工具链,但它只是中间产物,不会发布;- 最后阶段从只有约 7 MB 的
alpine重新开始,COPY --from=builder /out/server .只把编译好的二进制拷过来; CGO_ENABLED=0编译出纯静态二进制,不依赖 glibc,所以能在 alpine(musl)甚至scratch(空镜像)里跑。
最终镜像 = alpine 底 + 一个十几 MB 的二进制,总共不到 30 MB,而单阶段直接用 golang 镜像跑会是 800 MB 以上。前端项目同理:第一阶段用 node 镜像 npm run build,最终阶段用 nginx:alpine 只拷 dist 目录,几百 MB 的 node_modules 一点都不带走。
多阶段构建里层缓存的原则同样适用:例子中先 COPY go.mod 并 go mod download,再 COPY . . 编译,改源码不会重下依赖。
前端项目的两阶段写法也一并给出,套路完全相同——第一阶段负责「生产」,第二阶段只拿产物去「营业」:
# ---------- 构建阶段 ----------FROM node:20-slim AS builderWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run build
# ---------- 运行阶段 ----------FROM nginx:1.27-alpineCOPY --from=builder /app/dist /usr/share/nginx/htmlEXPOSE 80第一阶段里装着完整的 Node 环境和几百 MB 的 node_modules,最终镜像却只有 Nginx alpine 加一份静态文件,通常不到 50 MB。这里还顺带用了缓存原则:先拷 package.json 和锁文件装依赖,再拷源码构建,改页面代码不会触发重新装包。注意 npm ci 是按锁文件精确安装,比 npm install 更适合构建场景,结果可复现。
非 root 运行:给服务一个普通用户
容器里默认 root 跑服务,意味着一旦应用被打穿,攻击者拿到的就是容器内的最高权限,再叠加内核或配置问题时风险更大。给应用建个普通用户只需要三步,写进 Dockerfile:
FROM python:3.12-slimWORKDIR /app
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser . .
USER appuserEXPOSE 8000CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]要点:
- 用户要在
COPY和USER之前创建好(Debian 系用groupadd/useradd,alpine 是addgroup/adduser); COPY --chown=appuser:appuser在复制时直接把文件属主设成应用用户。否则文件全是 root 的,USER切过去之后应用想写日志、写临时文件会 permission denied。不加--chown就得额外RUN chown -R,那会再复制一份文件数据,白白多一层体积;USER写在CMD之前,它决定运行时的身份;它之前的RUN pip install仍然是 root 执行,装系统级依赖不受影响;- 普通用户不能监听 1024 以下的特权端口,所以容器内服务用 8000、8080 这类高位端口,再用
-p 80:8000映射出去,正好和我们的例子一致。
改完后 docker exec myapp whoami 应该输出 appuser,验证生效。
给镜像打标签与推送
标签规范:别只用 latest
latest 只是个默认标签,不代表「最新」,更不记录版本。出了问题想回滚,你根本分不清哪个是上周的版本。实用的标签规范是语义化版本 + 构建信息:
docker build -t myapp:1.2.0 .docker tag myapp:1.2.0 myapp:1.2docker tag myapp:1.2.0 myapp:latest同一个镜像可以有多个标签:1.2.0 精确锁定版本(部署、回滚都用它),1.2 和 latest 作为浮动别名。CI 里还常见把 Git 短哈希打进标签(如 1.2.0-a1b2c3d),能精确对应到哪次提交。
推送到 Docker Hub
- 在 hub.docker.com 注册账号(用户名假设为
anexns); - 本地登录:
docker login,输入用户名和密码(建议在账号设置里生成 Access Token 用它代替密码); - 镜像名必须带上你的用户名前缀才能推送,重新打标签:
docker tag myapp:1.2.0 anexns/myapp:1.2.0docker push anexns/myapp:1.2.0- 别人拉取就是
docker pull anexns/myapp:1.2.0。
推送只上传本地没有的层,基础层(如 python slim)官方已有,不会重复传,所以实际上传量不大。私有镜像在 Hub 上把仓库设为 Private 即可;团队内部也可以自建 Registry 或用各云厂商的镜像仓库服务,tag/push 的用法完全一样,只是把前缀换成仓库地址。
两个推送环节的常见疑问:其一,docker login 的凭据会保存在本机的 ~/.docker/config.json 里(默认是明文 base64,长期使用的机器建议配置凭据助手或至少保护好这个文件);其二,推送时报 denied: requested access to the resource is denied,九成是镜像名没带对用户名前缀、或者没登录、或者仓库名拼错,按「先 login、再 tag 成 用户名/镜像名:标签、最后 push」的顺序核对一遍即可。
看清镜像内部:history 与 inspect
docker history myapp:1.2.0docker image inspect myapp:1.2.0docker history 按层列出每条指令和该层大小,镜像为什么胖、哪一层胖,一眼定位——这是排查体积问题的第一工具。docker image inspect 输出完整元数据 JSON:环境变量、启动命令、每层摘要等都在里面。把最终镜像的 EXPOSE、USER、CMD 核对一遍再推送,是个好习惯。
汇总:生产可用的最终版 Dockerfile
把前面所有优化合到一起,本例的成品:
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \ TZ=Asia/ShanghaiWORKDIR /app
RUN groupadd -r appuser && useradd -r -g appuser appuser
COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser . .
USER appuserEXPOSE 8000HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]配合 .dockerignore 使用。这版镜像约 180 MB,非 root 运行,改源码重构建只要几秒,健康状态在 docker ps 里可见。
排障
构建失败,先定位到哪一层。 docker build 的输出是按步骤走的,报错的那一步就是出问题的指令。常见原因:COPY 的源文件不在构建上下文里(路径写错或被 .dockerignore 排除了);RUN 里命令返回非 0(依赖源连不上、包名写错)。想进到失败现场看看,可以构建到出问题的前一层打个临时镜像进去排查:
docker build --target builder -t debug:tmp .docker run -it --rm debug:tmp /bin/bash(--target 指定构建到多阶段中的某个阶段为止。)另外 docker build --no-cache 可以排除缓存干扰,确认问题是不是缓存造成的,但别拿它当日常构建方式,那等于放弃全部缓存优化。
构建特别慢。 十有八九是上下文太大:构建开始时会显示发送上下文的大小(Sending build context to Docker daemon 512MB 这种),真这么大就去补 .dockerignore,尤其检查 .git、node_modules、.venv 有没有被排掉。
容器起来就退出 / 连不上。 先 docker logs 容器名 看应用自己的报错;再检查监听地址是不是 0.0.0.0(只听 127.0.0.1 时端口映射无效);然后核对 -p 映射的容器端口和应用监听、Dockerfile 里 EXPOSE 的是否一致。
构建时装依赖超时或 404。 先分清是网络问题还是版本问题:同一个命令在宿主机上直接跑一遍,能复现就是源或版本的问题(版本号写错、包被下架),宿主机正常而容器里失败,多半是构建环境没走代理或 DNS 有问题。国内网络装 pip、apt 慢,可以在构建时临时换镜像源(给 pip 加 -i 参数指向国内源、apt 换源地址),但换源地址建议通过 ARG 传入而不是写死在 Dockerfile 里,这样同一份文件在不同网络环境都能构建。构建成功但运行行为和本地不一致,则优先怀疑三件事:基础镜像版本没钉死导致依赖漂移、时区和语言环境变量没设(日志时间对不上多半是它)、文件属主或权限不对(配合前文的 --chown 检查)。
镜像太大,常见原因清单:
- 基础镜像用了完整版(换 slim/alpine 或多阶段构建);
- 构建工具链、开发依赖进了运行镜像(多阶段构建,或把 dev 依赖拆到单独的 requirements 文件);
- pip/apt 缓存留在层里(
--no-cache-dir、清 apt lists,且和安装写在同一条RUN); - 在后面的层里
rm前面层的文件(删不掉,用同一层删除或多阶段); COPY . .把.git、日志、大文件一起拷了进来(.dockerignore没写全)。
定位顺序:docker history 找到胖的那一层 → 对照上面清单逐项排除。
小结
- Dockerfile 是镜像的菜谱,构建上下文决定
COPY能拿到什么,先写.dockerignore再构建; - 指令按变化频率从低到高排:依赖清单先于源码,改代码才不会重装依赖;
- 基础镜像选 slim 而非 alpine 图小,libc 兼容性更重要;体积的大头靠多阶段构建砍;
- 服务用普通用户跑,文件属主用
COPY --chown一步到位; CMD/ENTRYPOINT用 exec 形式,信号才能正确传给应用;- 标签打语义化版本,
latest只当别名;推送前用docker history和inspect体检一遍。
最后给一条练习路径建议:不要对着文档空学,拿自己手头任何一个小脚本,按「初版跑通 → 换 slim → 拆依赖层 → 补 .dockerignore → 切非 root」的顺序亲手改五遍,每改一步都 docker history 看一眼体积、docker build 掐一次时间。五遍走完,Dockerfile 的核心手感就有了,以后碰到别的语言(Java、Rust、PHP)也只是把构建命令换掉,骨架完全一样。
下一篇可以把这个镜像放进 Compose 里正式部署,再接上自动更新——你的应用就此进入和官方镜像一样的运维轨道了。