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

Dev Containers 开发环境:配置、Feature、缓存、Secret 和复现

Dev Container 是“在容器中运行开发工具和项目工作区”的开发环境模型。它不是一种新的容器运行时,也不是把生产镜像直接当作开发环境的同义词;它通常由以下几层组成:

  1. 容器运行时:Linux 上通常是 Docker Engine;Docker Desktop 则通过 Linux VM 提供 Linux 容器运行环境。
  2. 镜像构建层:Dockerfile、基础镜像、BuildKit、镜像缓存和 Secret。
  3. 开发环境描述层devcontainer.json、Docker Compose 或 Dev Container Feature。
  4. 工作区与状态层:Bind mount、命名卷、编辑器服务、端口转发和容器生命周期。
  5. 依赖与工具层:编译器、调试器、语言服务器、数据库客户端、项目依赖和凭据。

要获得可复现的开发环境,不能只提交一个 devcontainer.json。必须同时约束环境定义、依赖版本、构建输入、凭据注入方式、持久化数据以及宿主机边界。


一、Dev Container 的组件和生命周期

1. 配置文件不是容器本身

常见目录结构如下:

project/
├── .devcontainer/
│   ├── devcontainer.json
│   ├── Dockerfile
│   └── compose.yaml
├── src/
├── package.json
├── package-lock.json
└── README.md

devcontainer.json 是开发工具使用的描述文件。它可以要求工具:

  • 从某个镜像启动容器;
  • 根据 Dockerfile 构建镜像;
  • 使用 Docker Compose 创建服务;
  • 安装 Features;
  • 挂载工作区;
  • 设置环境变量;
  • 转发端口;
  • 在创建或启动阶段执行命令;
  • 指定容器内的开发用户。

它不会替代 Dockerfile,也不会改变 Docker 镜像构建的基本语义。

一个典型的基于 Dockerfile 的配置如下:

{
  "name": "node-dev",
  "build": {
    "dockerfile": "Dockerfile",
    "context": "..",
    "args": {
      "NODE_VERSION": "22"
    }
  },
  "remoteUser": "vscode",
  "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/app,type=bind,consistency=cached",
  "workspaceFolder": "/workspaces/app",
  "forwardPorts": [3000],
  "portsAttributes": {
    "3000": {
      "label": "web",
      "onAutoForward": "notify"
    }
  },
  "postCreateCommand": "npm ci"
}

这里使用的是 JSONC,因此允许注释;如果由严格 JSON 解析器读取,则必须删除注释。

关键字段的因果关系是:

  • build 决定如何得到镜像;
  • workspaceMount 决定宿主机项目目录如何进入容器;
  • remoteUser 决定编辑器终端和扩展通常以哪个用户运行;
  • postCreateCommand 在容器创建后执行,不属于镜像构建;
  • forwardPorts 只处理开发工具到宿主机的端口转发,不等同于 Dockerfile 的 EXPOSE
  • workspaceFolder 决定编辑器打开的容器内路径。

2. 生命周期分为构建、创建、启动和连接

一个开发容器通常经历以下状态:

flowchart LR
    A[读取 devcontainer.json] --> B{选择来源}
    B -->|image| C[拉取镜像]
    B -->|Dockerfile| D[BuildKit 构建镜像]
    B -->|Compose| E[Compose 创建服务]
    C --> F[创建容器]
    D --> F
    E --> G[选择 devcontainer service]
    G --> F
    F --> H[挂载 workspace 与 volumes]
    H --> I[启动容器]
    I --> J[执行 onCreateCommand]
    J --> K[执行 updateContentCommand]
    K --> L[执行 postCreateCommand]
    L --> M[连接编辑器与终端]

具体命令和钩子执行细节可能随 Dev Container 客户端版本和配置而变化,但必须区分三个时间点:

  • 构建时:Dockerfile 的 RUNCOPY、Feature 安装;
  • 创建后:容器已经存在,执行创建阶段的命令;
  • 每次启动或连接时:启动命令、编辑器连接命令和用户 shell 初始化。

这一区分很重要。例如:

RUN npm install

会进入镜像层,构建缓存可以复用;而:

{
  "postCreateCommand": "npm ci"
}

