ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

K8s部署Nacos集群:StatefulSet实战指南

K8s部署Nacos集群:StatefulSet实战指南

简介:这份面向 Kubernetes 初学者与运维人员的 Nacos 集群部署配置包,围绕在 k8s 环境中快速落地 Nacos 服务展开,旨在用尽量精简的清单减少繁琐参数与手工配置,降低集群部署门槛。压缩包共 6 个文件,主体为 5 个 YAML 配置清单和 1 个 txt 说明文件,整体约 3KB,体积轻量却覆盖数据库连接配置、无头服务、有状态工作负载、服务暴露及 Ingress 入口等关键环节,并按执行顺序组织,便于读者依次应用、理解各组件之间的衔接关系。已有 766 人学习下载,说明其作为入门实践参考具有一定认可度。读者可据此快速搭建可访问的 Nacos 集群雏形,同时借助清单结构梳理 StatefulSet 与 Service 的配合方式,掌握从配置到暴露的部署思路,适合用于学习验证、实验环境搭建及后续二次调整。

1. 从一份 Nacos 集群 YAML 说起:手写 Deployment 的人最后都回到了 StatefulSet

前段时间有位读者发来一段排查记录,Nacos 三个 Pod 全是 Running 状态,可控制台节点列表里只有自己一个,客户端注册上来的实例还在节点之间来回漂移。他贴出的 YAML 用的是 Deployment 加一个普通 ClusterIP Service,副本数写 3,看上去毫无问题。这个组合放在无状态服务里几乎没有争议,放到 Nacos 上就会出问题:Pod 重启后 IP 变了,cluster.conf 里写死的地址对不上,节点之间互相认不出来,注册中心就成了一个只进不出的摆设。

这份 k8s nacos 集群部署脚本 yaml 要解决的就是这些事:用 StatefulSet 保住稳定网络标识,用 Headless Service 提供可解析的成员地址,用 ConfigMap 收敛配置,再把元数据外置到 MySQL。它面向的是准备在自建 K8s 上跑一套 nacos 软件、把它当作注册中心和配置中心用的后端与运维,一套文件按依赖顺序执行 kubectl apply,出来就是一个能承担 nacos 插件级别职责的集群组件。

2. 拆开 YAML 之前:Nacos 在 K8s 里的部署形态与选型理由

2.1 三个端口与两条通信链路:集群内部到底在聊什么

Nacos 2.x 的端口设计比 1.x 复杂,很多部署失败不是因为 YAML 写错,而是端口没对上。默认情况下 8848 是 HTTP 主端口,控制台、OpenAPI、健康检查都走这里;9848 是客户端 gRPC 端口,所有 2.x 的 Java 客户端和部分其他语言客户端会向服务端发起长连接;9849 是服务端 gRPC 端口,集群内节点之间的数据同步走这条链路。1.x 时代还有一个 7848 用于 Raft 选举,2.x 虽然保留了但主要场景已经切换。

这里有个必须刻在脑子里的偏移规则:9848 = 8848 + 1000,9849 = 8848 + 1001。如果你在 application.properties 里把 server.port 改成 8858,那两个 gRPC 端口不会自动跟着变,你必须显式配置 nacos.server.grpc.port 或者接受它按新主端口重新推算。我见过最多的现场是主端口改了、Service 里只映射了新主端口,客户端报 "client not connected" 或者 "Server check fail",日志里一行 gRPC 超时翻来覆去看不出问题,其实就是 9858 没开。

这也是为什么整套 YAML 里 Service 的端口列表比 StatefulSet 的 containerPort 更容易出错。containerPort 写错只影响声明,Service 的 port 写错直接影响能不能连。我的习惯是把 8848、9848、9849 三个端口在三个地方保持一致:ConfigMap 的服务端配置、StatefulSet 的 containerPort、Service 的 ports 数组。

关于集群成员发现,Nacos 提供了两条路:一是挂载 cluster.conf 文件,每行一个IP:PORT;二是通过环境变量 NACOS_SERVERS 传入空格分隔的地址列表。在 K8s 里我更倾向后者,因为 Pod 的 IP 会变,而 hostname 不会。StatefulSet 的每个 Pod 有稳定域名nacos-0.nacos-headless.<namespace>.svc.cluster.local,把这一串写进 NACOS_SERVERS,节点扩缩容时只需改环境变量。

2.2 StatefulSet 与 Deployment:四个不能省的字段

把 Nacos 塞进 Deployment 并不是完全跑不起来,单副本模式下它工作得挺好,问题出在副本数大于 1 的那一刻。Deployment 的 Pod 名字是随机后缀,网络标识不稳定,存储卷在 Pod 重建后不一定跟着走,而这三点恰好是 Nacos 集群依赖的东西。

