kube-apiserver源码剖析与开发(五):KubeAPIServer创建(二)

71 阅读10分钟

KubeAPIServer的创建主要有三个步骤:

  • 创建 genericServer
  • 安装 legacyAPI 路由
  • 安装 api 路由

上一篇我们讲了 KubeAPIServer 中 genericServer 的创建,创建完 genericServer 后,那么请求的 handler 就已经创建好了,也就是请求到来后能被相应的 ServerHTTP 方法处理,但是 ServerHTTP 只是一个入口,请求最后还是得访问 ETCD 或者转发到下游服务器中去。那么,一个请求进来后是在哪个 Server 被处理,确定 Server 后,又是怎么组装请求访问 ETCD 的?这一篇文章就会讨论这些问题

在 k8s 中,API 资源是按照 GVK 的层级结构组织再一起的

  • G: Group,如 job 属于 batch 组,deployment 属于 apps 组,但是像 Pod、Configmap 这样的 API 的 Group 是空的,即 "",这样的 API 在 k8s 中叫 legacyAPI。legacy 有遗存的意思,最早的时候 k8s api里面没有组的概念,是后面才出现的,为了兼容已经存在的 API,已存在的 API 的 Group 认为是 ""。
  • V: Version,在一个集群内,一个 API 资源可以有多个版本,然后有一个内部版本可以兼容多个版本,用户可以选择支持的版本进行 api 创建。你可以先确定你想要的资源属于哪个组,然后执行下面命令就可以看到你想要创建的资源支持哪些版本
kubectl get apiservice

pC2T45F.png

  • K: Kind,就是你要创建的资源的类型,如 Configmap、Deployment。

在注册路由的过程中,根据 group 是否为 "",将路由注册分为 legacyAPI 路由注册和 API 路由注册。下面我先看看 legacyAPI 路由注册。

func (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget) (*Instance, error){
    ...
  if err := m.InstallLegacyAPI(&c, c.GenericConfig.RESTOptionsGetter); err != nil {
  return nil, err
}
    ...
}
func (m *Instance) InstallLegacyAPI(c *completedConfig, restOptionsGetter generic.RESTOptionsGetter) error {
    ...
  legacyRESTStorageProvider := corerest.LegacyRESTStorageProvider{
    StorageFactory:              c.ExtraConfig.StorageFactory,
    ProxyTransport:              c.ExtraConfig.ProxyTransport,
    KubeletClientConfig:         c.ExtraConfig.KubeletClientConfig,
    EventTTL:                    c.ExtraConfig.EventTTL,
    ServiceIPRange:              c.ExtraConfig.ServiceIPRange,
    SecondaryServiceIPRange:     c.ExtraConfig.SecondaryServiceIPRange,
    ServiceNodePortRange:        c.ExtraConfig.ServiceNodePortRange,
    LoopbackClientConfig:        c.GenericConfig.LoopbackClientConfig,
    ServiceAccountIssuer:        c.ExtraConfig.ServiceAccountIssuer,
    ExtendExpiration:            c.ExtraConfig.ExtendExpiration,
    ServiceAccountMaxExpiration: c.ExtraConfig.ServiceAccountMaxExpiration,
    APIAudiences:                c.GenericConfig.Authentication.APIAudiences,
  }

  legacyRESTStorage, apiGroupInfo, err := legacyRESTStorageProvider.NewLegacyRESTStorage(c.ExtraConfig.APIResourceConfigSource, restOptionsGetter)

    ...
}

重点看下 NewLegacyRESTStorage 方法,这个方法最重要的地方就是给各个 legacyAPI 资源关联(创建)了跟后端存储交互的 REST 对象,由于属于 legacyAPI 类型的 API 资源较多,我们就挑 Configmap 的说明(其他的略有差异,但是原理一样):

