Docker 基础体系 · 第 33/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker ARG、ENV、LABEL 与 Metadata:作用域、继承和敏感边界

在 Dockerfile 中,ARGENVLABEL 都表现为“给构建过程或镜像附加一个键值”,但它们属于不同的状态空间:

  • ARG:构建参数,主要服务于 Dockerfile 解析和构建过程。
  • ENV:环境变量,既影响后续构建步骤,也可成为运行时默认环境。
  • LABEL:镜像标签,属于镜像元数据,不是进程环境变量。
  • Metadata:更大的概念,包含镜像配置、标签、历史、清单、平台信息、签名与构建证明等。

如果只把三者都理解成“变量”,就容易产生三个危险误判:

  1. 认为 ARG 会自动出现在运行中的容器里;
  2. 认为 ENV 只是构建时临时配置;
  3. 认为 LABEL 是安全的备注字段,可以放入任意构建信息或凭据。

下面从作用域、继承、持久化位置和泄漏边界逐层展开。

一、先区分 Docker 的几类状态

一个镜像构建并不是单一过程。至少可以区分为四类状态:

flowchart LR
    A[Dockerfile 与构建上下文] --> B[BuildKit 构建状态]
    B --> C[镜像配置 Image Config]
    B --> D[镜像层 Filesystem Layers]
    B --> E[Manifest / Index]
    C --> F[运行时容器配置]
    D --> F
    E --> G[镜像分发与平台选择]
    B --> H[Provenance / Attestation]

1. 构建状态

BuildKit 在处理 Dockerfile 时需要保存:

  • 当前阶段;
  • 当前阶段可见的 ARG
  • 已设置的 ENV
  • 文件系统快照;
  • 指令依赖关系;
  • 缓存键;
  • 构建参数和平台参数;
  • Secret、SSH 等临时挂载。

这一状态只服务于构建。构建完成后,构建状态中的某些数据可能被丢弃,但不能因此假设它们从未进入缓存、历史、日志或构建证明。

2. 文件系统层

RUN, COPY, ADD 等指令通常会改变镜像文件系统,最终形成一个或多个层。文件本身、文件内容和文件权限属于这部分。

例如:

RUN echo "hello" > /app/message.txt

这里的 message.txt 是文件系统内容。

3. 镜像配置

镜像配置通常包含:

  • 默认环境变量;
  • 默认入口点和命令;
  • 工作目录;
  • 用户;
  • 暴露端口;
  • 根文件系统层的引用;
  • 镜像历史;
  • 标签等配置数据。

ENVLABEL 的结果主要进入镜像配置,而不是普通应用文件。

可以通过以下命令观察部分结果:

docker image inspect example:latest

典型结构中会看到类似字段:

{
  "Config": {
    "Env": [
      "APP_ENV=production"
    ],
    "Labels": {
      "org.opencontainers.image.title": "example"
    }
  },
  "RootFS": {
    "Layers": []
  },
  "History": []
}

实际输出还会包含架构、创建时间、入口点、层摘要等信息。不要把 Config.EnvConfig.Labels 和根文件系统内容混为一谈。

4. Manifest、Index 和构建证明

镜像 manifest 描述镜像层和镜像配置对象的摘要。多平台镜像还会有 manifest list,也称 image index,用来映射:

linux/amd64  -> amd64 镜像 manifest
linux/arm64  -> arm64 镜像 manifest

BuildKit 还可能生成 provenance、SBOM 或其他 attestation。它们不一定等同于镜像配置,但可能记录构建输入、源码、构建参数或构建过程信息。

因此,“没有写入最终文件系统”不等于“不会出现在任何构建产物中”。


二、ARG:构建期参数与阶段作用域

2.1 ARG 的基本语义

ARG 声明一个只能在构建期间使用的参数:

ARG VERSION=1.0
RUN echo "building version ${VERSION}"

构建时可以覆盖默认值:

docker build \
  --build-arg VERSION=2.0 \
  -t example:2.0 .

在上述构建中,RUN 可以读取 VERSION=2.0。但容器启动后,默认不会存在名为 VERSION 的环境变量:

docker run --rm example:2.0 sh -c 'printf "<%s>\n" "${VERSION-unset}"'

预期结果是:

<unset>

这是因为 ARG 是构建变量,不是运行时环境变量。

2.2 ARG 的阶段作用域

Dockerfile 的多阶段构建使 ARG 具有明确作用域。

ARG GLOBAL_NAME=global

FROM alpine:3.20 AS first
ARG STAGE_NAME=first
RUN echo "global=${GLOBAL_NAME}, stage=${STAGE_NAME}"

FROM alpine:3.20 AS second
RUN echo "global=${GLOBAL_NAME}"

这个例子不能按直觉工作:

  • GLOBAL_NAME 在第一个 FROM 之前声明;
  • 它可以用于 FROM
  • 但它不会自动进入 firstsecond 阶段;
  • STAGE_NAME 只在 first 阶段可见;
  • second 阶段也看不到 GLOBAL_NAME,因为没有重新声明。

正确写法是:

ARG GLOBAL_NAME=global

FROM alpine:3.20 AS first
ARG GLOBAL_NAME
ARG STAGE_NAME=first
RUN echo "global=${GLOBAL_NAME}, stage=${STAGE_NAME}"

FROM alpine:3.20 AS second
ARG GLOBAL_NAME
RUN echo "global=${GLOBAL_NAME}"

这里的第二个 ARG GLOBAL_NAME 不是重新指定一个独立默认值,而是把顶层构建参数引入当前阶段。构建时传入的值可以继续使用。

作用域可以形式化为:

  • G:第一次 FROM 之前的全局参数集合;
  • S_i:第 i 个构建阶段显式声明的参数集合;
  • Parent(i):阶段 i 的父阶段,如果该阶段基于另一个构建阶段;
  • VisibleARG(i):阶段 i 中可用的构建参数。

对于普通的顶层参数:

VisibleARG(i) = S_i

对于父阶段继承:

VisibleARG(child) = VisibleARG(parent) ∪ S_child

但这里有一个重要条件:父阶段必须是另一个构建阶段,例如:

FROM alpine:3.20 AS base
ARG BUILD_MODE=release

FROM base AS child
RUN echo "${BUILD_MODE}"

child 可以继承 base 中已经声明的 ARG

如果是直接从外部镜像开始:

FROM alpine:3.20 AS child
RUN echo "${BUILD_MODE}"

它不会知道其他 Dockerfile 阶段中的参数。

2.3 ARGFROM 中的特殊位置

构建基础镜像的参数必须在使用它的 FROM 之前声明:

ARG ALPINE_VERSION=3.20
FROM alpine:${ALPINE_VERSION}

以下写法不成立:

FROM alpine:${ALPINE_VERSION}
ARG ALPINE_VERSION=3.20

因为 Docker 在处理 FROM 时还没有得到这个参数。

完整示例:

ARG ALPINE_VERSION=3.20
ARG TARGETPLATFORM

FROM --platform=${TARGETPLATFORM} alpine:${ALPINE_VERSION}

ARG TARGETPLATFORM
RUN echo "target platform: ${TARGETPLATFORM}"

这里有两个不同问题:

  1. 顶层 ARG ALPINE_VERSION 用于解析 FROM
  2. ARG ALPINE_VERSION 若要在 RUN 中再次使用,需要在阶段内重新声明;
  3. TARGETPLATFORM 是 BuildKit 提供的自动平台参数之一;
  4. 自动平台参数可用于 FROM,若要在阶段的普通指令中使用,也应在阶段内声明。

常见自动参数包括:

  • BUILDPLATFORM:执行构建的目标平台;
  • TARGETPLATFORM:最终镜像目标平台;
  • BUILDOSBUILDARCH
  • TARGETOSTARGETARCH
  • TARGETVARIANT 等。

这些参数解决的是跨平台构建中的平台选择问题,不代表容器运行时自动获得同名环境变量。

2.4 ARG 与缓存

构建参数可能参与缓存键计算,但影响范围取决于它是否被实际使用。