下面这张表是我在选型时反复对照的差异点,看懂了就知道为什么脚本里必须是 StatefulSet:

维度DeploymentStatefulSetNacos 集群的影响
Pod 名称随机后缀固定序号 nacos-0/1/2成员地址可预测,NACOS_SERVERS 能写死
网络标识无稳定 DNS通过 headless Service 提供节点互相寻址不依赖 IP
存储共享 PVC 或 emptyDirvolumeClaimTemplates 每个 Pod 独立每个节点日志、data 目录隔离
启动顺序并行无序默认有序,可改 Parallel影响首次建集群的速度

四个关键字段里,serviceName必须指向一个 headless Service(clusterIP: None),这是 Stable Network ID 的来源;podManagementPolicy: Parallel建议显式写上,因为 Nacos 集群节点之间是对等关系,不需要像数据库主从那样严格按序启动,默认的 OrderedReady 会让三个 Pod 一个个串行起来,首次部署时间翻倍,而且如果 nacos-0 因为探针没准备好一直卡着,后面两个根本不会创建;volumeClaimTemplates决定每个 Pod 独立的 PVC 命名规则,删 Pod 不删卷;replicas在集群模式下建议 3 起步,奇数个方便后续做多数派判断。

2.3 一套脚本的文件清单与依赖顺序

拿到一个 yaml 资源包,第一件事不是急着 apply,而是先看清楚文件之间的依赖。这套部署脚本通常包含以下几类对象,顺序错了会出现 ConfigMap 还没建、Pod 已经开始拉配置的竞态:

文件内容关键字段依赖
00-namespace.yaml独立命名空间metadata.name无
01-configmap.yamlapplication.properties、cluster.confnacos.core.auth、db.urlNamespace
02-service-headless.yaml集群内互访clusterIP: None无
03-service-client.yaml对外/对客户端暴露type、ports无
04-statefulset.yaml工作负载主体serviceName、volumeClaimTemplates上述全部
05-init-sql.sqlMySQL 建表脚本schema.sql 内容外部 MySQL

执行顺序遵循 00 → 01 → 02/03 → 04。每逢升级版本,我一般会先kubectl apply --dry-run=server -f ./把整个目录过一遍,确认 API 版本在当前集群里都支持,再真正提交。很多"脚本跑不通"的问题,其实是集群版本比 YAML 里的 apiVersion 新或旧,比如apps/v1beta1在 1.16 之后就被移除了。

3. 逐段落地:ConfigMap、Service、StatefulSet 的配置写法

3.1 ConfigMap:application.properties 里真正要改的参数

Nacos 的镜像启动时会读取/home/nacos/conf/application.properties,ConfigMap 的作用就是把这个文件替换掉或者覆盖部分键值。下面这段是我常用的最小可用集合,只保留集群模式必须动的那几项:

apiVersion: v1 kind: ConfigMap metadata: name: nacos-config namespace: nacos data: application.properties: | # 服务端主端口,改了它就要同步改 9848/9849 的偏移 server.port=8848 # 关闭内嵌 Derby,走外部 MySQL spring.sql.init.platform=mysql db.num=1 db.url.0=jdbc:mysql://mysql.nacos.svc.cluster.local:3306/nacos_config?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=Asia/Shanghai db.user.0=nacos db.password.0=<数据库密码> # 开启鉴权,生产环境必须 nacos.core.auth.enabled=true nacos.core.auth.plugin.nacos.token.secret.key=<至少要 32 位 base64 字符串> nacos.core.auth.server.identity.key=<节点间通信标识 key> nacos.core.auth.server.identity.value=<节点间通信标识 value> # 会话与连接相关,按 Pod 内存调整 nacos.naming.expireInstance=true cluster.conf: | # 与 NACOS_SERVERS 二选一,这里留空表示由环境变量注入

spring.sql.init.platform=mysql这一行在 2.2 之后替代了旧版的spring.datasource.platform,两个键名写混是版本升级后最常见的翻车点,表现是启动日志里 Derby 照常初始化,你以为配了 MySQL 其实没生效。db.num=1表示单数据源,如果要做数据库高可用可以扩展到 db.url.1 并同步增加 num,不过 Nacos 对多数据源的写入策略并不复杂,一般企业内单实例 MySQL 加主从就够。

nacos.core.auth.plugin.nacos.token.secret.key要求是 base64 编码后的字符串,明文长度不足会直接启动失败,日志里通常会提示 "the length of secret key must great than or equal 32 bytes"。这个值一旦上线就别随意改,改完所有客户端的 token 全部失效,等于强制全量重启一次。