func (c LegacyRESTStorageProvider) NewLegacyRESTStorage(...) {
    ...
  if err != nil {
      return LegacyRESTStorage{}, genericapiserver.APIGroupInfo{}, err
  }
    ...
}
func NewREST(optsGetter generic.RESTOptionsGetter) (*REST, error) {
  store := &genericregistry.Store{
    NewFunc:                  func() runtime.Object { return &api.ConfigMap{} },
    NewListFunc:              func() runtime.Object { return &api.ConfigMapList{} },
    PredicateFunc:            configmap.Matcher,
    DefaultQualifiedResource: api.Resource("configmaps"),

    CreateStrategy: configmap.Strategy,
    UpdateStrategy: configmap.Strategy,
    DeleteStrategy: configmap.Strategy,

    TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)},
  }
  options := &generic.StoreOptions{
    RESTOptions: optsGetter,
    AttrFunc:    configmap.GetAttrs,
    TriggerFunc: map[string]storage.IndexerFunc{"metadata.name": configmap.NameTriggerFunc},
  }
  if err := store.CompleteWithOptions(options); err != nil {
    return nil, err
  }
  return &REST{store}, nil
}

在 NewREST 中,首先创建了一个 genericregistry.Store 类型的对象 store,创建出来的 store 对象 会使用入参 optsGetter 做初始化,即调用 CompleteWithOptions,在这个方法内最关键的一步如下:

func (e *Store) CompleteWithOptions(options *generic.StoreOptions) error {
    ...
  e.Storage.Storage, e.DestroyFunc, err = opts.Decorator(
  opts.StorageConfig,
  prefix,
  keyFunc,
  e.NewFunc,
  e.NewListFunc,
  attrFunc,
  options.TriggerFunc,
  options.Indexers,
)
    ...
}

那么 opts.Decorator 又是什么呢?它在下面的代码中被初始化:

// vendor/k8s.io/apiserver/pkg/server/options/etcd.go

func (f *SimpleRestOptionsFactory) GetRESTOptions(resource schema.GroupResource) (generic.RESTOptions, error) {
  ret := generic.RESTOptions{
    Decorator:                 generic.UndecoratedStorage,
        ....
  }
  ...
}
// vendor/k8s.io/apiserver/pkg/registry/generic/storage_decorator.go

func UndecoratedStorage(
  config *storagebackend.ConfigForResource,
  resourcePrefix string,
  keyFunc func(obj runtime.Object) (string, error),
  newFunc func() runtime.Object,
  newListFunc func() runtime.Object,
  getAttrsFunc storage.AttrFunc,
  trigger storage.IndexerFuncs,
  indexers *cache.Indexers) (storage.Interface, factory.DestroyFunc, error) {
  return NewRawStorage(config, newFunc)
}

func NewRawStorage(config *storagebackend.ConfigForResource, newFunc func() runtime.Object) (storage.Interface, factory.DestroyFunc, error) {
  return factory.Create(*config, newFunc)
}

// vendor/k8s.io/apiserver/pkg/storage/storagebackend/factory/factory.go

func Create(c storagebackend.ConfigForResource, newFunc func() runtime.Object) (storage.Interface, DestroyFunc, error) {
  switch c.Type {
  case storagebackend.StorageTypeETCD2:
    return nil, nil, fmt.Errorf("%s is no longer a supported storage backend", c.Type)
  case storagebackend.StorageTypeUnset, storagebackend.StorageTypeETCD3:
    return newETCD3Storage(c, newFunc)
  default:
    return nil, nil, fmt.Errorf("unknown storage type: %s", c.Type)
  }
}

到这里我们就能看到了,上面对 Decorate 的调用,最后返回的是一个 storage.Interface 接口类型的对象,实际类型是下面定义的类型

vendor/k8s.io/apiserver/pkg/storage/etcd3/store.go

type store struct {
  client              *clientv3.Client
  codec               runtime.Codec
  versioner           storage.Versioner
  transformer         value.Transformer
  pathPrefix          string
  groupResource       schema.GroupResource
  groupResourceString string
  watcher             *watcher
  pagingEnabled       bool
  leaseManager        *leaseManager
}

可以看到,实际上这个实际类型就是一个 etcd 的客户端,用来跟 etcd 服务端交互。

所以总结一下就是,一个 etcd 类型的客户端对象被赋值给了 e.Storage.Storage。

等等,这里还有一个关键的点,就是 e.Storage,它又是什么?

我们看一下它的定义

// vendor/k8s.io/apiserver/pkg/registry/generic/registry/store.go

type Store struct {
    ...
    Storage DryRunnableStorage
    ...
}
type DryRunnableStorage struct {
  Storage storage.Interface
  Codec   runtime.Codec
}