不会进入镜像层,每次重新创建容器时都可能执行。前者适合构建环境的一部分,后者适合依赖工作区内容或挂载状态的初始化动作。

3. remoteUser 不是 Dockerfile 的 USER

Dockerfile 中:

USER vscode

会影响镜像默认用户。devcontainer.json 中:

{
  "remoteUser": "vscode"
}

则告诉开发容器客户端以该用户连接、执行相关开发操作。两者经常一起设置,但语义不同。

如果只设置 remoteUser 而镜像中的进程仍以 root 启动,可能出现以下差异:

  • 编辑器终端使用 vscode
  • 容器入口进程仍可能由 Docker 默认用户启动;
  • 挂载目录中的文件所有者可能与宿主机用户不一致;
  • 安装全局工具时出现权限错误。

因此应在镜像中明确创建用户,并检查工作区的 UID/GID、挂载方式和包管理器缓存目录。


二、一个可运行的基础 Dev Container

下面的例子使用 Node.js 项目,假设宿主机已经安装 Docker Engine、BuildKit,以及支持 Dev Container 规范的客户端。

1. Dockerfile

# syntax=docker/dockerfile:1

ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-bookworm

ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=1000

RUN groupadd --gid "${USER_GID}" "${USERNAME}" \
    && useradd --uid "${USER_UID}" --gid "${USER_GID}" -m -s /bin/bash "${USERNAME}" \
    && apt-get update \
    && apt-get install -y --no-install-recommends \
       git \
       ca-certificates \
       curl \
       procps \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /workspaces/app

USER vscode

# 让 npm 缓存落入可持久化的用户目录,而不是工作区
ENV npm_config_cache=/home/vscode/.npm

CMD ["sleep", "infinity"]