nacos.core.auth.server.identity.key/value是节点之间的身份标识,它在集群模式下必须每个节点一致,如果不一致,节点列表里能看到对方但显示不健康。这个是很多人第一次搭鉴权集群时的黑匣子。

3.2 cluster.conf 与 NACOS_SERVERS:选一个,别都写

集群成员地址有两条注入路径,写重了反而出问题。cluster.conf 是文件方式,内容是每行一个地址:

nacos-0.nacos-headless.nacos.svc.cluster.local:8848 nacos-1.nacos-headless.nacos.svc.cluster.local:8848 nacos-2.nacos-headless.nacos.svc.cluster.local:8848

环境变量方式更贴合 K8s:

env: - name: NACOS_SERVERS value: "nacos-0.nacos-headless.nacos.svc.cluster.local:8848 nacos-1.nacos-headless.nacos.svc.cluster.local:8848 nacos-2.nacos-headless.nacos.svc.cluster.local:8848" - name: MODE value: "cluster" - name: PREFER_HOST_MODE value: "hostname"

MODE=cluster是打开集群模式的开关,单机模式下 Nacos 默认用 Derby 并且不认 cluster.conf,这个环境变量漏了的话,上面所有集群配置全部白搭。PREFER_HOST_MODE=hostname告诉服务端在对外暴露地址时优先使用 hostname 而不是 IP,配合 StatefulSet 的稳定域名,客户端拿到的实例地址才是可解析的。

注意一点:环境变量里必须用完整的 FQDN 或至少pod名.headless服务名的形式。只写nacos-0在同一个 namespace 内偶尔能解析,跨 namespace 或者 DNS 策略不是 ClusterFirst 时就断。我一般在脚本里统一写全 FQDN,多打几十个字符换来的确定性很值。

如果集群后续要扩到 5 个节点,正确姿势是先改 NACOS_SERVERS 加入新地址,再kubectl scale statefulset nacos --replicas=5,最后滚动重启老节点让它们重新读配置。顺序反了会出现新节点不知道老集群、老集群不认新节点的僵持状态。

3.3 两个 Service 的分工:Headless 给集群,ClusterIP 给客户端

apiVersion: v1 kind: Service metadata: name: nacos-headless namespace: nacos spec: clusterIP: None selector: app: nacos ports: - name: http port: 8848 targetPort: 8848 - name: grpc-client port: 9848 targetPort: 9848 - name: grpc-server port: 9849 targetPort: 9849 --- apiVersion: v1 kind: Service metadata: name: nacos-client namespace: nacos spec: type: ClusterIP selector: app: nacos ports: - name: http port: 8848 targetPort: 8848 - name: grpc-client port: 9848 targetPort: 9848

Headless Service 的clusterIP: None是关键,它让 K8s 不为这个 Service 分配虚拟 IP,而是直接返回后端 Pod 的 A 记录,nacos-0.nacos-headless才能解析到具体 Pod。StatefulSet 的serviceName必须填nacos-headless。

第二个 Service 是给业务客户端用的。这里有个容易忽略的细节:2.x 客户端连上 8848 之后,服务端会返回一个 gRPC 地址让客户端去连 9848,如果 ClusterIP Service 只暴露了 8848,客户端第一阶段能连上,第二阶段直接超时。所以 9848 必须一起暴露。9849 是服务端之间用的,客户端 Service 不需要。

另外,如果集群外部(比如 K8s 外面的虚拟机)也要注册,那就得再补一个 NodePort 或 LoadBalancer 类型的 Service,并且注意 9848 同样要映射出去。只映射 8848 是最常见的"本地能连、外部连不上"的原因。

3.4 StatefulSet 主体:探针、资源与优雅停机

apiVersion: apps/v1 kind: StatefulSet metadata: name: nacos namespace: nacos spec: serviceName: nacos-headless replicas: 3 podManagementPolicy: Parallel selector: matchLabels: app: nacos template: metadata: labels: app: nacos spec: terminationGracePeriodSeconds: 30 affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: topologyKey: kubernetes.io/hostname labelSelector: matchLabels: app: nacos containers: - name: nacos image: nacos/nacos-server:<替换成你实际使用的版本> imagePullPolicy: IfNotPresent envFrom: - configMapRef: name: nacos-config-env resources: requests: cpu: "500m" memory: "2Gi" limits: cpu: "2" memory: "2Gi" ports: - containerPort: 8848 - containerPort: 9848 - containerPort: 9849 readinessProbe: httpGet: path: /nacos/actuator/health port: 8848 initialDelaySeconds: 20 periodSeconds: 10 failureThreshold: 6 livenessProbe: httpGet: path: /nacos/actuator/health port: 8848 initialDelaySeconds: 60 periodSeconds: 20 failureThreshold: 3 lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 10"] volumeMounts: - name: conf mountPath: /home/nacos/conf/application.properties subPath: application.properties - name: data mountPath: /home/nacos/data - name: logs mountPath: /home/nacos/logs volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] storageClassName: <你的 StorageClass 名> resources: requests: storage: 20Gi - metadata: name: logs spec: accessModes: ["ReadWriteOnce"] storageClassName: <你的 StorageClass 名> resources: requests: storage: 10Gi