其中最重要的就是 storage.Interface 这个接口类型成员,这个接口定义如下:

// vendor/k8s.io/apiserver/pkg/storage/interfaces.go

type Interface interface {
  Versioner() Versioner
  Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error
  Delete(
    ctx context.Context, key string, out runtime.Object, preconditions *Preconditions,
    validateDeletion ValidateObjectFunc, cachedExistingObject runtime.Object) error
  Watch(ctx context.Context, key string, opts ListOptions) (watch.Interface, error)
  Get(ctx context.Context, key string, opts GetOptions, objPtr runtime.Object) error
  GetList(ctx context.Context, key string, opts ListOptions, listObj runtime.Object) error
  GuaranteedUpdate(
    ctx context.Context, key string, destination runtime.Object, ignoreNotFound bool,
    preconditions *Preconditions, tryUpdate UpdateFunc, cachedExistingObject runtime.Object) error
  Count(key string) (int64, error)
}

我们看到里面有 Get、Create 这样这样的方法,这不就是我们操作 k8s 的一些常规动作么,在前面我们说过,这个成员被赋予了一个 etcd 客户端对象,这个etcd 对象实现了这个接口,所以后面在访问 etcd 的时候就可以通过 e.Storage.Storag.Get()这种方式完成。

在 NewREST 这个函数的最后,store 对象被赋给了 REST 中 genericregistry.Store 类型内嵌字段。后续就可以通过这个对象操作 API 资源了。

我们再回到 NewLegacyRESTStorage 函数中,我们还是以configmap为例

func (c LegacyRESTStorageProvider) NewLegacyRESTStorage(...) {
    ...
    storage := map[string]rest.Storage{}
  if resource := "configmaps"; apiResourceConfigSource.ResourceEnabled(corev1.SchemeGroupVersion.WithResource(resource)) {
      storage[resource] = configMapStorage
  }
    ...
    if len(storage) > 0 {
    apiGroupInfo.VersionedResourcesStorageMap["v1"] = storage
  }
  ...
}

将前面创建的 etcd 类型的 storage 存放到 map[string]rest.Storage{} 类型的 map 中,所有 legacyAPI 类型资源对应的 storage 对象都会放入这个 map,最后把这个 map 赋值给 apiGroupInfo.VersionedResourcesStorageMap。可以看到,legacyAPI (group 为 "")使用的版本都是 v1 的,这一点在前面 get apiservice 也能看得到。

有了 apiGroupInfo.VersionedResourcesStorageMap 这个对象后,后面在安装 legacyAPI 路由的时候,只要把 map 中所有的元素取出来进行处理即可。

现在我们总结下 NewLegacyRESTStorage 这个函数做的事:

  • 为每个 legacyAPI 类型资源创建 storage 对象,这对象用于和 etcd 交互;
  • 将所有 legacyAPI 类型资源 storage 对象存放在一个 map 中,用于后续遍历注册路由。

接下来,我们再看看 InstallLegacyAPI 中第二个关键函数:

func (m *Instance) InstallLegacyAPI(...) {
  if err := m.GenericAPIServer.InstallLegacyAPIGroup(genericapiserver.DefaultLegacyAPIPrefix, &apiGroupInfo); err != nil {
      return fmt.Errorf("error in registering group versions: %v", err)
  }
}
func (s *GenericAPIServer) installAPIResources(apiPrefix string, apiGroupInfo *APIGroupInfo, openAPIModels openapiproto.Models) error {
  var resourceInfos []*storageversion.ResourceInfo
  for _, groupVersion := range apiGroupInfo.PrioritizedVersions {
    if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 {
      klog.Warningf("Skipping API %v because it has no resources.", groupVersion)
      continue
    }

    apiGroupVersion, err := s.getAPIGroupVersion(apiGroupInfo, groupVersion, apiPrefix)
    if err != nil {
      return err
    }
        ...

    r, err := apiGroupVersion.InstallREST(s.Handler.GoRestfulContainer)
    if err != nil {
      return fmt.Errorf("unable to setup API %v: %v", apiGroupInfo, err)
    }
    resourceInfos = append(resourceInfos, r...)
  }
    ...
  return nil
}

InstallREST 这个函数的调用比较深: InstallREST -> Install -> registerResourceHandlers

// vendor/k8s.io/apiserver/pkg/endpoints/installer.go

