Dockerfile入门教程:从零构建你的第一个Docker镜像(含实例)

凌晨1点,我盯着终端里滚动的错误信息——“COPY ../config.json: no such file or directory”。这已经是今晚第8次构建失败了。明明本地能跑,为啥一打包成Docker镜像就各种报错?我翻遍了Stack Overflow,照着top answer改了半天,结果镜像体积从200MB膨胀到2GB。说实话那一刻我挺崩溃的,心想这Dockerfile到底是个啥玩意儿。
你可能也遇到过类似的情况。看着项目里那个Dockerfile文件,满屏的FROM、RUN、COPY、CMD,每个词单独看都认识,合在一起就是看不懂。网上找个教程照着抄,十有八九跑不通。最让人抓狂的是,错误提示永远那么模糊——是路径不对?还是指令用错了?谁知道呢。
其实Dockerfile没那么玄乎。后来我花了两天时间,把每个指令的作用和常见坑都摸清楚了,发现关键就那么几个点。今天这篇文章,我会用最直白的方式,带你从零开始构建第一个Docker镜像。不讲虚的,每个指令都配实际代码,还会告诉你初学者最容易踩的3个坑。看完之后,你就能给自己的Node.js或Python项目写一个能跑的Dockerfile。
Dockerfile是什么?
简单来说,Dockerfile就是一个文本文件,里面写了构建Docker镜像的所有步骤。你可以把它想象成装修房子的施工图纸——图纸上写明了先铺地板,再刷墙,最后装灯具。Docker引擎按照这份图纸,一步步把你的应用”装修”好,最后打包成一个镜像。
这个镜像就是一个可以直接运行的”环境快照”。比如你的Node.js应用需要Node 18、某些npm包、还有你的源代码,Dockerfile会把这些东西全装进镜像里。别人拿到这个镜像,执行docker run就能跑起来,不用操心环境配置。
说白了,Dockerfile做的事就三步:
- 选个基础环境(比如Node.js 18)
- 把你的代码和依赖装进去
- 告诉容器启动时该执行啥命令
写好Dockerfile后,跑一条docker build命令,镜像就生成了。听起来挺简单?确实不复杂,但魔鬼在细节里。接下来我们聊聊那几个核心指令。
6大核心指令详解
FROM - 选择基础镜像
FROM必须是Dockerfile的第一条指令(除了注释和ARG),它决定了你的”地基”长啥样。
就像盖房子要先选地基,写Dockerfile得先选个基础镜像。你的应用是Node.js写的?那就用node:18-alpine。Python项目?python:3.11-slim是个不错的选择。Nginx做反向代理?直接nginx:alpine。
# 选择Node.js 18的Alpine Linux版本作为基础镜像
FROM node:18-alpine这里说下alpine和slim的区别。alpine是基于Alpine Linux的超轻量级镜像,体积只有5MB左右,适合生产环境。完整的node镜像有900MB,差了180倍。但alpine有个小坑:它用的是musl libc而不是glibc,个别原生依赖可能会报错。如果遇到奇怪的编译错误,试试换成node:18-slim。
新手常犯错误:随便选了个node镜像,结果版本对不上项目要求,装依赖时各种报错。记住:package.json里写的Node版本是多少,FROM里就用多少。
RUN - 执行命令构建镜像
RUN是在构建镜像时执行命令,用来装软件、创建目录、改配置文件这些活。关键点:每条RUN会创建一个新的镜像层。
看个反例:
# ❌ 不好的写法:创建了3个镜像层
RUN apt-get update
RUN apt-get install -y python3
RUN apt-get clean每条RUN都会在镜像里加一层,像洋葱一样一层层往外包。问题是Docker的分层存储机制,即使你后面删除了文件,前面层里的数据还在,镜像体积照样大。我之前就是这么踩坑的,写了7条RUN装各种东西,最后镜像2GB,传到服务器等了半小时。
正确姿势是用&&把命令串起来:
# ✅ 推荐写法:只创建1个镜像层
RUN apt-get update && \
apt-get install -y python3 && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*注意反斜杠\是换行符,让命令看起来不那么挤。最后那个rm很关键——清理安装包缓存,能省好几十MB。
还有个坑:永远别单独写RUN apt-get update。Docker会缓存每一层,如果update单独一层,后面装软件时可能用的是旧缓存,导致装不上新版本。永远把update和install写一起。
COPY vs ADD - 复制文件
这俩指令都是往镜像里复制文件,但COPY简单直接,ADD功能多但容易出幺蛾子。Docker官方建议:能用COPY就别用ADD。
先看COPY的基本用法:
# 复制单个文件
COPY package.json /app/
# 复制整个目录
COPY ./src /app/src
# 复制当前目录所有内容到容器的/app
COPY . /app看着挺简单?但有个致命误区——路径是相对于构建上下文的,不是相对于Dockerfile。
啥是构建上下文?就是你执行docker build命令时最后那个点(.)指定的目录。比如你在项目根目录执行docker build .,那构建上下文就是项目根目录,COPY只能访问这个目录及其子目录的文件。
这就是我开头提到的那个报错的原因:
# ❌ 错误写法:超出构建上下文
COPY ../config.json /app/
COPY /opt/myfile.txt /app/第一个想访问上层目录,第二个想访问绝对路径,都会失败。Docker这么设计是为了安全和可重复构建——不能让你随便访问宿主机的任意文件。
解决办法:
- 要么把config.json移到项目目录里
- 要么在上层目录执行构建:
docker build -f myproject/Dockerfile .
再说说ADD。ADD除了复制文件,还会自动解压tar包,还能从URL下载文件:
# ADD会自动解压
ADD myarchive.tar.gz /app/
# ADD能下载URL(但不推荐)
ADD https://example.com/file.txt /app/听起来挺方便?问题是行为不透明。别人看到ADD不知道这个文件到底会不会被解压,容易踩坑。要我说,需要解压就明确写RUN tar -xzf,需要下载就用RUN curl,清清楚楚。
WORKDIR - 设置工作目录
WORKDIR就是Linux里的cd命令,设置后续指令的工作目录。如果目录不存在,Docker会自动创建。
WORKDIR /app
COPY . . # 现在是复制到/app目录下
RUN npm install # 在/app目录下执行推荐用绝对路径,别用相对路径。因为相对路径会基于上一个WORKDIR,容易搞混。
有了WORKDIR,你就不用在每个RUN里写cd /app &&了,代码看起来清爽多了。
CMD vs ENTRYPOINT - 容器启动命令
这俩是最容易搞混的。简单记:CMD可以被覆盖,ENTRYPOINT不能被覆盖。
先说CMD。它设置容器启动时的默认命令:
CMD ["node", "server.js"]这样写的话,docker run my-app就会执行node server.js。但如果你运行docker run my-app npm test,CMD就被覆盖了,实际执行的是npm test。
再看ENTRYPOINT。它定义容器的主进程,不会被覆盖:
ENTRYPOINT ["node"]
CMD ["server.js"]这样组合的话,docker run my-app执行node server.js,docker run my-app script.js执行node script.js。看出区别了吗?ENTRYPOINT是固定的,CMD或docker run的参数会附加到ENTRYPOINT后面。
啥时候用哪个?
- 只用CMD:应用服务,可能需要不同启动方式(比如生产跑
npm start,测试跑npm test) - ENTRYPOINT + CMD组合:工具类镜像,主命令固定,只是参数不同(比如Python脚本,主命令是
python,脚本名是参数) - 只用ENTRYPOINT:特别固定的场景,容器就是干一件事的
举个对比:
# 场景1:Web应用(用CMD)
FROM node:18-alpine
WORKDIR /app
COPY . .
CMD ["npm", "start"]
# docker run my-app → npm start
# docker run my-app npm test → npm test(CMD被覆盖)
# 场景2:Python工具(用ENTRYPOINT + CMD)
FROM python:3.11-slim
ENTRYPOINT ["python"]
CMD ["main.py"]
# docker run my-tool → python main.py
# docker run my-tool script.py → python script.py我一开始也搞不清楚,后来记住了一句话:ENTRYPOINT是”做什么”,CMD是”怎么做”。
ENV - 环境变量
ENV用来设置环境变量,在容器运行时生效。你可以在后续的RUN、CMD等指令中使用这些变量。
ENV NODE_ENV=production
ENV PORT=3000
# 在RUN中使用
RUN echo "Environment: $NODE_ENV"
# 应用代码里也能读到这些环境变量
CMD ["node", "server.js"]常见用法:
- 设置
NODE_ENV=production告诉Node.js这是生产环境 - 设置
PATH添加自定义命令路径 - 配置应用参数(端口号、数据库地址等)
注意ENV设置的变量会保留在最终镜像里,如果里面有敏感信息(比如密码),别用ENV。应该在运行容器时通过docker run -e传入,或者用Docker Secrets。
实战演练 - 构建第一个镜像
讲了这么多理论,动手试试才能真正理解。我们以一个简单的Node.js应用为例,一步步构建第一个Docker镜像。
步骤1:准备项目
创建一个最简单的Node.js应用:
mkdir my-node-app
cd my-node-app创建package.json:
{
"name": "my-node-app",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}创建server.js:
const express = require('express');
const app = express();
const PORT = 3000;
app.get('/', (req, res) => {
res.send('Hello from Docker!');
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});步骤2:编写Dockerfile
在项目根目录创建Dockerfile(注意没有扩展名):
# 1. 选择基础镜像
FROM node:18-alpine
# 2. 设置工作目录
WORKDIR /app
# 3. 复制依赖文件(利用缓存)
COPY package*.json ./
# 4. 安装依赖
RUN npm install --production
# 5. 复制应用代码
COPY . .
# 6. 暴露端口
EXPOSE 3000
# 7. 启动应用
CMD ["npm", "start"]为啥要分开复制package.json和源代码?关键在于Docker的缓存机制。
Docker按顺序构建,每条指令是一层。如果某层变化了,后面的层都会重新构建。package.json很少变,但源代码经常改。如果先复制所有文件再装依赖,每次改代码都要重装依赖,超慢。
现在这个顺序的话,只要package.json不变,Docker就会用缓存的依赖层,直接跳到复制代码那步,构建速度快多了。
步骤3:构建镜像
在项目根目录执行:
docker build -t my-node-app:1.0 .参数说明:
-t my-node-app:1.0:给镜像打标签,格式是名称:版本.:构建上下文,指当前目录
你会看到终端输出一堆东西,每一步对应Dockerfile的一条指令。如果一切顺利,最后会显示Successfully built xxx。
步骤4:运行容器
docker run -p 3000:3000 my-node-app:1.0参数说明:
-p 3000:3000:端口映射,格式是宿主机端口:容器端口my-node-app:1.0:要运行的镜像
你会看到终端输出”Server running on port 3000”。
步骤5:验证效果
打开浏览器访问http://localhost:3000,看到”Hello from Docker!”就成功了!
按Ctrl+C停止容器。
总结一下
整个流程就是:
- 写代码(package.json + server.js)
- 写Dockerfile(告诉Docker怎么打包)
- 构建镜像(
docker build) - 运行容器(
docker run)
是不是没那么复杂?关键是理解每条指令的作用,以及构建上下文和缓存机制这两个概念。
初学者避坑指南
前面讲了正确写法,现在聊聊新手最容易踩的几个坑。这些坑我全踩过,能帮你省不少时间。
坑1:构建上下文路径错误
现象:COPY指令报错”no such file or directory”,明明文件就在那儿。
原因:COPY的路径是相对于构建上下文的,不是相对于Dockerfile。
# ❌ 错误:想访问上层目录
COPY ../config.json /app/
# ❌ 错误:想访问绝对路径
COPY /opt/myfile.txt /app/解决办法:
- 把文件移到项目目录内
- 或者调整构建命令:
docker build -f subdir/Dockerfile .(用-f指定Dockerfile位置,最后的点还是上层目录作为上下文)
还有个隐藏坑:如果你在根目录执行docker build .,Docker会把整个目录都发送给Docker守护进程,包括node_modules、.git这些大文件夹。我就遇到过发送了几个GB,等了10分钟才开始构建。
解决办法:创建.dockerignore文件,排除不需要的文件:
node_modules
.git
.env
*.log坑2:镜像层过多导致体积膨胀
现象:镜像体积巨大,明明代码才几MB,镜像却有几GB。
原因:每条RUN/COPY/ADD都创建新层,即使后面删除文件,前面层的数据还在。
# ❌ 创建7个层,每层都保留数据
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
RUN curl -o tool.sh https://example.com/tool.sh
RUN chmod +x tool.sh
RUN ./tool.sh
RUN rm tool.sh # 这个删除没用!前面层里还有tool.sh解决办法:合并RUN指令,在同一层完成安装、使用、清理:
# ✅ 只创建1个层,清理在同一层有效
RUN apt-get update && \
apt-get install -y curl git && \
curl -o tool.sh https://example.com/tool.sh && \
chmod +x tool.sh && \
./tool.sh && \
rm tool.sh && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*我之前就是分开写了7条RUN,最后镜像2GB。合并之后降到200MB,差了10倍。
坑3:依赖缓存失效
现象:每次构建都要重装依赖,超级慢。
原因:COPY顺序不对,代码和依赖文件一起复制,每次改代码都触发重装依赖。
# ❌ 错误顺序:改代码就要重装依赖
COPY . .
RUN npm installDocker的缓存是按顺序的。上面这个写法,只要源代码变了(比如改个注释),COPY那层就变了,后面的npm install也得重新跑。
解决办法:先复制依赖文件,装好依赖,再复制源代码:
# ✅ 正确顺序:只有package.json变化才重装
COPY package*.json ./
RUN npm install
COPY . .这样的话,改源代码不会触发npm install重跑,构建速度从5分钟降到10秒,香得很。
额外提示:EXPOSE不是必须的
很多教程写了EXPOSE 3000,新手以为不写就无法访问端口。其实EXPOSE只是文档说明,告诉别人这个镜像用哪个端口,不写也能跑。
真正管端口映射的是docker run -p命令:
# 即使Dockerfile没写EXPOSE,这样也能访问
docker run -p 3000:3000 my-app不过建议还是写上EXPOSE,方便其他人理解。
结论
说了这么多,其实Dockerfile入门就抓住三个关键点:
- 理解核心指令:FROM选基础镜像、RUN装东西、COPY搬文件、CMD启动应用,剩下的WORKDIR和ENV是辅助
- 搞清楚构建上下文:记住COPY只能访问
docker build最后那个点指定的目录,别试图访问上层或绝对路径 - 善用缓存机制:把变化少的操作(装依赖)放前面,变化多的(复制代码)放后面,合并RUN指令减少镜像层数
现在就找个自己的小项目试试吧。先写个最基本的Dockerfile,能跑起来就行,不用追求完美。等熟练了,再去学多阶段构建、优化镜像体积这些进阶技巧。
Docker没那么难,关键是动手练。我第一次写Dockerfile报了一晚上错,但搞懂之后发现其实就那么回事。你也能行的。
下一步学什么:
- Docker Compose(管理多容器应用)
- 多阶段构建(进一步减小镜像体积)
- Docker网络和数据卷(容器间通信和数据持久化)
祝你早日构建出自己的第一个Docker镜像!有问题欢迎留言交流。
11 分钟阅读 · 发布于: 2025年12月17日 · 修改于: 2025年12月26日



评论
使用 GitHub 账号登录后即可评论