把 C/C++ 服务装进集装箱:用 Docker 构建、运行、分发
Posted on 日 04 10月 2026 in Tech
| Abstract | 把 C/C++ 服务装进集装箱:用 Docker 构建、运行、分发 |
|---|---|
| Authors | Walter Fan |
| Category | Tech |
| Status | v1.0 |
| Updated | 2026-10-04 |
| License | CC-BY-NC-ND 4.0 |
大纲
展开看看
- C/C++ 的真正痛点在交付:代码再漂亮,换台机器缺个
.so、glibc 版本不对就跑不起来。"在我机器上能跑"在 C/C++ 这里尤其致命。 - Docker 解决的是三件事:build(锁死编译环境)、run(给二进制配齐运行时依赖)、distribute(整包发出去)。一个镜像把这三件事焊在一起。
- 一个能跑的例子:CMake + cpp-httplib 写的小 HTTP 服务,从源码到
docker run全流程。 - multi-stage build 是关键一招:构建阶段几 GB,运行阶段几十 MB。编译器、头文件、中间产物全留在上一层,最终镜像只带二进制和它真正需要的运行时。
- 运行镜像到底该多瘦:debian-slim、alpine(musl 的坑)、distroless、scratch,怎么选。
- 真会咬人的坑:glibc 静态链接把 DNS 搞坏、动态库漏拷、CMake 缓存污染、时区和证书。
- 分发:打 tag、推到 registry、别人一条
docker run就能跑;版本即镜像。
写 C/C++ 的人都有过这个时刻:本地编译、本地跑、一切正常,信心满满地把二进制拷到测试机,./myservice 一敲——
./myservice: error while loading shared libraries: libstdc++.so.6: cannot open shared object file: No such file or directory
或者更隐蔽的:能启动,跑着跑着在某个调用上 core dump,gdb 一看,是某个库的 ABI 和编译时的版本对不上。你开始一个个 ldd,一个个 apt install,装到第五个依赖的时候已经忘了最初要干嘛了。
C/C++ 这门语言,难的从来不是写,是交付。 Java 有 jar 和 JVM 兜底,Go 干脆静态编译成一个胖二进制,Python 有 venv 和 pip。到了 C/C++,你交付的是一个裸二进制,它对运行环境的假设全是隐式的:哪个版本的 glibc、哪些 .so 在 /usr/lib 下、编译器是 GCC 还是 Clang、是不是同一个发行版。这些假设只要有一条在目标机器上不成立,服务就起不来。
这篇就讲一件事:用 Docker 把这些隐式假设全部变成显式的、可复现的、能一键分发的东西。 面向已经会用 g++/cmake、但还没系统用 Docker 交付过 C/C++ 服务的工程师。看完你应该能把自己手上的服务打成一个几十 MB、换任何装了 Docker 的机器都能跑的镜像。
Docker 到底替 C/C++ 解决了什么
容器不是虚拟机,它不虚拟化硬件,只是用 Linux 的 namespace 和 cgroup 把一个进程和它的文件系统视图隔离出来。对 C/C++ 交付来说,真正有用的是后半句:镜像把你的二进制和它需要的整个用户态环境(libc、.so、证书、时区)打包成一个只读的层叠文件系统,连同二进制一起分发。
一句话:
容器交付的不是你的程序,是你的程序 + 它运行所需的整个世界。 目标机器只需要有 Docker,不需要懂你用了哪个版本的 Boost。
把这件事拆成三步,正好对应标题里的 build / run / distribute:
| 阶段 | 没有 Docker 时的痛 | Docker 怎么管 |
|---|---|---|
| build | "我装的是 GCC 11,CI 上是 9,编出来行为不一样" | 构建镜像里锁死编译器、CMake、第三方库版本,谁 build 都一样 |
| run | "目标机缺 libstdc++、glibc 版本太老" |
运行镜像自带配齐的运行时,二进制对环境的假设全写进 Dockerfile |
| distribute | "拷二进制 + 写一页部署文档 + 祈祷" | docker push / docker pull,版本即镜像 tag |
下面用一个能跑的例子把这三步走一遍。
一个能跑的例子:CMake + cpp-httplib
先把示例立住,后面所有讨论都基于它。cpp-httplib 是一个单头文件的 C++ HTTP 库,适合做例子:没有一堆外部依赖要装,又真的是个监听端口的服务,比 hello world 更贴近实际。
项目结构:
cpp-docker-demo/
├── CMakeLists.txt
├── src/
│ └── main.cpp
└── Dockerfile
src/main.cpp —— 一个监听 8080、返回 JSON 的小服务:
#include <httplib.h>
#include <string>
int main() {
httplib::Server svr;
svr.Get("/healthz", [](const httplib::Request&, httplib::Response& res) {
res.set_content("ok", "text/plain");
});
svr.Get("/hello", [](const httplib::Request& req, httplib::Response& res) {
std::string name = req.has_param("name")
? req.get_param_value("name")
: "world";
res.set_content("{\"msg\":\"hello, " + name + "\"}",
"application/json");
});
// 容器里一定要监听 0.0.0.0,不是 127.0.0.1——否则端口映射出去也连不上
svr.listen("0.0.0.0", 8080);
return 0;
}
这里埋了第一个常见坑:容器里的服务必须监听 0.0.0.0,不能监听 127.0.0.1。 容器有自己的网络栈,监听 127.0.0.1 意味着只接受容器内部的连接,你从宿主机 -p 8080:8080 映射出去也连不上。这个坑我见过不止一个人踩,因为在本机直接跑的时候 127.0.0.1 是好使的。
CMakeLists.txt —— 用 CMake 的 FetchContent 把 cpp-httplib 拉进来,省得手动管头文件:
cmake_minimum_required(VERSION 3.14)
project(cpp_docker_demo CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
FetchContent_Declare(
httplib
GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
GIT_TAG v0.18.1 # 锁一个具体版本,别用 master
)
FetchContent_MakeAvailable(httplib)
add_executable(myservice src/main.cpp)
target_link_libraries(myservice PRIVATE httplib::httplib)
注意 GIT_TAG 我锁了一个具体版本而不是 master。这不是洁癖——可复现的构建要求每一个输入都是固定的,依赖拉 master 意味着今天编出来和下周编出来可能是两个东西,而这正是 Docker 想消灭的不确定性。
到这一步,本机能不能编出来还取决于你装没装对 CMake 和编译器。接下来把这份不确定性交给 Docker。
第一版 Dockerfile:能跑,但臃肿
先写一个最直白的、把所有东西塞一个镜像里的版本,看看问题在哪:
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y \
g++ cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
&& cmake --build build -j$(nproc)
EXPOSE 8080
CMD ["/app/build/myservice"]
$ docker build -t myservice:fat .
$ docker run --rm -p 8080:8080 myservice:fat
$ curl localhost:8080/hello?name=walter
{"msg":"hello, walter"}
能跑。但 docker images 一看就知道问题了:这个镜像带着整个 g++、cmake、git、一堆头文件、构建中间产物——一个几 KB 的服务,镜像轻松上到 1GB 级别。你把编译工具链和源码全发给了生产环境,而生产环境一行都用不上。
这不只是浪费磁盘。镜像越大,docker pull 越慢,攻击面越大(生产容器里有编译器,对安全团队是个红灯),每次改一行代码重新分发的成本也越高。
第二版:multi-stage build,这是关键一招
Docker 的 multi-stage build 允许在一个 Dockerfile 里写多个 FROM,前一个阶段负责编译,后一个阶段只从前一个阶段拷贝产物,中间的编译器、源码、缓存全部不进入最终镜像。
# ---------- 构建阶段 ----------
FROM ubuntu:24.04 AS builder
RUN apt-get update && apt-get install -y \
g++ cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
&& cmake --build build -j$(nproc)
# ---------- 运行阶段 ----------
FROM ubuntu:24.04 AS runtime
# 只拷二进制,不拷编译器和源码
COPY --from=builder /app/build/myservice /usr/local/bin/myservice
EXPOSE 8080
CMD ["/usr/local/bin/myservice"]
关键就是 COPY --from=builder:运行镜像从 ubuntu:24.04 干净起步,只把编译好的二进制拿过来。编译器、CMake、源码、build/ 里几百个中间 .o 全留在 builder 阶段,随构建结束被丢弃。
现在运行镜像从 1GB 级别掉到 ubuntu:24.04 的基础大小(约 78MB)加上你的二进制。工具链一点没进去。
但还能更瘦。ubuntu:24.04 里有一整套 shell、包管理器、系统工具,你的服务一个都不用。下一步就是选一个更小的运行基础镜像——而这一步会把 C/C++ 的链接问题逼到台面上。
运行镜像该多瘦:从 slim 到 scratch
运行阶段的基础镜像有一条明确的梯子,越往下越小、也越考验你对链接的理解:
| 基础镜像 | 量级 | 带什么 | 适合 |
|---|---|---|---|
ubuntu:24.04 / debian:12 |
~78MB | 完整 userland | 图省事、要进容器调试 |
debian:12-slim |
~30MB | 精简 userland + glibc | 动态链接、想保留一点调试能力 |
alpine |
~8MB | musl libc + busybox | 想要极小,但要接受 musl 不是 glibc |
gcr.io/distroless/cc |
~20MB | glibc + libstdc++,无 shell | 动态链接的 C/C++ 服务,安全优先 |
scratch |
0 | 什么都没有 | 完全静态链接的二进制 |
这张表里藏着 C/C++ 容器化真正的分水岭:你的二进制是动态链接还是静态链接?
默认 g++ 编出来是动态链接的——它运行时要去系统里找 libstdc++.so.6、libc.so.6 等等。这意味着运行镜像必须提供这些 .so,版本还得兼容。对动态链接的服务,gcr.io/distroless/cc 是个很好的落点:它在 glibc 基础上加了 libgcc 和 libstdc++,专门伺候 C/C++,又没有 shell 和包管理器(攻击面小),大小只有 20MB 出头。
用 distroless 的运行阶段长这样:
# ---------- 运行阶段(distroless)----------
FROM gcr.io/distroless/cc-debian12 AS runtime
COPY --from=builder /app/build/myservice /usr/local/bin/myservice
EXPOSE 8080
CMD ["/usr/local/bin/myservice"]
distroless 没有 shell,
docker exec -it ... /bin/sh进不去。 调试时用它的:debugtag(带一个 busybox),上生产再换回无 shell 版本。
如果你想一路干到 scratch(镜像里真的什么都没有,只有你的二进制),那二进制必须完全静态链接。这里是全文最该小心的一个坑。
最该小心的坑:glibc 静态链接会悄悄搞坏 DNS
很多人以为静态链接就是加个 -static 的事。对 glibc 来说,它能链上,但会在运行时坑你。
glibc 的名字解析(getaddrinfo、gethostbyname)靠一套叫 NSS(Name Service Switch)的机制,而 NSS 是运行时用 dlopen 动态加载模块实现的。你把 glibc 静态链进二进制,NSS 那部分并没有真的被静态进去——它仍然想在运行时找 .so,而 scratch 里什么都没有。结果就是:二进制能启动,但一做 DNS 解析(比如连另一个服务的域名)就失败。
编译时 glibc 其实会警告你:
warning: Using 'getaddrinfo' in statically linked applications requires
at runtime the shared libraries from the glibc version used for linking
这条警告很多人直接划过去了,然后在生产上调试几个小时为什么连不上下游。想真正做到 scratch 级别的静态二进制,实际路线是用 musl libc 静态链接(Alpine 的工具链,或专门的 musl 交叉编译器),musl 没有 glibc 这个 NSS 的历史包袱。
所以这条梯子的务实建议是:
- 大多数 C/C++ 服务:动态链接 +
distroless/cc。省心,20MB 够小了。 - 真要极致小 /
scratch:接受 musl 工具链这套额外复杂度,别用-static硬链 glibc 然后指望 DNS 还能用。 - 用 Alpine 当运行镜像:记住它的 libc 是 musl 不是 glibc,你在 ubuntu
builder里动态编出来的二进制不能直接丢进 alpine 跑(ABI 不同),要么在 alpine 里编,要么静态链接。这是 Alpine 省体积的隐藏代价。
其他几个真会咬人的坑
除了链接,容器化 C/C++ 时还有几个反复出现的坑,列成一张自检表:
-
动态库漏拷:如果你的服务依赖某个自己编的
.so(而不是系统库),COPY --from=builder只拷了二进制、没拷那个.so,运行时就cannot open shared object file。用ldd /app/build/myservice列出所有动态依赖,确认运行镜像里每一个都在。 -
CMake 缓存污染:别把本机的
build/目录COPY进镜像。本机 CMake 缓存里存着本机的绝对路径和编译器路径,进了容器会导致诡异的构建失败。用.dockerignore把build/挡在外面:
build/
.git/
*.o
-
证书和时区:distroless 和 slim 镜像默认带了
ca-certificates和 tzdata,但scratch什么都没有。如果你的服务要发 HTTPS 请求,scratch里没有 CA 证书会导致 TLS 握手失败——需要显式从 builder 把/etc/ssl/certs/ca-certificates.crt拷过去。 -
CMAKE_BUILD_TYPE忘了设:不写-DCMAKE_BUILD_TYPE=Release,CMake 默认是空的(既不是 Debug 也不是 Release),很多优化不开。容器构建里一定要显式指定。 -
用非 root 用户跑:生产容器别用 root。distroless 有
:nonroottag,或者在 Dockerfile 里USER切到一个普通用户,降低被攻破后的影响面。
分发:版本即镜像
build 和 run 都收拾干净了,分发就是水到渠成的一步。核心观念是:镜像 tag 就是你的版本号,推到 registry 后,别人不需要知道任何构建细节。
# 打一个带版本的 tag
$ docker build -t registry.example.com/team/myservice:1.2.0 .
# 推到镜像仓库(公司内网 Harbor、AWS ECR、GitHub/GitLab Registry 都行)
$ docker push registry.example.com/team/myservice:1.2.0
# 任何装了 Docker 的机器,一条命令就能跑
$ docker run -d -p 8080:8080 registry.example.com/team/myservice:1.2.0
对方不需要装 g++、不需要对 glibc 版本,不需要你那页部署文档。他 pull 下来的是你编译好的二进制加上它运行所需的整个世界——这正是开头那句话的闭环。
几个分发实践:
- tag 用语义化版本 + git commit,比如
1.2.0配一个1.2.0-a1b2c3d。latest只适合随手测,生产部署永远用确定的 tag,别让"今天的 latest"和"昨天的 latest"是两个东西。 - build 一次,到处跑:同一个镜像从测试环境一路推到生产,中间不重新构建。重新构建就是重新引入不确定性。
- 把构建放进 CI:Dockerfile 里锁死了环境,CI 里
docker build出来的和你本地一致,这是 Docker 给 C/C++ 最大的一份红利——"在我机器上能跑"这句话从此作废,因为根本没有"我的机器"这个变量了。
总结:交付的是环境,不只是二进制
C/C++ 的交付难,难在二进制对运行环境的假设全是隐式的。Docker 的价值不是"让程序跑在容器里"这么浅,而是逼你把那些隐式假设一条条写进 Dockerfile,变成可复现、可分发的东西:
- build:构建镜像锁死编译器和依赖版本,
FetchContent的 tag、CMAKE_BUILD_TYPE都钉死,谁构建结果都一样。 - run:multi-stage 把几 GB 的构建环境瘦成几十 MB 的运行环境,动态链接配
distroless/cc,想上scratch先搞定 musl 静态链接别碰 glibc 的 DNS 坑。 - distribute:tag 即版本,build 一次到处跑,
docker push取代那页没人看的部署文档。
一份可以照抄的最终 Dockerfile 骨架:
FROM ubuntu:24.04 AS builder
RUN apt-get update && apt-get install -y g++ cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
&& cmake --build build -j$(nproc)
FROM gcr.io/distroless/cc-debian12 AS runtime
COPY --from=builder /app/build/myservice /usr/local/bin/myservice
EXPOSE 8080
USER nonroot
CMD ["/usr/local/bin/myservice"]
下次再有人把二进制拷到测试机、对着 libstdc++.so.6: cannot open shared object file 发愁,你知道该递给他的不是又一条 apt install,而是一个 Dockerfile。
全文思维导图
@startmindmap
<style>
mindmapDiagram {
node {
BackgroundColor #F8F9FA
RoundCorner 10
Padding 10
FontSize 13
}
:depth(0) {
BackgroundColor #1E3A5F
FontColor white
FontSize 18
FontStyle bold
}
:depth(1) {
FontSize 15
FontStyle bold
}
:depth(2) {
FontSize 13
}
}
</style>
* Docker 交付 C/C++ 服务
** 为什么难
*** 裸二进制,环境假设全是隐式
*** 缺 .so / glibc 版本 / ABI
** build
*** 构建镜像锁死编译器与依赖
*** FetchContent 锁 tag
*** CMAKE_BUILD_TYPE=Release
** run
*** multi-stage:构建与运行分离
*** 基础镜像梯子 slim/alpine/distroless/scratch
*** 动态链接 → distroless/cc
*** 静态链接 → musl,别碰 glibc DNS 坑
** distribute
*** tag 即版本
*** build 一次到处跑
*** push 到 registry
** 坑
*** 监听 0.0.0.0 不是 127.0.0.1
*** 动态库漏拷 / ldd 自检
*** build 缓存污染 → .dockerignore
*** 证书时区 / 非 root
@endmindmap

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。