Helm源码分析-init命令

1,995 阅读9分钟

1. 知识准备

Kubernetes(K8s)。了解K8s架构,熟悉K8s中deployment,service的概念。
Go语言。熟悉Go执行和编译命令,掌握Go语法格式。

2. Helm简介

Helm(helm.sh)是K8s用于包管理的工具。和CentOS的YUM包管理比较类似。它使得应用部署变得更加简洁化,标准化,通过减少应用部署的复杂性提升了应用开发人员的生产率。
应用发布者可以将应用所需的K8s资源编排成对象声明文件后,打包成软件包并上传到软件仓库来对外提供服务。应用使用者从仓库获取到软件包后,可以根据软件包内定义,填写指定的应用参数,在K8s中一键式部署应用。
使用者既可以从网络上下载第三方软件包,也可以根据自己的业务需求自定义软件包。

3. Helm名词解释

1)helm
可执行命令,helm是一个二进制文件,在版本发布时提供,也可以手动编译。
2)tiller
服务端程序,负责和K8s交互,进行release的生命周期管理 。
3)Chart
软件包,包含应用资源编排文件,可以认为是一种类型的模板。通过在chart中对外暴露变量,可以让用户在部署chart时一定范围内对应用进行配置。
4)Repository
软件包仓库,用来存储chart。如需部署仓库中的chart,首先需要在helm中注册该仓库的地址。
5)Release
chart部署后生成的实例。chart是模板,release则是根据这个模板部署出来的应用实体。release可以升级、回滚和软删除。

4. Helm架构图

5. helm init介绍

“helm init”(以下简称“init”)命令用于初始化helm。init命令是helm官网介绍的第一个命令,主要用来部署,因此分析init命令,一方面可以让我们了解helm的代码架构,另一方面也可以简单知晓如何通过K8s的sdk创建资源。
通过执行"helm help init",可以看到:

This command installs Tiller (the Helm server-side component) onto your
Kubernetes Cluster and sets up local configuration in $HELM_HOME (default ~/.helm/).

...
Usage:
  helm init [flags]

从help信息其实也可以简要看出,init主要做了两件事情,一是将tiller安装到K8s集群中,另一件事情是在helm命令所在的机器上配置工作空间,包括新建用于repository注册及chart缓存的文件夹“./helm”。
可传入的flag显示init命令提供的配置参数:

Flags:
      --automount-service-account-token   auto-mount the given service account to tiller (default true)
      --canary-image                      use the canary Tiller image
  -c, --client-only                       if set does not install Tiller
      --dry-run                           do not install local or remote
      --force-upgrade                     force upgrade of Tiller to the current helm version
  -h, --help                              help for init
      --history-max int                   limit the maximum number of revisions saved per release. Use 0 for no limit.
      --local-repo-url string             URL for local repository (default "http://127.0.0.1:8879/charts")
      --net-host                          install Tiller with net=host
      --node-selectors string             labels to specify the node on which Tiller is installed (app=tiller,helm=rocks)
  -o, --output OutputFormat               skip installation and output Tiller's manifest in specified format (json or yaml)
      --override stringArray              override values for the Tiller Deployment manifest (can specify multiple or separate values with commas: key1=val1,key2=val2)
      --replicas int                      amount of tiller instances to run on the cluster (default 1)
      --service-account string            name of service account
      --skip-refresh                      do not refresh (download) the local repository cache
      --stable-repo-url string            URL for stable repository (default "https://kubernetes-charts.storage.googleapis.com")
  -i, --tiller-image string               override Tiller image
      --tiller-tls                        install Tiller with TLS enabled
      --tiller-tls-cert string            path to TLS certificate file to install with Tiller
      --tiller-tls-hostname string        the server name used to verify the hostname on the returned certificates from Tiller
      --tiller-tls-key string             path to TLS key file to install with Tiller
      --tiller-tls-verify                 install Tiller with TLS enabled and to verify remote certificates
      --tls-ca-cert string                path to CA root certificate
      --upgrade                           upgrade if Tiller is already installed
      --wait                              block until Tiller is running and ready to receive requests