这段里有几个参数值得单独说明。terminationGracePeriodSeconds: 30配合preStop里的 sleep 10 秒,是给服务端留出注销实例的窗口。Nacos 客户端有本地缓存,强制 kill 之后,客户端最长要等一次心跳超时才会摘除实例,这段时间流量还可能打到已经死掉的 Pod 上。加了 preStop 之后,Pod 会先从 Endpoints 摘除,再等 10 秒让存量连接自然断开。

readinessProbe用/nacos/actuator/health而不是/nacos/,是因为后者在实例注册表未就绪时也可能返回 200,探针会误判。initialDelaySeconds给到 20 秒是经验值,Pod 冷启动时 JVM 加上 MySQL 连接池初始化通常在 15 到 25 秒之间,给太短会导致第一次就绪检查失败,Pod 被反复重启。

limits和requests的内存建议写成相等,JVM 在容器里读到的是 cgroup limit,如果 limit 明显大于 request,节点压力大时容易被驱逐,而 Nacos 被驱逐一次意味着要重新加入集群,代价不小。堆内存可以在环境变量里加JVM_XMS和JVM_XMX,一般设成 limit 的 60% 到 70%,留出堆外和元空间。

podAntiAffinity用的是 preferred 而不是 required,是因为很多测试集群只有两三个节点,required 会导致第三个 Pod 一直 Pending。生产环境节点足够时改成 required 更稳妥,避免三个副本落在同一台机器上一次故障全挂。

4. 数据持久化与外部 MySQL:把 Nacos 从单机玩具变成可用集群

4.1 内嵌 Derby 的边界:三副本一起来就打架

Nacos 单机模式默认使用内嵌 Derby,数据落在${nacos.home}/data/derby-data。它的定位是开箱即用,官方文档写得很清楚,不适合集群。原因不复杂:Derby 是嵌入式数据库,文件被进程独占,三个 Pod 各自挂自己的 PVC,等于三份互不相干的数据;如果为了"共享"把同一个 PVC 挂到三个 Pod 上,多个进程同时写同一个 Derby 文件,损坏几乎是必然的。我见过有人用 NFS 共享卷跑三副本 Derby,跑了两天开始出现配置丢失,日志里是各种文件锁异常。

所以集群模式的第一道分水岭就是外部 MySQL。它存的是配置内容、命名空间、用户权限、历史版本这些元数据。即使 MySQL 短暂不可用,Nacos 服务端启动会失败,但已经在运行的节点还能继续提供注册服务,因为注册数据主要在内存和节点间同步,配置读取有本地快照。理解这个分层,才能判断什么样的故障是"必须立刻修"、什么可以"等一等"。

要注意的是,Derby 到 MySQL 没有官方一键迁移工具。如果之前已经用单机版跑了一段时间、配置数据有积累,通常的做法是把单机版导出的配置通过 OpenAPI 批量拉下来再推到集群版。这个动作在 YAML 脚本层面不会体现,但它是上线前必须补的一步,否则会出现"集群搭好了,配置全是空的"。

4.2 建库建表与连接参数:初始化 SQL 只跑一次

MySQL 侧的准备工作和 K8s 无关,但漏做一步整个部署就卡在 CrashLoopBackOff。典型操作如下:

-- 1. 创建独立库,字符集必须显式指定 CREATE DATABASE nacos_config DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 2. 创建专用账号,不要用 root CREATE USER 'nacos'@'%' IDENTIFIED BY '<数据库密码>'; GRANT SELECT, INSERT, UPDATE, DELETE ON nacos_config.* TO 'nacos'@'%'; FLUSH PRIVILEGES; -- 3. 切入目标库后导入官方 schema USE nacos_config; SOURCE /path/to/nacos-mysql.sql;

schema.sql在 Nacos 发行包的conf目录下,文件名是mysql-schema.sql或nacos-mysql.sql,不同版本命名略有差异。它包含config_info、config_info_gray、his_config_info、users、roles、permissions等表,缺一张表都会导致启动时的建表检查失败。导入之后执行SHOW TABLES;确认有十几张表,少于这个数说明脚本没跑完。