FROM alpine:3.20
ARG VERSION
RUN echo "version=${VERSION}" > /version.txt
RUN apk add --no-cache curl

VERSION 改变时,使用它的第一条 RUN 指令需要重新执行。其后的指令通常也会因为前一层发生变化而失去缓存。

如果某个参数只是声明但没有被指令使用:

FROM alpine:3.20
ARG UNUSED
RUN echo "constant"

它通常不会因为单纯改变 UNUSED 就让这个 RUN 失效。这里应区分规范语义和实现细节:BuildKit 会根据指令实际依赖构造缓存,但不能把所有未使用参数的行为当成业务逻辑依赖。

构建参数还可能进入:

  • 构建日志;
  • 镜像历史的指令表示;
  • BuildKit 缓存记录;
  • provenance 等构建证明。

因此 ARG 不是 Secret 机制。


三、ENV:构建期环境与运行时默认环境

3.1 ENV 同时影响两个时期

ENV 设置环境变量:

FROM alpine:3.20

ENV APP_ENV=production
RUN echo "build environment: ${APP_ENV}"

CMD ["sh", "-c", "echo runtime environment: ${APP_ENV}"]

它有两个效果:

  1. 对后续 Dockerfile 指令可见;
  2. 写入镜像配置,作为由该镜像创建的容器的默认环境。

构建时的 RUN 使用的是构建阶段环境;运行时的 CMD 使用的是容器进程环境。两者处于不同生命周期,但都可以看到这个 ENV

运行:

docker build -t env-demo .
docker run --rm env-demo

预期输出:

runtime environment: production

如果覆盖运行时变量:

docker run --rm -e APP_ENV=staging env-demo

输出变为:

runtime environment: staging

因此,镜像中的 ENV 是默认值,不是不可修改的强制值。

3.2 ENV 的顺序关系

变量通常只对后续指令可见:

FROM alpine:3.20

RUN echo "before=${APP_MODE-unset}"

ENV APP_MODE=production

RUN echo "after=${APP_MODE}"

输出类似:

before=unset
after=production

同一条 ENV 指令中也应注意展开关系:

ENV APP_DIR=/opt/app \
    APP_BIN=${APP_DIR}/bin

Dockerfile 变量替换遵循指令解析时可见的环境值。为了降低歧义,更稳妥的写法是拆开:

ENV APP_DIR=/opt/app
ENV APP_BIN=${APP_DIR}/bin

RUN 的 shell 形式和 exec 形式也不同:

ENV GREETING=hello

RUN echo "$GREETING"
RUN ["sh", "-c", "echo $GREETING"]
RUN ["echo", "$GREETING"]

前两条会通过 shell 展开变量;最后一条直接执行 echo,不会由 Docker 自动把 "$GREETING"替换成 hello,输出通常是字面量:

$GREETING

这里的关键不是 ENV 是否存在,而是变量由谁展开:

  • Dockerfile 指令解析器可以做一部分环境替换;
  • shell 可以做 shell 变量展开;
  • exec-form 程序自身不会自动进行 shell 展开。

3.3 ENV 的继承

如果一个阶段基于另一个阶段,则父阶段的环境变量会进入子阶段:

FROM alpine:3.20 AS base
ENV APP_HOME=/srv/app

FROM base AS build
RUN echo "${APP_HOME}"

build 可以看到 /srv/app

但如果只是从阶段中复制文件:

FROM alpine:3.20 AS base
ENV APP_HOME=/srv/app
RUN mkdir -p "${APP_HOME}"

FROM alpine:3.20 AS final
COPY --from=base /srv/app /srv/app
RUN echo "${APP_HOME-unset}"

final 只复制了文件,不继承 baseENV。输出中的变量会是 unset

这说明:

COPY --from=stage

传递的是文件系统内容;而:

FROM stage

建立的是父阶段关系,能够继承部分构建和镜像配置状态。

3.4 ARGENV 同名时的优先关系

常见模式如下:

FROM alpine:3.20