func (a *APIInstaller) Install() ([]metav1.APIResource, []*storageversion.ResourceInfo, *restful.WebService, []error) {
  var apiResources []metav1.APIResource
  var resourceInfos []*storageversion.ResourceInfo
  var errors []error
  ws := a.newWebService()

  // Register the paths in a deterministic (sorted) order to get a deterministic swagger spec.
  paths := make([]string, len(a.group.Storage))
  var i int = 0
  for path := range a.group.Storage {
    paths[i] = path
    i++
  }
  sort.Strings(paths)
  // storage["pod"] ["pod/podstatus"]
  for _, path := range paths {
    apiResource, resourceInfo, err := a.registerResourceHandlers(path, a.group.Storage[path], ws)
    if err != nil {
      errors = append(errors, fmt.Errorf("error in registering resource: %s, %v", path, err))
    }
    if apiResource != nil {
      apiResources = append(apiResources, *apiResource)
    }
    if resourceInfo != nil {
      resourceInfos = append(resourceInfos, resourceInfo)
    }
  }
  return apiResources, resourceInfos, ws, errors
}

在 Install 方法中首先创建了 webservice(ws),什么是 webservice 呢?我们在上一篇文章中说过,如果一个 api 需要在某个 Server 中注册,那么他会被注册成 goRestFul 类型的路由,goRestFul 的模型如下

pCfCsxI.png

他有一个 container,这个container 用来存放 webservice。每一个 webservice 有一个路径前缀,表示某一类共同前缀的 url 都会注册在这个 webservice 下面;每个 webservice 下面可以注册多个子路径的处理方法,如请求的 url 为 /fruit/apple,那么请求就会被最左下角的那个 handler 处理。

对应 legacyAPI 来说,只有一个公共前缀 /api/v1,所以 container 下面只有一个 webservice,这个 webservice 下面可以注册多个子路径的路由,如下图:

pCfiq5F.png

当然,我们没有在图中画出不同 http 请求方法的示意图,但是 webservice 在注册路由的时候需要指定这个请求的 http 方法,注册方式类似下面代码

ws.GET(action.Path).To(handler).
    Doc(doc).
    Param(ws.QueryParameter("pretty", "If 'true', then the output is pretty printed.")).
    Operation("list"+namespaced+kind+strings.Title(subresource)+operationSuffix).
    Produces(append(storageMeta.ProducesMIMETypes(action.Verb), allMediaTypes...)...).
    Returns(http.StatusOK, "OK", versionedList).
    Writes(versionedList)

讲完 goRestFul 模式路由后,我们继续说 Install 方法

接着在 Install 方法中遍历了在 NewLegacyRESTStorage 函数中创建的那个 Storage 对象,它是一个 map(map[string]rest.Storage),key 为资源的名称,如我们前面说的 configmaps,它在这个 map 中可以表示为 storage["configmaps"]Storage实例 又例如 pod,pod 因为它有子对象 status,所以它在 map 中有多个元素 ,如 storage["pod"]Storage实例、storage["pod/status"]Storage实例,所以字符串数组 paths 中就是所有 legacyAPI 资源(如 pod)和子资源名称(如 pod/status),并循环遍历 paths 中的路径,并调动 registerResourceHandlers 方法。registerResourceHandlers 方法非常的长,有 800 多行,是路由构造的核心,它主要做了如下事情

  1. 通过上一步骤构造的 REST Storage 判断该资源可以执行哪些操作(如 create、update等)
func (a *APIInstaller) registerResourceHandlers(...) {
    ...
  creater, isCreater := storage.(rest.Creater)
  namedCreater, isNamedCreater := storage.(rest.NamedCreater)
  lister, isLister := storage.(rest.Lister)
  getter, isGetter := storage.(rest.Getter)
  getterWithOptions, isGetterWithOptions := storage.(rest.GetterWithOptions)
  gracefulDeleter, isGracefulDeleter := storage.(rest.GracefulDeleter)
  collectionDeleter, isCollectionDeleter := storage.(rest.CollectionDeleter)
  updater, isUpdater := storage.(rest.Updater)
  patcher, isPatcher := storage.(rest.Patcher)
  watcher, isWatcher := storage.(rest.Watcher)
  connecter, isConnecter := storage.(rest.Connecter)
  storageMeta, isMetadata := storage.(rest.StorageMetadata)
    ...
}