“--tiller-image”可以用来替换默认的tiller镜像版本。tiller镜像版本默认和helm保持一致,如果要使用其他版本的tiller镜像,可通过这个参数配置。

6. 源码分析

为保持简洁,下文仅介绍init命令执行步骤的主体部分,忽略错误处理等分支代码。
项目地址:github.com/helm/helm
版本:v2.14.1
项目目录:

CONTRIBUTING.md
LICENSE
Makefile
OWNERS
README.md
SECURITY_CONTACTS
_proto
bin
cmd
code-of-conduct.md
docs
glide.lock
glide.yaml
pkg
rootfs
scripts
testdata
vendor
versioning.mk

查找命名入口。从架构中我们可以知道,Helm是一个C/S模型的项目,helm是作为客户端的二进制文件。那么helm是如何被编译出来的呢?
首先进入到“docs”目录查看一下项目文档,在目录列表中可以看到developers.md。一般情况系,文档中会详细记录如何进行代码开发,编译,提交等步骤。从文档中“Building Helm/Tiller”一段中可以找到编译入口。

## Building Helm/Tiller

We use Make to build our programs. The simplest way to get started is:
$ make bootstrap build

查看项目中Makefile,找到“bootstrap”和“build”这个两个编译目标。

.PHONY: bootstrap
bootstrap:
ifndef HAS_GLIDE
    go get -u github.com/Masterminds/glide
endif
ifndef HAS_GOX
    go get -u github.com/mitchellh/gox
endif

ifndef HAS_GIT
    $(error You must install Git)
endif
    glide install --strip-vendor
    go build -o bin/protoc-gen-go ./vendor/github.com/golang/protobuf/protoc-gen-go

.PHONY: build
build:
    GOBIN=$(BINDIR) $(GO) install $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' k8s.io/helm/cmd/...

bootstrap目标中主要是进行编译前期的环境准备,包括1)安装glide和gox,2)检查git,3)通过glide将项目中的依赖包缓存到本地,4)编译protoc-gen-go。其中Glide是Go语言的一种包管理工具。Gox是Go的交叉编译工具,本文中并不涉及。proto-gen-go是一个二进制文件,用来生成protobuf对应的*.pb.go文件。这里只做简单介绍,和本文内容没有太大关联。
build目标中则可以从结尾中清晰的看到,最终被go编译的目录是"k8s.io/helm/cmd",查看该目录:

helm
rudder
tiller

最终找到了位于“cmd”目录下的“helm”目录。根据Go的编译规则,helm命令应该从此编译得来。在"helm"目录下,可以找到main函数位于helm.go文件下,
helm/cmd/helm/helm.go

func main() {
    cmd := newRootCmd(os.Args[1:])
    if err := cmd.Execute(); err != nil {
        switch e := err.(type) {
        case pluginError:
            os.Exit(e.code)
        default:
            os.Exit(1)
        }
 z   }
}

“newRootCmd”用来初始化命令,“Execute”用来执行命名。
helm/cmd/helm/helm.go

