把 code-server 塞进开发镜像:我的 Dev Containers 替代方案
我先后用过 nix-shell、Flakes 和 Devbox 管理开发环境。它们都能把编译器、运行时和命令行工具声明出来,但用久之后,我还是不太满意。
问题不在于这些工具不能复现依赖,而在于它们并没有给开发环境划出一个真正独立的家目录。Gradle、Pub、npm、Cargo、语言服务器和编辑器插件仍然会不断往 $HOME 下面写缓存和配置。项目删掉了,~/.cache、~/.config、~/.gradle 和各种语言自己的目录却还留着。理论上当然可以继续重写 HOME、XDG_CACHE_HOME 和每一种工具的缓存路径,但这很快又会变成另一套需要维护的约定。
绕了一圈后,我发现 Docker 本身就是我想要的东西:工具链在镜像里,源码通过 bind mount 进入容器,缓存留在容器的可写层。容器不需要了,删掉即可,不必再去家目录里考古。
不过我也不想让桌面版 VS Code 和 Dev Containers 扩展来负责启动、连接和初始化容器。我的做法是把 code-server 也装进开发镜像,让编辑器成为容器内工具链的一部分:
浏览器
│ http://127.0.0.1:8080
▼
code-server
├─ VS Code 扩展
├─ 集成终端
├─ Flutter / Dart / JDK / Android SDK
└─ 开发服务端口代理
│
├─ 源码:从宿主机挂载
└─ 缓存:留在容器可写层
以后启动开发环境只需要 docker compose up,再打开浏览器。
这不是在否定 Dev Containers
VS Code Dev Containers 本来就使用容器隔离工具链,也支持 Dockerfile、Compose、Features、生命周期钩子和远程宿主机。它解决的问题很完整,而且 Dev Container Specification 也不是 VS Code 私有的配置格式。
我替换的是它的使用入口,不是“用容器开发”这件事本身。
Dev Containers 的典型工作流是:
桌面版 VS Code
└─ Dev Containers 扩展
└─ 创建或连接开发容器
└─ 在容器中安装 VS Code Server 和扩展
我想要的工作流则是:
Docker Compose
└─ 启动已经包含 code-server 的开发镜像
└─ 任意浏览器连接
这样做对我有几个直接的好处:
- 开发容器可以脱离编辑器独立启动、停止和检查;
- 镜像构建完成后,编辑器和扩展也已经准备好;
- 本机不需要安装桌面版 VS Code 和 Dev Containers 扩展;
- 同一套入口可以运行在本机、局域网服务器或远程主机上;
- 环境的主要定义收敛到 Dockerfile、Compose 和几个普通配置文件里。
代价也很明确:我放弃了一部分 Dev Containers 的生态和桌面集成,换成浏览器里的 VS Code 体验。这不是通用的上位替代,只是更符合我的个人工作流。
为什么不用 Distrobox 做这件事
Distrobox 也是很好的工具,但它的目标是让容器与宿主机紧密集成。它默认让容器访问宿主机的家目录、图形会话和设备,适合在一个发行版里使用另一个发行版的软件,隔离并不是它的首要目标。
我确实会把 Distrobox 当作一种安装软件的方式,但不打算用它承载开发环境。对我的机器来说还有两个很现实的问题:
第一,Distrobox 对 Rootless Docker 的支持并不理想。Podman 有 --userns=keep-id 这类围绕宿主用户映射设计的能力,而 Rootless Docker 的 UID 映射规则不同。Distrobox 自己的文档目前也仍然注明 Rootless Docker 没有按预期工作。
第二,我使用的是 Void Linux,没有 systemd。Distrobox 配合 Rootless Podman 在非 systemd 环境中存在停止容器后卡在 Stopping 的问题;Distrobox 仓库中也有一份发生在 Gentoo/OpenRC 上的同类报告。我遇到这类情况时只能再用 podman kill 收尾。一个每天都会启动和停止的开发环境,我不太愿意接受这个摩擦。
况且在 Void Linux 上,Flatpak 已经覆盖了不少桌面应用,AppImage 也能补上剩余的一部分软件。Distrobox 对我仍然有价值,但没有必要同时承担软件分发和项目开发两个角色。
以 Flutter 开发镜像为例
我的 Flutter 项目把配置放在 .code-server/ 下:
.code-server/
├── .dockerignore
├── .env
├── Dockerfile
├── compose.yml
├── compose.rootless.yml
├── extensions.txt
└── settings.json
其中:
Dockerfile安装 Flutter、Dart、JDK、Android SDK、Chromium、Linux 桌面构建依赖和 code-server;.env集中保存工具链版本、SHA-256、宿主机路径、UID/GID 和端口;compose.yml用于普通的 rootful Docker;compose.rootless.yml用于 Rootless Docker;extensions.txt是随镜像安装的扩展清单;settings.json是需要在宿主机保留的编辑器设置。
.dockerignore 默认排除所有文件,只让构建真正需要的三个文件进入上下文:
**
!Dockerfile
!extensions.txt
!settings.json
项目源码不会被 COPY 进开发镜像。这样修改源码不会让整个 Flutter 工具链重新构建,镜像也可以在多个工作副本之间复用。
code-server 就是镜像入口
Dockerfile 的基础阶段先安装通用命令行工具和语言工具链,然后下载固定版本的 code-server:
FROM node:24-trixie AS base
ARG CODE_SERVER_VERSION
ARG CODE_SERVER_SHA256
RUN set -eux; \
deb_arch="$(dpkg --print-architecture)"; \
code_server_deb="/tmp/code-server_${CODE_SERVER_VERSION}_${deb_arch}.deb"; \
curl -fL \
"https://github.com/coder/code-server/releases/download/v${CODE_SERVER_VERSION}/code-server_${CODE_SERVER_VERSION}_${deb_arch}.deb" \
-o "${code_server_deb}"; \
echo "${CODE_SERVER_SHA256} ${code_server_deb}" \
| sha256sum --check --strict; \
apt-get update; \
apt-get install -y --no-install-recommends "${code_server_deb}"; \
rm -f "${code_server_deb}"; \
rm -rf /var/lib/apt/lists/*
EXPOSE 8080
ENTRYPOINT ["code-server"]
CMD ["--bind-addr", "0.0.0.0:8080", "--auth", "none"]
这里不使用 code-server 官方镜像作为基础镜像,因为开发工具链才是镜像的主体。以 Node 的 Debian 镜像为基础,再把 code-server 当成一个普通 Debian 包装进去,安装 Flutter、Android SDK、Chromium 和本地编译依赖会更直接。
我把 code-server、Flutter、Android command-line tools 的版本和下载文件 SHA-256 都放在 .env 中。升级时主动修改版本和校验值,而不是让每次构建悄悄拿到不同的 SDK。
扩展在构建时安装,设置从宿主机挂载
扩展清单是一个普通文本文件:
dart-code.flutter
yzhang.markdown-all-in-one
pkief.material-icon-theme
esbenp.prettier-vscode
enkia.tokyo-night
镜像在切换到最终运行用户后安装这些扩展:
COPY extensions.txt /usr/local/share/code-server/extensions.txt
RUN xargs -r -n 1 \
code-server --install-extension \
< /usr/local/share/code-server/extensions.txt
扩展属于环境定义的一部分,修改清单后重新构建镜像。这样新建容器时不需要再等插件逐个下载。
settings.json 则以可写方式挂载:
volumes:
- type: bind
source: ./settings.json
target: /home/${USER_NAME}/.local/share/code-server/User/settings.json
设置更接近个人偏好,经常会在编辑器里调整,没有必要为了换字体或主题重建整个 Flutter 镜像。在 code-server 中修改设置时,变化也会直接写回项目里的文件。
需要注意的是,code-server 默认使用 Open VSX 扩展源,并不等同于桌面版 VS Code 使用的 Microsoft Marketplace。少数扩展可能找不到,或者因为依赖桌面 API 而无法在浏览器环境中工作。把常用扩展放进 extensions.txt,也正好能在构建阶段提前暴露这类兼容性问题。
Rootful 与 Rootless Docker 使用不同的用户
bind mount 最麻烦的地方通常不是路径,而是文件所有权。我的 Dockerfile 因此有两个最终阶段。
普通 Docker 使用与宿主机相同的 UID/GID 创建用户:
FROM base AS rootful
ARG USER_NAME=developer
ARG USER_UID=1000
ARG USER_GID=1000
# 省略创建用户、用户组和 home 目录的脚本
ENV HOME=/home/${USER_NAME}
USER ${USER_NAME}
WORKDIR /workspace
对应的 Compose 文件明确选择 rootful target,并以相同的 UID/GID 运行:
build:
target: rootful
args:
USER_NAME: ${USER_NAME}
USER_UID: ${USER_UID}
USER_GID: ${USER_GID}
user: "${USER_UID}:${USER_GID}"
Rootless Docker 则反过来,让进程以容器内的 root 运行:
FROM base AS rootless
ENV HOME=/root
WORKDIR /workspacebuild:
target: rootless
user: "0:0"
这看起来有点反直觉,但符合 Rootless Docker 的 UID/GID 映射:容器内 UID 0 映射到启动 Rootless Docker 的宿主用户,容器内其他 UID 则映射到 /etc/subuid 中的从属 UID。于是宿主用户拥有的源码目录,在容器内正好显示为 root 所有。
所以这里的“容器内 root”不是宿主机 root。它是 user namespace 里的 root,落到宿主机上仍然是运行 Rootless Docker 的普通用户。若在 Rootless Docker 里继续强行使用容器 UID 1000,反而容易让 bind mount 的权限变得难处理。
两套 Compose 文件显得有些重复,但用户模型一眼就能看明白,我认为比把所有分支塞进启动脚本更容易维护。
只把必要的宿主状态带进来
Compose 中最重要的不是挂载了什么,而是没有挂载整个家目录。
working_dir: /workspace/${WORKSPACE_DIR_NAME}
volumes:
- type: bind
source: ${WORKSPACE_HOST_PATH}
target: /workspace/${WORKSPACE_DIR_NAME}
- type: bind
source: ${HOST_SSH_AUTH_SOCK}
target: /tmp/ssh-agent.sock
- type: bind
source: ${HOST_HOME}/.gitconfig
target: /home/${USER_NAME}/.gitconfig
environment:
SSH_AUTH_SOCK: /tmp/ssh-agent.sock
我的实际配置还选择性挂载了 .codex 和 .agents,因为我需要在开发容器里继续使用这些工具。它们可能包含凭据或登录状态,是否挂载应该按自己的信任边界决定,不能当成无害的默认项。
各类状态的去向如下:
| 状态 | 保存位置 | 删除容器后 |
|---|---|---|
| 项目源码 | 宿主机 bind mount | 保留 |
| code-server 设置 | 项目内 settings.json | 保留 |
| SSH 私钥 | 不挂载,只转发 agent socket | 保留在宿主机 |
| Git 配置 | 单文件 bind mount | 保留 |
| VS Code 扩展 | 镜像层 | 重建镜像时重装 |
| Pub、Gradle 等缓存 | 容器可写层 | 删除 |
| 临时工具配置 | 容器可写层 | 删除 |
我没有给 Pub、Gradle 和 code-server 数据创建命名卷。这会牺牲第一次重建容器后的缓存命中率,却换来了一个非常清楚的清理动作:删除容器,就等于删除这次开发环境积累的所有垃圾。
如果某个项目的依赖下载确实很慢,也可以只给那一类缓存增加命名卷。关键不是禁止持久化,而是让每一份持久化状态都经过明确选择。
开发服务不再逐个映射端口
Compose 只把 code-server 的 8080 映射到宿主机回环地址:
ports:
- "${CODE_SERVER_BIND_IP:-127.0.0.1}:${CODE_SERVER_PORT:-8080}:8080"
容器内的 Web 开发服务器仍然监听 0.0.0.0,然后交给 code-server 的内置代理。我的启动参数和环境变量大致如下:
command:
- --bind-addr
- 0.0.0.0:8080
- --auth
- none
- --proxy-domain
- ${PROXY_DOMAIN:-code.localhost}:${CODE_SERVER_PORT:-8080}
- /workspace/${WORKSPACE_DIR_NAME}
environment:
VSCODE_PROXY_URI: http://{{port}}.${PROXY_DOMAIN}:${CODE_SERVER_PORT}
例如 Flutter Web 只需要在 code-server 终端里运行:
flutter run -d web-server --web-hostname 0.0.0.0
code-server 检测到端口后,会在 Ports 面板中生成经过代理的地址。React、Vite、文档服务器或其他本地 Web 服务也可以使用相同方式,不需要每增加一个端口就修改 Compose。code-server 的代理文档 同时支持子域名和 /proxy/<port>/ 子路径;如果子域名在当前网络中不好用,可以把 VSCODE_PROXY_URI 改成相对路径形式:
VSCODE_PROXY_URI=./proxy/{{port}}/启动和验证
普通 Docker:
sudo docker compose \
--env-file .code-server/.env \
-f .code-server/compose.yml \
up -d --build
Rootless Docker:
docker compose \
--env-file .code-server/.env \
-f .code-server/compose.rootless.yml \
up -d --build
默认访问地址是 http://localhost:8080。镜像构建完成后,我还会直接在容器里跑一次工具链自检:
docker compose \
--env-file .code-server/.env \
-f .code-server/compose.rootless.yml \
exec code-server flutter doctor -v
如果只是暂停工作,用 docker compose stop 保留容器和缓存;想获得完全干净的环境,就用 docker compose down 删除容器后重新创建。
关于“可复现”的一点诚实说明
这套配置目前锁定并校验了 code-server、Flutter 和 Android command-line tools,但 node:24-trixie 标签、Debian 软件仓库以及没有指定版本的 npm 全局包仍然会随时间变化。因此它还不是严格意义上的 bit-for-bit reproducible build。
我真正需要的是下面三件事:
- 环境定义跟着项目走;
- 换机器后可以重新构建出功能一致的工具链;
- 所有未声明的状态都能随容器一起丢弃。
如果需要更严格的复现,还可以继续锁定基础镜像 digest、使用 Debian snapshot、固定 npm 包版本,并把下载文件全部做 checksum 校验。但对个人开发环境来说,我更看重“可以放心删除并重建”,而不是两个镜像的每一个字节都相同。
安全边界不要自欺欺人
我的本地配置使用 --auth none,唯一前提是宿主端口固定绑定到 127.0.0.1:
CODE_SERVER_BIND_IP=127.0.0.1
容器内部监听 0.0.0.0:8080 是为了让 Docker 做端口转发,不代表应该把宿主端口暴露到局域网或公网。code-server 中有终端,拿到页面基本就等于拿到容器里的代码执行权限。官方文档 也明确建议不要在缺少认证和加密的情况下直接暴露它。
需要远程使用时,我会选择以下方式之一:
- 保持监听回环地址,通过 SSH tunnel 访问;
- 启用 code-server 密码认证,并在前面放带 HTTPS 的反向代理;
- 使用额外的身份认证层,例如 OAuth2 Proxy 或 Cloudflare Access。
另外,我的 Flutter 镜像为了在容器里运行 Chromium 和需要 sandbox 的开发工具,还加入了:
cap_add:
- SYS_ADMIN
security_opt:
- seccomp=unconfined
这两个选项会显著放宽容器边界。再加上 SSH agent、Git 配置和 AI 工具目录的挂载,这套环境适合运行自己信任的项目,不是用来执行陌生代码的安全沙箱。不需要这些能力时应该直接删掉它们;使用 rootful Docker 时尤其要谨慎。
哪些场景不适合
code-server 放进镜像并不会消除容器开发原本的限制:
- 浏览器快捷键、剪贴板和部分扩展体验不如桌面版 VS Code;
- Android 模拟器、USB 设备和硬件调试仍然需要额外映射;
- Flutter Linux 可以在无图形会话时完成构建,但运行 GUI 仍要转发 Wayland 或 X11;
- 工具链和编辑器放在同一镜像后,镜像体积更大,升级 code-server 也要重建;
- 团队如果已经围绕
devcontainer.json、Features 和 Codespaces 建立流程,改用这套方案未必划算。
对我而言,这些代价可以接受。我主要需要的是终端、语言服务器、浏览器预览和一个不会污染宿主家目录的完整工具链,而 code-server 恰好把这些东西放进了同一个容器边界。
结语
我曾经把“可复现开发环境”理解成精确描述一组软件包,后来才发现自己更在意的是:开发过程中产生的文件究竟落在哪里,以及环境能不能毫无心理负担地被整个删除。
Nix 和 Devbox 仍然擅长声明工具链,Distrobox 仍然适合把其他发行版的软件带进宿主系统,Dev Containers 也仍然是成熟的容器开发方案。只是对我的 Void Linux 工作流来说,把 code-server 直接塞进项目开发镜像,再用 Rootless Docker 和 Compose 管理生命周期,边界最清楚。
最后留下的宿主依赖只有 Docker、Compose 和浏览器。其余东西,都应该待在容器里。