这里有几个值得注意的边界:

  • node:22-bookworm 是一个标签,不等于永久不可变的内容;标签未来可能指向新摘要。
  • apt-get update 和安装必须在同一个 RUN 中,避免索引层与软件包层失配。
  • rm -rf /var/lib/apt/lists/* 减少镜像层中的无用索引,不会影响已经安装的软件。
  • CMD ["sleep", "infinity"] 只是让开发容器保持运行;它不是生产服务入口。
  • USER vscode 让默认进程以非 root 身份运行,但软件包安装等操作就不能直接写入系统目录。

2. devcontainer.json

{
  "name": "node-dev",
  "build": {
    "dockerfile": "Dockerfile",
    "context": "..",
    "args": {
      "NODE_VERSION": "22",
      "USER_UID": "1000",
      "USER_GID": "1000"
    }
  },

  "remoteUser": "vscode",
  "workspaceFolder": "/workspaces/app",

  "mounts": [
    "type=volume,source=node-npm-cache,target=/home/vscode/.npm"
  ],

  "forwardPorts": [3000],
  "portsAttributes": {
    "3000": {
      "label": "Node application",
      "onAutoForward": "notify"
    }
  },

  "postCreateCommand": "npm ci"
}

context: ".." 表示构建上下文是项目根目录,而不是 .devcontainer 目录。这样 Dockerfile 才能在构建阶段访问根目录中的文件;同时应配合项目根目录的 .dockerignore

.git
node_modules
dist
coverage
.env
.env.*

.dockerignore 的作用不是安全隔离。它只决定哪些文件被发送到构建上下文。真正的 Secret 不应依赖“希望它被 .dockerignore 排除”,因为:

  • 它可能已经存在于 Git 历史;
  • 其他构建路径可能仍能访问它;
  • 错误的构建上下文或复制规则可能重新包含它;
  • COPY 进入镜像后,删除文件也不等于删除历史层中的内容。

3. 验证结果

进入项目目录后构建或由客户端打开 Dev Container。进入容器后可以验证:

whoami
# vscode

pwd
# /workspaces/app

node --version
# v22.x.x

npm config get cache
# /home/vscode/.npm

如果执行:

npm ci

它要求项目中存在锁文件,例如 package-lock.json。没有锁文件时,依赖解析会依赖当前注册表状态,复现性会明显降低。


三、Feature:可组合的开发工具安装单元

1. Feature 的定义

Dev Container Feature 是一个可被开发容器客户端发现和安装的工具包。它通常包含:

  • Feature 元数据;
  • 安装脚本;
  • 可声明的选项;
  • 可能的环境变量或 PATH 修改;
  • 适用的基础镜像和系统架构信息。

Feature 适合封装“开发环境能力”,例如:

  • Docker CLI;
  • GitHub CLI;
  • Node.js、Python、Go 等工具链;
  • 常见的 shell 工具;
  • 语言运行时及其相关配置。

Feature 不是一个普通的 Docker 镜像层,也不是 Docker Compose 服务。它由 Dev Container 工具在镜像构建或容器创建流程中处理。Feature 的具体安装顺序、选项和兼容范围由 Feature 自己的元数据及 Dev Container 规范实现决定。

2. 使用 Feature

例如:

{
  "name": "python-dev",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",

  "features": {
    "ghcr.io/devcontainers/features/python:1": {
      "version": "3.12"
    },
    "ghcr.io/devcontainers/features/docker-in-docker:2": {
      "version": "latest"
    }
  },

  "remoteUser": "vscode"
}

这里的含义是:

  1. 使用基础镜像;
  2. 获取指定的 Feature 引用;
  3. version 等选项传给 Feature;
  4. 生成或启动带有这些工具的开发环境。

Feature 引用中的 :1 通常表示 Feature 的主版本线,而不是精确的不可变内容。若组织需要严格复现,应进一步固定 Feature 的具体版本、来源和内容摘要,并在升级时显式修改,而不是依赖浮动标签。

3. Feature 与 Dockerfile 的取舍

下面两种写法都可能正确:

RUN apt-get update \
    && apt-get install -y --no-install-recommends jq \
    && rm -rf /var/lib/apt/lists/*

或者使用一个提供 jq 的 Feature。

选择 Feature 的条件是:工具安装逻辑需要在多个项目之间复用,并且安装参数已经被 Feature 清晰封装。选择 Dockerfile 的条件是:安装步骤是当前项目独有的,或者需要完全控制软件包版本、系统配置和构建顺序。

反例是把所有命令都塞进 Feature,只因为“Feature 更现代”。这样会使环境依赖隐藏在外部引用中,诊断时必须同时检查:

  • Feature 的版本;
  • Feature 的安装脚本;
  • 基础镜像发行版;
  • 架构支持;
  • Feature 选项的默认值。

4. Feature 的失败路径

Feature 安装失败通常发生在镜像构建阶段,而不是项目容器运行阶段。应从以下顺序诊断:

docker build --progress=plain .

观察:

  1. 基础镜像是否能拉取;
  2. Feature 的来源注册表是否可访问;
  3. Feature 是否支持当前架构;
  4. Feature 依赖的发行版是否匹配;
  5. 安装脚本是否需要 root;
  6. 安装完成后 PATH 是否对 remoteUser 生效。

如果工具已经安装但终端找不到,应检查环境变量是在:

  • 镜像中的 ENV
  • 用户 shell 的启动文件;
  • remoteEnv
  • 编辑器扩展进程环境;

哪一层设置的。容器内的交互式 shell 能找到命令,不代表编辑器扩展进程一定拥有相同的 PATH。


四、缓存:镜像层缓存、包管理器缓存和挂载缓存

“缓存”不是一个单一对象。至少应区分三种状态:

类型 存放位置 主要用途 是否进入最终镜像
镜像层缓存 BuildKit 缓存存储 跳过未变化的构建步骤 只有构建结果进入镜像
RUN --mount=type=cache BuildKit 缓存目录 加速包管理器下载 通常不进入镜像
命名卷/宿主机卷 容器运行时存储 保留开发期间的依赖缓存 不属于镜像内容

1. 镜像层缓存的失效条件

Dockerfile 的每个指令会产生构建结果。一个后续步骤能否复用,取决于前置状态和指令输入是否仍然等价。

常见的低效写法:

COPY . .
RUN npm ci

只要源代码任意文件变化,COPY . . 的结果就可能变化,于是后面的 npm ci 也失去缓存。

更合理的顺序是:

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

推导过程是:

  1. 依赖安装只由清单和锁文件决定;
  2. 源代码修改不应改变依赖输入;
  3. 因此先复制依赖描述文件;
  4. 只有锁文件变化时才重新安装依赖;
  5. 最后复制源码。

这不是“Docker 特殊技巧”,而是把构建步骤按照输入依赖关系排序。

2. 使用 BuildKit cache mount

# syntax=docker/dockerfile:1

FROM node:22-bookworm

WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
    npm ci

COPY . .

type=cache 的目录是 BuildKit 管理的可复用缓存。npm ci 仍然会根据锁文件安装依赖,但下载过的包可以从缓存中读取。

它具有以下边界:

  • 缓存内容不应被视为构建结果;
  • 删除缓存不会改变正确性,只会降低下一次构建速度;
  • 多个项目共用相同 id 时,可能产生无关数据混合;
  • 包管理器应能正确处理缓存目录中的并发访问;
  • CI 环境如果每次都是全新构建器,则需要导出和导入 BuildKit 缓存才能获得跨任务收益。

对于 apt,可以使用:

RUN rm -rf /var/lib/apt/lists/* \
    && apt-get update \
    && apt-get install -y --no-install-recommends git curl \
    && rm -rf /var/lib/apt/lists/*

apt 的索引与软件包缓存路径受发行版和配置影响,不能机械地把 npm 的缓存路径替换成 apt 路径。缓存挂载必须以具体工具的缓存目录为准。

3. 开发容器运行时的命名卷

devcontainer.json 中:

{
  "mounts": [
    "type=volume,source=node-npm-cache,target=/home/vscode/.npm"
  ]
}

这个卷的生命周期独立于镜像。删除并重建容器时,卷通常仍可存在,因此 npm ci 可以复用下载缓存。

但卷不会让 node_modules 自动跨平台安全复用。特别是以下情况容易出错:

  • 宿主机和容器的 CPU 架构不同;
  • Node 原生扩展在宿主机编译;
  • glibc 与 musl 不同;
  • Node.js 主版本不同;
  • 依赖中包含平台相关二进制。

因此通常应让 node_modules 在 Linux 容器内部生成,而不是把宿主机的 node_modules Bind mount 到容器中。


五、Secret:构建时凭据与运行时凭据必须分开

1. Secret 的核心条件

Secret 是需要被某个步骤读取、但不应成为镜像永久内容的敏感值,例如:

  • 私有 npm 注册表令牌;
  • 私有 Python 包索引凭据;
  • Git 私有仓库访问令牌;
  • 云平台临时凭据;
  • 企业内部证书或签名材料。

必须区分:

  • 构建时 Secret:只在 Dockerfile 某个 RUN 步骤执行期间可见;
  • 运行时 Secret:容器启动后由运行时注入;
  • 环境变量:只是进程环境的一种传递方式,不天然安全;
  • 镜像层中的文件:一旦写入层,删除文件也不能可靠消除历史层中的内容。

2. 错误做法:ARG 和 ENV

ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken="${NPM_TOKEN}"

或者:

ENV NPM_TOKEN=secret-value

这两种方式都不适合作为 Secret 机制:

  • ARG 会出现在构建参数和构建记录的可观察信息中;
  • ENV 会进入镜像配置,并可能被 docker inspect、进程环境或调试工具看到;
  • 如果把令牌写入配置文件,令牌会进入某个镜像层;
  • 后续 rm 只是在新层中删除文件,不会消除旧层。

3. BuildKit 的构建时 Secret

Dockerfile:

# syntax=docker/dockerfile:1

FROM node:22-bookworm

WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \
    --mount=type=cache,id=npm-cache,target=/root/.npm \
    npm ci

构建命令:

docker build \
  --secret id=npmrc,src="$HOME/.npmrc" \
  -t example-node-dev:local \
  .

执行过程是:

  1. BuildKit 读取本机的 .npmrc
  2. 只在带有 id=npmrcRUN 步骤中挂载它;
  3. npm ci 读取私有注册表凭据;
  4. 该文件不会通过 COPY 进入镜像;
  5. RUN 结束后挂载消失;
  6. 包管理器缓存可能保留下载内容,但不应把令牌写入缓存。

required=true 的意义是:没有 Secret 时立即失败,而不是悄悄回退到公共注册表。这样可以避免“本地能构建、CI 实际使用了错误源”的隐蔽错误。

但仍要检查包管理器行为。如果工具把认证信息复制进缓存、日志或生成文件,BuildKit 不能替应用程序清理这些副作用。

4. Compose 的运行时 Secret

运行中的服务可以使用 Compose Secret:

services:
  app:
    build:
      context: .
    secrets:
      - npm_token
    environment:
      NPM_TOKEN_FILE: /run/secrets/npm_token
    command: ["node", "server.js"]

secrets:
  npm_token:
    file: ./.secrets/npm_token

容器内的典型结果是:

/run/secrets/npm_token

应用程序读取该文件,而不是把令牌直接写入 environment。运行:

docker compose config

可以检查 Compose 展开后的配置,但不要把真正的 Secret 文件提交到 Git,也不要把带有敏感内容的诊断输出发到公共日志系统。

这里存在一个重要边界:Compose Secret 的具体挂载和生命周期由 Compose 与 Docker 实现共同处理;它不等同于云平台的 Secret Manager。生产环境中通常应由外部 Secret 管理系统负责授权、轮换和审计,而不是把固定文件放在开发仓库附近。


六、配置中的环境变量、Secret 和插值

以下三类变量经常被混淆:

1. 构建参数

{
  "build": {
    "args": {
      "NODE_VERSION": "22"
    }
  }
}

它用于影响 Dockerfile 构建过程:

ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-bookworm

构建参数适合非敏感的版本选择,不适合令牌。

2. 容器环境变量

{
  "containerEnv": {
    "APP_ENV": "development"
  }
}

它影响容器环境,通常适合应用进程读取的非敏感配置。

3. 连接客户端环境变量

{
  "remoteEnv": {
    "DEBUG": "1"
  }
}

它主要影响 Dev Container 客户端连接的终端、任务或扩展环境。它与容器中所有进程的环境并不总是等价。

remoteEnvcontainerEnv、Compose 的 environment、Dockerfile 的 ENV 和宿主机的 shell 环境,处在不同处理阶段。诊断时应明确变量是在“解析配置时”“创建容器时”“启动进程时”还是“连接编辑器时”生效。

不要使用:

{
  "containerEnv": {
    "DATABASE_PASSWORD": "hard-coded-password"
  }
}

这会让密码出现在项目配置、版本历史和工具诊断信息中。


七、Bind mount、命名卷与开发环境复现

1. 工作区通常使用 Bind mount

开发容器需要编辑器实时修改宿主机文件,因此工作区通常采用:

宿主机项目目录 ──Bind mount──> /workspaces/app

其优点是源码仍由宿主机编辑器、Git 和文件系统管理。其代价是:

  • 文件系统性能受宿主机和 Docker Desktop 文件共享实现影响;
  • UID/GID 可能不一致;
  • 文件事件通知可能有延迟或丢失;
  • 宿主机路径语义与 Linux 容器内路径不同;
  • Windows、macOS 和 Linux 的大小写、权限、换行符行为可能不同。

这也是 Linux 容器边界:容器内的进程看到的是 Linux 用户空间和 Linux 内核接口;它不等于宿主机原生 Linux 进程。Docker Desktop 上还存在 Linux VM 和文件共享层,开发性能与网络表现因此不能简单等同于 Linux 主机上的 Docker Engine。

2. 数据卷不应与工作区混用

数据库数据、包管理器缓存和编译产物适合放在命名卷:

services:
  db:
    image: postgres:16
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  postgres-data:

数据库数据卷的复现语义是“持久化运行状态”,而不是“从零得到同样数据库”。要复现数据库内容,仍需要迁移脚本、种子数据或备份恢复流程。

同理,命名卷中的 node_modules 会提高某些环境下的性能,但它可能隐藏工作区内容:

volumes:
  - .:/workspaces/app
  - node-modules:/workspaces/app/node_modules

此时容器内 /workspaces/app/node_modules 的内容来自命名卷,而不是 Bind mount 中的目录。删除卷后依赖会消失;不同 Node 版本或架构还可能使旧的原生扩展失效。


八、复现性:从“能启动”到“输入可证明相同”

开发环境复现不是只检查容器能否启动。可以把一次构建抽象为:

I=(B,D,F,C,A,P)I = (B, D, F, C, A, P)

其中:

  • BB:基础镜像内容;
  • DD:Dockerfile 和构建上下文;
  • FF:Features 及其版本;
  • CC:依赖清单和锁文件;
  • AA:架构与平台;
  • PP:外部包源、代理和证书等构建环境。

若这些输入中任一项变化,输出镜像 OO 就可能变化:

O=Build(I)O = \operatorname{Build}(I)

因此“同一个 node:22 标签”不能证明输出相同;“同一个 package.json”也不能证明依赖相同。

1. 需要锁定的对象

至少应锁定:

Dockerfile
devcontainer.json
Feature 版本
基础镜像版本或摘要
package-lock.json / yarn.lock / pnpm-lock.yaml
Python requirements lock
Go modules 与校验和
系统架构
包源和代理配置

基础镜像可以使用摘要:

FROM node:22-bookworm@sha256:<digest>

摘要固定的是镜像清单或镜像内容引用。升级基础镜像时,应显式更新摘要并记录原因。若使用多架构镜像,还要确认摘要对应的是索引还是具体平台清单,以及构建目标是否一致。

2. 完整算例:依赖变更如何影响缓存

假设 Dockerfile 为:

COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src

初次构建时:

步骤 1:复制 package.json 和 package-lock.json  -> 执行
步骤 2:npm ci                                  -> 执行
步骤 3:复制 src                                  -> 执行

只修改 src/index.js

步骤 1:输入未变                                  -> 命中缓存
步骤 2:输入未变                                  -> 命中缓存
步骤 3:输入变化                                  -> 执行

修改 package-lock.json

步骤 1:输入变化                                  -> 执行
步骤 2:前一步变化                                -> 执行
步骤 3:继续执行                                  -> 执行

这个结果来自步骤输入的依赖关系,而不是缓存“猜测源码是否重要”。

3. 架构差异是复现边界

在 Linux amd64 上构建的依赖,不一定能在 Linux arm64 上运行。典型原因包括:

  • 原生 Node 模块包含架构相关二进制;
  • Python wheel 只发布了部分平台;
  • Go、Rust、C/C++ 工具链产生不同目标文件;
  • 基础镜像的架构清单不同;
  • QEMU 模拟执行可能改变性能和故障表现。

可以显式指定平台:

docker buildx build \
  --platform=linux/amd64 \
  -t example-node-dev:amd64 \
  .

但这只说明目标平台,不会自动解决所有交叉编译问题。应在目标平台容器内执行测试和依赖安装,尤其是包含原生扩展时。


九、Compose 开发环境:多服务、覆盖和调试

当开发环境包含数据库、消息队列或依赖服务时,Compose 通常比单容器配置更清晰:

services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile
    working_dir: /workspaces/app
    command: ["sleep", "infinity"]
    volumes:
      - ..:/workspaces/app
      - node-modules:/workspaces/app/node_modules
    ports:
      - "3000:3000"
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: dev-only-password
      POSTGRES_DB: app
    volumes:
      - postgres-data:/var/lib/postgresql/data

volumes:
  node-modules:
  postgres-data:

对应的 devcontainer.json

{
  "name": "compose-node-dev",
  "dockerComposeFile": "compose.yaml",
  "service": "app",
  "workspaceFolder": "/workspaces/app",
  "remoteUser": "vscode",
  "forwardPorts": [3000, 5432],
  "postCreateCommand": "npm ci"
}

service 指定编辑器连接哪个 Compose 服务;db 只是依赖服务,不应被当作开发终端容器。

这里的 depends_on 只表达启动顺序或依赖关系,不能证明 PostgreSQL 已经可以接受连接。应用仍应通过重试、健康检查或显式等待处理数据库尚未就绪的情况。

例如:

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 2s
      timeout: 3s
      retries: 20

  app:
    depends_on:
      db:
        condition: service_healthy

健康检查降低了启动竞态,但应用本身仍应处理数据库运行期间的断连、重启和迁移失败。


十、常见误解和诊断路径

1. “重新打开容器后,配置一定被重新执行”

不一定。修改 devcontainer.json、Dockerfile、Feature 或构建参数后,客户端可能需要重新构建容器,而不是仅重启容器。应区分:

  • 重启容器:保留容器文件系统和挂载;
  • 重建镜像:重新计算 Dockerfile 和 Feature;
  • 重建容器:使用新镜像重新创建容器;
  • 删除卷:清除独立持久化状态。

诊断时先检查:

docker compose ps
docker image ls
docker volume ls
docker inspect <container>

确认当前运行的是哪个镜像、哪些挂载和哪些环境变量。

2. “容器里有代码,所以编辑器一定打开了正确目录”

如果工作区路径配置错误,编辑器可能连接到了容器,但打开的是镜像内空目录。验证:

mount
ls -la /workspaces/app
git -C /workspaces/app status

如果 .git 不存在,可能是:

  • Bind mount 源路径错误;
  • contextworkspaceMount 被混淆;
  • Compose 使用了错误的相对路径;
  • Windows/macOS 路径没有被 Docker Desktop 正确共享。

3. “缓存命中,所以构建内容一定可靠”

缓存命中只说明构建器认为输入等价,不说明:

  • 外部包源今天仍提供相同内容;
  • 上游标签没有移动;
  • 缓存没有被错误共享;
  • 构建步骤没有把不应缓存的状态带进去。

需要可靠复现时,应固定依赖校验和、镜像摘要、Feature 版本,并在 CI 中进行干净构建验证。

4. “把密码从文件删除,就不会泄露”

错误。若密码曾经被:

COPY .npmrc /root/.npmrc
RUN npm install
RUN rm /root/.npmrc

复制进镜像,则它可能存在于早期层、构建缓存、导出的镜像或构建日志中。应使用 BuildKit Secret,并在凭据泄露后立即轮换,而不是只修改 Dockerfile。

5. “Linux 容器等于 Linux 主机环境”

在 Linux 主机上,这两者仍不完全相同:容器使用宿主机内核,但拥有独立的用户空间、进程、网络和挂载命名空间。在 Docker Desktop 上,Linux 容器运行在 Linux VM 中,宿主机目录通过文件共享机制进入 VM 和容器。

因此以下行为需要专门验证:

  • 文件事件监听;
  • 大量小文件读写;
  • 宿主机与容器之间的权限映射;
  • DNS 和代理;
  • 容器访问宿主机服务;
  • amd64 主机运行 arm64 镜像或反之。

十一、开发环境的安全与生产边界

开发容器常见的 Docker 访问方式是挂载 Docker socket:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock

这让容器内的 Docker CLI 可以控制宿主机 Docker Engine。它不是普通文件共享;拥有该 socket 的进程通常能够创建特权容器、挂载宿主机路径并影响宿主机上的其他容器。因此:

  • 不应把它视为低风险开发配置;
  • 不应在不可信项目中随意启用;
  • 不能把“容器内非 root”误认为已经消除 socket 权限;
  • CI 和企业环境应根据隔离要求选择远程 Engine、Rootless Docker、Docker-in-Docker 或其他方案。

同样,开发镜像不应因为方便而默认包含生产凭据、云平台长期密钥或宿主机 SSH 私钥。需要访问私有资源时,应优先使用短期凭据、代理转发、BuildKit Secret 或外部 Secret 管理方案。


十二、交付前的复现检查

一个可交付的 Dev Container 至少应能回答以下问题:

  1. 基础镜像从哪里来,是否固定版本或摘要?
  2. Dockerfile 的构建上下文是否最小且明确?
  3. Feature 使用了哪些版本,是否支持目标架构?
  4. 项目依赖是否有锁文件和校验信息?
  5. 哪些缓存只用于加速,删除后是否仍能正确构建?
  6. 哪些数据位于 Bind mount、命名卷或镜像层?
  7. 构建时 Secret 是否通过 BuildKit Secret 注入?
  8. 运行时 Secret 是否避免进入环境变量、日志和镜像?
  9. 编辑器连接用户是否与文件权限一致?
  10. 修改配置后,团队知道应该重启、重建还是删除卷吗?
  11. 在 Linux 主机、Docker Desktop Linux VM 和目标 CI 平台上,哪些行为存在差异?
  12. 多服务启动是否处理了健康检查、网络解析和数据库初始化竞态?

当这些问题都能通过仓库中的配置、锁文件和验证命令回答时,Dev Container 才不仅是“某台机器上的启动脚本”,而是一个具有明确输入、状态和故障边界的开发环境。


系列导航与关联阅读

官方资料

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