01. istiod 启动全过程:从 main() 到第一个 gRPC 端口 原创
这一篇回答一个问题:istiod 这个进程,从按下回车到能接活,中间发生了什么,顺序为什么是这个顺序。
读完你能做到两件事:看到 istiod 启动日志的任何一行,都能说出它在整条链上的位置;知道哪几处初始化一旦调换顺序就会静默出错——不报错、不崩溃、健康检查还是绿的,但行为已经偏了。
源码基线
istio/istio1.31.4,定位用文件路径 + 函数名,不用行号。
一、先看两个数字
第一个数字:pilot/cmd/pilot-discovery/main.go 连版权头一共 34 行,main() 占 8 行。
func main() {
log.EnableKlogWithCobra()
rootCmd := app.NewRootCommand()
if err := rootCmd.Execute(); err != nil {
log.Error(err)
os.Exit(-1)
}
}第二个数字:真正干活的 bootstrap.NewServer() 主体 200 行左右,里面顺序执行了 20 多个 initXxx。
所以"istiod 怎么启动"这个问题的答案,几乎不在这 8 行里。main() 只做一件事:把控制权交给 cobra 命令,然后等信号。
主线其实只有三条语句,藏在 pilot/cmd/pilot-discovery/app/cmd.go 的 RunE 里:
discoveryServer, err := bootstrap.NewServer(serverArgs) // ① 装配
if err != nil { return fmt.Errorf("failed to create discovery service: %v", err) }
if err := discoveryServer.Start(stop); err != nil { // ② 生效
return fmt.Errorf("failed to start discovery service: %v", err)
}
cmd.WaitSignal(stop) // ③ 等退出
discoveryServer.WaitUntilCompletion()flowchart TD A["main()<br/>rootCmd.Execute()"] --> B["RunE<br/>PreRunE 校验"] B --> C["NewServer()<br/>装配阶段"] C --> D["Start()<br/>生效阶段"] D --> E["WaitSignal()<br/>等退出"] classDef hot fill:#fff3bf,stroke:#f08c00,color:#663c00 class C,D hot
C、D 两个黄框就是那条分界线:黄色的部分只注册不开监听,出了黄框才 net.Listen。
① 装配,② 生效——这条分界线是整篇的骨架,也是理解那堆"顺序敏感"代码的钥匙。装配阶段只注册、只建对象;生效阶段才真的 net.Listen。
为什么必须这么分?因为装配阶段会往两个地方塞东西:
| 塞什么 | 谁塞 | 什么时候被读 |
|---|---|---|
启动任务 startFunc | addStartFunc() 写进 pilot/pkg/server/instance.go 的 components channel | Start() 里 s.server.Start(stop) 统一 drain |
监听参数 grpcAddress / secureGrpcAddress / httpsServer | initServers() / initSecureDiscoveryService() / initSecureWebhookServer() | Start() 的 C 段逐个 net.Listen |
装配阶段犯的错,生效阶段才可能暴露;而生效阶段暴露的错,往往已经是"能跑"的状态了。 这就是后面所有反例的共同来源。
二、装配:二十来次 initXxx,只有六步是真骨架
把 bootstrap.NewServer() 从头抄一遍没有意义。按"依赖谁"归一下,它只有六步,每步都在为下一步铺路:
flowchart TD S1["① 骨架对象<br/>Environment"] --> S2["② 进程内 server<br/>initServers"] S2 --> S3["③ 读配置<br/>mesh / kubeClient"] S3 --> S4["④ 造身份<br/>maybeCreateCA"] S4 --> S5["⑤ 生成器与证书<br/>InitGenerators"] S5 --> S6["⑥ 事件与认证<br/>startCA"] classDef hot fill:#fff3bf,stroke:#f08c00,color:#663c00 class S4,S6 hot
六个框对应的函数,和它们各自的依赖,逐条写在下面 2.1~2.6 里。黄框那两步是整篇的重点:CA 和认证器一旦挪错位置,症状全在客户端侧。
2.1 第 ① 步:先有"环境",才有"服务器"
顺序不能反:NewDiscoveryServer() 要吃 *model.Environment。
func NewServer(args *PilotArgs, initFuncs ...func(*Server)) (*Server, error) {
e := model.NewEnvironment()
e.DomainSuffix = args.RegistryOptions.KubeOptions.DomainSuffix
ac := aggregate.NewController(aggregate.Options{
MeshHolder: e,
ConfigClusterID: getClusterID(args),
})
e.ServiceDiscovery = ac
// ...
s := &Server{ environment: e, /* ... */ }
s.XDSServer = xds.NewDiscoveryServer(e, args.RegistryOptions.KubeOptions.ClusterAliases, args.KrtDebugger)
configGen := core.NewConfigGenerator(s.XDSServer.Cache)注意 core.NewConfigGenerator(s.XDSServer.Cache) 这一行:配置生成器拿到的是 xDS 服务器的缓存。"怎么生成 CDS/LDS"从第一行起就和"生成结果缓存在哪"绑定了。第 13 篇讲 XdsCache 时会回到这个绑定。
model.Environment 是整个进程的共享状态容器:ServiceDiscovery、ConfigStore、Watcher(mesh 配置)、PushContext、Cache、EndpointIndex 都挂在它上面。后面每个 initXxx 本质上都在往这个容器里塞一个字段。
2.2 第 ② 步:注意,8080 在装配阶段就开了
这是最容易看漏的一处。NewServer() 前三分之一就调用了 s.serveHTTP():
s.initReadinessProbes()
s.initServers(args) // 造 http / grpc 的 Server 对象
if err := s.initIstiodAdminServer(args, s.webhookInfo.GetTemplates); err != nil {
return nil, fmt.Errorf("error initializing debug server: %v", err)
}
if err := s.serveHTTP(); err != nil { // 真开监听::8080
return nil, fmt.Errorf("error serving http: %v", err)
}serveHTTP() 里是 net.Listen("tcp", s.httpServer.Addr) 加一个 goroutine 跑 Serve。所以在 kube client 还没建、配置文件还没读、CA 还不知道在哪的时候,8080 已经在应答 HTTP 请求了。
为什么允许这种"提前开"?因为 kubelet 的 readiness 探针从容器起来就一直在打 /ready,早点开端口能让"未就绪"变得可查询,而不是 TCP 直接连不上。代价是 /ready 必须真的能返回"还没好":
func (s *Server) istiodReadyHandler(w http.ResponseWriter, _ *http.Request) {
for name, fn := range s.readinessProbes {
if ready := fn(); !ready {
log.Warnf("%s is not ready", name)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
}
w.WriteHeader(http.StatusOK)
}三个探针在 initReadinessProbes() 里注册,全是"某个状态位是不是被点亮了":
| 探针名 | 判断依据 | 谁点亮它 |
|---|---|---|
discovery | XDSServer.IsServerReady() | Start() 里的 CachesSynced() |
sidecar injector | readinessFlags.sidecarInjectorReady | 15017 开始 ServeTLS 之后的几行 |
config validation | readinessFlags.configValidationReady | 同上 |
反例:initReadinessProbes() 被挪到 initServers() 之后。这不会崩,但存在一个极短的时间窗——8080 已经在 serve,readinessProbes 还是空 map,istiodReadyHandler 遍历空 map 直接返回 200。kubelet 认为 istiod 就绪,把 Service 的 Endpoint 挂上去,Envoy 开始连,而这时 xDS 缓存还是空的。
这类 bug 的特征是只在高频滚动重启时偶发,看着像"新 Pod 起来后一小部分 Envoy 配置丢失"。istio/istio#25495 就是这一类,Stream() 开头那段 IsServerReady() 检查是它后来的补丁(第三节展开)。
2.3 第 ③ 步:配置读取,"必须早、且必须同步"
if err := s.initKubeClient(args); err != nil { /* ... */ }
s.initMeshConfiguration(args, s.fileWatcher) // mesh 配置
if s.kubeClient != nil { // 依赖 mesh 配置,所以在这里
namespaces := kclient.New[*corev1.Namespace](s.kubeClient)
filter := namespace.NewDiscoveryNamespacesFilter(namespaces, s.environment.Watcher, s.internalStop)
s.kubeClient = kubelib.SetObjectFilter(s.kubeClient, filter)
}
s.initMeshNetworks(args, s.fileWatcher) // meshNetworks
s.initMeshHandlers(configGen.MeshConfigChanged)
s.environment.Init()这段塞了两个顺序依赖,都很值得记。
第一个:kube client 必须在 mesh 配置之后再加过滤器。 注释写得很直白——Because this relies on meshconfig, it needs to be outside initKubeClient。discoverySelectors 是 mesh 配置里的一项,它决定 istiod 能不能看某些命名空间;过滤器本身要读 mesh 配置,所以只能等 mesh 读完才能包装 client。
第二个:initMeshConfiguration 里的 WaitUntilSynced 是同步阻塞的,而且用的是 internalStop 不是 stop。
func (s *Server) initMeshConfiguration(args *PilotArgs, fileWatcher filewatcher.FileWatcher) {
col := s.getMeshConfiguration(args, fileWatcher)
col.AsCollection().WaitUntilSynced(s.internalStop)
s.environment.Watcher = meshwatcher.ConfigAdapter(col)
}注释解释了为什么:We need to get mesh configuration up-front, before we start anything。mesh 配置渗透到几乎每个生成逻辑里(协议识别、TrustDomain、DNS 域名、Sidecar 默认值),没有它,后面任何一步都可能是错的。
反例:把它改成不阻塞。NewServer() 会继续往下跑到 maybeCreateCA(),而 caOpts.TrustDomain 是从 s.environment.Mesh().TrustDomain 取的——这时 mesh 还可能是零值,CA 用空 trust domain 建起来,签出来的证书 CN 少一段。进程不报错,插件不报错,只有 Envoy 拒绝证书时你才会回头找。
2.4 第 ④ 步:先造 CA,再造"依赖 CA 的东西"
caOpts := &caOptions{
TrustDomain: s.environment.Mesh().TrustDomain,
Namespace: args.Namespace,
ExternalCAType: ra.CaExternalType(externalCaType),
// ...
}
if err := s.maybeCreateCA(caOpts); err != nil { // 建 CA 对象(不注册 gRPC)
return nil, err
}
if err := s.initControllers(args); err != nil { // 控制器
return nil, err
}maybeCreateCA() 只创建 CA,不注册、不监听。真正注册在装配阶段末尾的 startCA():
func (s *Server) startCA(caOpts *caOptions) {
if s.CA == nil && s.RA == nil { return }
if s.RA != nil {
s.initCAServer(s.RA, caOpts)
} else if s.CA != nil {
s.initCAServer(s.CA, caOpts)
}
s.addStartFunc("ca", func(stop <-chan struct{}) error {
grpcServer := s.secureGrpcServer
if s.secureGrpcServer == nil {
grpcServer = s.grpcServer
}
s.RunCA(grpcServer) // 只是 caServer.Register(grpcServer)
return nil
})
}而 RunCA() 的全部工作量,就是往一个已经存在的 gRPC server 上注册服务:
func (s *Server) RunCA(grpc *grpc.Server) {
// ... 处理 JWT issuer / audience,必要时补一个 out-of-cluster 认证器
s.caServer.Register(grpc)
log.Info("Istiod CA has started")
}于是第一个顺序约束浮出来了:CA 注册必须发生在一个已经建好的 gRPC server 上。所以 initSecureDiscoveryService() 必须排在 startCA() 前面——gRPC server 在它里面 grpc.NewServer() 出来。
2.5 第 ⑤ 步:生成器与 istiod 自己用的证书
InitGenerators(s.XDSServer, configGen, args.Namespace, s.clusterID, s.internalDebugMux)
istiodHost, _, err := e.GetDiscoveryAddress()
if err := s.initIstiodCerts(args, string(istiodHost)); err != nil { return nil, err }
// Secure gRPC Server must be initialized after CA is created as may use a Citadel generated cert.
if err := s.initSecureDiscoveryService(args, s.environment.Mesh().GetTrustDomain()); err != nil {
return nil, fmt.Errorf("error initializing secure gRPC Listener: %v", err)
}InitGenerators() 是给每个 typeURL 塞一个生成器对象。调用处注释只字未提顺序,但它有个隐蔽前提:
if env.CredentialsController != nil {
generators[v3.SecretType] = xds.NewSecretGen(env.CredentialsController, s.Cache, clusterID, env.Mesh())
ecdsGen.SetCredController(env.CredentialsController)
}env.CredentialsController 是 initControllers → initSDSServer() 里设的。如果 InitGenerators() 被挪到 initControllers() 前面,SDS 生成器根本不会注册——不是报错,是 generators[v3.SecretType] 这个 key 直接不存在。
后果长什么样:Envoy 连上来,订阅 Secret 类型,istiod 找不到 generator,这个 typeURL 永远拿不到资源。表现是 sidecar 起不来,但 istiod 一切正常,因为 xDS 链路本身没有任何异常。查这个问题要从 Envoy 的 config_dump 里翻 sds_config,绕很大一圈。
initIstiodCerts() 则解释了为什么 initSecureDiscoveryService() 必须排在它后面——TLS 配置里那个 GetCertificate 回调读的是 s.istiodCert:
cfg := &tls.Config{
GetCertificate: s.getIstiodCertificate, // 返回 s.istiodCert
ClientAuth: tls.VerifyClientCertIfGiven,
// ...
}s.istiodCert 由 initIstiodCertLoader() → loadIstiodCert() 填。顺序反了,回调永远返回 cert not initialized,所有走 15012 的 TLS 握手全失败——但进程健康、8080 正常、日志里只有 Could not verify certificate 这种看着像"对端证书有问题"的误导信息。
2.6 第 ⑥ 步:事件回调、认证器,最后才是 CA
s.initRegistryEventHandlers() // 配置/服务变更 → ConfigUpdate
s.initDiscoveryService() // 把 XDSServer.Start 塞进 startFunc 队列
authenticators := []security.Authenticator{&authenticate.ClientCertAuthenticator{}}
if args.JwtRule != "" { /* 追加 OIDC 认证器 */ }
if s.kubeClient != nil {
authenticators = append(authenticators, kubeauth.NewKubeJWTAuthenticator(...))
}
if features.XDSAuth {
s.XDSServer.Authenticators = authenticators
}
caOpts.Authenticators = authenticators
s.startCA(caOpts) // 注册 CA 到 secureGrpcServer
if s.kubeClient != nil {
s.addStartFunc("kube client", func(stop <-chan struct{}) error {
s.kubeClient.RunAndWait(stop)
return nil
})
}两个细节值得单独说。
第一,认证器列表是共享的。 同一份 authenticators 既给了 XDSServer.Authenticators(校验连上来的 Envoy),也给了 caOpts.Authenticators(校验来申请证书的 workload)。注释还特别说明顺序有意义:
Notice that the order of authenticators matters, since at runtime authenticators are activated sequentially and the first successful attempt is used as the authentication result.
第二,"kube client 必须最后启动"是有注释写明的:
// This must be last, otherwise we will not know which informers to registerkubeClient.RunAndWait() 会启动所有已注册的 informer。它跑在前面,后面 initControllers 里新建的 informer 就赶不上这一趟——而 Start() 里的 waitForCacheSync() 等的是"所有 informer 都 synced",一个没启动的 informer 永远不会 synced。
这个 bug 的表现是启动卡死:Waiting for caches to be synced 打出来之后一直没下文,直到探针超时被 kubelet 杀掉重启。区别在于它至少明显,比前面几个静默的友好。
三、生效:Start() 只有四段,每段的顺序都是被逼出来的
装配结束,Start(stop) 接手。四段,一条注释夹在中间:
func (s *Server) Start(stop <-chan struct{}) error {
log.Infof("Starting Istiod Server with primary cluster %s", s.clusterID)
// A. 跑完装配阶段攒下来的所有 startFunc
if err := s.server.Start(stop); err != nil { return err }
// B. 等 informer 和 push context 都到位
if !s.waitForCacheSync(stop) { return fmt.Errorf("failed to sync cache") }
s.XDSServer.CachesSynced()
// C. 按顺序开监听:15012 → 15010 → 15017
// Race condition - if waitForCache is too fast and we run this as a startup function,
// the grpc server would be started before CA is registered. Listening should be last.
if s.secureGrpcAddress != "" { /* net.Listen + Serve */ }
if s.grpcAddress != "" { /* net.Listen + Serve */ }
if s.httpsServer != nil { /* net.Listen + ServeTLS,点亮两个 webhook 就绪位 */ }
// D. 挂上优雅退出
s.waitForShutdown(stop)
return nil
}sequenceDiagram participant S as Start() participant REG as server.instance participant K as kube informer participant CA as caServer participant L as 15012 / 15010 / 15017 S->>REG: server.Start(stop) 同步 drain 队列 REG->>REG: xds server → go 起 debounce / pushQueue REG->>REG: secure gRPC → 只注册停服回调 REG->>CA: ca → RunCA(secureGrpcServer) REG->>K: kube client → RunAndWait Note over S,K: A 段结束,全部 startFunc 已执行 S->>S: waitForCacheSync(含 push context 追平) S->>S: XDSServer.CachesSynced() 点亮 discovery 就绪位 S->>L: net.Listen 15012 S->>L: net.Listen 15010 S->>L: net.Listen 15017 + ServeTLS Note over S,L: C 段开始,端口才对外
3.1 A 段:为什么 components 是 channel 不是 slice
addStartFunc() 只是往 channel 里写,谁读?pilot/pkg/server/instance.go 的 Start():
func (i *instance) Start(stop <-chan struct{}) error {
// First, drain all startup tasks and immediately return if any fail.
for startupDone := false; !startupDone; {
select {
case next := <-i.components:
t0 := time.Now()
if err := next.task(stop); err != nil {
shutdown()
return err
}
if runtime := time.Since(t0); runtime > time.Second {
log.Warnf("slow startup task")
}
default:
startupDone = true
}
}
// 之后新加的任务走异步 goroutine,出错只记日志
}用 channel 而不是 slice,就为这一句 default: startupDone = true——能拿到就拿,拿不到说明排完了。它带来两个后果:
- 顺序严格等于
addStartFunc()的调用顺序。装配阶段谁先写,A 段谁先跑。没有依赖图,没有拓扑排序。 - 每个 task 必须自己起 goroutine。注释写得很清楚:
These are run synchronously in order, so the function should start a go routine if it needs to do anything blocking。
这就是为什么 initDiscoveryService() 的 startFunc 长这样:
s.addStartFunc("xds server", func(stop <-chan struct{}) error {
log.Infof("Starting ADS server")
s.XDSServer.Start(stop) // Start 内部全是 go,自己立刻返回
return nil
})而 XDSServer.Start() 里那五行 go,就是 xDS 的全部后台循环:
func (s *DiscoveryServer) Start(stopCh <-chan struct{}) {
go s.WorkloadEntryController.Run(stopCh) // WorkloadEntry 自动注册与健康
go s.handleUpdates(stopCh) // debounce → Push
go s.periodicRefreshMetrics(stopCh) // 周期上报 push 指标
go s.sendPushes(stopCh) // PushQueue → 并发推送
go s.Cache.Run(stopCh) // XdsCache 后台清理
}反例:把 s.XDSServer.Start(stop) 写成阻塞版(去掉内部的 go 和不返回)。A 段的 drain 循环会卡在这个 task 上,waitForCacheSync() 压根不执行,进程停在 Starting Istiod Server 那一行。那行 slow startup task 的 Warnf 就是为抓这类问题准备的——看到它就先去查任务里有没有漏 go。
3.2 B 段:两个等待,第二个才是关键
waitForCacheSync() 等两件事:
func (s *Server) waitForCacheSync(stop <-chan struct{}) bool {
log.Info("Waiting for caches to be synced")
if !kubelib.WaitForCacheSync("server", stop, s.cachesSynced) {
log.Errorf("Failed waiting for cache sync")
return false
}
log.Infof("All controller caches have been synced up in %v", time.Since(start))
expected := s.XDSServer.InboundUpdates.Load()
return kubelib.WaitForCacheSync("push context", stop, func() bool {
return s.pushContextReady(expected)
})
}第一个等待是常规 informer 同步。第二个容易被漏掉,逻辑是这样:
flowchart TD A["① informer 收到<br/>初始全量配置"] --> B["② ConfigUpdate()<br/>InboundUpdates++"] B --> C["③ pushChannel"] C --> D["④ debounce → Push()"] D --> E["⑤ InitContext<br/>CommittedUpdates++"] E --> F["⑥ pushContextReady()<br/>committed 追平 expected"] classDef hot fill:#fff3bf,stroke:#f08c00,color:#663c00 class F hot
①~⑥ 是这条链上的位置,不是必须串行执行的顺序:② 每来一次配置变更就加一次,④ 是攒窗之后才跑的一次。B 段要等的是"② 的计数"和"⑤ 的计数"追平,中间隔着 ③④ 这两步。
expected 取的是读这一刻的 InboundUpdates——即在等之前,已经有多少次配置变更触发了 ConfigUpdate()。然后等 CommittedUpdates 追平。
为什么 informer synced 还不够?因为"informer 收到第一批配置"和"PushContext 把配置编译进去"之间,还隔着一次 debounce(默认 100ms)和一次 Push()。这段空窗期里缓存标记是 synced 的,但 PushContext 里的 Service/VirtualService 索引可能还是空的。
这时若允许 xDS 接入,Stream() 里那句 s.globalPushContext().InitContext(s.Env, nil, nil) 就会用一个空快照完成初始化,随后所有新连上来的 Envoy 拿到"什么都没有"的配置。重连的老 Envoy 更惨——原来有配置,一重连全变空。
这就是 Stream() 开头那段注释在说的事:
// Currently ready means caches have been synced and hence can build
// clusters correctly. Without this check, InitContext() call below would
// initialize with empty config, leading to reconnected Envoys losing
// configuration.
// See https://github.com/istio/istio/issues/25495.
if !s.IsServerReady() {
return status.Error(codes.Unavailable, "server is not ready to serve discovery information")
}反例:只等 informer synced、不等 push context。做法很简单——把 pushContextReady 那段删掉。结果不会立刻炸:CachesSynced() 照样点亮 discovery 就绪位,Service 挂上去,Envoy 连进来,配置是空的,等下一轮配置变更才修好。现象就是"istiod 重启后有几秒全网 503",几秒后自愈,日志干干净净。
3.3 C 段:那条注释值得单独抄一遍
// Race condition - if waitForCache is too fast and we run this as a startup function,
// the grpc server would be started before CA is registered. Listening should be last.拆开看,它是三个事实的合成:
ca的 startFunc 只做caServer.Register(secureGrpcServer)——往 server 上挂服务,不监听。- 监听动作放在
Start()的 C 段,在server.Start()之后。 - 如果监听也写成 startFunc(和
ca同一个队列),两个任务的执行顺序就取决于谁先addStartFunc——只要有人往中间插一个新任务,顺序就可能反。
顺序反了会怎样?gRPC 允许先 Serve() 后 Register():已经在跑的 server 收到一个尚未注册的方法调用,返回 Unimplemented。现象是:
- istiod 启动成功,15012 在监听,TCP 能连。
- workload 过来要证书,收到
Unimplemented。 - pilot-agent 侧报
failed to fetch certs: rpc error: code = Unimplemented。 - 但进程不会退出,因为 CA 服务确实"存在",只是晚了几毫秒。
"Listening should be last" 翻译成规则就是:凡是"监听"这种对外暴露的动作,一律放在所有注册动作之后,不要塞回任务队列。 这条规则和框架无关,自己写服务端时同样成立。
顺带看另外两个监听,模式完全一致:
if s.httpsServer != nil {
httpsListener, err := net.Listen("tcp", s.httpsServer.Addr)
if err != nil { return err }
// Mark webhooks ready only after the dedicated server has started accepting connections.
s.readinessFlags.sidecarInjectorReady.Store(true)
s.readinessFlags.configValidationReady.Store(true)
go func() {
log.Infof("starting webhook service at %s", httpsListener.Addr())
if err := s.httpsServer.ServeTLS(httpsListener, "", ""); network.IsUnexpectedListenerError(err) {
log.Errorf("error serving https server: %v", err)
}
}()
s.httpsAddr = httpsListener.Addr().String()
}net.Listen 成功之后、go ServeTLS 之前,先把两个就绪位点亮。这个顺序也是刻意的:端口已经在处于 listen 状态(连接进 backlog),但 ServeTLS 可能还没开始 accept。先点亮有极短的时间窗,反过来代价更大——先 Serve 再点亮,中间进来的注入请求会因为 sidecarInjectorReady=false 被 503,而这时 Pod 创建是卡住的(failPolicy 决定,见第 22 篇)。
3.4 D 段:优雅退出的 10 秒
s.shutdownDuration = args.ShutdownDuration // 默认 10swaitForShutdown() 起一个 goroutine,收到信号后依次做:关 internalStop → 关 fileWatcher → GracefulStop() 两个 gRPC server(带 panic 兜底,因为有些实现不支持)→ 超时就 Stop() 强杀 → 关两个 HTTP server → XDSServer.Shutdown()。
值得一提:GracefulStop() 会等所有活跃的 xDS stream 结束,而 xDS stream 是长连接,理论上不会自己结束。所以真实退出路径几乎总是"等满 10 秒再 Stop()"。这也解释了 terminationGracePeriodSeconds 为什么通常要配得比 10 秒大一点。
四、七条顺序约束,收成一张表
散着说的约束归拢一下。这张表是这篇最该带走的东西——改 NewServer() 时照着对一遍,能挡掉大部分"能跑但不对"的坑:
| 顺序约束 | 违反后的现象 | 有错误日志吗 |
|---|---|---|
initReadinessProbes() 早于 serveHTTP() | 启动瞬间 /ready 返回 200 | 无 |
initMeshConfiguration() 早于 maybeCreateCA() | CA 用空 trust domain 签发 | 无 |
initControllers() 早于 InitGenerators() | SDS 生成器不注册,sidecar 拿不到证书 | 只有 Envoy 侧报错 |
initIstiodCerts() 早于 initSecureDiscoveryService() | GetCertificate 返回 cert not initialized | 误导性的 TLS 报错 |
startCA() 早于 C 段监听 | 证书请求收到 Unimplemented | 只有客户端侧报错 |
| kube client 的 startFunc 最后注册 | Waiting for caches to be synced 卡死 | 无(只有这一行) |
waitForCacheSync() 里少等 push context | 重启后几秒全网空配置 | 无 |
七条里五条完全静默(另外两条的报错也在客户端侧,istiod 日志里看不出来)。这不是 Istio 质量差,而是初始化顺序错误的通用特征:代码在语法上全都合法,只是依赖还没准备好;而 Go 的零值让"没准备好"看起来像一个合理的默认值。
五、动手验证:从日志反推启动到哪一段
不改代码,一个 demo profile 的集群就够。
第一步,把启动日志按阶段切开。
kubectl -n istio-system logs deploy/istiod --tail=-1 | grep -nE \
"Starting Istiod Server|initializing (mesh|controllers|secure discovery service|Istiod DNS)|creating CA|Waiting for caches|All controller caches|All caches have been synced|starting (secure Grpc|secure gRPC|gRPC discovery|HTTP service|webhook service)"预期按 A→D 的顺序出现这些行:
starting HTTP service at [::]:8080 ← 装配 ②,最早
initializing mesh configuration ./etc/istio/config/mesh ← 装配 ③
creating CA and initializing public key ← 装配 ④
initializing controllers ← 装配 ④
initializing Istiod DNS certificates host: ... ← 装配 ⑤
initializing secure discovery service ← 装配 ⑤
initializing registry event handlers ← 装配 ⑥
Starting Istiod Server with primary cluster Kubernetes ← 生效 A 入口
Waiting for caches to be synced ← 生效 B
All controller caches have been synced up in ... ← 生效 B
All caches have been synced up in ..., marking server ready ← CachesSynced()
starting secure gRPC discovery service at [::]:15012 ← 生效 C
starting gRPC discovery service at [::]:15010 ← 生效 C
starting webhook service at [::]:15017 ← 生效 C重点看 starting HTTP service 的位置:它排在 initializing mesh configuration 之前。这条就是第二节那句"8080 在装配阶段就开了"的实物证据——用默认参数抓一次完整日志,这行会比你预想的早得多。
第二步,确认就绪位和端口是两件事。
# istiod 自带的本地点查入口,固定打 127.0.0.1:15014
kubectl -n istio-system exec deploy/istiod -- pilot-discovery request GET /version
kubectl -n istio-system exec deploy/istiod -- pilot-discovery request GET /ready/ready 返回 200 表示三个探针全绿;任一探针为假时返回 503,并在日志里打出 <探针名> is not ready。这就是"端口通"和"能接活"之间的那个差别。
第三步,验 waitForCacheSync 的第二段真的在起作用。
kubectl -n istio-system rollout restart deploy/istiod
for i in $(seq 1 40); do
if kubectl -n istio-system exec deploy/istiod -- pilot-discovery request GET /ready >/dev/null 2>&1; then
echo "ready at attempt $i"; break
fi
sleep 0.5
done在 /ready 变 200 之前,日志里应该只有 Waiting for caches to be synced;之后才出现 marking server ready。如果 ready 和 marking server ready 之间隔了很久,说明 B 段被绕过了——这是个可以主动监测的信号。
第四步,看 push 计数器,验证 Push() 在启动期间就跑过。
kubectl -n istio-system port-forward deploy/istiod 15014:15014 >/dev/null 2>&1 &
sleep 1
curl -s localhost:15014/debug/push_status | head -c 600push_status 里是最近一次 PushContext 的 ProxyStatus,含 pilot、proxy_total 以及 cds/lds/rds/eds 各推了多少。刚启动就能看到非零计数,说明 Push() 在 waitForCacheSync 期间已经跑过了——这正是 B 段后半存在的理由。
第五步(可选),验证 startFunc 的顺序真的等于注册顺序。
# 打开 debug 日志后重启,slow startup task 会暴露卡住的那个任务
kubectl -n istio-system set env deploy/istiod PILOT_LOG_LEVEL=debug
kubectl -n istio-system rollout status deploy/istiod
kubectl -n istio-system logs deploy/istiod | grep -i "slow startup task\|started task"started task 按出现顺序就是 A 段真实的执行顺序,可以直接和 NewServer() 里 addStartFunc 的调用顺序对账。
六、收口
这篇只回答了一个问题:pilot-discovery 从 main() 到第一个 gRPC 端口,中间做了什么。答案是三步——cobra 交出控制权、NewServer() 装配、Start() 生效——外加贯穿其中的一条分界线:装配只注册,生效才监听。
带走三件事:
main()只有 8 行,真正的顺序在NewServer()那二十来次initXxx里。 它是手写顺序,不是依赖注入框架排的,所以改代码的人得自己保证顺序。addStartFunc()是把任务塞进 channel,执行顺序严格等于调用顺序,每个任务必须自己go,否则会卡住整个 drain 循环。- "Listening should be last" 指的是监听不能和注册挤在同一个队列里。 这条规则和 Istio 无关,自己写服务端同样成立。
七条顺序约束里五条在 istiod 侧完全没有错误日志,这是最难的部分。它们的共同形状是:Go 的零值让"依赖还没准备好"看起来像一个合理的默认值。
下一篇往下走一层:装配阶段塞进 Environment 的那些控制器,到底是怎么组织的。pkg/kube/krt 是 Istio 自己造的声明式控制器运行时——Collection、NewSingleton()、NewCollection()、Fetch() 这几个原语,替代了 client-go 那套 informer + workqueue。读懂它,后面所有控制器的代码(包括这篇里 initServiceControllers() 建的那一堆)都会突然变得顺眼。