ARG APP_MODE=production
ENV APP_MODE=${APP_MODE}

RUN echo "mode=${APP_MODE}"

这里的含义是:

  1. ARG APP_MODE 提供构建期输入;
  2. ENV APP_MODE=${APP_MODE} 把它转换为镜像默认环境;
  3. 后续的 RUN 和运行中的容器都可以使用 APP_MODE

ENV 一旦设置,就会覆盖同名 ARG 在后续指令中的作用:

FROM alpine:3.20

ARG APP_MODE=production
ENV APP_MODE=debug

RUN echo "${APP_MODE}"

输出是:

debug

形式化地说,在某个阶段中,构建指令读取名为 x 的变量时:

Value(x) =
    ENV(x),如果当前阶段已经设置 ENV(x)
    ARG(x),否则如果当前阶段可见 ARG(x)
    未定义,否则

这也是为什么“使用同名 ARGENV”虽然方便,却会增加排查成本。若两者含义不同,最好使用不同名称,例如:

ARG BUILD_VERSION
ENV APP_VERSION=${BUILD_VERSION}

四、LABEL:镜像元数据而不是环境变量

4.1 LABEL 的语义

LABEL 为镜像增加键值形式的元数据:

FROM alpine:3.20

LABEL org.opencontainers.image.title="example" \
      org.opencontainers.image.description="A small demo image" \
      org.opencontainers.image.version="1.0.0"

查看:

docker image inspect example:latest \
  --format '{{json .Config.Labels}}'

可能得到:

{
  "org.opencontainers.image.title": "example",
  "org.opencontainers.image.description": "A small demo image",
  "org.opencontainers.image.version": "1.0.0"
}

LABEL 不会:

  • 创建环境变量;
  • 创建文件;
  • 自动传给应用进程;
  • 自动改变容器行为;
  • 替代镜像版本标签,例如 example:1.0.0

应用若要读取镜像标签,通常需要由外部工具通过 Docker API 或镜像检查命令读取,而不是从进程环境中读取。

4.2 LABEL 的继承与覆盖

如果一个阶段基于父阶段,父镜像的标签可以被继承。相同键再次设置时,后面的值覆盖前面的值:

FROM alpine:3.20 AS base
LABEL com.example.owner="team-a" \
      com.example.tier="base"

FROM base
LABEL com.example.owner="team-b"

结果中通常是:

com.example.owner = team-b
com.example.tier  = base

“继承”并不表示每个标签都应该由应用依赖。基础镜像的标签可能描述基础镜像的构建者、版本或来源;最终镜像可以继续保留这些标签,也可以覆盖冲突键。

4.3 LABEL 可以引用构建变量

可以用 ARGENV 生成标签:

FROM alpine:3.20

ARG VERSION=dev
LABEL org.opencontainers.image.version="${VERSION}"

构建:

docker build \
  --build-arg VERSION=2.4.1 \
  -t example:2.4.1 .

查询:

docker image inspect example:2.4.1 \
  --format '{{index .Config.Labels "org.opencontainers.image.version"}}'

输出:

2.4.1

这里 VERSION 最终进入了镜像元数据。若把它换成令牌:

ARG REGISTRY_TOKEN
LABEL com.example.registry-token="${REGISTRY_TOKEN}"

令牌就可能进入镜像配置以及后续的镜像分发对象,任何能拉取并检查镜像的人都可能获得它。LABEL 是公开元数据边界,不是私密存储。


五、Metadata 的完整边界:镜像标签只是其中一部分

5.1 Docker 镜像 Metadata

本文中的 Metadata 指描述镜像及其构建来源的非业务文件内容信息。常见组成如下:

类型 典型内容 是否等同于文件系统
Image Config ENV、入口点、工作目录、用户、标签
History Dockerfile 指令的历史记录及层关联
Manifest 配置对象和层的摘要、媒体类型
Image Index 多平台镜像映射
Provenance 构建来源、输入、构建器等
SBOM/Attestation 软件清单或其他证明
Container Config 创建容器时的运行时覆盖设置

