C++服务容器化上K8s的完整踩坑指南:镜像、探针与内存治理
发布时间:2026/9/29 23:09:14 锦皓数字建站

最近帮一个同事把一套 C 写的消息中间件搬上 Kubernetes。本来想着无非是写个 Dockerfile、套个 Deployment 的事结果从镜像构建到探针配置再到内存限制一路踩坑踩到怀疑人生。这篇文章就把整个过程里值得记录的东西整理出来包括踩坑的完整链路和方案取舍希望对打算把 C 服务容器化、或者正在 K8s 里部署 C 服务的同学有帮助。先说下背景。这次要部署的是一个基于 C11 开发的实时消息服务底层用 libevent 做网络 IO业务逻辑里大量使用 STL 容器和自研内存池。集群是 Kubernetes 1.26用 kubeadm 初始化的当时控制台还在一堆 [init] using kubernetes version: v1.26.0 [preflight] running pre-flight checks 的输出里反复确认节点状态。节点系统是 Ubuntu 22.04容器运行时是 containerd。后面所有结论都是在这个环境里一步步实测出来的。1. 为什么 C 上 K8s第一步就栽在镜像上我一度以为只要本地能编译跑起来容器里就应该没问题。事实证明这是最大的错觉。C 服务和 Go 服务在容器化上的难度完全不在一个量级。Go 编译出来是纯静态二进制扔进 scratch 镜像都能跑C 默认动态链接一堆系统库换一个镜像环境就立刻原形毕露。1.1 动态库依赖ldd 一看全是坑第一次构建镜像时我图省事选了 alpine 做基础镜像。容器启动后直接报错server: error while loading shared libraries: libstdc.so.6: cannot open shared object file: No such file or directory当时第一反应是少了跑库装上不就行了但等我真的去查时才发现事情没那么简单。alpine 用的是 musl libc而大多数 C 工具链GCC、Clang默认产出的是基于 glibc 的二进制。用 ldd 看一眼本地编译产物$ ldd ./server linux-vdso.so.1 (0x00007fff...) libstdc.so.6 /usr/lib/x86_64-linux-gnu/libstdc.so.6 libm.so.6 /lib/x86_64-linux-gnu/libm.so.6 libgcc_s.so.1 /lib/x86_64-linux-gnu/libgcc_s.so.1 libc.so.6 /lib/x86_64-linux-gnu/libc.so.6这一串依赖除了 linux-vdso 是内核提供的其他每一个都依赖 glibc 的版本和路径。alpine 的 musl 即使能通过 musl-compat 兼容层解决一部分但在涉及 DNS 解析getaddrinfo、多线程pthread和 locale 时依然有各种诡异行为。所以第一轮镜像直接放弃重新选型。1.2 基础镜像选型alpine 不是你想用就能用经过这一轮我把基础镜像的选择标准定成了三条glibc 版本兼容性、能否跑 ldd 验证、镜像体积是否可接受。基础镜像glibc 支持体积适合场景ubuntu:22.04完整约 70MB通用推荐调试方便debian:bookworm-slim完整约 80MB和 ubuntu 类似包管理更轻alpine:3.18musl约 10MB仅适合纯静态编译或无 native 依赖gcr.io/distroless/cc完整约 30MB生产可用但调试要额外工具scratch无0必须全静态编译且要自行解决 CA 证书和 DNS 问题我的结论是C 服务不要一上来就追求最小镜像优先保证行为一致。生产环境我最后选了 debian:bookworm-slim因为它的 glibc 版本覆盖了绝大多数 GCC 9 到 GCC 13 的构建产物同时体积比 ubuntu 小一些。调试环境直接用 ubuntu:22.04本地是什么环境容器里就保持什么环境。1.3 多阶段构建与静态编译的取舍镜像的最终方案是典型的多阶段构建FROM gcc:13 AS builder WORKDIR /build COPY . . RUN cmake -B build -DCMAKE_BUILD_TYPERelease \ cmake --build build -j$(nproc) FROM debian:bookworm-slim RUN apt-get update \ apt-get install -y --no-install-recommends libstdc6 ca-certificates netbase \ rm -rf /var/lib/apt/lists/* COPY --frombuilder /build/build/server /app/server CMD [/app/server]这里没有选择完全静态编译是因为要照顾 DNS 解析。C 的 getaddrinfo 会走到 glibc 的 NSS 模块/etc/nsswitch.conf、libnss_dns如果编译时加了-static这些模块会被丢进二进制里但容器内的 /etc/resolv.conf 和 nsswitch 配置未必匹配尤其当 K8s 的 kubelet 会动态注入 DNS 配置时容易出现容器能 Ping 通 IP 但解析不了 Service 域名的诡异问题。如果确实要上 scratch 镜像必须做好 DNS 专项测试否则保留一点 glibc 运行时用 debian-slim 完全够用镜像差异也就几十 MB。注意就算选择动态链接也要保证 builder 和 runtime 环境的 libstdc 版本兼容。GCC 13 编译的二进制放在只带 GCC 8 版本 libstdc 的系统上还是会报 GLIBCXX_3.4.29 not found这类问题排查起来比缺文件更隐蔽。2. 探针设计K8s 如何感知一个活着的 C 进程K8s 默认的存活检查是看进程是否存在。但 C 服务经常出现进程活着、业务却已经假死的状态比如某个 worker 线程死锁、事件循环卡在某个阻塞调用里。这种情况下进程还挂在系统上K8s 却认为一切正常流量照打不误用户侧就开始超时、报错。2.1 进程存活不等于服务可用第一次给这套消息服务配探针的时候我原本只打算配一个 livenessProbe 检查 TCP 端口。后来在一次发布过程中发现旧 Pod 在滚动更新时不断有连接超时但 Pod 状态始终是 Running。进容器一看进程确实活着但业务线程已经全部卡在条件变量上——典型的逻辑层死锁。这说明探针不能只探端口通不通要探业务是否真的能处理请求。所以我对探针的策略做了分化livenessProbe探测进程是否还能响应健康检查。如果挂了K8s 杀掉重启。readinessProbe探测服务是否已经就绪、能否接流量。如果失败K8s 会把 Pod 从 Service 的 Endpoints 里摘掉。两者不能共用一个接口。业务初始化阶段进程活着但服务未就绪此时的 liveness 应该通过readiness 才应该失败。livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 15 periodSeconds: 10 timeoutSeconds: 3 failureThreshold: 3 readinessProbe: httpGet: path: /readyz port: 8080 initialDelaySeconds: 15 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 6这里有几个细节值得展开。initialDelaySeconds 要按 C 服务实际启动耗时来定。有的 C 服务启动时要做配置解析、连接池预热、加载词典或模型文件这个阶段可能长达几秒甚至十几秒。如果 initialDelaySeconds 设太短liveness 会在服务还没起来时就判定失败把 Pod 杀掉形成重启循环。timeoutSeconds 也要注意。健康检查接口内部如果要查询下游依赖状态而下游刚好在重试HTTP 响应可能会被拖住。我把探针的 timeout 控制在 3 秒同时让 /healthz 只检查本进程核心状态不做完整依赖链检查避免探针本身成为引入延迟的假故障。2.2 健康检查接口的 C 实现C 服务平时不常写 HTTP 接口很多人会觉得为探针引入一个 HTTP 框架太重。实际上一个简单的健康检查接口完全不需要引入重量级框架。我用的是 cpp-httplib它是一个单头文件库直接 include 就能用#include httplib.h #include atomic std::atomicbool g_ready{false}; void startHealthServer() { httplib::Server svr; svr.Get(/healthz, [](const httplib::Request, httplib::Response res) { res.status 200; res.set_content(ok, text/plain); }); svr.Get(/readyz, [](const httplib::Request, httplib::Response res) { if (g_ready.load()) { res.status 200; res.set_content(ready, text/plain); } else { res.status 503; res.set_content(not ready, text/plain); } }); svr.listen(0.0.0.0, 8080); }这里 g_ready 是一个全局原子变量业务主流程初始化完成后把它置为 true。这样 readiness 探针能准确反映服务是否完成预热。要注意健康检查服务不能和业务服务共用同一个线程池或事件循环否则业务一旦卡死健康检查也会跟着卡住探针就失效了。把它跑在独立线程上最多用独立端口这个开销对于 C 服务来说几乎可以忽略。2.3 优雅终止不处理 SIGTERM 会怎样这可能是 C 服务在 K8s 里最常被忽视的问题。K8s 在滚动更新或 Pod 删除时会向容器主进程发送 SIGTERM等待 terminationGracePeriodSeconds默认 30 秒超时后发 SIGKILL。C 程序如果不做任何信号处理默认行为是收到 SIGTERM 直接终止进程。问题就出现了进程被终止时可能还有正在处理的请求。对消息服务来说可能就是连接刚建立、数据还在缓冲区里进程就没了。客户端感知到的是连接被强制复位。而且容器退出时如果主进程退出太快Kubelet 甚至可能来不及把 Pod 状态从 Running 更新为 TerminatingService 的 Endpoints 还没有及时摘掉这个 Pod外部流量依然往这个正在退出实例上打间歇性 502 就是这么来的。所以我给服务加了一个最基本的 SIGTERM 处理#include csignal #include atomic std::atomicbool g_running{true}; void handleSignal(int) { // 信号处理函数里只做原子读写保证 async-signal-safe g_running.store(false); } int main() { std::signal(SIGTERM, handleSignal); std::signal(SIGINT, handleSignal); // 主循环 while (g_running.load()) { // 事件循环处理 } // 进入清理流程停止接受新请求等待存量请求完成 g_ready.store(false); // 等待线程池任务完成设置超时上限 return 0; }注意SIGTERM 处理里决不能直接调用 printf、malloc、锁这类非异步安全函数轻则死锁重则未定义行为。想打印日志的话用 write 或提前写好线程安全的方式。清理流程要有一个软超时。不要无限等待存量请求。我给主流程的信号处理定了 15 秒的存量任务缓冲接近 K8s 的 30 秒默认宽限期。如果 15 秒结束还有未完成任务直接强制退出避免出现进程收到信号后卡在清理流程被 K8s 拖到 SIGKILL 才结束的场景。terminationGracePeriodSeconds 我也根据服务实际需要调整成了 40 秒给清理留一点余量。3. 资源限制与 C 的 OOM 博弈在裸机上跑 C 服务的时候内存问题很少引起注意大不了进程崩了重启一下。但在 K8s 里一个进程的内存超限会直接触达 cgroup 的 OOM Killer带来的是整个 Pod 被杀以及伴随而来的 137 退出码。更麻烦的是C 服务的 RSS 常常比实际使用量高一大截让人摸不着头脑。3.1 为什么 C 程序 RSS 居高不下C 的内存分配走的是 glibc 的 malloc。为了兼顾多线程并发glibc 引入了 arena 机制默认情况下每个线程严格说每个 CPU 核都可能创建独立的 arena每个 arena 有自己独立的堆。线程多了以后内存碎片和预留空间会显著膨胀。一个典型例子服务里开了 16 个业务线程每个线程在高峰期各自分配几百个小对象由于每个 arena 互不共享产生的碎片无法在 arena 间复用。最终结果是进程实际只使用了 1GB 内存但 RSS 已经涨到 1.6GB。这个时候你设置resources.limits.memory: 1.5Gi大概率会被 OOM。我用两个手段解决这个问题第一个手段是设置 MALLOC_ARENA_MAXexport MALLOC_ARENA_MAX2这个环境变量可以有效限制 arena 数量降低内存碎片。实测在某些场景下能让 RSS 下降 20% 到 30%。副作用是线程并发分配时锁竞争会加剧对高并发小对象分配有轻微性能影响但对大多数服务来说可以接受。第二个手段是换分配器。如果 arena 调整还不够可以直接切换到 jemalloc 或 tcmalloc。jemalloc 在碎片控制和可观测性上表现很好适合长期运行的服务tcmalloc 的优势是线程缓存机制但在大内存释放和归还操作系统上各有取舍。我当时优先试了 jemalloc链接期用-ljemalloc加上 LD_PRELOAD 方式覆盖默认 malloc验证一段时间的吞吐和延迟后稳定运行。3.2 OOMKilled 不是只改 limit 就能解决的事OOM 出现时第一步要看的是 Pod 事件而不是直接调大 limit$ kubectl describe pod server-7d8b5f9f5c-x2k4n ... Last State: Terminated Reason: OOMKilled Exit Code: 137137 等于 128 9对应 SIGKILL。看到这个基本都是 cgroup 内存超限被 OOM Killer 干掉了。接下来要确认是内存真的不够用还是C 的分配器没归还内存。如果 RSS 在正常运行时就一直贴近 limit而且持续一段时间才 OOM那大概率是业务内存增长需要看代码和压测数据来定合理 limit。如果 RSS 启动后瞬间冲到 limit 附近就要怀疑是不是虚拟内存或线程栈的问题。C 程序每个线程默认栈大小是 8MB如果开了 100 个线程虚拟内存空间会立刻预留 800MB。limit 限额的是 RSS不是虚拟内存但某些情况下 glibc 的栈页会按需提交也可能推高 RSS。经验limit 的设置不能脱离服务的内存画像。先压测用监控数据画出一个周期的内存曲线再定 limit。建议设置requests.memory为均值、limits.memory为峰值预留 20% 到 30% 的缓冲而不是随手写一个 1Gi、2Gi。3.3 启动阶段内存尖峰与 limit 冲突还有一个容易踩的坑服务启动时有一段内存尖峰。比如配置加载时一次性读入几个词典、初始化连接池时批量建连、或者用了一些全局单例 RMW。这个尖峰可能持续几十秒如果 limit 是按稳态峰值设定的就可能被尖峰打穿。我的做法是把初始化流程改成分批 延迟释放大对象加载改成流式处理避免一次性分配超大内存初始化完成后显式释放临时缓冲必要时调用malloc_trim(0)主动归还 heap 顶部空闲内存启动阶段的特殊内存行为用代码注释和监控面板标记出来防止后来人误以为是泄漏。排查内存问题时还有个常用命令是看内核日志。如果宿主机有权限journalctl -k | grep -i oom可以看到具体触发了哪个 cgroup、哪个进程被杀。但如果是托管云环境或没有宿主机权限这条路经常走不通所以 Pod 事件和 metrics 监控才是主要依据。4. 配置管理C 服务怎么读取 K8s 的配置C 服务读配置的方式五花八门有的用 YAML、有的用 JSON、有的用自研的 keyvalue 格式。到了 K8s 里怎么把配置递进去以及配置改了之后进程能不能感知到这个问题值得单独拎出来说。4.1 环境变量与 getenv 的局限K8s 支持把 ConfigMap 里的字段直接映射成环境变量。C 侧用 getenv 读取const char* dbHost std::getenv(DB_HOST); if (dbHost nullptr) { // fallback to default }这种方式适合配置项少、变化不频繁的场景。缺点也很明显环境变量是字符串需要手动解析成数值、布尔值代码写起来冗长而且 K8s 更新 ConfigMap 后已经运行中的 Pod 里的环境变量不会自动刷新必须重启 Pod 才能生效。如果配置项多比如几十上百个环境变量的维护成本就上来了。我见过一个项目把所有配置全塞进环境变量Dockerfile 和 Deployments 被各种-e和envFrom塞得满满当当排查问题光对参数就对半天。对这种场景我建议走配置文件挂载。4.2 ConfigMap 挂载文件与热更新用 ConfigMap 挂载配置文件的方式很直接apiVersion: v1 kind: ConfigMap metadata: name: message-server-config data: server.yaml: | listen: 0.0.0.0:9200 workers: 8 max_conn: 10000 db: host: postgres-service port: 5432 --- apiVersion: apps/v1 kind: Deployment spec: template: spec: containers: - name: server volumeMounts: - name: config mountPath: /etc/message-server volumes: - name: config configMap: name: message-server-configK8s 更新 ConfigMap 后挂载的文件最终会更新但这段更新有延迟取决于 kubelet 的 sync 周期官方默认是 1 分钟左右实际可能在几十秒到几分钟之间。而且很重要的一点文件更新了C 进程不会自动重读配置。C 程序通常在 main 里一次性把配置读进内存之后就不再访问文件即使磁盘上的文件已经变了进程里用的还是旧配置。4.3 配置文件 reload 机制要让配置热生效需要进程主动感知文件变化。我见过三种常见方案定时 stat 文件 mtime简单粗暴但可靠。比如每隔 30 秒 stat 一次配置文件路径如果 mtime 变了就触发重新加载。用 inotify 监听目录实时性更好但需要处理 inotify 事件丢失、文件被原子替换rename等情况。外部信号触发运维或用例里通过 kill -HUP 触发重读这是很多老牌 C 服务的做法。我自己用的是定时 stat 方案因为实现最简单且不易出错。核心代码示意static std::atomictime_t s_lastMTime{0}; bool configChanged(const std::string path) { struct stat st; if (::stat(path.c_str(), st) ! 0) { return false; } time_t current st.st_mtime; time_t last s_lastMTime.load(); if (current ! last) { s_lastMTime.store(current); return true; } return false; } // 在后台线程里周期性调用 void configReloadLoop(const std::string path) { while (g_running.load()) { if (configChanged(path)) { reloadConfig(path); // 加锁替换配置快照 } std::this_thread::sleep_for(std::chrono::seconds(30)); } }配置切换时最好使用快照指针 读写锁的方式让业务线程一直读到一致的配置避免在 reload 过程中读到半份新配置。这个设计在老服务改造时尤其重要。5. 日志与可观测性让 C 服务在 K8s 里可排错很多 C 服务在裸机时代是写日志文件的比如直接 fwrite 到本地路径再用 logrotate 滚动。到了 K8s 里这个习惯不改会引发大问题Pod 被调度到别的节点日志留在原节点上排错时根本找不到Pod 重建后本地文件消失历史日志直接丢。5.1 stdout 日志与容器日志收集K8s 对容器日志的基本约定是应用把日志写到 stdout/stderr由容器运行时和 kubelet 负责采集和轮转。containerd 会把 stdout/stderr 重定向到底层日志文件kubectl logs就是从这个链路读的。对 C 服务来说最简单的改动就是把原来写文件的日志封装改成写 stdoutstd::cerr logLine std::endl;这里要特别提醒std::endl会 flush 缓冲区如果你在日志量很大时用std::cout ... std::endl一次 flush 一次系统调用性能开销可能大到拖慢业务线程。我更建议用std::cerr因为 stderr 默认无缓冲日志能立刻到达容器日志文件减少服务崩溃时日志丢失的问题。如果要控制日志格式、加时间戳和级别过滤自写一个轻量 LogSink 比每次写裸std::cerr更实用。注意在高频日志里字符串拼接本身就是成本。如果每条日志都通过 ostringstream 格式化再写入 stderr每分钟百万级的日志量会额外吃掉大量 CPU。建议日志级别限制在前格式化在后先判断当前级别是否允许输出再做处理和 IO。5.2 结构化日志与字段规划K8s 本身对日志格式没有强制要求但一旦你接了 EFK、Loki 或者自建的日志平台纯文本日志的解析简直就是噩梦。我在这次改造中把日志统一成了 JSON one-line 格式{ts:2025-01-20T14:23:11.123Z,level:info,service:message-server,msg:connection established,peer:10.0.0.2:45321,conn_id:12345,duration_ms:3}每条日志一行所有字段名固定。这样在采集端按 JSON 解析后K8s 的 Pod 名、Node 名由采集组件自动加上检索效率高很多。对 C 实现来说自己写一个简单的 JSON 拼接器完全够用我甚至没有引入 JSON 库因为日志字段是固定的手动序列化反而更快。但要注意对字段值做转义避免业务消息里带了引号或换行破坏单行结构。5.3 Prometheus 指标暴露在 K8s 里做监控标准路径是 Prometheus 拉取指标。C 服务要暴露指标主流方案有 prometheus-cpp 库或者自己写一个极简的 HTTP 文本接口。如果你不想引入额外依赖Prometheus 的文本格式并不复杂。一个最简单的计数器# HELP message_server_connections_total Total connections established # TYPE message_server_connections_total counter message_server_connections_total 12345我选择用一个独立的线程和端口比如 9090来服务这些文本请求。里面维护几个原子变量每秒钟更新一次。然后用一个简单的 HTTP parser 处理 GET /metrics 请求输出拼接好的文本。这种方式没有额外依赖逻辑完全可控还能顺手把进程本身的内存、线程数、fd 数打出来void exposeMetrics(httplib::Server svr) { svr.Get(/metrics, [](const httplib::Request, httplib::Response res) { std::ostringstream os; os # HELP message_server_conn_total Total connections\n; os # TYPE message_server_conn_total counter\n; os message_server_conn_total g_connCount.load() \n; os # HELP process_rss_bytes Resident memory\n; os # TYPE process_rss_bytes gauge\n; os process_rss_bytes getRssBytes() \n; res.set_content(os.str(), text/plain; version0.0.4); }); }有了 metrics就可以在 Grafana 里画内存增长曲线、连接数变化、队列深度等排查问题时能快速定位是流量增长导致的还是内存泄漏导致的问题。6. 一次真实的 502 排查C 服务在 K8s 里的间歇性失败这一节记录一次比较有代表性的排查经历。虽然不是特别玄学的故障但把 K8s 和 C 服务之间的典型问题串联了起来很有参考价值。6.1 症状与现场现象是下游 Java 服务调用我们这套 C 消息服务时偶尔报 502 Bad Gateway。不是大面积故障但峰值时刻每个小时会有几十次。我一开始怀疑是 C 服务处理不了并发于是看负载CPU 和内存都很正常看 Pod 状态全是 Running看 Service Endpoints数量也对。诡异的是出问题的时间点并不固定和发布窗口高度吻合。每次发布的滚动更新期间502 数量会明显增多发布结束又回落。我第一反应是优雅终止没做对因为滚动更新时会删除旧 Pod如果旧 Pod 的 SIGTERM 没有处理好服务就会在退出过程中继续接收连接并立即断开。6.2 排查链路我先看滚动更新时的 Pod 事件$ kubectl get events --field-selector involvedObject.namepod ... Killing container with id docker://...s: needs to be killed这说明 Kubelet 在等待 grace period 结束后用 SIGKILL 强制杀掉了容器。也就是旧 Pod 没有在预期时间内自行退出。但我的 SIGTERM 处理明明加上了为什么还会被杀接着看 Pod 的终止日志。发现一个问题服务进程使用了一个和健康检查服务器共享的全局线程池来执行清理任务而这个线程池里混入了阻塞的业务任务比如等待下游确认的超时导致清理流程迟迟走不完超过 grace period 后被强杀。客户端表现就是连接被强制复位502。再往下排查 readiness 探针我在优雅终止流程里把 g_ready 设置成了 false理论上 5 秒内 readiness 探针就会返回 503K8s 就能把 Pod 从 Endpoints 中摘掉。但这里有个关键延迟即使 Pod 的 readiness 已经失败Service 的 Endpoints 控制器也要通过 kube-proxy 或 coreDNS 的传播才能让下游停止转发流量。这个传播窗口是秒级的。那为什么 502 集中在峰值时段因为峰值时段连接数多下游每个节点都在持续维持大量长连接终止阶段几个在途请求正好落在退出窗口里就被断掉了。低峰期连接少落在这个窗口的概率低所以不明显。6.3 根因与修复最终根因有三层优雅终止流程里混入了阻塞任务导致 SIGTERM 后进程不能快速退出被 K8s 强杀清理任务没有和业务任务隔离共享同一个线程池发布窗口内readiness 摘除和实际断连之间有时间差在途连接数多的时候放大成了可见的间歇性 502。修复方案是把清理流程改为独立线程池严格限制清理任务的数量和超时超时就放弃等待并退出用 method 上的终止回调在收到 SIGTERM 后立即停止监听端口拒绝新连接建立滚动更新策略里增加maxSurge: 1和maxUnavailable: 0先启动新 Pod、确认 ready 后再摘除旧 Pod把发布窗口的流量抖动降到最低最后把 terminationGracePeriodSeconds 从默认 30 秒调整为 50 秒给清理留足空间。这套组合拳打完之后再观察了三个完整发布周期502 完全消失。我很清楚记得那次改动后第一次发布时我在终端前盯了几分钟kubectl get pods -w看着旧 Pod 一个个正常进入 Terminating 状态、平稳退出那种终于对齐了的踏实感比任何监控面板的绿色指标都让人安心。这次C 与 Kubernetes 集成的实战走下来我个人最大的体会有两个。第一C 服务上 K8s 不是简单写个清单文件就完事它的难点在运行时的行为差异动态库、内存分配、信号处理、日志输出每一个都和 bare metal 时的前提不同。第二越是底层的东西越要提前用工程手段把边界划清楚探针只探测和业务相关的真实状态清理流程只做有限等待内存画像之后再去谈 limit。先让服务在 K8s 里能死得明白才能做到活得稳定。最后再送你一个小技巧所有 C 服务容器化之后第一件事不是看功能是否正常而是主动 kill 一个 Pod观察它从 SIGTERM 到进程退出、Endpoints 摘除再到新 Pod 接管流量的整个过程通常这个实验做完大部分隐患就已经暴露出来了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。