账号权限这里,我一般只给 DML 权限而不给 DDL,代价是版本升级时需要手工执行增量 SQL,好处是应用账号没法误删表。如果团队规模小、升级频繁,直接给 ALL 也常见,但要接受一次误操作可能清空配置历史的风险。

连接串里几个参数值得强调:connectTimeout=1000和socketTimeout=3000是官方示例里的值,网络稍差的跨可用区场景建议放大到 3000 和 10000,否则启动时容易因为一次慢查询直接放弃;useSSL=false在内网可以接受,跨网段建议打开并配置证书;serverTimezone必须显式指定,不写会在部分 MySQL 8.0 实例上报时区错误导致连接失败。这些参数写错,表现都是 Nacos 启动日志里一行Communications link failure,从 K8s 层面看不出任何异常,只能进容器看日志。

4.3 PVC、StorageClass 与扩容:几个容易被忽略的字段

存储项建议配置理由
data 卷容量20Gi 起存快照和本地缓存,配置量大的环境会涨
logs 卷容量10Gi 起访问日志按天滚动,未挂卷时写容器层会撑爆
accessModesReadWriteOnce每个 Pod 独立卷,不需要共享
storageClassName显式指定不写会用集群默认 SC,换集群时行为不一致
reclaimPolicyRetain误删 StatefulSet 时数据还在,能救回来

volumeClaimTemplates生成的 PVC 名字是># 第一层:Pod 与 Service 状态 kubectl -n nacos get statefulset,pod,svc -o wide kubectl -n nacos rollout status statefulset/nacos --timeout=300s # 第二层:集群节点列表,看每个节点的 state 是否为 UP kubectl -n nacos exec nacos-0 -- \ curl -s "http://127.0.0.1:8848/nacos/v1/ns/operator/servers" | head -c 500 # 第三层:真实注册一次,确认读写链路都通 curl -X POST "http://nacos-client.nacos.svc.cluster.local:8848/nacos/v1/ns/instance" \ -d "serviceName=demo-service&ip=10.0.0.1&port=8080&namespaceId=public" curl "http://nacos-client.nacos.svc.cluster.local:8848/nacos/v1/ns/instance/list?serviceName=demo-service"

第一层看的是 K8s 视角的就绪状态,rollout status返回成功说明所有副本都通过了 readinessProbe。第二层是 Nacos 自己的视角,接口返回的 JSON 里每个节点的state字段应为UP,address应该是完整域名而不是 IP,否则回去检查PREFER_HOST_MODE。第三层最关键,注册和查询都通,说明控制面、数据面、gRPC 链路都没问题。

第三层验证完之后记得把测试实例删掉,否则它会一直挂在服务列表里,直到心跳超时。

6.2 滚动升级的一次预演

升级镜像版本前,我会在测试环境先做一次预演,重点看两件事:配置是否兼容、节点能否重新加入。

# 先只改一个副本的镜像,观察它能否正常加入集群 kubectl -n nacos set image statefulset/nacos \ nacos=nacos/nacos-server:<新版本> # 盯住滚动过程,任何 Pod 卡住就暂停 kubectl -n nacos rollout status statefulset/nacos --timeout=600s kubectl -n nacos rollout pause statefulset/nacos # 出现异常时手动暂停 # 升级完成后复核集群节点 kubectl -n nacos exec nacos-0 -- \ curl -s "http://127.0.0.1:8848/nacos/v1/ns/operator/servers"

滚动策略用默认的 RollingUpdate,但 StatefulSet 是从序号大的往小滚,这一点和 Deployment 相反。如果新版本启动异常,rollout pause能帮你把已经升级的节点停住,避免全量翻车。升级窗口内客户端会有短暂重连,2.x 的客户端重连逻辑比较完善,通常感知不到,但配置监听可能会有一次延迟,业务侧要能容忍。

还有一个经常被忽视的验证点:升级后确认config_info表的记录数没有变化。某些版本在表结构变更时会执行 DDL,如果数据库账号没有 DDL 权限,升级会在启动阶段静默失败,Pod 反复重启但不报明显错误。提前给账号临时开 DDL 权限、升级完再收回,是我固定走的流程。

从那以后,我每次动这套 YAML 都强制走一遍三段验证:先 dry-run,再单副本灰度,最后全量滚动,一次都不省。这套脚本本身不复杂,真正花时间的是确认每一个参数在你自己的集群里成立,这份谨慎比脚本本身更值钱。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进