在这里通过类型断言的方式判断我们传入的 Storage 对象是不是实现了某个接口(如 rest.Getter),如果实现了该接口,说明这个 legacyAPI 资源可以可以被执行这种操作(如 Get、Create等)

  1. 将其对应的操作存入到 action 中,每一个 action 对应一个标准的 REST 操作,如 create 对应的 action 操作为 POST、update 对应的 action 操作为 PUT。
func (a *APIInstaller) registerResourceHandlers(...) {
    ...
    actions = appendIf(actions, action{"LIST", resourcePath, resourceParams, namer, false}, isLister)
  actions = appendIf(actions, action{"POST", resourcePath, resourceParams, namer, false}, isCreater)
  actions = appendIf(actions, action{"DELETECOLLECTION", resourcePath, resourceParams, namer, false}, isCollectionDeleter)
  // DEPRECATED in 1.11
  actions = appendIf(actions, action{"WATCHLIST", "watch/" + resourcePath, resourceParams, namer, false}, allowWatchList)

  // Add actions at the item path: /api/apiVersion/resource/{name}
  actions = appendIf(actions, action{"GET", itemPath, nameParams, namer, false}, isGetter)
  if getSubpath {
    actions = appendIf(actions, action{"GET", itemPath + "/{path:*}", proxyParams, namer, false}, isGetter)
  }
  actions = appendIf(actions, action{"PUT", itemPath, nameParams, namer, false}, isUpdater)
  actions = appendIf(actions, action{"PATCH", itemPath, nameParams, namer, false}, isPatcher)
  actions = appendIf(actions, action{"DELETE", itemPath, nameParams, namer, false}, isGracefulDeleter)
  // DEPRECATED in 1.11
  actions = appendIf(actions, action{"WATCH", "watch/" + itemPath, nameParams, namer, false}, isWatcher)
  actions = appendIf(actions, action{"CONNECT", itemPath, nameParams, namer, false}, isConnecter)
  actions = appendIf(actions, action{"CONNECT", itemPath + "/{path:*}", proxyParams, namer, false}, isConnecter && connectSubpath)
  ...
}

在这里根据上面类型断言的结果,判断是不是要把具体的某个 action 加入到 actions 数组中

  1. 最终根据 actions 数组依次遍历,对每一个操作创建 handler,并将 handler 注册到 webservice 中去,如下简化代码
func (a *APIInstaller) registerResourceHandlers(...) {
    ...
    for _, action := range actions {
        case "GET":
        ...
        case "LIST":
        ...
        case "POST":
        ...
    }
    ...
}

这是很关键的一步,为了说明原理,我们挑 POST(也就是对应 Create) 方法进行说明

case "POST": // Create a resource.
  var handler restful.RouteFunction
  // 5、初始化 handler
  if isNamedCreater {
    handler = restfulCreateNamedResource(namedCreater, reqScope, admit)
  } else {
    handler = restfulCreateResource(creater, reqScope, admit)
  }
  handler = metrics.InstrumentRouteFunc(action.Verb, group, version, resource, subresource, requestScope, metrics.APIServerComponent, deprecated, removedRelease, handler)
  handler = utilwarning.AddWarningsHandler(handler, warnings)
  article := GetArticleForNoun(kind, " ")
  doc := "create" + article + kind
  if isSubresource {
    doc = "create " + subresource + " of" + article + kind
  }
  route := ws.POST(action.Path).To(handler).
    Doc(doc).
    Param(ws.QueryParameter("pretty", "If 'true', then the output is pretty printed.")).
    Operation("create"+namespaced+kind+strings.Title(subresource)+operationSuffix).
    Produces(append(storageMeta.ProducesMIMETypes(action.Verb), mediaTypes...)...).
    Returns(http.StatusOK, "OK", producedObject).
    // TODO: in some cases, the API may return a v1.Status instead of the versioned object
    // but currently go-restful can't handle multiple different objects being returned.
    Returns(http.StatusCreated, "Created", producedObject).
    Returns(http.StatusAccepted, "Accepted", producedObject).
    Reads(defaultVersionedObject).
    Writes(producedObject)
  if err := AddObjectParams(ws, route, versionedCreateOptions, disabledParams...); err != nil {
    return nil, nil, err
  }
  addParams(route, action.Params)
  routes = append(routes, route)

