05. Dockerfile 编写实战:把自己的 Python/Node.js 应用打包成镜像

前面几篇文章我们都在用别人做好的镜像,docker pull nginx、docker pull python……但每次都得手动进容器改配置、装依赖、拷代码,太费劲了。能不能把我写好的应用直接打包成一个镜像,以后 docker run 一把梭就跑起来?今天就来搞定这件事——学会写 Dockerfile,让你的应用也能变成 Docker 镜像。

▲ 别把 Dockerfile 当复杂语法,它就像一份"菜谱":基础镜像、工作目录、依赖、代码、启动命令,照着一步步来就能"出锅"。


一、Dockerfile 是什么?一份"菜谱"

你可以把 Dockerfile 想象成一份菜谱:

  • 你告诉 Docker:"先准备什么食材(基础镜像)、用什么锅(工作目录)、放什么调料(依赖)、加什么主菜(应用代码)、最后怎么上菜(启动命令)"

  • Docker 照着这份菜谱一步步执行,最后给你做出一道完整的"菜"——也就是一个镜像

技术上说,Dockerfile 是一个纯文本文件(就叫 Dockerfile,没有扩展名),里面写满了 Docker 能识别的指令。你用 docker build 命令告诉 Docker "照着这个文件构建镜像",它就会逐行执行这些指令。

一句话:Dockerfile 是镜像的源代码,镜像是 Dockerfile 编译后的产物。


二、常用指令速查表

先混个脸熟,后面会一个个详细讲:

指令作用类比
FROM指定基础镜像选个锅
WORKDIR设置工作目录进厨房
COPY复制文件到镜像放食材
ADD复制文件(支持自动解压 tar)放食材+开罐头
RUN执行命令(装软件、编译等)烹饪过程
ENV设置环境变量调口味
EXPOSE声明端口号(仅文档说明)贴标签
CMD容器启动时执行的默认命令上菜方式
ENTRYPOINT容器启动时执行的主命令固定菜品
ARG构建时变量(运行时不可用)厨师备注

三、常用指令详解(每条都有示例)

3.1 FROM:指定基础镜像

每条 Dockerfile 的第一行必须是 FROM。 它决定了你的镜像"站在谁的肩膀上":

# Python 应用常用
FROM python:3.11-slim
​
# Node.js 应用常用
FROM node:20-alpine
​
# 极简场景(只跑个二进制文件)
FROM alpine:3.19
​
# Java 应用
FROM eclipse-temurin:17-jdk-alpine

选镜像版本的技巧:

标签体积适用场景
latest较大不推荐生产使用(版本不固定)
3.11中等开发调试够用
3.11-slim小(约 120MB)推荐生产环境
3.11-alpine极小(约 50MB)追求极致体积(但某些库编译会报错)

建议:生产环境用 -slim 版本,兼顾体积和兼容性。


3.2 WORKDIR:设置工作目录

相当于在容器里 cd 到某个目录,后续所有指令都在这个目录下执行:

# 创建并进入 /app 目录(目录不存在会自动创建)
WORKDIR /app
​
# 后面的 COPY、RUN 都在 /app 下操作
COPY . .
RUN pip install -r requirements.txt

好习惯:永远不要省略 WORKDIR。 不设的话,默认在 / 根目录操作,文件散落在根目录既乱又不安全。


3.3 COPY vs ADD:复制文件进镜像

这两个都能把宿主机文件复制到镜像里,但有区别:

# COPY:简单粗暴,只干复制这一件事
COPY requirements.txt /app/
COPY . /app/
​
# ADD:多了两个"超能力"
ADD archive.tar.gz /app/     # 自动解压 tar 包
ADD https://example.com/file.zip /tmp/  # 可以从 URL 下载

对比表:

特性COPYADD
复制本地文件✅✅
自动解压 tar❌✅
支持 URL 下载❌✅
推荐使用推荐只在需要解压时用

最佳实践:99% 的场景用 COPY 就够了。 ADD 的"超能力"容易引发意外行为(比如你以为只是复制,结果它给你解压了),所以 Docker 官方也建议优先用 COPY。


3.4 RUN:执行命令

RUN 用来在构建镜像时执行命令,最常见的用途是安装依赖:

# Ubuntu/Debian 系装软件
RUN apt update && apt install -y curl git
​
# Python 装依赖
RUN pip install flask requests
​
# Node.js 装依赖
RUN npm install

⚠️ 层缓存陷阱(新手必知)

每一条 RUN 都会在镜像里创建一个新层。反模式:

# ❌ 错误示范:三条 RUN 产生三个层,而且 apt 缓存占空间
RUN apt update
RUN apt install -y curl
RUN apt install -y git

正确做法:合并命令 + 清理缓存

# ✅ 正确示范:一条 RUN,清理缓存减小体积
RUN apt update \
    && apt install -y --no-install-recommends curl git \
    && rm -rf /var/lib/apt/lists/*

3.5 ENV:设置环境变量

# 格式 1:单个变量
ENV APP_ENV=production
​
# 格式 2:多个变量
ENV APP_PORT=8080 APP_HOST=0.0.0.0
​
# 在后续指令和容器运行时都能用到
RUN echo "当前环境:$APP_ENV"
CMD python app.py --port $APP_PORT

3.6 EXPOSE:声明端口(但别当真)

# 声明应用监听的端口
EXPOSE 8080
EXPOSE 5000

注意:EXPOSE 只是个"文档说明",不会真的映射端口!

要让外部访问,必须在 docker run 时用 -p 参数:

# EXPOSE 8080 只是告诉你"这个应用用 8080 端口"
# 真正映射端口还得靠 -p
docker run -p 8080:8080 myapp

把它想象成在包装盒上贴标签:"本产品使用 8080 端口"——贴不贴标签,产品本身都一样工作。


3.7 CMD vs ENTRYPOINT:最容易搞混的两个指令

这是 Dockerfile 里最容易踩坑的地方,务必仔细看清楚。

核心区别
特性CMDENTRYPOINT
作用容器启动时的默认命令容器启动时的主命令
被覆盖docker run 后加参数会被覆盖不会被覆盖(参数会追加)
适用场景可灵活替换的默认行为固定的主程序入口
代码对比
# 使用 CMD
FROM python:3.11-slim
WORKDIR /app
COPY . .
CMD ["python", "app.py"]

运行:

# 正常启动:执行 python app.py
docker run myapp
​
# 覆盖 CMD:执行 bash(进入了 shell,不启动应用了)
docker run -it myapp bash
# 使用 ENTRYPOINT
FROM python:3.11-slim
WORKDIR /app
COPY . .
ENTRYPOINT ["python", "app.py"]

运行:

# 正常启动:执行 python app.py
docker run myapp
​
# 追加参数:执行 python app.py --debug
docker run myapp --debug
​
# 想覆盖 ENTRYPOINT:必须显式指定 --entrypoint
docker run --entrypoint bash -it myapp
黄金组合

最佳实践:ENTRYPOINT + CMD 搭配使用

# ENTRYPOINT 是"主菜"(固定的)
# CMD 是"配菜"(可替换的默认参数)
ENTRYPOINT ["python"]
CMD ["app.py"]

这样:

  • docker run myapp → 执行 python app.py

  • docker run myapp script.py → 执行 python script.py(覆盖了默认参数)


3.8 .dockerignore:排除不需要的文件

在项目根目录创建一个 .dockerignore 文件,类似 .gitignore:

# Python 项目
__pycache__/
*.pyc
*.pyo
.git/
.gitignore
venv/
.venv/
*.md
.env
.pytest_cache/
​
# Node.js 项目
node_modules/
npm-debug.log*
.DS_Store

为什么需要它?

如果你 COPY . . 把整个项目拷进镜像,会把这些垃圾也拷进去:

  • node_modules/:几百 MB 的依赖,容器里会重装

  • .git/:整个 Git 历史,白白占空间

  • venv/:本地虚拟环境,容器里用不到

不加 .dockerignore,镜像体积可能膨胀好几倍,构建速度也会变慢。


四、实战 1:Python Flask 应用完整打包

来一个真实场景:把一个 Flask Web 应用打包成 Docker 镜像。

4.1 项目结构

my-flask-app/
├── app.py              # Flask 应用主程序
├── requirements.txt    # Python 依赖
├── Dockerfile          # Docker 菜谱
└── .dockerignore       # 排除文件

4.2 编写 Flask 应用

# app.py
from flask import Flask, jsonify
import socket
​
app = Flask(__name__)
​
@app.route('/')
def hello():
    return jsonify({
        "message": "Hello from Docker!",
        "hostname": socket.gethostname(),
        "python_version": "3.11"
    })
​
@app.route('/health')
def health():
    return jsonify({"status": "healthy"})
​
if __name__ == '__main__':
    # 注意:host 必须是 0.0.0.0,否则容器外访问不到
    app.run(host='0.0.0.0', port=5000)
# requirements.txt
flask==3.0.0

4.3 编写 Dockerfile

# 1. 选择基础镜像
FROM python:3.11-slim
​
# 2. 设置工作目录
WORKDIR /app
​
# 3. 先复制依赖文件(利用层缓存,后面会讲)
COPY requirements.txt .
​
# 4. 安装依赖
RUN pip install --no-cache-dir -r requirements.txt
​
# 5. 再复制应用代码
COPY . .
​
# 6. 声明端口(文档说明)
EXPOSE 5000
​
# 7. 设置环境变量
ENV FLASK_APP=app.py
​
# 8. 启动命令
CMD ["python", "app.py"]

4.4 构建镜像

# 在项目目录下执行(注意最后那个点 .)
docker build -t my-flask-app:v1 .

构建输出:

[+] Building 15.3s (9/9)
 => [1/5] FROM docker.io/library/python:3.11-slim          3.2s
 => [2/5] WORKDIR /app                                     0.1s
 => [3/5] COPY requirements.txt .                          0.0s
 => [4/5] RUN pip install --no-cache-dir -r requirements   8.5s
 => [5/5] COPY . .                                         0.1s
 => exporting to image                                     3.4s
 => => naming to docker.io/library/my-flask-app:v1

4.5 运行容器

docker run -d -p 5000:5000 --name flask-demo my-flask-app:v1

测试访问:

curl http://localhost:5000/

输出:

{
  "hostname": "a3f5b7c8d9e1",
  "message": "Hello from Docker!",
  "python_version": "3.11"
}

看到 hostname 是容器 ID 而不是你的本机名,说明应用确实在容器里跑起来了!


五、实战 2:Node.js Express 应用完整打包

5.1 项目结构

my-express-app/
├── index.js           # Express 应用主程序
├── package.json       # Node.js 依赖声明
├── Dockerfile         # Docker 菜谱
└── .dockerignore      # 排除文件

5.2 编写 Express 应用

// index.js
const express = require('express');
const os = require('os');
const app = express();
​
app.get('/', (req, res) => {
  res.json({
    message: 'Hello from Docker!',
    hostname: os.hostname(),
    platform: os.platform(),
    node_version: process.version
  });
});
​
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', uptime: process.uptime() });
});
​
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running on port ${PORT}`);
});
// package.json
{
  "name": "my-express-app",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}

5.3 编写 Dockerfile

# 1. 选择基础镜像(alpine 版本极小,约 180MB)
FROM node:20-alpine
​
# 2. 设置工作目录
WORKDIR /app
​
# 3. 先复制 package.json(利用层缓存)
COPY package*.json ./
​
# 4. 安装生产依赖(--production 不装 devDependencies)
RUN npm install --production
​
# 5. 再复制应用代码
COPY . .
​
# 6. 声明端口
EXPOSE 3000
​
# 7. 启动命令
CMD ["node", "index.js"]

5.4 构建和运行

# 构建
docker build -t my-express-app:v1 .
​
# 运行
docker run -d -p 3000:3000 --name express-demo my-express-app:v1
​
# 测试
curl http://localhost:3000/

输出:

{
  "message": "Hello from Docker!",
  "hostname": "b4e6c8d0f2a3",
  "platform": "linux",
  "node_version": "v20.11.0"
}

六、构建上下文:那个点 . 到底是什么意思?

docker build -t myapp:v1 .
#                      ↑
#                      这个点是啥?

这个点叫构建上下文(Build Context),它告诉 Docker:"把当前目录下的所有文件发送给 Docker 守护进程,供构建使用。"

为什么要发送文件?

因为 Dockerfile 里的 COPY . . 需要知道"从哪里拷"——答案就是构建上下文。

常见错误:

# ❌ 错误:上下文是 /home/user,但 Dockerfile 在子目录里
cd /home/user
docker build -t myapp myproject/Dockerfile
# 报错:unable to prepare context
​
# ✅ 正确:上下文是包含 Dockerfile 的目录
cd /home/user/myproject
docker build -t myapp .

也可以指定其他路径:

# 上下文是 /path/to/project,Dockerfile 也在那
docker build -t myapp /path/to/project
​
# 上下文是当前目录,Dockerfile 用别的名字
docker build -t myapp -f Dockerfile.prod .

记住:构建上下文决定了哪些文件能被 COPY 进镜像,也决定了 .dockerignore 要放在哪里。


七、镜像层缓存优化:顺序真的很重要

Docker 构建镜像时,每一行指令都会生成一层。如果某一层的内容没变,Docker 会直接用缓存,不再重新执行。

▲ 层缓存的精髓:把依赖清单放前面、应用代码放后面,只有改依赖才会触发"重装依赖"。

7.1 错误顺序:每次都重装依赖

# ❌ 每次改代码都要重装依赖(超慢)
FROM python:3.11-slim
WORKDIR /app
COPY . .                          # 拷入全部代码(包括 app.py)
RUN pip install -r requirements.txt  # 只要代码变了,这一层就重跑
CMD ["python", "app.py"]

问题:你改了 app.py 一行代码 → COPY . . 这一层失效 → 后面的 RUN pip install 也跟着失效 → 每次构建都重装依赖,慢得要死。

7.2 正确顺序:先拷依赖文件,再拷代码

# ✅ 改代码不会重装依赖(超快)
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .              # 只拷入依赖文件
RUN pip install -r requirements.txt  # 依赖没变就用缓存
COPY . .                             # 再拷入应用代码(改代码只影响这一层)
CMD ["python", "app.py"]

对比效果:

操作错误顺序耗时正确顺序耗时
首次构建~30s~30s
只改了 app.py~30s(重装依赖)~2s(用缓存)
改了 requirements.txt~30s~30s(必须重装)

一句话总结:把不常变的文件(依赖清单)放前面,常变的文件(应用代码)放后面。


八、本文要点回顾

  1. Dockerfile 是一份"菜谱",告诉 Docker 怎么一步步构建镜像;

  2. FROM 必须第一行,选择合适的基础镜像(推荐 -slim 版本);

  3. COPY vs ADD:99% 场景用 COPY,ADD 只在需要自动解压时用;

  4. CMD vs ENTRYPOINT:CMD 可被覆盖,ENTRYPOINT 固定执行,推荐两者搭配使用;

  5. EXPOSE 只是文档说明,真正映射端口靠 docker run -p;

  6. .dockerignore 必须写,排除 node_modules、.git、venv 等大文件;

  7. 构建上下文:docker build 最后那个 . 决定了哪些文件能被拷进镜像;

  8. 层缓存优化:先 COPY requirements.txt 再 COPY . .,改代码不重装依赖。


下集预告

镜像打包好了,但生产环境不会只用一个容器。下一章我们学习 Docker 网络——多个容器之间怎么互相通信?怎么让 Flask 容器访问 MySQL 容器?Bridge 网络、Host 网络、自定义网络各自怎么用?敬请期待!


附:完整 Dockerfile 模板(可直接复制使用)

Python 应用模板

FROM python:3.11-slim
​
WORKDIR /app
​
# 先拷依赖
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
​
# 再拷代码
COPY . .
​
EXPOSE 8000
​
CMD ["python", "app.py"]

Node.js 应用模板

FROM node:20-alpine
​
WORKDIR /app
​
# 先拷依赖
COPY package*.json ./
RUN npm install --production
​
# 再拷代码
COPY . .
​
EXPOSE 3000
​
CMD ["node", "index.js"]

.dockerignore 通用模板

# 通用排除
.git/
.gitignore
.vscode/
.idea/
*.md
.env
.env.*
​
# Python 专属
__pycache__/
*.pyc
venv/
.venv/
.pytest_cache/
​
# Node.js 专属
node_modules/
npm-debug.log*
dist/

更多推荐