func newRootCmd(args []string) *cobra.Command {
    cmd := &cobra.Command{
        Use:          "helm",
        Short:        "The Helm package manager for Kubernetes.",
        Long:         globalUsage,
        SilenceUsage: true,
      ...
    cmd.AddCommand(
        // chart commands
      ...

        newCompletionCmd(out),
        newHomeCmd(out),
        newInitCmd(out),
        newPluginCmd(out),
        newTemplateCmd(out),
      ...

cobra是Go的一个命令行实现库,可以用来生成命令行文件,这里我们只需要知道如何使用即可。从“AddCommand”函数中,可以发现所有的子命令都在这里被注册,其中包含有“newInitCmd”。
helm/cmd/helm/helm.go

func newInitCmd(out io.Writer) *cobra.Command {
    i := &initCmd{out: out}

    cmd := &cobra.Command{
        Use:   "init",
        Short: "initialize Helm on both client and server",
        Long:  initDesc,
        RunE: func(cmd *cobra.Command, args []string) error {
            if len(args) != 0 {
                return errors.New("This command does not accept arguments")
            }
            i.namespace = settings.TillerNamespace
            i.home = settings.Home
            i.client = ensureHelmClient(i.client)

            return i.run()
        },
    }
...
return cmd

作为helm的子命令,init的初始化方式和helm一致。“RunE”属性是将真正执行的函数设置到cobra命令中,因此重点在于“initCmd”和“i.run”两个命令。值的注意的是,“ensureHelmClient”返回的是一个helm资源操作相关的client,比如release。虽然在init命令中并不需要操作helm中的资源,但是还是可以看下这个函数。先查看下initCmd这个结构体:
helm/cmd/helm/init.go

type initCmd struct {
    image          string
    clientOnly     bool
    canary         bool
    upgrade        bool
    namespace      string
    dryRun         bool
    forceUpgrade   bool
    skipRefresh    bool
    out            io.Writer
    client         helm.Interface
    home           helmpath.Home
    opts           installer.Options
    kubeClient     kubernetes.Interface
    serviceAccount string
    maxHistory     int
    replicas       int
    wait           bool
}

可以看出基本上和上文中init命令可传入的flag参数一致。这些属性中,opts是用来保存安装tiller中所需的配置项,kubeclient用来和k8s交互。
ensureHelmClient函数返回的是newClient。
helm/cmd/helm/init.go

func newClient() helm.Interface {
...
    options := []helm.Option{helm.Host(settings.TillerHost), helm.ConnectTimeout(settings.TillerConnectionTimeout)}

...
return helm.NewClient(options...)
}

helm/cmd/helm/client.go

func NewClient(opts ...Option) *Client {
    var c Client
    // set some sane defaults
    c.Option(ConnectTimeout(5))
    return c.Option(opts...)
}

func (h *Client) Option(opts ...Option) *Client {
    for _, opt := range opts {
        opt(&h.opts)
    }
    return h
}

这段代码中核心变量是Client和options,从Option函数可以看到,参数opts是某些函数的集合,通过执行这些函数,可以操作Client。这里实际上是使用了访问者模式,每一个opt函数都可以设置Client中opts选项中自相关属性。
helm/cmd/helm/client.go

type Client struct {
    opts options
}

helm/cmd/helm/options.go

type options struct {
    // value of helm home override
    host string
    // if set dry-run helm client calls
    dryRun bool
    // if set enable TLS on helm client calls
    useTLS bool
    // if set, re-use an existing name
    reuseName bool
    // if set, performs pod restart during upgrade/rollback
    recreate bool
    // if set, force resource update through delete/recreate if needed
    force bool
    // if set, skip running hooks
...
    releaseName string
    // tls.Config to use for rpc if tls enabled
    tlsConfig *tls.Config
    // release list options are applied directly to the list releases request
    listReq rls.ListReleasesRequest
...
}

options则是最终列举了helm所有子命令执行时的所有的可选项,包括release的各种操作。可以看到Option函数中的Option函数集合是如何设置这些可选项的。以“helm.Host(settings.TillerHost)” 为例:
helm/cmd/helm/options.go

func Host(host string) Option {
    return func(opts *options) {
        opts.host = host
    }
}
type Option func(*options)

因为涉及到了不同package下的多个以Option命名的函数或变量,所以需要注意区分。 至此i.client初始化完成,最终结果实例化了一个可以操作helm资源的客户端对象。
下面通过i.run函数看下init命名执行时真正做了哪些事情。
helm/cmd/helm/init.go

func (i *initCmd) run() error {
...
    if err := installer.Initialize(i.home, i.out, i.skipRefresh, settings, stableRepositoryURL, localRepositoryURL); err != nil {
        return fmt.Errorf("error initializing: %s", err)
    }
...   
if !i.clientOnly {
        if i.kubeClient == nil {
            _, c, err := getKubeClient(settings.KubeContext, settings.KubeConfig)
            if err != nil {
                return fmt.Errorf("could not get kubernetes client: %s", err)
            }
            i.kubeClient = c
        }
        if err := installer.Install(i.kubeClient, &i.opts); err != nil {
            if !apierrors.IsAlreadyExists(err) {
                return fmt.Errorf("error installing: %s", err)
            }
         ...
        } else {...}
}

核心函数为 installer.Initialize和 installer.Install,在本文开时层介绍init的help信息中表述该命名做了两件事,一是在helm命令所在机器上初始化一个目录用于helm的工作空间,另一个是在Kubernetes中安装tiller。这两个步骤分别对应了上面的两个函数。
helm/cmd/helm/installer/init.go

func Initialize(home helmpath.Home, out io.Writer, skipRefresh bool, settings helm_env.EnvSettings, stableRepositoryURL, localRepositoryURL string) error {
    if err := ensureDirectories(home, out); err != nil {
        return err
    }
    if err := ensureDefaultRepos(home, out, skipRefresh, settings, stableRepositoryURL, localRepositoryURL); err != nil {
        return err
    }

    return ensureRepoFileFormat(home.RepositoryFile(), out)
}

ensureDirectories函数即是创建目录,ensureDefaultRepos函数为helm添加一个默认的chart仓库,ensureRepoFileFormat用来确认添加的仓库的说明文件的正确性。ensureDefaultRepos函数是核心部分。
helm/cmd/helm/installer/init.go

func ensureDefaultRepos(home helmpath.Home, out io.Writer, skipRefresh bool, settings helm_env.EnvSettings, stableRepositoryURL, localRepositoryURL string) error {
    repoFile := home.RepositoryFile()
    if fi, err := os.Stat(repoFile); err != nil {
 ...
        sr, err := initStableRepo(home.CacheIndex(stableRepository), home, out, skipRefresh, settings, stableRepositoryURL)
        if err != nil {
            return err
        }
        lr, err := initLocalRepo(home.LocalRepository(LocalRepositoryIndexFile), home.CacheIndex("local"), home, out, settings, localRepositoryURL)
...
    } ...
}

func initStableRepo(cacheFile string, home helmpath.Home, out io.Writer, skipRefresh bool, settings helm_env.EnvSettings, stableRepositoryURL string) (*repo.Entry, error) {
   ...
    c := repo.Entry{
        Name:  stableRepository,
        URL:   stableRepositoryURL,
        Cache: cacheFile,
    }
    r, err := repo.NewChartRepository(&c, getter.All(settings))
   ...
    if err := r.DownloadIndexFile(""); err != nil {
        return nil, fmt.Errorf("Looks like %q is not a valid chart repository or cannot be reached: %s", stableRepositoryURL, err.Error())
    }
    ...
}

initStableRepo和initLocalRepo分别初始化了两个repository,这里只看前者。stablerepo的URL被设置为“kubernetes-charts.storage.googleapis.com”。
helm/pkg/repo/chartrepo.go

func (r *ChartRepository) DownloadIndexFile(cachePath string) error {
    var indexURL string
    parsedURL, err := url.Parse(r.Config.URL)
...
    parsedURL.Path = strings.TrimSuffix(parsedURL.Path, "/") + "/index.yaml"

    indexURL = parsedURL.String()

    r.setCredentials()
    resp, err := r.Client.Get(indexURL)
...
    index, err := ioutil.ReadAll(resp)
...

    if _, err := loadIndex(index); err != nil {
        return err
    }
...
}

从DownloadIndexFile函数可以看出,helm尝试从parsedURL.Path这个访问地址load出index.yaml文件。index.yaml文件在上文中已有介绍,存储了仓库的中的软件包列表信息。这个步骤将helm和仓库中的软件包联系起来,配置helm工作空间的核心工作完成。
重点看下init中和Kubernetes做交互的部分。
helm/cmd/helm/helm.go

func getKubeClient(context string, kubeconfig string) (*rest.Config, kubernetes.Interface, error) {
    config, err := configForContext(context, kubeconfig)
    if err != nil {
        return nil, nil, err
    }
    client, err := kubernetes.NewForConfig(config)
    if err != nil {
        return nil, nil, fmt.Errorf("could not get Kubernetes client: %s", err)
    }
    return config, client, nil
}
 
func configForContext(context string, kubeconfig string) (*rest.Config, error) {
    config, err := kube.GetConfig(context, kubeconfig).ClientConfig()
    if err != nil {
        return nil, fmt.Errorf("could not get Kubernetes config for context %q: %s", context, err)
    }
    return config, nil
}

kubernetes.NewForConfig初始化了一个用来和Kubernetes交互的客户端,由client-go提供,在更多的代码中都可以看到这个函数,使用十分广泛。configForContext函数则是读取kubeconfig指明路径下的配置文件,如果配置文件不存在,则默认读取$HOME/.kube/config这个文件,否则使用localhost:8080作为和k8s中apiserver的地址。由此引发了另一个问题,如果1)helm命令所在的机器上没有默认的kubeconfig,2)helm命令也没有指明配置文件路径,3)helm命令所在机器上apiserver没有监听8080端口,那么helm所有和tiller交互的命令都会失败,原因是helm无法获取到tiller所在的K8s集群信息,也就无法获取到tiller服务信息。
helm/cmd/helm/installer/install.go

func Install(client kubernetes.Interface, opts *Options) error {
    if err := createDeployment(client.ExtensionsV1beta1(), opts); err != nil {
        return err
    }
    if err := createService(client.CoreV1(), opts.Namespace); err != nil {
        return err
    }
 ...
    return nil
}

最终到了Install函数,createDeployment和createService函数十分清楚的表明,init命令最终是在K8s集群中新建了一个Deployment和一个service。
helm/cmd/helm/installer/install.go

func createDeployment(client extensionsclient.DeploymentsGetter, opts *Options) error {
    obj, err := generateDeployment(opts)
    if err != nil {
        return err
    }
    _, err = client.Deployments(obj.Namespace).Create(obj)
    return err
}
 
func generateDeployment(opts *Options) (*v1beta1.Deployment, error) {
  ...
    d := &v1beta1.Deployment{
  ...
        Spec: v1beta1.DeploymentSpec{
            Replicas: opts.getReplicas(),
            Template: v1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: labels,
                },
                Spec: v1.PodSpec{
                    ServiceAccountName:           opts.ServiceAccount,
                    AutomountServiceAccountToken: &opts.AutoMountServiceAccountToken,
                    Containers: []v1.Container{
                        {
                            Name:            "tiller",
                            Image:           opts.SelectImage(),
                            ImagePullPolicy: opts.pullPolicy(),
                            Ports: []v1.ContainerPort{
                                {ContainerPort: environment.DefaultTillerPort, Name: "tiller"},
                                {ContainerPort: environment.DefaultTillerProbePort, Name: "http"},
                            },
...
 

generateDeployment中对于Deployment的初始化代码较长,不全部展示。这里是K8s中deploy/tiller-deploy被渲染的地方,obj对象最后被Create函数创建。创建service的流程类似,不再展示。 至此init中资源创建流程全部结束。

7. 总结

总体来说,init命令代码比较简单,只负责安装tiller,并没有真正和tiller进行交互,所以前文介绍的“ensureHelmClient”创建的helm client并没有调用,但是在其他release相关的子命令中,helm client是和tiller交互的入口,具有普适性,因此这里仍然进行了介绍。

参考链接

github.com/helm
www.jianshu.com/p/d55e91e28…