文件系统层中的一个配置文件也可以被视为应用配置,但它不是镜像 Metadata 的同一层概念。

5.2 查看镜像历史

docker history --no-trunc example:2.4.1

历史记录可能展示类似:

RUN echo "version=2.4.1" > /version.txt
ENV APP_ENV=production
LABEL org.opencontainers.image.version="2.4.1"

具体是否完整显示、是否经过 BuildKit 处理、是否显示参数值,受构建器和镜像格式影响。不能依赖“某次命令没有显示出来”来判断数据安全。

5.3 查看远程镜像的多平台 Metadata

使用 Buildx 时可以查看镜像索引:

docker buildx imagetools inspect alpine:3.20

该命令可以帮助确认镜像支持的目标平台及其摘要。它观察的是分发层级的 metadata,不等于查看某一个容器内的环境变量。


六、敏感边界:为什么 ARG、ENV、LABEL 都不能存 Secret

6.1 Secret 的定义

Secret 是一旦泄露就可能造成权限扩大或数据访问的值,例如:

  • 云服务访问密钥;
  • 私有仓库令牌;
  • SSH 私钥;
  • 数据库密码;
  • 签名密钥;
  • API Token。

判断一个值是否敏感,不取决于它叫不叫 PASSWORD,而取决于泄露后是否能代表某个身份或权限。

6.2 把 Secret 放进 ARG 的失败路径

FROM alpine:3.20

ARG NPM_TOKEN
RUN npm config set //registry.example.com/:_authToken "${NPM_TOKEN}" \
 && npm install \
 && npm config delete //registry.example.com/:_authToken

即使最后删除配置,仍有多个泄漏路径:

  1. RUN 指令及其参数可能进入构建历史;
  2. 构建日志可能打印命令或错误信息;
  3. 缓存记录可能关联该次构建输入;
  4. provenance 可能记录构建参数;
  5. 安装过程可能把令牌写入其他文件或错误报告;
  6. 恶意依赖可能读取构建环境。

“最后删除文件”只解决文件系统最终状态,不解决构建过程中的暴露。

6.3 把 Secret 放进 ENV 的失败路径

ENV DATABASE_PASSWORD=secret-value

这会使密码进入镜像配置,并作为默认环境传给容器。任何可以检查镜像、读取容器配置或在容器中读取进程环境的人,都可能得到它。

在 Linux 容器中,进程环境通常可通过:

tr '\0' '\n' < /proc/1/environ

读取,前提是权限和进程关系允许。环境变量还可能被应用错误日志、诊断接口、崩溃转储或监控系统收集。

6.4 把 Secret 放进 LABEL 的失败路径

LABEL com.example.password="secret-value"

标签往往更容易被自动化扫描、镜像仓库索引或运维工具读取。它是公开描述信息,暴露边界通常比应用文件更宽。

6.5 BuildKit Secret Mount 的正确数据流

构建时需要使用 Secret,应使用 BuildKit 的临时挂载:

Dockerfile:

# syntax=docker/dockerfile:1

FROM alpine:3.20

RUN --mount=type=secret,id=repo_token \
    token="$(cat /run/secrets/repo_token)" && \
    test -n "$token" && \
    echo "authenticated operation would run here"

构建:

printf '%s' 'temporary-token' > repo_token

DOCKER_BUILDKIT=1 docker build \
  --secret id=repo_token,src=repo_token \
  -t secret-demo .

rm -f repo_token

其数据流是:

宿主机 Secret 文件
        │
        ▼
BuildKit 在某个 RUN 期间挂载
        │
        ▼
/run/secrets/repo_token
        │
        ▼
RUN 结束后卸载
        │
        ▼
不自动进入最终文件系统层

这个机制降低了 Secret 进入镜像层和 Dockerfile 参数的风险,但并不保证应用绝不会泄露它。以下情况仍然会泄露:

RUN --mount=type=secret,id=repo_token \
    cat /run/secrets/repo_token > /tmp/token.txt

因为 /tmp/token.txt 会成为该层文件系统的一部分。

还应避免:

RUN --mount=type=secret,id=repo_token \
    echo "$(cat /run/secrets/repo_token)"

因为 Secret 会进入构建日志。

Secret mount 解决的是“构建阶段如何临时提供凭据”,不是运行时 Secret 管理。运行时需要由编排系统、平台 Secret、挂载文件或外部密钥服务提供,并配合轮换和权限控制。


七、一个完整示例:参数、环境、标签和多阶段边界

下面的 Dockerfile 展示四种状态如何分工:

# syntax=docker/dockerfile:1

ARG ALPINE_VERSION=3.20

FROM alpine:${ALPINE_VERSION} AS build

ARG APP_VERSION=dev
ARG TARGETARCH

ENV BUILD_APP_VERSION=${APP_VERSION}

RUN mkdir -p /out && \
    printf '%s\n' "built-version=${BUILD_APP_VERSION}" > /out/version.txt && \
    printf '%s\n' "target-arch=${TARGETARCH}" >> /out/version.txt

FROM alpine:${ALPINE_VERSION} AS runtime

ARG APP_VERSION=dev

ENV APP_VERSION=${APP_VERSION} \
    APP_HOME=/app

LABEL org.opencontainers.image.title="arg-env-label-demo" \
      org.opencontainers.image.version="${APP_VERSION}"

WORKDIR ${APP_HOME}
COPY --from=build /out/version.txt .

CMD ["sh", "-c", "cat /app/version.txt && echo runtime-version=${APP_VERSION}"]

构建:

docker build \
  --build-arg APP_VERSION=1.2.3 \
  --platform linux/amd64 \
  -t arg-env-label-demo:1.2.3 .

逐步分析:

  1. 顶层 ALPINE_VERSION 可用于两个 FROM
  2. build 阶段单独声明 APP_VERSIONTARGETARCH
  3. build 阶段的 BUILD_APP_VERSION 是环境变量,只在该阶段及其后续指令中使用;
  4. runtime 阶段不是 FROM build,因此不会继承 build 阶段的 ENV
  5. COPY --from=build 只复制 /out/version.txt
  6. runtime 阶段重新声明 APP_VERSION,再转换为运行时 ENV
  7. LABEL 将版本写入镜像配置;
  8. 容器启动后只能看到 runtime 阶段设置的 APP_VERSION
  9. TARGETARCH 被写入文件,因为构建逻辑显式使用了它;它不会自动成为运行时环境变量。

验证:

docker run --rm arg-env-label-demo:1.2.3

可能输出:

built-version=1.2.3
target-arch=amd64
runtime-version=1.2.3

查看环境和标签:

docker image inspect arg-env-label-demo:1.2.3 \
  --format 'env={{json .Config.Env}} labels={{json .Config.Labels}}'

可能看到:

env=["APP_VERSION=1.2.3","APP_HOME=/app"] labels={"org.opencontainers.image.title":"arg-env-label-demo","org.opencontainers.image.version":"1.2.3"}

这个结果同时说明:

  • 文件中的 built-version 来自构建阶段;
  • APP_VERSION 是运行时默认环境;
  • org.opencontainers.image.version 是镜像标签;
  • 三者值相同只是因为它们都使用了同一个构建参数,并不表示它们属于同一种机制。

八、常见误解与失败表现

8.1 误解:ARG 会自动传给 docker run

错误示例:

FROM alpine:3.20
ARG PORT=8080
CMD ["sh", "-c", "echo PORT=$PORT"]

构建成功并不代表运行时有 PORT。修复方式是明确转换:

FROM alpine:3.20

ARG PORT=8080
ENV PORT=${PORT}

CMD ["sh", "-c", "echo PORT=$PORT"]

但如果 PORT 只是运行时配置,更合适的做法通常是使用运行命令或 Compose 注入,而不是在镜像中固化一个环境值。

8.2 误解:COPY --from 会继承前一阶段所有设置

不会。它只复制指定文件或目录:

COPY --from=build /app /app

不会自动复制:

  • ENV
  • ARG
  • LABEL
  • CMD
  • ENTRYPOINT
  • 工作目录。