首先判断这个资源是不是 namespace 级别的,如 Node 资源就不是 namespace 级别的,而 deployment 就是,但是创建 handler 原理类似,我们只看非 naemspace 的。restfulCreateResource 函数返回一个函数,这个函数就是最终处理请求的,restfulCreateResource 函数往下调用,最后会到 createHandler,我们挑重点的部分说

func createHandler(r rest.NamedCreater, scope *RequestScope, admit admission.Interface, includeName bool) http.HandlerFunc {
  return func(w http.ResponseWriter, req *http.Request) {
      ...
      admit = admission.WithAudit(admit)
      requestFunc := func() (runtime.Object, error) {
      return r.Create(
        ctx,
        name,
        obj,
        rest.AdmissionToValidateObjectFunc(admit, admissionAttributes, scope),
        options,
      )
    }
    
    result, err := finisher.FinishRequest(ctx, func() (runtime.Object, error) {
      if scope.FieldManager != nil {
        liveObj, err := scope.Creater.New(scope.Kind)
        if err != nil {
          return nil, fmt.Errorf("failed to create new object (Create for %v): %v", scope.Kind, err)
        }
        obj = scope.FieldManager.UpdateNoErrors(liveObj, obj, managerOrUserAgent(options.FieldManager, req.UserAgent()))
        admit = fieldmanager.NewManagedFieldsValidatingAdmissionController(admit)
      }

      // 执行 admit 操作,即执行 kube-apiserver 启动时加载的 admission-plugins,
      if mutatingAdmission, ok := admit.(admission.MutationInterface); ok && mutatingAdmission.Handles(admission.Create) {
        if err := mutatingAdmission.Admit(ctx, admissionAttributes, scope); err != nil {
          return nil, err
        }
      }
      // Dedup owner references again after mutating admission happens
      dedupOwnerReferencesAndAddWarning(obj, req.Context(), true)
      result, err := requestFunc()
      // If the object wasn't committed to storage because it's serialized size was too large,
      // it is safe to remove managedFields (which can be large) and try again.
      if isTooLargeError(err) {
        if accessor, accessorErr := meta.Accessor(obj); accessorErr == nil {
          accessor.SetManagedFields(nil)
          result, err = requestFunc()
        }
      }
      return result, err
    })

  }
}

requestFunc 就是最后处理请求的函数,会调用 Create 接口,操作 etcd。这里有个比较重要的点,在访问 etcd 前,会先执行准入插件,首先会执行 mutatingAdmission 类型的插件,即下面代码

if mutatingAdmission, ok := admit.(admission.MutationInterface); ok && mutatingAdmission.Handles(admission.Create) {
  if err := mutatingAdmission.Admit(ctx, admissionAttributes, scope); err != nil {
    return nil, err
  }
}

然后执行 validationAdmission 类型的插件,即在 Craete 方法中执行。

mutatingAdmission 可以修改用户的请求,如给 deployment 加 annotation 等;validationAdmission 验证用户的请求合法性。

  1. 注册路由到 webservices
// vendor/k8s.io/apiserver/pkg/endpoints/installer.go

func (a *APIInstaller) registerResourceHandlers(...) {
    ...
    for _, route := range routes {
      route.Metadata(ROUTE_META_GVK, metav1.GroupVersionKind{
        Group:   reqScope.Kind.Group,
        Version: reqScope.Kind.Version,
        Kind:    reqScope.Kind.Kind,
      })
      route.Metadata(ROUTE_META_ACTION, strings.ToLower(action.Verb))
      ws.Route(route)
    }
    ...
}

webservice 最终会注册到 container 中

func (g *APIGroupVersion) InstallREST(container *restful.Container) ([]*storageversion.ResourceInfo, error) {
    ...
  apiResources, resourceInfos, ws, registrationErrors := installer.Install()
  versionDiscoveryHandler := discovery.NewAPIVersionHandler(g.Serializer, g.GroupVersion, staticLister{apiResources})
  versionDiscoveryHandler.AddToWebService(ws)
  container.Add(ws)
    ...
}

到这里,我们就分析完了 legacyAPI 注册的流程了。