这两年云原生很火,我们团队也把业务逐步迁移到了Kubernetes上。过程中踩了不少坑,有些坑让我熬了好几个通宵。今天把这些踩坑经历整理出来,从容器化到K8s部署,从网络到存储,从监控到安全,一次说清楚。

先说说背景。我们团队做的是一个互联网产品,有十几个微服务,之前部署在传统的虚拟机上,用脚本发布,用Nginx做负载均衡。随着业务发展,服务越来越多,发布越来越频繁,传统的部署方式越来越难维护:发布一次要半小时,环境不一致导致"我本地是好的",扩容要手动创建虚拟机,资源利用率低。

于是我们决定全面拥抱云原生,把所有服务都容器化,部署到Kubernetes集群上。整个迁移过程花了大概三个月,踩了无数的坑。今天把印象最深刻的一些坑分享出来,希望正在做云原生迁移的团队能少走弯路。

一、容器化的坑

第一个大坑是容器化。把一个传统应用打包成Docker镜像,看起来简单,但实际上有很多细节需要注意。

第一个坑是镜像太大。我们一开始写Dockerfile,直接用了一个基础镜像,然后把应用代码和依赖都装进去,结果镜像大小有1.5GB。镜像太大导致拉取镜像很慢,发布一次要等十几分钟,而且占用大量的镜像仓库存储空间。

后来我们优化了Dockerfile,用了多阶段构建(multi-stage build)。第一阶段用完整的编译环境构建应用,第二阶段用alpine或者distroless这种精简的基础镜像,只把构建好的可执行文件和必要的运行时依赖拷贝进去。优化之后,镜像大小从1.5GB降到了200MB,拉取速度快了很多。