如果运行阶段需要某个变量,必须在运行阶段显式设置。

8.3 误解:标签是容器标签

Docker 中至少要区分:

  • 镜像标签:docker image inspect 中的 Config.Labels
  • 容器标签:创建容器时附加到容器对象的标签;
  • 镜像引用标签:example:1.2.3 中冒号后的 tag。

例如:

docker run -d \
  --name demo \
  --label com.example.owner=team-a \
  example:1.2.3

这里的 --label 是容器标签,不会修改已经存在的镜像标签。

检查容器标签:

docker inspect demo \
  --format '{{json .Config.Labels}}'

检查镜像标签:

docker image inspect example:1.2.3 \
  --format '{{json .Config.Labels}}'

两者对象不同,生命周期也不同。

8.4 误解:删除了 Secret 文件就没有泄漏

以下过程仍然有风险:

RUN echo "${TOKEN}" > /tmp/token \
 && use-token /tmp/token \
 && rm -f /tmp/token

删除动作只影响当前层之后的文件系统视图。构建历史、日志、缓存、最终生成文件以及工具自身的诊断输出仍可能保留凭据。

8.5 误解:修改 ENV 只影响运行时

ENV 设置后会影响后续构建指令:

ENV NODE_ENV=production
RUN npm install

这里 NODE_ENV 可能改变依赖安装结果,因此它不仅是运行时配置,也是构建输入。若构建和运行需要不同值,应分开表达:

ARG BUILD_NODE_ENV=production
RUN NODE_ENV="${BUILD_NODE_ENV}" npm install

ENV NODE_ENV=production

这样可以避免构建期变量和运行期变量互相覆盖。


九、诊断方法:从“值在哪里”反推机制

遇到变量不生效或敏感值疑似泄露时,应先定位它进入了哪一类状态。

9.1 构建阶段看不到变量

检查顺序:

ARG NAME=value
FROM alpine:3.20
RUN echo "$NAME"

这里的问题是 ARGFROM 前声明,未在阶段内重新声明。应改为:

ARG NAME=value
FROM alpine:3.20
ARG NAME
RUN echo "$NAME"

如果变量用于 FROM,看它是否位于第一个 FROM 之前;如果变量来自父阶段,看当前阶段是否确实使用了 FROM parent AS child

9.2 运行容器看不到变量

检查:

docker image inspect image:tag \
  --format '{{json .Config.Env}}'

如果不存在,说明 Dockerfile 没有把它转换成 ENV。如果镜像中存在但容器值不同,检查运行时覆盖:

docker inspect container \
  --format '{{json .Config.Env}}'

还要检查 Compose、docker run -e、平台部署配置是否覆盖了镜像默认值。

9.3 标签看不到或值不对

检查镜像而不是容器:

docker image inspect image:tag \
  --format '{{json .Config.Labels}}'

若标签依赖 ARG,确认构建命令确实传入了:

docker build --build-arg VERSION=1.2.3 .

同时确认没有后续 LABEL 使用同一个键覆盖前值。

9.4 怀疑 Secret 已进入镜像

可检查:

docker history --no-trunc image:tag
docker image inspect image:tag

对于远程 registry,还应检查构建器生成的 provenance、缓存导出物和日志系统。不能只扫描最终文件系统:

docker run --rm image:tag find / -type f

因为 Secret 可能不在最终文件中,却已进入历史、配置、标签或构建证明。

已经把凭据写入镜像后,正确恢复路径通常是:

  1. 立即撤销或轮换凭据;
  2. 删除暴露的镜像和相关缓存;
  3. 修正 Dockerfile,改用 Secret mount;
  4. 重新构建并推送;
  5. 检查日志、构建证明、缓存和仓库访问记录;
  6. 不把旧镜像仅仅改名或覆盖 tag 当作清除,因为旧 digest 可能仍可访问。

十、Compose 与 Dockerfile 的边界

Compose 经常同时管理构建参数、运行环境和 Secret,因此更容易把三者混淆。

概念上:

services:
  app:
    build:
      context: .
      args:
        APP_VERSION: "1.2.3"
    environment:
      APP_ENV: production

这里:

  • build.args 对应 Dockerfile 的 ARG
  • environment 对应容器运行时环境;
  • ENV 只是镜像提供的默认运行时环境;
  • Compose 的运行时设置可以覆盖镜像中的 ENV

例如 Dockerfile:

ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}

Compose:

services:
  app:
    build:
      args:
        APP_VERSION: "1.2.3"
    environment:
      APP_VERSION: "runtime-override"

最终容器中通常是:

APP_VERSION=runtime-override

因为构建参数先决定镜像默认值,Compose 的 environment 再在容器创建阶段覆盖它。

运行时 Secret 则不应放进 build.args 或镜像 environment 的明文配置中。应使用 Compose 规范支持的 Secret 机制或部署平台的 Secret 注入方式,并确认实际实现如何挂载、授权和轮换。


十一、可重复构建中的 Metadata 取舍

ARGENVLABEL 都可能影响可重复构建,但影响方式不同。

1. 基础镜像引用

ARG ALPINE_VERSION=3.20
FROM alpine:${ALPINE_VERSION}

使用可变 tag 时,未来同一个 Dockerfile 可能解析到不同的基础镜像内容。更强的可重复性通常需要固定 digest:

FROM alpine:3.20@sha256:<digest>

digest 必须由实际镜像发布者或可信仓库提供,不能随意填写。

2. 构建参数

版本、目标平台和特性开关如果参与了构建逻辑,就应作为显式输入记录:

docker build \
  --build-arg APP_VERSION=1.2.3 \
  --platform linux/amd64 \
  -t example:1.2.3 .

相同源码、相同基础镜像 digest、相同构建参数和相同工具链,才有机会得到可比较的结果。ARG 本身不会自动让构建可重复。

3. 标签中的时间戳

下面的标签会使 Metadata 每次构建都不同:

ARG BUILD_TIME
LABEL org.opencontainers.image.created="${BUILD_TIME}"

这并非错误,但会妨碍按 digest 比较构建结果。若时间信息只是审计信息,可以交给 provenance 或外部发布系统保存,而不是强行写入影响镜像身份的字段。

4. ENV 的构建影响

如果某个 ENV 参与包安装、编译或生成文件,它就是构建输入:

ENV GOFLAGS=-trimpath
RUN go build ./...

如果它只用于容器启动,也可以在最终阶段设置,以减少构建阶段和运行阶段的耦合。


十二、实际分工原则

可以用下面的判断过程选择机制:

这个值是否只影响 Dockerfile 构建?
    是 -> ARG

这个值是否应该成为容器进程的默认环境?
    是 -> ENV,或由运行平台注入

这个值是否用于描述镜像来源、版本、组件或所有者?
    是 -> LABEL / 其他构建 Metadata

这个值泄露后是否会造成权限或数据风险?
    是 -> 不使用 ARG、ENV、LABEL;使用 Secret 机制

典型映射如下:

需求 合适机制
选择基础镜像版本 ARG,必要时固定 digest
选择编译特性 ARG
将构建版本展示给运行中的应用 显式转换为 ENV 或写入版本文件
描述镜像版本和来源 LABEL,并结合 provenance
注入数据库密码 运行时 Secret
构建时访问私有包仓库 BuildKit Secret mount
容器启动时覆盖默认配置 docker run -e、Compose、平台配置
记录构建平台 自动平台参数、标签或 provenance

核心边界可以概括为:

ARG 是构建输入;
ENV 是构建环境与运行时默认环境;
LABEL 是镜像描述信息;
Metadata 是比 LABEL 更大的镜像与构建描述集合;
Secret 不应进入上述任何公开或可持久化边界。

理解这些边界后,多阶段构建中的行为就可以按数据流准确判断:FROM stage 继承阶段状态,COPY --from 只复制文件,ARG 需要在作用域内声明,ENV 可能进入镜像和容器,LABEL 进入镜像元数据,而 Secret 应只在需要它的最短生命周期内出现。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。