另外,还要注意清理镜像层中的缓存。比如apt-get install之后要rm -rf /var/lib/apt/lists/*,npm install之后要清理npm cache,pip install之后要清理pip cache。这些缓存如果不清理,会留在镜像层中,增加镜像大小。

第二个坑是容器时间不对。我们有一个服务需要记录精确的时间,部署到容器里之后发现时间差了8个小时。排查了半天才发现,容器的默认时区是UTC,而我们需要的是东八区。解决方法是在Dockerfile里设置时区,比如安装tzdata包,然后设置ENV TZ=Asia/Shanghai,或者把宿主机的/etc/localtime挂载到容器里。

这个坑看起来简单,但很容易忽略,而且时间不对会导致很多奇怪的问题:日志时间不对、定时任务执行时间不对、接口返回的时间戳不对。如果不仔细排查,很难想到是容器时区的问题。

第三个坑是容器内的用户权限。我们一开始图省事,容器里的应用都用root用户运行。后来做安全扫描的时候被指出了这个问题,因为用root用户运行容器有安全风险,如果容器被攻破,攻击者就有root权限,可以访问宿主机的资源。

后来我们把所有容器都改成了用非root用户运行。在Dockerfile里创建一个普通用户,然后用USER指令切换到这个用户。改的时候踩了一些坑,比如应用需要写日志文件,但普通用户没有写权限,需要在构建的时候创建日志目录并设置正确的权限。还有一些应用需要绑定1024以下的端口,但非root用户没有权限,这时候要么改用1024以上的端口,要么用setcap给可执行文件授予绑定低端口的权限。

第四个坑是容器的健康检查。一开始我们没有配置健康检查,容器启动了就算成功,但有时候应用虽然启动了,实际上还没有准备好接收请求(比如数据库连接还没建立、缓存还没预热),这时候流量进来就会报错。后来我们配置了readinessProbe和livenessProbe,readinessProbe用来判断应用是否准备好接收流量,livenessProbe用来判断应用是否还活着,如果失败了就重启容器。

配置健康检查的时候也踩了坑。一开始把initialDelaySeconds设得太短,应用还没启动完就开始检查,导致检查失败,容器被反复重启。后来根据应用的启动时间,把initialDelaySeconds设成了合适的值,并且用了startupProbe(K8s 1.18+支持)来处理启动慢的应用。另外,健康检查的接口要单独实现,不要用业务接口,因为业务接口可能依赖数据库、缓存等外部服务,如果外部服务挂了,健康检查也会失败,导致容器被不必要地重启。

二、Kubernetes部署的坑

容器化搞定之后,接下来是部署到Kubernetes集群,这部分的坑更多。

第一个坑是资源请求和限制的设置。一开始我们不知道该怎么设置CPU和内存的requests和limits,要么设得太大导致资源浪费,要么设得太小导致OOM或者CPU throttling。

我们有一个Java应用,一开始把内存limits设成了1GB,但JVM默认会用物理内存的1/4作为堆内存,也就是256MB,结果应用经常因为堆内存不够而OOM。后来我们在启动参数里显式设置了JVM的堆大小(-Xmx),并且把容器的内存limits设成了堆内存的1.5到2倍,就没有这个问题了。另外,Java 10+支持了容器内存感知,可以自动根据容器的内存限制来调整JVM堆大小,如果用的是比较新的JDK版本,可以不用手动设置。

CPU的设置也踩了坑。我们有一个服务,CPU limits设成了1核,但实际上这个服务在高峰期需要2核的CPU,结果被throttling了,响应时间变得很长。后来我们根据监控数据,把CPU limits调整到了合适的值。这里有一个经验:CPU requests可以设得保守一点(保证基本运行),但limits要设得宽松一点(应对突发流量),或者干脆不设limits,只设requests。当然,不设limits有风险,可能会被其他服务抢占CPU,需要根据实际情况权衡。

第二个坑是配置管理。我们一开始把配置直接写在了镜像里,每次改配置都要重新构建镜像,非常麻烦。后来改成了用ConfigMap和Secret来管理配置,配置和镜像分离,改配置不需要重新构建镜像。

用ConfigMap的时候也踩了坑。一开始我们把配置文件挂载成了ConfigMap的volume,但是发现更新ConfigMap之后,容器里的配置文件不会自动更新,需要重启容器才能生效。后来查了文档才知道,ConfigMap挂载的volume更新是有延迟的,而且应用需要支持配置热加载才能自动生效。如果应用不支持热加载,就需要滚动重启容器来让新配置生效。我们后来用了一个工具(reloader)来监听ConfigMap和Secret的变化,自动触发滚动重启,就方便多了。

另外,敏感信息(比如数据库密码、API密钥)不要放在ConfigMap里,要放在Secret里。虽然Secret默认只是base64编码,不是加密的,但至少比ConfigMap多了一层保护,而且可以配合RBAC来限制访问。如果对安全性要求更高,可以用外部的密钥管理服务(比如Vault)来管理敏感信息。

第三个坑是服务发现和负载均衡。Kubernetes内置了服务发现,通过Service来访问Pod,默认是轮询的负载均衡。但我们有一个有状态的服务,需要会话保持(同一个用户的请求路由到同一个Pod),默认的Service不支持。后来我们用了Ingress的会话保持功能,或者用了StatefulSet + Headless Service的方式来实现有状态服务的服务发现。

还有一个坑是Service的类型。一开始我们所有服务都用了ClusterIP,只能在集群内部访问,外部访问需要通过Ingress。但有一个TCP服务(不是HTTP),Ingress不支持,后来用了NodePort或者LoadBalancer类型的Service来暴露。对于TCP/UDP服务,需要根据实际情况选择合适的Service类型,不能都用Ingress。

第四个坑是滚动更新的策略。Kubernetes默认的滚动更新策略是先创建新的Pod,等新Pod就绪了再删除旧的Pod。这个策略对于大多数无状态服务来说没问题,但对于一些特殊场景会有问题。

比如我们有一个服务,启动的时候需要从数据库加载大量数据到内存,启动时间比较长。如果用默认的滚动更新策略,新Pod还没启动完,旧Pod已经被删了,就会导致服务不可用。后来我们把maxSurge设成了1,maxUnavailable设成了0,确保新Pod完全就绪之后才删除旧Pod。另外,还配置了PodDisruptionBudget,确保在节点维护或者集群缩容的时候,至少有一定数量的Pod在运行,避免服务不可用。

还有一个问题是滚动更新的时候,旧Pod正在处理的请求会被中断。Kubernetes默认会给Pod发送SIGTERM信号,然后等30秒(terminationGracePeriodSeconds)之后强制杀死。如果应用没有正确处理SIGTERM信号,没有等正在处理的请求完成就退出了,就会导致请求失败。后来我们在应用里实现了优雅关闭:收到SIGTERM信号之后,停止接收新请求,等正在处理的请求完成之后再退出。同时把terminationGracePeriodSeconds设成了足够长的时间,确保应用有足够的时间完成正在处理的请求。

三、网络的坑

Kubernetes的网络是最复杂的部分,也是坑最多的部分。

第一个坑是CNI插件的选择。Kubernetes本身不提供网络实现,需要安装CNI插件。常见的CNI插件有Flannel、Calico、Cilium、Weave等,每个插件的特性和性能都不一样。我们一开始选了Flannel,因为它简单、容易部署。但用了一段时间之后发现,Flannel不支持NetworkPolicy(网络策略),无法做Pod之间的网络隔离,安全性不够。后来我们换成了Calico,Calico支持NetworkPolicy,而且性能也不错。

换CNI插件的时候踩了大坑。CNI插件是集群级别的配置,更换的时候需要重新配置所有节点的网络,而且可能会导致网络中断。我们当时是在生产环境上换的,结果换的过程中网络断了十几分钟,影响了线上服务。后来总结经验:更换CNI插件一定要在测试环境充分测试,然后在业务低峰期操作,并且要有回滚方案。最好是在搭建集群的时候就选好CNI插件,避免后期更换。

第二个坑是Service之间的访问延迟。我们有两个服务,A服务调用B服务,都是通过Service名称来访问的。一开始发现调用延迟很高,排查了半天才发现,是因为DNS解析的问题。Kubernetes的DNS(CoreDNS)在解析Service名称的时候,如果配置不当,会有延迟或者解析失败。

我们的问题是因为Pod的DNS配置里有多个search domain,解析Service名称的时候会先尝试加上search domain,失败了再尝试原始名称,导致多了几次DNS查询,增加了延迟。后来我们优化了DNS配置,把ndots设成了2(默认是5),减少了不必要的DNS查询。另外,对于调用频繁的服务,可以考虑用Service的ClusterIP直接访问,跳过DNS解析,或者用NodeLocal DNSCache来缓存DNS查询结果,减少CoreDNS的压力和延迟。

第三个坑是网络策略(NetworkPolicy)的配置。我们用了Calico之后,开始配置NetworkPolicy来做Pod之间的网络隔离。一开始配置了一个默认拒绝所有入站流量的策略,然后逐个开放需要的端口。结果配置完之后,很多服务之间的调用都失败了,排查了半天才发现,是因为我们只开放了业务端口,没有开放一些必要的系统端口,比如健康检查的端口、metrics的端口等。

后来我们总结了配置NetworkPolicy的经验:第一,先从宽松的策略开始,逐步收紧,不要一开始就配置最严格的策略,否则很容易出问题;第二,确保健康检查和监控的端口是开放的,否则Kubelet无法访问健康检查接口,会导致Pod被反复重启;第三,用标签来选择Pod,而不是用IP,因为Pod的IP是动态变化的;第四,配置完之后要充分测试,确保所有服务之间的调用都正常。

第四个坑是Ingress的配置。我们用了Nginx Ingress Controller来暴露HTTP服务。一开始配置了一个简单的Ingress,把域名路由到对应的Service,一切正常。但后来需要配置HTTPS,就踩了坑。

HTTPS需要TLS证书,我们用了cert-manager来自动申请和续期Let's Encrypt的证书。配置cert-manager的时候踩了很多坑:比如DNS验证的配置、HTTP验证的路径冲突、证书申请的速率限制等。最坑的一次是,证书续期失败了,我们没有及时发现,结果证书过期了,用户访问网站的时候提示不安全,影响了用户体验。后来我们配置了证书过期的监控告警,并且用了cert-manager的自动续期功能,就没有再出现这个问题了。

另外,Ingress的一些高级配置也踩了坑,比如请求体大小限制(client-max-body-size)、超时时间(proxy-connect-timeout、proxy-read-timeout)、WebSocket支持等。这些配置需要根据业务需求来调整,默认值可能不满足需求。比如我们有一个文件上传的接口,请求体比较大,默认的Ingress请求体大小限制是1MB,导致上传失败。后来在Ingress的annotation里设置了nginx.ingress.kubernetes.io/proxy-body-size: 50m,就解决了。

四、存储的坑

Kubernetes的存储也是一个容易踩坑的地方,尤其是有状态服务。

第一个坑是PV和PVC的使用。我们一开始用了emptyDir来存储数据,emptyDir是Pod本地的临时存储,Pod删除之后数据就没了。对于无状态服务来说没问题,但对于有状态服务(比如数据库)来说,数据不能丢。后来我们改成了用PersistentVolume(PV)和PersistentVolumeClaim(PVC)来持久化存储。

用PV/PVC的时候踩了坑。一开始我们用了静态PV,需要手动创建PV,然后PVC来绑定。但PV的容量和访问模式需要和PVC匹配,否则绑定不上。后来我们改成了用StorageClass做动态供给,创建PVC的时候自动创建PV,就方便多了。

但动态供给也有坑,比如不同的云厂商的StorageClass支持的参数不一样,需要根据实际情况配置。还有,删除PVC的时候,默认的回收策略是Delete,会自动删除对应的PV和底层存储。如果误删了PVC,数据就没了。我们后来把重要数据的PV回收策略改成了Retain,删除PVC之后PV还在,数据不会丢,需要手动删除PV才能释放存储。虽然麻烦了一点,但更安全。

第二个坑是数据库容器化。我们一开始尝试把MySQL也部署到Kubernetes里,用StatefulSet + PVC来持久化数据。但用了一段时间之后发现了很多问题:比如数据库的性能不如直接部署在虚拟机上(因为存储的网络延迟)、备份和恢复比较复杂、主从复制的配置比较麻烦。

后来我们做了一个决定:数据库不容器化,还是部署在专门的虚拟机或者云数据库上,Kubernetes只部署无状态的应用服务。这个决定让我们少踩了很多坑。当然,数据库容器化不是不行,有很多团队也在生产环境中用了容器化的数据库,但需要对Kubernetes的存储和数据库的运维有比较深入的理解。对于我们这种中小团队来说,把数据库放在外面是更稳妥的选择。

第三个坑是日志的收集和存储。容器的日志默认是输出到stdout和stderr的,Kubernetes会把这些日志保存到节点上的文件里。但如果Pod被删除了,或者节点挂了,日志就没了。所以需要一个集中式的日志收集系统。

我们用了ELK(Elasticsearch + Logstash + Kibana)来收集和分析日志,用Filebeat作为日志采集器,部署在每个节点上(DaemonSet),收集容器的日志文件,发送到Elasticsearch。

配置日志收集的时候踩了坑。一开始Filebeat采集的日志格式不对,解析不出来,导致Kibana里看不到结构化的日志。后来我们调整了应用的日志输出格式,用了JSON格式输出,Filebeat直接解析JSON,就方便多了。另外,Elasticsearch的存储和性能也是一个坑,日志量大了之后,Elasticsearch的查询会变慢,需要合理设置索引的分片和副本,并且定期清理旧日志(用ILM,Index Lifecycle Management)。

五、监控和告警的坑

云原生环境下,服务的数量和动态性都大大增加了,监控和告警变得更加重要。

第一个坑是监控指标的收集。我们用了Prometheus来收集监控指标,用Grafana来做可视化。Prometheus通过抓取(pull)的方式收集指标,每个服务需要暴露一个/metrics端点,Prometheus定期来抓取。

配置Prometheus的时候踩了坑。一开始我们用了静态配置,手动写每个服务的地址,但Kubernetes里的Pod是动态变化的,IP会变,静态配置很快就失效了。后来我们用了Prometheus的Kubernetes服务发现,自动发现Service和Pod,动态更新抓取目标。

另外,指标的命名和标签也很重要。一开始我们的指标命名不规范,标签也很乱,导致查询和告警都很麻烦。后来我们制定了指标命名规范,按照Prometheus的最佳实践来命名指标和标签,并且用了一些常用的客户端库(比如prom-client for Node.js、micrometer for Java)来自动收集一些基础指标(比如JVM指标、HTTP请求指标),就规范多了。

第二个坑是告警的配置。监控的目的是为了发现问题,告警是为了及时通知到相关人员。我们一开始配置了很多告警规则,结果告警太多了,每天收到几十条告警,大部分都是无关紧要的,导致大家对告警麻木了,真正重要的告警反而被忽略了。

后来我们对告警做了治理:第一,减少告警数量,只保留真正重要的、需要人工介入的告警;第二,对告警分级,P0级别的告警(服务不可用、错误率飙升)电话通知,P1级别的短信通知,P2级别的只在群里通知;第三,配置告警的抑制和聚合,避免同一个问题触发多条告警;第四,定期回顾告警,把不需要的告警删掉或者调整阈值。治理之后,告警数量大大减少,大家对告警的重视程度也提高了。

第三个坑是分布式链路追踪。微服务架构下,一个请求可能经过多个服务,出了问题很难定位是哪个服务的问题。这时候就需要分布式链路追踪。

我们用了Jaeger来做分布式链路追踪,每个服务在处理请求的时候生成和传递traceId,把请求的调用链记录下来,发送到Jaeger。配置链路追踪的时候踩了坑,比如traceId的传递格式不统一,导致跨服务的调用链断了;比如采样率设置不当,要么采样太多影响性能,要么采样太少查不到问题。后来我们统一了traceId的格式(用了W3C的Trace Context标准),并且根据服务的重要性设置了不同的采样率,核心服务采样率高一些,非核心服务采样率低一些,就比较合理了。

六、写在最后

云原生是一个很庞大的技术体系,涉及容器、编排、网络、存储、监控、安全等方方面面。迁移到云原生不是一件简单的事情,需要团队有足够的技术积累和运维能力。在迁移的过程中,踩坑是难免的,关键是要从坑中学习,总结经验,逐步完善。

回顾我们这三个月的迁移过程,虽然踩了很多坑,熬了很多夜,但收获也是巨大的。迁移完成之后,发布时间从半小时降到了五分钟,扩容从手动创建虚拟机变成了自动伸缩,资源利用率提高了40%,环境一致性问题基本消失了。更重要的是,团队的技术能力得到了提升,大家对云原生有了更深入的理解。

如果你也在做云原生迁移,或者打算做,我的建议是:第一,不要急于求成,先从简单的无状态服务开始,逐步积累经验,再迁移复杂的服务;第二,做好充分的测试,每个环节都要在测试环境验证通过之后再上生产;第三,建立完善的监控和告警,出了问题能及时发现和定位;第四,团队要持续学习,云原生技术发展很快,需要不断跟进新的技术和最佳实践。

云原生不是目的,而是手段。它的最终目的是提高研发效率、降低运维成本、提升系统稳定性。不要为了云原生而云原生,要根据自己的业务需求和团队能力来选择合适的技术方案。适合自己的,才是最好的。

希望我的这些踩坑经验能对大家有帮助。如果有什么问题或者不同的看法,欢迎在评论区交流。