zhongpan

zhongpan 查看完整档案

武汉编辑武汉大学  |  计算机 编辑烽火  |  架构师 编辑 zhongpan.tech 编辑
编辑

13年研发经验
云原生实践者
敏捷教练
C++/Python/Golang

个人动态

zhongpan 发布了文章 · 2019-12-01

kubernetes版本号是怎么生成的

通过源码编译kubernetes时,可以使用go build(或go install)单独编译某个组件,例如对于apiserver,可以cd到k8s.io/kubernetes/cmd/kube-apiserver,然后执行:

go install -i -v -gcflags='-N -l'

编译结果安装到GOBIN下,即GOBIN/kube-apiserver,使用这种方式编译时有一个小问题,版本号是一段奇怪的字符串:

kube-apiserver --version
Kubernetes v0.0.0-master+$Format:%h$

在遇到一些需要依赖kubernetes版本号的场景就会有问题,例如使用helm安装chart时,有些chart对kubernetes版本号有要求,就会无法安装。

<!--more-->

有哪些版本号

kubernetes在很多场合都会看到版本号,我们先梳理一下。

  1. --version

    每个组件有--version参数,这时输出本组件的版本号。

  2. kubectl version

    kubectl version
    Client Version: version.Info{Major:"", Minor:"", GitVersion:"v0.0.0-master+$Format:%h$", GitCommit:"$Format:%H$", GitTreeState:"", BuildDate:"1970-01-01T00:00:00Z", GoVersion:"go1.12.9", Compiler:"gc", Platform:"linux/amd64"}
    Server Version: version.Info{Major:"1", Minor:"17+", GitVersion:"v1.17.0-alpha.3.227+7d13dfe3c34f44-dirty", GitCommit:"7d13dfe3c34f44ff505afe397c7b05fe6e5414ed", GitTreeState:"dirty", BuildDate:"2019-11-18T14:42:24Z", GoVersion:"go1.12.9", Compiler:"gc", Platform:"linux/amd64"}

    Client Version即kubectl的版本号,Server Version即apiserver的版本号。

  3. kubectl get nodes

    kubectl get nodes
    NAME          STATUS   ROLES    AGE   VERSION
    10.10.10.15   Ready    <none>   12d   v1.17.0-alpha.3.227+7d13dfe3c34f44-dirty
    10.10.10.16   Ready    <none>   11d   v1.17.0-alpha.3.227+7d13dfe3c34f44-dirty

    此处的版本号为kubelet的版本号。

版本号的含义

上述长长的一串版本号v1.17.0-alpha.3.227+7d13dfe3c34f44-dirty,看起来非常复杂,其实熟悉git的同学一眼就能看出正是git的源码版本信息。

| 分解字段 | 说明 |
| --------------- | -------------------------------------- |
| v1.17.0-alpha.3 | 上一次tag,表示v1.17.0的第3个alpha版本 |
| 227 | 上一次tag之后提交数 |
| 7d13dfe3c34f44 | 最新一次提交id |
| dirty | 源码是否修改 |

这里描述了kubernetes的版本管理规则,据此可以看出上述版本一定是在master分支上。

版本号如何生成

那么版本号是如何写入到程序中的,主要有两个步骤:

  1. 从git获取源码版本信息

    v1.17.0-alpha.3.227+7d13dfe3c34f44这一段是通过如下命令获取,并稍作转换:

    git describe --tags --match='v*' --abbrev=14
    v1.17.0-alpha.3-227-g7d13dfe3c34f44

    如果tag之后没有再提交过,则后面就没有提交次数和最新提交id,只有tag,这通常发生在release分支上最后释放时。

    -dirty是通过如下命令获取,如果源码修改过则版本号添加-dirty:

    git status --porcelain
  2. 通过ldflags将上述获取版本信息写入程序

    golang中在链接阶段可以覆盖程序中的全局变量,通过在go build时增加如下参数。

    -ldflags -X importpath.name=value

    其中importpath为包的导入路径,name为变量名,value为新的值。

    对于kubernetes,版本信息相关的变量,服务端定义在k8s.io/component-base/version包中,源码见k8s.io/component-base/version/base.go,客户端定义在k8s.io/client-go/pkg/version中,源码见k8s.io/client-go/pkg/version/base.go,两个base.go的内容是一样的:

    var (
        // TODO: Deprecate gitMajor and gitMinor, use only gitVersion
        // instead. First step in deprecation, keep the fields but make
        // them irrelevant. (Next we'll take it out, which may muck with
        // scripts consuming the kubectl version output - but most of
        // these should be looking at gitVersion already anyways.)
        gitMajor string = "" // major version, always numeric
        gitMinor string = "" // minor version, numeric possibly followed by "+"
    
        // semantic version, derived by build scripts (see
        // https://git.k8s.io/community/contributors/design-proposals/release/versioning.md
        // for a detailed discussion of this field)
        //
        // TODO: This field is still called "gitVersion" for legacy
        // reasons. For prerelease versions, the build metadata on the
        // semantic version is a git hash, but the version itself is no
        // longer the direct output of "git describe", but a slight
        // translation to be semver compliant.
    
        // NOTE: The $Format strings are replaced during 'git archive' thanks to the
        // companion .gitattributes file containing 'export-subst' in this same
        // directory.  See also https://git-scm.com/docs/gitattributes
        gitVersion   string = "v0.0.0-master+$Format:%h$"
        gitCommit    string = "$Format:%H$" // sha1 from git, output of $(git rev-parse HEAD)
        gitTreeState string = ""            // state of git tree, either "clean" or "dirty"
    
        buildDate string = "1970-01-01T00:00:00Z" // build date in ISO8601 format, output of $(date -u +'%Y-%m-%dT%H:%M:%SZ')
    )

    一开始出现的奇怪的字符串v0.0.0-master+$Format:%h$就是出在这里。当然直接修改这个文件也可以得到正确的版本号,但是每次源码版本变化都需要修改,非常麻烦,采用ldflags就非常简单:

    go build -ldflags='-X k8s.io/component-base/version.gitVersion=v1.17.0-alpha.3.227+7d13dfe3c34f44-dirty -X k8s.io/client-go/pkg/version.gitVersion=v1.17.0-alpha.3.227+7d13dfe3c34f44-dirty' 

    上述命令就将gitVersion变量修改为从git中得到的源码版本号,这个过程通过脚本完全可以做到自动化,这个脚本就是k8s.io/kubernetes/hack/lib/version.sh。

go build时植入版本号

在使用make编译kubernetes时会自动从git获取版本信息并通过ldflags植入程序中,但是通过go build编译时kubernetes并没有提供脚本实现上述过程,这就导致了版本号总是v0.0.0-master+$Format:%h$

为了解决上述问题,只需要对k8s.io/kubernetes/hack/lib/version.sh稍作改造,复用其逻辑即可:

首先修改version.sh,因为启用了go module,如果你没有显示启用可以不需要修改:

    ldflags+=(
      "-X '${KUBE_GO_PACKAGE}/vendor/k8s.io/client-go/pkg/version.${key}=${val}'"
      "-X '${KUBE_GO_PACKAGE}/vendor/k8s.io/component-base/version.${key}=${val}'"
    )

改为:

    ldflags+=(
      "-X '${KUBE_GO_PACKAGE}/client-go/pkg/version.${key}=${val}'"
      "-X '${KUBE_GO_PACKAGE}/component-base/version.${key}=${val}'"
    )

然后在k8s.io/kubernetes/hack目录下添加脚本文件goinstall如下:

#!/usr/bin/env bash
basepath=$(cd `dirname $0`; pwd)
KUBE_ROOT=${basepath}/..
source "${KUBE_ROOT}/hack/lib/version.sh"
KUBE_GO_PACKAGE="k8s.io"
ldflags=$(kube::version::ldflags)
echo ldflags:${ldflags}
go install -i -v -gcflags='-N -l' -ldflags="${ldflags}"

将k8s.io/kubernetes/hack目录添加到path,然后在单个组件目录下就可以直接执行:

goinstall
欢迎访问钟潘的博客
查看原文

赞 0 收藏 0 评论 0

zhongpan 发布了文章 · 2019-11-30

深入理解kubelet认证和授权

在通过kubectl访问pod信息,例如执行kubectl logs,常常会遇到类似如下错误:

Error from server (Forbidden): Forbidden (user=system:anonymous, verb=get, resource=nodes, subresource=proxy) ( pods/log tiller-deploy-6b5ffb6f-lg9jb)

网上搜索可以通过启用anonymous访问,也就是使用--anonymous-auth=true或者配置文件添加:

authentication:
    anonymous:
        enabled: true

但是设置之后错误依旧,为此我探究了一下kubelet的认证机制,终于将问题解决,其实很简单,答案后面揭晓。

我们知道kubectl只会和apiserver交互,对于kubectl logs、kubectl exec等需要访问pod的这些命令,实际上是apiserver调用kubelet接口完成的,上述错误正是出在这个过程,而不是kubectl到apiserver的过程。

kubelet通过port指定的端口(默认10250)对外暴露服务,这个服务是需要TLS认证的,同时也可以通过 readOnlyPort 端口(默认10255,0表示关闭)对外暴露只读服务,这个服务是不需要认证的。apiserver通过--kubelet-https参数指定调用哪个服务,true为前者,false为后者,此时只能执行只读操作。下面主要说一下前者。

<!--more-->

认证过程

配置认证方式

有三种可配置认证方式:

  1. TLS认证,这也是默认的

    authentication:
        anonymous:
            enabled: false
        webhook:
            enabled: false
        x509:
            clientCAFile: xxxx
  2. 允许anonymous,这时可不配置客户端证书

    authentication:
        anonymous:
            enabled: true
  3. webhook,这时可不配置客户端证书

    authentication:
        webhook:
            enabled: true

    这时kubelet通过bearer tokens,找apiserver认证,如果存在对应的serviceaccount,则认证通过。

如果2开启,则忽略x509和webhook认证;否则,如果1和3同时开启,则按1、3的顺序依次认证,任何一个认证通过则返回通过,否则认证不通过。

通过kubectl命令行访问kubelet时,无法传递bearer tokens,所以无法使用webhook认证,这时只能使用x509认证。

证书配置

kubelet对外暴露https服务,必须设置服务端证书,如果通过x509证书认证客户端,那么还需要配置客户端证书。下面说明证书配置的三种方法:

手工指定证书

  1. 假设ca的证书和key:ca.pem,ca-key.pem
  2. 用上述ca生成kubelet服务端证书和key:kubelet-server.pem、kubelet-server-key.pem
  3. 用上述ca生成apiserver使用的客户端证书和key:kubelet-client.pem、kubelet-client-key.pem,证书CN为kubelet-client
  4. 修改kubelet的配置文件:

    tlsCertFile: kubelet-server.pem
    tlsPrivateKeyFile: kubelet-server-key.pem
    authentication:
        x509:
            clientCAFile: ca.pem
  5. 修改apiserver参数:

    --kubelet-certificate-authority=ca.pem --kubelet-client-certificate=kubelet-client.pem --kubelet-client-key=kubelet-client-key.pem
  6. 授权kubelet-client用户:

    kubectl create clusterrolebinding kubelet-admin --clusterrole=system:kubelet-api-admin --user=kubelet-client

    经过上面5步,认证的过程实际已经OK了,第6步是为授权过程服务的,kubelet的授权是通过webhook委托给apiserver的。

自签名证书和key

实际上是上述过程的特化,不指定tlsCertFile和tlsPrivateKeyFile时,kubelet会自动生成服务端证书保存在--cert-dir指定目录中,文件名为kubelet.crt和kubelet.key,这个证书是自签名的,所以apiserver不需要指定--kubelet-certificate-authority,其他配置是一样的。

通过TLS bootstrap机制

还可以通过TLS bootstrap机制分配kubelet服务证书。跟配置分配访问apiserver的客户端证书方法是一样的(参考官方文档),额外的配置如下:

  1. 修改kubelet配置文件:

    serverTLSBootstrap: true 
  2. 授权允许创建kubelet服务端证书

    首先创建system:certificates.k8s.io:certificatesigningrequests:selfnodeserver,默认是没有创建的。

    selfnodeserver.yaml文件如下:

# A ClusterRole which instructs the CSR approver to approve a node requesting a
# serving cert matching its client cert.
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
 name: system:certificates.k8s.io:certificatesigningrequests:selfnodeserver
rules:
- apiGroups: ["certificates.k8s.io"]
 resources: ["certificatesigningrequests/selfnodeserver"]
 verbs: ["create"]
kubectl create -f selfnodeserver.yaml

然后创建绑定:

kubectl create clusterrolebinding node-server-auto-renew-crt --clusterrole=system:certificates.k8s.io:certificatesigningrequests:selfnodeserver --group=system:nodes
  1. 手工批准证书请求:

    先查询证书请求:

    [vagrant@localhost etc]$ kubectl get csr
    NAME                                                   AGE     REQUESTOR                 CONDITION
    csr-2jxvn                                              76s     system:node:10.10.10.16   Pending

    然后批准证书:

    kubectl certificate approve csr-2jxvn

    之后在相应kubelet的--cert-dir目录可以看到服务端证书已经生成。

  2. 配置客户端证书和前面的方法是一样的,上面3步只是生成服务端证书。

选择哪种方式

客户端证书配置是免不了的,区别是在服务端证书,显然自动生成更加方便,TLS bootstrap相对于自签名证书更加安全,集群统一使用信任的CA签名。

TLS bootstrap还可以配置证书过期后自动重新生成,方法是修改kubelet配置文件:

rotateCertificates: true 

TLS bootstrap分配证书的有效期可以通过kube-controller-manager如下参数修改,默认8760h0m0s,也就是1年:

--experimental-cluster-signing-duration

授权过程

配置授权方式

可配置两种授权方式:

  1. AlwaysAllow:从字面意思就可知道

    authorization:
        mode: AlwaysAllow
  2. Webhook:这是默认模式

    authorization:
        mode: Webhook

    这时授权过程是委托给apiserver的,使用apiserver一样的授权模式,也就是RBAC。

配置权限

如果通过Webhook授权,就需要通过RBAC为用户配置权限。

首先要弄清楚通过认证的用户是什么,通过x509证书认证的用户名是客户端证书中的CN字段,用户组为O字段;通过webhook认证的用户是token对应的serviceaccount;没有通过认证或使能anonymous,则用户为system:anonymous。

其次要弄清楚应该授权什么权限,系统已经存在一个system:kubelet-api-admin角色,这是最高的权限,可以根据需要创建低权限角色。

[vagrant@localhost kube-apiserver]$ kubectl describe clusterrole system:kubelet-api-admin
Name:         system:kubelet-api-admin
Labels:       kubernetes.io/bootstrapping=rbac-defaults
Annotations:  rbac.authorization.kubernetes.io/autoupdate: true
PolicyRule:
  Resources      Non-Resource URLs  Resource Names  Verbs
  ---------      -----------------  --------------  -----
  nodes/log      []                 []              [*]
  nodes/metrics  []                 []              [*]
  nodes/proxy    []                 []              [*]
  nodes/spec     []                 []              [*]
  nodes/stats    []                 []              [*]
  nodes          []                 []              [get list watch proxy]

最后将用户和角色绑定,即完成权限的配置。

总结

如何配置kubelet的认证和授权,归结起来常用如下2种做法:

  1. 省事型,可用于开发环境

    authentication:
        anonymous:
            enabled: true
    authorization:
        mode: AlwaysAllow        

    一开始出现的Forbidden问题就是没有配置AlwaysAllow,默认是Webhook。

  2. 安全型,生产环境使用

    authentication:
        anonymous:
            enabled: false
    authorization:
        mode: Webhook

    服务端证书通过TLS bootstrap,客户端证书需要手工配置。

欢迎访问钟潘的博客
查看原文

赞 0 收藏 0 评论 0

zhongpan 发布了文章 · 2019-11-30

使用traefik反向代理k8s dashboard

生产环境下,k8s集群对外暴露服务主要有LoadBalancer和Ingress两种方式:

  • LoadBalancer:需要云厂商支持,使用k8s service的负载均衡能力,也就是依靠iptables/ipvs的能力,可用于各种协议
  • Ingress:相对更加灵活,通过反向代理服务器实现负载均衡,仅用于http/https协议,这种场景下需要额外的反向代理服务以及ingress controller,nginx是大家熟知的反向代理,在k8s时代,出现了nginx-ingress,就是nginx+ingress controller的组合,ingress controller负责根据ingress资源生成nginx配置,当配置有变化是重启nginx。同时也出现了云原生的反向代理traefik,它相当于把ingress controller包含到其中合为一体,并且能够动态感知路由规则变化,不需重启。

traefik是一个相对较新的反向代理,网上相关资料不是特别丰富,研究了好几天,才成功访问到k8s dashboard,将其中的关键点记录于此。

<!--more-->

安装traefik

使用helm安装,最新chart使用的traefik 1.7.19:

helm install stable/traefik -f traefik-values.yaml

traefik-values.yaml:

rbac:
  enabled: true
dashboard:
  enabled: true # 启用traefik dashboard
  ingress:
    annotations:
      traefik.ingress.kubernetes.io/rule-type: PathPrefixStrip
deployment:
  hostPort:
    httpEnabled: true # traefik pod所在node上开启80端口
    httpsEnabled: true # traefik pod所在node上开启443端口
    dashboardEnabled: true # traefik pod所在node上开启8080端口,共traefik dashboard使用
ssl:
  insecureSkipVerify: true # frontend不验证https的benkend
  enabled: true # 启用https入口
extraVolumes:
  - name: traefik-ssl
    hostPath:
      path: /share/k8s/traefik/ssl # 其中存放https入口的证书和key,名字必须为tls.crt,tls.key
      type: DirectoryOrCreate
extraVolumeMounts:
  - name: traefik-ssl
    mountPath: /ssl   # traefik pod从/ssl目录读取上述tls.crt,tls.key

详细的配置方法见官方文档,上述关键点如下:

  1. 开启https入口,设置ssl.enabled=true,然后提供证书和key,上述通过从node节点本地目录mount到pod的方式,所以每个node节点要先放好证书和key,更好的方式是通过k8s secret,创建secret然后mount到pod
  2. 如何访问到入口,我是通过在node上打开端口,这时通过pod所在node就可以访问到入口,通过http://nodeiphttps://nodeip;还可以使用NodePort类型service,这样通过http://any-nodeip:http-nodeporthttps://any-nodeip:https-nodeport访问,value设置 serviceType: NodePort
  3. 路由匹配规则我使用的PathPrefixStrip,默认是host名匹配

因为启用了traefik dashboard,安装traefik会自动创建dashboard的ingress:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  annotations:
    traefik.ingress.kubernetes.io/rule-type: PathPrefixStrip
  labels:
    app: traefik
    chart: traefik-1.82.1
    heritage: Tiller
    release: traefik
  name: traefik-dashboard
  namespace: default
spec:
  rules:
  - host: traefik.example.com
    http:
      paths:
      - backend:
          serviceName: traefik-dashboard
          servicePort: dashboard-http

traefik是通过标签app: traefik选择到需要感知的ingress。自己添加的ingress注意包含这个标签。上述annotations和host是从value而来。因为我不想配host,所以用PathPrefixStrip路由规则,我修改了上述ingress如下:

spec:
  rules:
  - http:
      paths:
      - backend:
          serviceName: traefik-dashboard
          servicePort: dashboard-http
        path: /traefik

这样当使用http://nodeip/traefik就可以访问到dashboard,因为在node上也开启了dashboard端口,也可以通过http://nodeip:8080访问。

代理k8s dashboard

目前最新的k8s dashboard(v2.0.0-beta6)安装在kubernetes-dashboard namespace:

kubectl get svc -n kubernetes-dashboard
NAME                        TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)         AGE
dashboard-metrics-scraper   ClusterIP   10.254.238.13    <none>        8000/TCP        21d
kubernetes-dashboard        LoadBalancer   10.254.253.226   <pending>     443:30223/TCP   21d

增加ingress:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  annotations:
    traefik.ingress.kubernetes.io/rule-type: PathPrefixStrip
  labels:
    app: traefik
  name: kubernetes-dashboard
  namespace: default
spec:
  rules:
  - http:
      paths:
      - backend:
          serviceName: kubernetes-dashboard
          servicePort: 443
        path: /k8s

代理https后端

k8s dashboard只支持https访问,首先卡住的问题是如何代理https服务,frontend到backend的路由会出现以下几种情形:

  1. http->http
  2. http->https
  3. https->http
  4. https->https

当backend为https时,无论frontend是http或https,也就是2和4,都会报500错误,因为frontend无法验证backend,此时解决方法:

  • 要么设置insecureSkipVerify,这样比较简单,如果采用这种方式frontend最好总是采用https,也就是设置redirect
  • 要么设置ingress tls,配置host的tls证书信息

我采用的设置insecureSkipVerify的方法。一般最佳的使用方式也是入口总是用https,然后终结tls,后端是否https不重要。

代理不同namespace服务

解决上述问题后,接下来遇到k8s dashboard服务无法访问问题,在traefik dashboard中显示为红色,原因是helm安装traefik默认在default namespace中,而k8s dashboard安装在kubernetes-dashboard namespace中,不能跨namespace访问到服务,解决方法:

  • 要么将traefik安装到和k8s dashboard同一空间
  • 要么通过ExternalName将dashboard service引入到default namespace
  apiVersion: v1
  kind: Service
  metadata:
   name: kubernetes-dashboard
   namespace: default
  spec:
   ports:
   - name: https
     port: 443
     protocol: TCP
     targetPort: 443
   sessionAffinity: None
   type: ExternalName
   externalName: kubernetes-dashboard.kubernetes-dashboard.svc.cluster.local

我采用的ExternalName方法。service的完整域名是servicename.namespace.svc.cluster.local,cluster.local是kubelet中配置的。

基于path路由

服务可以访问了,但是又出现了MIME type is not a supported stylesheet MIME type错误。

image-20191129064738766

一开始以为是traefik在reponse header中加入了 X-Content-Type-Options: nosniff,但是发现traefik默认是不加入的。

最后发现是url路径问题,我的ingress仅使用path路由,没有使用host。

当使用https://nodeip/k8s访问k8s dashboard时,因为路由规则是PathPrefixStrip,到后端的请求是https://nodeip,这时得到主页,文件名是k8s,主页面k8s中的css,js等文件路径是相对于当前文档路径的,所以request url是https://nodeip/xxx.css,这时就匹配不上路由规则,出现上述错误。

如果使用https://nodeip/k8s/访问dashboard,就一切正常了。

所以使用路径匹配路由时是存在一定风险的,和主页中的资源路径定义有关:

主页中css,js等资源路径定义方式说明
没有定义base,资源路径不以./或../或/开头
<base href="./">,资源路径以./开头
1.匹配/path时,只能通过https://xxxx/path/访问
2.匹配/path,并且后端重定向到sub/,这时通过https://xxxx/path/https://xxxx/path都可以访问
<base href="/">,资源路径不以./或../或/开头只能匹配/,其他路径匹配都无法正常工作
确实碰到这种情况,例如monocular

所以最好的方式还是通过host匹配路由。

dashboard认证

了解上述问题后,终于进入到dashboard登陆界面:

image-20191129063718980

一开始我是使用的http入口,使用Token方式登陆,没有任何响应,通过开发者工具查看,发现问题是在使用http入口时,header中没有携带jweToken,导致认证失败,必须使用https入口。

回想起之前通过kubectl proxy,即http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/也是登陆不了,其实是一样的问题。

使用http入口登陆失败:

image-20191129064020553

使用https入口时,jweToken是携带了,登陆成功:

image-20191129064137163 )

所以果断设置frontend总是https,values增加traefik.ingress.kubernetes.io/redirect-entry-point: https,然后helm upgrade,自己增加的ingress需要自己修改:

dashboard:
  enabled: true
  ingress:
    annotations:
      traefik.ingress.kubernetes.io/rule-type: PathPrefixStrip
      traefik.ingress.kubernetes.io/redirect-entry-point: https
      # 不要使用ingress.kubernetes.io/ssl-redirect: "true",因为会丢掉path

这样无论使用http://nodeip/k8s/还是https://nodeip/k8s/都可以成功登陆。

欢迎访问钟潘的博客
查看原文

赞 0 收藏 0 评论 0

zhongpan 发布了文章 · 2019-11-05

什么是kubernetes服务端打印

喜欢尝鲜的同学可能会注意到最新的kubernetes在执行kubectl get cs时输出内容有一些变化,以前是这样的:

> kubectl get componentstatuses
NAME                 STATUS    MESSAGE             ERROR
controller-manager   Healthy   ok
scheduler            Healthy   ok
etcd-0               Healthy   {"health":"true"}

现在变成了:

> kubectl get componentstatuses
NAME                 Age    
controller-manager   <unknown>
scheduler            <unknown>
etcd-0               <unknown>

起初可能会以为集群部署有问题,通过kubectl get cs -o yaml发现status、message等信息都有,只是没有打印出来。原来是kubectl get cs的输出格式有变化,那么为什么会有此变化,我们来一探究竟。

<!--more-->

定位问题原因

尝试之前的版本,发现1.15还是正常的,调试代码发现对componentstatuses的打印代码在k8s.io/kubernetes/pkg/printers/internalversion/printers.go中,其中的AddHandlers方法负责把各种资源的打印函数注册进来。

// AddHandlers adds print handlers for default Kubernetes types dealing with internal versions.
// TODO: handle errors from Handler
func AddHandlers(h printers.PrintHandler) {
......
    componentStatusColumnDefinitions := []metav1beta1.TableColumnDefinition{
        {Name: "Name", Type: "string", Format: "name", Description: metav1.ObjectMeta{}.SwaggerDoc()["name"]},
        {Name: "Status", Type: "string", Description: "Status of the component conditions"},
        {Name: "Message", Type: "string", Description: "Message of the component conditions"},
        {Name: "Error", Type: "string", Description: "Error of the component conditions"},
    }
    h.TableHandler(componentStatusColumnDefinitions, printComponentStatus)
    h.TableHandler(componentStatusColumnDefinitions, printComponentStatusList)    

对AddHandlers的调用位于k8s.io/kubernetes/pkg/kubectl/cmd/get/humanreadable_flags.go(正在迁移到staging中,如果找不到就去staging中找)中,如下32行位置:

// ToPrinter receives an outputFormat and returns a printer capable of
// handling human-readable output.
func (f *HumanPrintFlags) ToPrinter(outputFormat string) (printers.ResourcePrinter, error) {
    if len(outputFormat) > 0 && outputFormat != "wide" {
        return nil, genericclioptions.NoCompatiblePrinterError{Options: f, AllowedFormats: f.AllowedFormats()}
    }

    showKind := false
    if f.ShowKind != nil {
        showKind = *f.ShowKind
    }

    showLabels := false
    if f.ShowLabels != nil {
        showLabels = *f.ShowLabels
    }

    columnLabels := []string{}
    if f.ColumnLabels != nil {
        columnLabels = *f.ColumnLabels
    }

    p := printers.NewTablePrinter(printers.PrintOptions{
        Kind:          f.Kind,
        WithKind:      showKind,
        NoHeaders:     f.NoHeaders,
        Wide:          outputFormat == "wide",
        WithNamespace: f.WithNamespace,
        ColumnLabels:  columnLabels,
        ShowLabels:    showLabels,
    })
    printersinternal.AddHandlers(p)

    // TODO(juanvallejo): handle sorting here

    return p, nil
}

查看humanreadable_flags.go文件的修改历史,发现是在2019.6.28日特意去掉了对内部对象的打印,影响版本从1.16之后。

为什么修改

我没有查到官方的说明,在此做一些个人猜测,还原整个过程:

  1. 最初对api resource的表格打印都是在kubectl中实现
  2. 这样对于其他客户端需要做重复的事情,而且可能实现的行为不一致,因此有必要将表格打印放到服务端,也就是apiserver中
  3. 服务端打印的支持经过一个逐步的过程,所以客户端并没有完全去除,是同时支持的,kubectl判断服务端返回的Table就直接打印,否则使用具体对象的打印,客户端和服务端对特定对象的打印都是调用k8s.io/kubernetes/pkg/printers/internalversion/printers.go来实现
  4. 1.11版本将kubectl的命令行参数--server-print默认设置为true
  5. 到了1.16版本,社区可能认为所有的对象都移到服务端了,这时就去除了客户端kubectl中的打印
  6. 但实际上componentstatuses被遗漏了,那么为什么遗漏,可能主要是因为componentstatuses对象跟其他对象不一样,它是每次实时获取,而不是从缓存获取,其他对象,例如pod是从etcd获取,对结果的格式化定义在k8s.io/kubernetes/pkg/registry/core/pod/storage/storage.go中,如下15行位置:

    // NewStorage returns a RESTStorage object that will work against pods.
    func NewStorage(optsGetter generic.RESTOptionsGetter, k client.ConnectionInfoGetter, proxyTransport http.RoundTripper, podDisruptionBudgetClient policyclient.PodDisruptionBudgetsGetter) PodStorage {
    
        store := &genericregistry.Store{
            NewFunc:                  func() runtime.Object { return &api.Pod{} },
            NewListFunc:              func() runtime.Object { return &api.PodList{} },
            PredicateFunc:            pod.MatchPod,
            DefaultQualifiedResource: api.Resource("pods"),
    
            CreateStrategy:      pod.Strategy,
            UpdateStrategy:      pod.Strategy,
            DeleteStrategy:      pod.Strategy,
            ReturnDeletedObject: true,
    
            TableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)},
        }

    componentstatuses没有用到真正的Storge,而它又相对不起眼,所以被遗漏了。

暂时解决办法

如果希望打印和原来类似的内容,目前只有通过模板:

kubectl get cs -o=go-template='{{printf "|NAME|STATUS|MESSAGE|\n"}}{{range .items}}{{$name := .metadata.name}}{{range .conditions}}{{printf "|%s|%s|%s|\n" $name .status .message}}{{end}}{{end}}'

输出结果:

|NAME|STATUS|MESSAGE|
|controller-manager|True|ok|
|scheduler|True|ok|
|etcd-0|True|{"health":"true"}|

深入打印处理流程

kubectl通过-o参数控制输出的格式,有yaml、json、模板和表格几种样式,上述问题是出在表格打印时,不加-o参数默认就是表格打印,下面我们详细分析一下kubectl get的打印输出过程。

kubectl

kubectl get的代码入口在k8s.io/kubernetes/pkg/kubectl/cmd/get/get.go中,Run字段就是命令执行方法:

func NewCmdGet(parent string, f cmdutil.Factory, streams genericclioptions.IOStreams) *cobra.Command {
......
        Run: func(cmd *cobra.Command, args []string) {
            cmdutil.CheckErr(o.Complete(f, cmd, args))
            cmdutil.CheckErr(o.Validate(cmd))
            cmdutil.CheckErr(o.Run(f, cmd, args))
        },
......
    }

Complete方法完成了Printer初始化,位于k8s.io/kubernetes/pkg/kubectl/cmd/get/get_flags.go中:

func (f *PrintFlags) ToPrinter() (printers.ResourcePrinter, error) {
......
    if p, err := f.HumanReadableFlags.ToPrinter(outputFormat); !genericclioptions.IsNoCompatiblePrinterError(err) {
        return p, err
    }
......
}

不带-o参数时,上述方法返回的是f.HumanReadableFlags.ToPrinter(outputFormat),最后返回的是HumanReadablePrinter对象,位于k8s.io/cli-runtime/pkg/printers/tableprinter.go中:

// NewTablePrinter creates a printer suitable for calling PrintObj().
func NewTablePrinter(options PrintOptions) ResourcePrinter {
    printer := &HumanReadablePrinter{
        options: options,
    }
    return printer
}

再回到命令执行主流程,Complete之后主要是Run,其中完成向apiserver发送http请求并打印结果的动作,在发送http请求前,有一个很重要的动作,加入服务端打印的header,服务端打印可以通过--server-print参数控制,从1.11默认为true,这样服务端如果支持转换就会返回metav1beta1.Table类型,置为false也可以禁用服务端打印:

func (o *GetOptions) transformRequests(req *rest.Request) {
......
    if !o.ServerPrint || !o.IsHumanReadablePrinter {
        return
    }

    group := metav1beta1.GroupName
    version := metav1beta1.SchemeGroupVersion.Version

    tableParam := fmt.Sprintf("application/json;as=Table;v=%s;g=%s, application/json", version, group)
    req.SetHeader("Accept", tableParam)

......
}

最后打印是调用的HumanReadablePrinter.PrintObj方法,先判断服务端如果返回的metav1beta1.Table类型就直接打印,其次如果是metav1.Status类型也有专门的处理器,最后就会到默认处理器:

func (h *HumanReadablePrinter) PrintObj(obj runtime.Object, output io.Writer) error {
......
    // Parameter "obj" is a table from server; print it.
    // display tables following the rules of options
    if table, ok := obj.(*metav1beta1.Table); ok {

        return printTable(table, output, localOptions)
    }

    // Could not find print handler for "obj"; use the default or status print handler.
    // Print with the default or status handler, and use the columns from the last time
    var handler *printHandler
    if _, isStatus := obj.(*metav1.Status); isStatus {
        handler = statusHandlerEntry
    } else {
        handler = defaultHandlerEntry
    }
......
    if err := printRowsForHandlerEntry(output, handler, eventType, obj, h.options, includeHeaders); err != nil {
        return err
    }
......
    return nil
}

默认处理器会打印Name和Age两列,因为componetstatuses是实时获取,没有存储在etcd中,没有创建时间,所以Age打印出来就是unknown。

apiserver

再来看服务端的处理流程,apiserver对外提供REST接口实现在k8s.io/apiserver/pkg/endpoints/handlers目录下,kubectl get cs会进入get.go中ListResource方法,如下列出关键的三个步骤:

func ListResource(r rest.Lister, rw rest.Watcher, scope *RequestScope, forceWatch bool, minRequestTimeout time.Duration) http.HandlerFunc {
    return func(w http.ResponseWriter, req *http.Request) {
......

        outputMediaType, _, err := negotiation.NegotiateOutputMediaType(req, scope.Serializer, scope)
......

        result, err := r.List(ctx, &opts)
......
        transformResponseObject(ctx, scope, trace, req, w, http.StatusOK, outputMediaType, result)
......
    }
}

NegotiateOutputMediaType中根据客户端的请求header设置服务端的一些行为,包括是否服务端打印;r.List从Storage层获取资源数据,具体实现在k8s.io/kubernetes/pkg/registry下;transformResponseObject将结果返回给客户端。

先说transformResponseObject,其中就会根据NegotiateOutputMediaType返回的outputMediaType的Convert字段判断是否转为换目标类型,如果为Table就会将r.List返回的具体资源类型转换为Table类型:

func doTransformObject(ctx context.Context, obj runtime.Object, opts interface{}, mediaType negotiation.MediaTypeOptions, scope *RequestScope, req *http.Request) (runtime.Object, error) {
......

    switch target := mediaType.Convert; {
    case target == nil:
        return obj, nil        
......
    case target.Kind == "Table":
        options, ok := opts.(*metav1beta1.TableOptions)
        if !ok {
            return nil, fmt.Errorf("unexpected TableOptions, got %T", opts)
        }
        return asTable(ctx, obj, options, scope, target.GroupVersion())
......g
    }
}

上述asTable最终调用scope.TableConvertor.ConvertToTable完成表格转换工作,在本文的问题中,就是因为mediaType.Convert为空而没有触发这个转换,那么为什么为空呢,问题就出在NegotiateOutputMediaType,它最终会调用到k8s.io/apiserver/pkg/endpoints/handlers/rest.go的AllowsMediaTypeTransform方法,是因为scope.TableConvertor为空,最终转换为Table也是调用的它:

func (scope *RequestScope) AllowsMediaTypeTransform(mimeType, mimeSubType string, gvk *schema.GroupVersionKind) bool {
......
    if gvk.GroupVersion() == metav1beta1.SchemeGroupVersion || gvk.GroupVersion() == metav1.SchemeGroupVersion {
        switch gvk.Kind {
        case "Table":
            return scope.TableConvertor != nil &&
                mimeType == "application" &&
                (mimeSubType == "json" || mimeSubType == "yaml")
......
}

进一步跟踪,RequestScope是在apiserver初始化的时候创建的,每类资源一个,比如componentstatuses有一个全局的,pod有一个全局的,初始化的过程如下:

apiserver初始化入口在k8s.io/kubernetes/pkg/master/master.go的InstallLegacyAPI和InstallAPIs方法中,前者主要针对一些老的资源,具体有哪些见下面的NewLegacyRESTStorage方法,其中就包含componentStatuses,其他资源通过InstallAPIs初始化:

// InstallLegacyAPI will install the legacy APIs for the restStorageProviders if they are enabled.
func (m *Master) InstallLegacyAPI(c *completedConfig, restOptionsGetter generic.RESTOptionsGetter, legacyRESTStorageProvider corerest.LegacyRESTStorageProvider) error {
    legacyRESTStorage, apiGroupInfo, err := legacyRESTStorageProvider.NewLegacyRESTStorage(restOptionsGetter)
......
}

初始化Storage:

func (c LegacyRESTStorageProvider) NewLegacyRESTStorage(restOptionsGetter generic.RESTOptionsGetter) (LegacyRESTStorage, genericapiserver.APIGroupInfo, error) {
    apiGroupInfo := genericapiserver.APIGroupInfo{
        PrioritizedVersions:          legacyscheme.Scheme.PrioritizedVersionsForGroup(""),
        VersionedResourcesStorageMap: map[string]map[string]rest.Storage{},
        Scheme:                       legacyscheme.Scheme,
        ParameterCodec:               legacyscheme.ParameterCodec,
        NegotiatedSerializer:         legacyscheme.Codecs,
    }

......
    restStorageMap := map[string]rest.Storage{
        "pods":             podStorage.Pod,
        "pods/attach":      podStorage.Attach,
        "pods/status":      podStorage.Status,
        "pods/log":         podStorage.Log,
        "pods/exec":        podStorage.Exec,
        "pods/portforward": podStorage.PortForward,
        "pods/proxy":       podStorage.Proxy,
        "pods/binding":     podStorage.Binding,
        "bindings":         podStorage.LegacyBinding,

        "podTemplates": podTemplateStorage,

        "replicationControllers":        controllerStorage.Controller,
        "replicationControllers/status": controllerStorage.Status,

        "services":        serviceRest,
        "services/proxy":  serviceRestProxy,
        "services/status": serviceStatusStorage,

        "endpoints": endpointsStorage,

        "nodes":        nodeStorage.Node,
        "nodes/status": nodeStorage.Status,
        "nodes/proxy":  nodeStorage.Proxy,

        "events": eventStorage,

        "limitRanges":                   limitRangeStorage,
        "resourceQuotas":                resourceQuotaStorage,
        "resourceQuotas/status":         resourceQuotaStatusStorage,
        "namespaces":                    namespaceStorage,
        "namespaces/status":             namespaceStatusStorage,
        "namespaces/finalize":           namespaceFinalizeStorage,
        "secrets":                       secretStorage,
        "serviceAccounts":               serviceAccountStorage,
        "persistentVolumes":             persistentVolumeStorage,
        "persistentVolumes/status":      persistentVolumeStatusStorage,
        "persistentVolumeClaims":        persistentVolumeClaimStorage,
        "persistentVolumeClaims/status": persistentVolumeClaimStatusStorage,
        "configMaps":                    configMapStorage,
        
        "componentStatuses": componentstatus.NewStorage(componentStatusStorage{c.StorageFactory}.serversToValidate),
    }
......
    apiGroupInfo.VersionedResourcesStorageMap["v1"] = restStorageMap

    return restStorage, apiGroupInfo, nil
}

注册REST接口的handler,handler中包含RequestScope,RequestScope中的TableConvertor字段是从storage取出,也就是上述NewLegacyRESTStorage创建的资源对应的storage,例如componentStatuses,就是componentstatus.NewStorage(componentStatusStorage{c.StorageFactory}.serversToValidate),提取的方法是类型断言storage.(rest.TableConvertor),也就是storage要实现rest.TableConvertor接口,否则取出来为空:

func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storage, ws *restful.WebService) (*metav1.APIResource, error) {
......

    tableProvider, _ := storage.(rest.TableConvertor)

    var apiResource metav1.APIResource
......
    reqScope := handlers.RequestScope{
......

        // TODO: Check for the interface on storage
        TableConvertor: tableProvider,

......
    for _, action := range actions {
......
        switch action.Verb {
......
        case "LIST": // List all resources of a kind.
            doc := "list objects of kind " + kind
            if isSubresource {
                doc = "list " + subresource + " of objects of kind " + kind
            }
            handler := metrics.InstrumentRouteFunc(action.Verb, group, version, resource, subresource, requestScope, metrics.APIServerComponent, restfulListResource(lister, watcher, reqScope, false, a.minRequestTimeout))
......
        }
        // Note: update GetAuthorizerAttributes() when adding a custom handler.
    }
......
}

具体看componentStatuses的storage,在k8s.io/kubernetes/pkg/registry/core/componentstatus/rest.go中,确实没有实现rest.TableConvertor接口,所以componentStatuses的handler的RequestScope中的TableConvertor字段就为空,最终导致了问题:

type REST struct {
    GetServersToValidate func() map[string]*Server    
}

// NewStorage returns a new REST.
func NewStorage(serverRetriever func() map[string]*Server) *REST {
    return &REST{
        GetServersToValidate: serverRetriever,    
    }
}

代码修复

找到了根本原因之后,修复就比较简单了,就是storage需要实现rest.TableConvertor接口,接口定义在k8s.io/apiserver/pkg/registry/rest/rest.go中:

type TableConvertor interface {
    ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1beta1.Table, error)
}

参照其他资源storage代码,修改k8s.io/kubernetes/pkg/registry/core/componentstatus/rest.go代码如下,问题得到解决,kubectl get cs打印出熟悉的表格,如果使用kubectl get cs --server-print=false仍会只打印Name、Age两列:

type REST struct {
    GetServersToValidate func() map[string]*Server    
    tableConvertor printerstorage.TableConvertor
}

// NewStorage returns a new REST.
func NewStorage(serverRetriever func() map[string]*Server) *REST {
    return &REST{
        GetServersToValidate: serverRetriever,    
        tableConvertor: printerstorage.TableConvertor{TableGenerator: printers.NewTableGenerator().With(printersinternal.AddHandlers)},    
    }
}
func (r *REST) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1beta1.Table, error) {
    return r.tableConvertor.ConvertToTable(ctx, object, tableOptions)
}

最后

本想提交一个PR给kubernetes,发现已经有人先一步提了,解决方法和我一摸一样,只是上述tableConvertor字段是大写开头,我觉得小写更好,有点遗憾。而这个问题在2019.9.23已经有人提出,也就是1.16刚发布的时候,9.24就有人提了PR,解决速度非常之快,可见开源软件的优势以及k8s热度之高,有无数的开发者为其贡献力量,k8s就像聚光灯下的明星,无数双眼睛注目着。不过这个PR现在还没有合入主干,还在代码审查阶段,这个bug相对来讲不是很严重,所以优先级不那么高,要知道现在还有1097个PR。虽然最后有一点小遗憾,不过在解决问题的过程中对kubernetes的理解也更进一步,还是收获良多。在阅读代码的过程中,随处可见各种TODO,发现代码不断在重构,今天代码在这里,明天代码就搬到另一个地方了,k8s这么一个冉冉升起的新星,虽然从2017年起就成为容器编排的事实标准,并被广泛应用到生产环境,但它本身还在不断进化,还需不断完善。

欢迎访问钟潘的博客
查看原文

赞 2 收藏 1 评论 1

zhongpan 赞了文章 · 2019-10-03

干货满满的 Go Modules 和 goproxy.cn

大家好,我是一只普通的煎鱼,周四晚上很有幸邀请到 goproxy.cn 的作者 @盛傲飞(@aofei) 到 Go 夜读给我们进行第 61 期 《Go Modules、Go Module Proxy 和 goproxy.cn》的技术分享。

本次 @盛傲飞 的夜读分享,是对 Go Modules 的一次很好的解读,比较贴近工程实践,我必然希望把这块的知识更多的分享给大家,因此有了今天本篇文章,同时大家也可以多关注 Go 夜读,每周会通过 zoom 在线直播的方式分享 Go 相关的技术话题,希望对大家有所帮助。

原文地址:干货满满的 Go Modules 和 goproxy.cn

前言

Go 1.11 推出的模块(Modules)为 Go 语言开发者打开了一扇新的大门,理想化的依赖管理解决方案使得 Go 语言朝着计算机编程史上的第一个依赖乌托邦(Deptopia)迈进。随着模块一起推出的还有模块代理协议(Module proxy protocol),通过这个协议我们可以实现 Go 模块代理(Go module proxy),也就是依赖镜像。

Go 1.13 的发布为模块带来了大量的改进,所以模块的扶正就是这次 Go 1.13 发布中开发者能直接感觉到的最大变化。而问题在于,Go 1.13 中的 GOPROXY 环境变量拥有了一个在中国大陆无法访问到的默认值 proxy.golang.org,经过大家在 golang/go#31755 中激烈的讨论(有些人甚至将话提上升到了“自由世界”的层次),最终 Go 核心团队仍然无法为中国开发者提供一个可在中国大陆访问的官方模块代理。

为了今后中国的 Go 语言开发者能更好地进行开发,七牛云推出了非营利性项目 goproxy.cn,其目标是为中国和世界上其他地方的 Gopher 们提供一个免费的、可靠的、持续在线的且经过 CDN 加速的模块代理。可以预见未来是属于模块化的,所以 Go 语言开发者能越早切入模块就能越早进入未来。

如果说 Go 1.11 和 Go 1.12 时由于模块的不完善你不愿意切入,那么 Go 1.13 你则可以大胆地开始放心使用。本次分享将讨论如何使用模块和模块代理,以及在它们的使用中会常遇见的坑,还会讲解如何快速搭建自己的私有模块代理,并简单地介绍一下七牛云推出的 goproxy.cn 以及它的出现对于中国 Go 语言开发者来说重要在何处。

目录

  • Go Modules 简介
  • 快速迁移项目至 Go Modules
  • 使用 Go Modules 时常遇见的坑

    • 坑 1:判断项目是否启用了 Go Modules
    • 坑 2:管理 Go 的环境变量
    • 坑 3:从 dep、glide 等迁移至 Go Modules
    • 坑 4:拉取私有模块
    • 坑 5:更新现有的模块
    • 坑 6:主版本号
  • Go Module Proxy 简介
  • Goproxy 中国(goproxy.cn)

Go Modules 简介

image

Go modules (前身 vgo) 是 Go team (Russ Cox) 强推的一个理想化类语言级依赖管理解决方案,它是和 Go1.11 一同发布的,在 Go1.13 做了大量的优化和调整,目前已经变得比较不错,如果你想用 Go modules,但还停留在 1.11/1.12 版本的话,强烈建议升级。

三个关键字

强推

首先这并不是乱说的,因为 Go modules 确实是被强推出来的,如下:

  • 之前:大家都知道在 Go modules 之前还有一个叫 dep 的项目,它也是 Go 的一个官方的实验性项目,目的同样也是为了解决 Go 在依赖管理方面的短板。在 Russ Cox 还没有提出 Go modules 的时候,社区里面几乎所有的人都认为 dep 肯定就是未来 Go 官方的依赖管理解决方案了。
  • 后来:谁都没想到半路杀出个程咬金,Russ Cox 义无反顾地推出了 Go modules,这瞬间导致一石激起千层浪,让社区炸了锅。大家一致认为 Go team 实在是太霸道、太独裁了,连个招呼都不打一声。我记得当时有很多人在网上跟 Russ Cox 口水战,各种依赖管理解决方案的专家都冒出来发表意见,讨论范围甚至一度超出了 Go 语言的圈子触及到了其他语言的领域。

理想化

从他强制要求使用语义化版本控制这一点来说就很理想化了,如下:

  • Go modules 狠到如果你的 Tag 没有遵循语义化版本控制那么它就会忽略你的 Tag,然后根据你的 Commit 时间和哈希值再为你生成一个假定的符合语义化版本控制的版本号。
  • Go modules 还默认认为,只要你的主版本号不变,那这个模块版本肯定就不包含 Breaking changes,因为语义化版本控制就是这么规定的啊。是不是很理想化。

类语言级:

这个关键词其实是我自己瞎编的,我只是单纯地个人认为 Go modules 在设计上就像个语言级特性一样,比如如果你的主版本号发生变更,那么你的代码里的 import path 也得跟着变,它认为主版本号不同的两个模块版本是完全不同的两个模块。此外,Go moduels 在设计上跟 go 整个命令都结合得相当紧密,无处不在,所以我才说它是一个有点儿像语言级的特性,虽然不是太严谨。

推 Go Modules 的人是谁

那么在上文中提到的 Russ Cox 何许人也呢,很多人应该都知道他,他是 Go 这个项目目前代码提交量最多的人,甚至是第二名的两倍还要多。

Russ Cox 还是 Go 现在的掌舵人(大家应该知道之前 Go 的掌舵人是 Rob Pike,但是听说由于他本人不喜欢特朗普执政所以离开了美国,然后他岁数也挺大的了,所以也正在逐渐交权,不过现在还是在参与 Go 的发展)。

Russ Cox 的个人能力相当强,看问题的角度也很独特,这也就是为什么他刚一提出 Go modules 的概念就能引起那么大范围的响应。虽然是被强推的,但事实也证明当下的 Go modules 表现得确实很优秀,所以这表明一定程度上的 “独裁” 还是可以接受的,至少可以保证一个项目能更加专一地朝着一个方向发展。

总之,无论如何 Go modules 现在都成了 Go 语言的一个密不可分的组件。

GOPATH

Go modules 出现的目的之一就是为了解决 GOPATH 的问题,也就相当于是抛弃 GOPATH 了。

Opt-in

Go modules 还处于 Opt-in 阶段,就是你想用就用,不用就不用,不强制你。但是未来很有可能 Go2 就强制使用了。

"module" != "package"

有一点需要纠正,就是“模块”和“包”,也就是 “module” 和 “package” 这两个术语并不是等价的,是 “集合” 跟 “元素” 的关系,“模块” 包含 “包”,“包” 属于 “模块”,一个 “模块” 是零个、一个或多个 “包” 的集合。

Go Modules 相关属性

image

go.mod

module example.com/foobar

go 1.13

require (
    example.com/apple v0.1.2
    example.com/banana v1.2.3
    example.com/banana/v2 v2.3.4
    example.com/pineapple v0.0.0-20190924185754-1b0db40df49a
)

exclude example.com/banana v1.2.4
replace example.com/apple v0.1.2 => example.com/rda v0.1.0 
replace example.com/banana => example.com/hugebanana

go.mod 是启用了 Go moduels 的项目所必须的最重要的文件,它描述了当前项目(也就是当前模块)的元信息,每一行都以一个动词开头,目前有以下 5 个动词:

  • module:用于定义当前项目的模块路径。
  • go:用于设置预期的 Go 版本。
  • require:用于设置一个特定的模块版本。
  • exclude:用于从使用中排除一个特定的模块版本。
  • replace:用于将一个模块版本替换为另外一个模块版本。

这里的填写格式基本为包引用路径+版本号,另外比较特殊的是 go $version,目前从 Go1.13 的代码里来看,还只是个标识作用,暂时未知未来是否有更大的作用。

go.sum

go.sum 是类似于比如 dep 的 Gopkg.lock 的一类文件,它详细罗列了当前项目直接或间接依赖的所有模块版本,并写明了那些模块版本的 SHA-256 哈希值以备 Go 在今后的操作中保证项目所依赖的那些模块版本不会被篡改。

example.com/apple v0.1.2 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 
example.com/apple v0.1.2/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= example.com/banana v1.2.3 h1:qHgHjyoNFV7jgucU8QZUuU4gcdhfs8QW1kw68OD2Lag= 
example.com/banana v1.2.3/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= example.com/banana/v2 v2.3.4 h1:zl/OfRA6nftbBK9qTohYBJ5xvw6C/oNKizR7cZGl3cI= example.com/banana/v2 v2.3.4/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= 
...

我们可以看到一个模块路径可能有如下两种:

example.com/apple v0.1.2 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 
example.com/apple v0.1.2/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

前者为 Go modules 打包整个模块包文件 zip 后再进行 hash 值,而后者为针对 go.mod 的 hash 值。他们两者,要不就是同时存在,要不就是只存在 go.mod hash。

那什么情况下会不存在 zip hash 呢,就是当 Go 认为肯定用不到某个模块版本的时候就会省略它的 zip hash,就会出现不存在 zip hash,只存在 go.mod hash 的情况。

GO111MODULE

这个环境变量主要是 Go modules 的开关,主要有以下参数:

  • auto:只在项目包含了 go.mod 文件时启用 Go modules,在 Go 1.13 中仍然是默认值,详见
    :golang.org/issue/31857。
  • on:无脑启用 Go modules,推荐设置,未来版本中的默认值,让 GOPATH 从此成为历史。
  • off:禁用 Go modules。

GOPROXY

这个环境变量主要是用于设置 Go 模块代理,主要如下:

  • 它的值是一个以英文逗号 “,” 分割的 Go module proxy 列表(稍后讲解)

    • 作用:用于使 Go 在后续拉取模块版本时能够脱离传统的 VCS 方式从镜像站点快速拉取。它拥有一个默认:https://proxy.golang.org,direct,但很可惜 proxy.golang.org 在中国无法访问,故而建议使用 goproxy.cn 作为替代,可以执行语句:go env -w GOPROXY=https://goproxy.cn,direct
    • 设置为 “off” :禁止 Go 在后续操作中使用任 何 Go module proxy。

刚刚在上面,我们可以发现值列表中有 “direct” ,它又有什么作用呢。其实值列表中的 “direct” 为特殊指示符,用于指示 Go 回源到模块版本的源地址去抓取(比如 GitHub 等),当值列表中上一个 Go module proxy 返回 404 或 410 错误时,Go 自动尝试列表中的下一个,遇见 “direct” 时回源,遇见 EOF 时终止并抛出类似 “invalid version: unknown revision...” 的错误。

GOSUMDB

它的值是一个 Go checksum database,用于使 Go 在拉取模块版本时(无论是从源站拉取还是通过 Go module proxy 拉取)保证拉取到的模块版本数据未经篡改,也可以是“off”即禁止 Go 在后续操作中校验模块版本

  • 格式 1:<SUMDB_NAME>+<PUBLIC_KEY>
  • 格式 2:<SUMDB_NAME>+<PUBLIC_KEY> <SUMDB_URL>
  • 拥有默认值:sum.golang.org (之所以没有按照上面的格式是因为 Go 对默认值做了特殊处理)。
  • 可被 Go module proxy 代理 (详见:Proxying a Checksum Database)。
  • sum.golang.org 在中国无法访问,故而更加建议将 GOPROXY 设置为 goproxy.cn,因为 goproxy.cn 支持代理 sum.golang.org

Go Checksum Database

Go checksum database 主要用于保护 Go 不会从任何源头拉到被篡改过的非法 Go 模块版本,其作用(左)和工作机制(右)如下图:

image

如果有兴趣的小伙伴可以看看 Proposal: Secure the Public Go Module Ecosystem,有详细介绍其算法机制,如果想简单一点,查看 go help module-auth 也是一个不错的选择。

GONOPROXY/GONOSUMDB/GOPRIVATE

这三个环境变量都是用在当前项目依赖了私有模块,也就是依赖了由 GOPROXY 指定的 Go module proxy 或由 GOSUMDB 指定 Go checksum database 无法访问到的模块时的场景

  • 它们三个的值都是一个以英文逗号 “,” 分割的模块路径前缀,匹配规则同 path.Match。
  • 其中 GOPRIVATE 较为特殊,它的值将作为 GONOPROXY 和 GONOSUMDB 的默认值,所以建议的最佳姿势是只是用 GOPRIVATE。

在使用上来讲,比如 GOPRIVATE=*.corp.example.com 表示所有模块路径以 corp.example.com 的下一级域名 (如 team1.corp.example.com) 为前缀的模块版本都将不经过 Go module proxy 和 Go checksum database,需要注意的是不包括 corp.example.com 本身。

Global Caching

这个主要是针对 Go modules 的全局缓存数据说明,如下:

  • 同一个模块版本的数据只缓存一份,所有其他模块共享使用。
  • 目前所有模块版本数据均缓存在 $GOPATH/pkg/mod和 ​$GOPATH/pkg/sum 下,未来或将移至 $GOCACHE/mod $GOCACHE/sum 下( 可能会在当 $GOPATH 被淘汰后)。
  • 可以使用 go clean -modcache 清理所有已缓存的模块版本数据。

另外在 Go1.11 之后 GOCACHE 已经不允许设置为 off 了,我想着这也是为了模块数据缓存移动位置做准备,因此大家应该尽快做好适配。

快速迁移项目至 Go Modules

  • 第一步: 升级到 Go 1.13。
  • 第二步: 让 GOPATH 从你的脑海中完全消失,早一步踏入未来。

    • 修改 GOBIN 路径(可选):go env -w GOBIN=$HOME/bin
    • 打开 Go modules:go env -w GO111MODULE=on
    • 设置 GOPROXY:go env -w GOPROXY=https://goproxy.cn,direct # 在中国是必须的,因为它的默认值被墙了。
  • 第三步(可选): 按照你喜欢的目录结构重新组织你的所有项目。
  • 第四步: 在你项目的根目录下执行 go mod init <OPTIONAL_MODULE_PATH> 以生成 go.mod 文件。
  • 第五步: 想办法说服你身边所有的人都去走一下前四步。

迁移后 go get 行为的改变

  • go help module-getgo help gopath-get分别去了解 Go modules 启用和未启用两种状态下的 go get 的行为
  • go get 拉取新的依赖

    • 拉取最新的版本(优先择取 tag):go get golang.org/x/text@latest
    • 拉取 master 分支的最新 commit:go get golang.org/x/text@master
    • 拉取 tag 为 v0.3.2 的 commit:go get golang.org/x/text@v0.3.2
    • 拉取 hash 为 342b231 的 commit,最终会被转换为 v0.3.2:go get golang.org/x/text@342b2e
    • go get -u 更新现有的依赖
    • go mod download 下载 go.mod 文件中指明的所有依赖
    • go mod tidy 整理现有的依赖
    • go mod graph 查看现有的依赖结构
    • go mod init 生成 go.mod 文件 (Go 1.13 中唯一一个可以生成 go.mod 文件的子命令)
  • go mod edit 编辑 go.mod 文件
  • go mod vendor 导出现有的所有依赖 (事实上 Go modules 正在淡化 Vendor 的概念)
  • go mod verify 校验一个模块是否被篡改过

这里我们注意到有两点比较特别,分别是:

  • 第一点:为什么 “拉取 hash 为 342b231 的 commit,最终会被转换为 v0.3.2” 呢。这是因为虽然我们设置了拉取 @342b2e commit,但是因为 Go modules 会与 tag 进行对比,若发现对应的 commit 与 tag 有关联,则进行转换。
  • 第二点:为什么不建议使用 go mod vendor,因为 Go modules 正在淡化 Vendor 的概念,很有可能 Go2 就去掉了。

使用 Go Modules 时常遇见的坑

坑 1: 判断项目是否启用了 Go Modules

image

坑 2: 管理 Go 的环境变量

image

这里主要是提到 Go1.13 新增了 go env -w 用于写入环境变量,而写入的地方是 os.UserConfigDir 所返回的路径,需要注意的是 go env -w 不会覆写。

坑 3: 从 dep、glide 等迁移至 Go Modules

image

这里主要是指从旧有的依赖包管理工具(dep/glide 等)进行迁移时,因为 BUG 的原因会导致不经过 GOPROXY 的代理,解决方法有如下两个:

  • 手动创建一个 go.mod 文件,再执行 go mod tidy 进行补充。
  • 上代理,相当于不使用 GOPROXY 了。

坑 4:拉取私有模块

image

这里主要想涉及两块知识点,如下:

  • GOPROXY 是无权访问到任何人的私有模块的,所以你放心,安全性没问题。
  • GOPROXY 除了设置模块代理的地址以外,还需要增加 “direct” 特殊标识才可以成功拉取私有库。

坑 5:更新现有的模块

image

坑 6:主版本号

image

Go Module Proxy 简介

image

在这里再次强调了 Go Module Proxy 的作用(图左),以及其对应的协议交互流程(图右),有兴趣的小伙伴可以认真看一下。

Goproxy 中国(goproxy.cn)

在这块主要介绍了 Goproxy 的实践操作以及 goproxy.cn 的一些 Q&A 和 近况,如下:

Q&A

Q:如果中国 Go 语言社区没有咱们自己家的 Go Module Proxy 会怎么样?

A:在 Go 1.13 中 GOPROXY 和 GOSUMDB 这两个环境变量都有了在中国无法 访问的默认值,尽管我在 golang.org/issue/31755 里努力尝 试过,但最终仍然无法为咱们中国的 Go 语言开发者谋得一个完美的解决方案。所以从今以后咱 们中国的所有 Go 语言开发者,只要是 使用了 Go modules 的,那么都必须先修改 GOPROXY 和 GOSUMDB 才能正常使用 Go 做开发,否则可能连一个最简单的程序都跑不起 来(只要它有依 赖第三方模 块)。

Q: 我创建 Goproxy 中国(goproxy.cn)的主要原因?

A:其实更早的时候,也就是今年年初我也曾 试图在 golang.org/issue/31020 中请求 Go team 能想办法避免那时的 GOPROXY 即将拥有的默认值可以在中国正常访问,但 Go team 似乎也无能为力,为此我才坚定了创建 goproxy.cn 的信念。既然别人没法儿帮忙,那咱们就 得自己动手,不为别的,就为了让大家以后能够更愉快地使用 Go 语言配合 Go modules 做开发。

最初我先是和七牛云的 许叔(七牛云的 创始人兼 CEO 许式伟)提出了我打算 创建 goproxy.cn 的想法,本是抱着 试试看的目的,但没想 到 许叔几乎是没有超过一分钟的考虑便认可了我的想法并表示愿意一起推 动。那一阵子刚好赶上我在写毕业论文,所以项目开发完后就 一直没和七牛云做交接,一直跑在我的个人服 务器上。直到有一次 goproxy.cn 被攻击了,一下午的功夫 烧了我一百多美元,然后我才 意识到这种项目真不能个人来做。个人来做不靠 谱,万一依赖这个项目的人多了,项目再出什么事儿,那就会给大家􏰁成不必要的损 失。所以我赶紧和七牛云做了交接,把 goproxy.cn 完全交给了七牛云,甚至连域名都过户了去。

近况

image

  • Goproxy 中国 (goproxy.cn) 是目前中国最可靠的 Go module proxy (真不是在自卖自夸)。
  • 为中国 Go 语言开发者量身打􏰁,支持代理 GOSUMDB 的默认值,经过全球 CDN 加速,高可用,可 应用进公司复杂的开发环境中,亦可用作上游代理。
  • 由中国倍受信赖的云服务提供商七牛云无偿提供基础设施支持的开源的非营利性项目。
  • 目标是为中国乃至全世界的 Go 语言开发者提供一个免 费的、可靠的、持 续在线的且经过 CDN 加􏰀的 Go module proxy。
  • 域名已由七牛云进行了备案 (沪ICP备11037377号-56)。

情况

image

此处呈现的是存储大小,主要是针对模块包代码,而一般来讲代码并不会有多大,0-10MB,10-50MB 占最大头,也是能够理解,但是大于 100MB 的模块包代码就比较夸张了。

image

此时主要是展示了一下近期 goproxy.cn 的网络数据情况,我相信未来是会越来越高的,值得期待。

Q&A

Q:如何解决 Go 1.13 在从 GitLab 拉取模块版本时遇到的,Go 错误地按照非期望值的路径寻找目标模块版本结果致使最终目标模块拉取失败的问题?

A:GitLab 中配合 goget 而设置的 <meta> 存在些许问题,导致 Go 1.13 错误地识别了模块的具体路径,这是个 Bug,据说在 GitLab 的新版本中已经被修复了,详细内容可以看 https://github.com/golang/go/... 这个 Issue。然后目前的解决办法的话除了升级 GitLab 的版本外,还可以参考 https://github.com/developer-... 这条回复。

Q:使用 Go modules 时可以同时依赖同一个模块的不同的两个或者多个小版本(修订版本号不同)吗?

A:不可以的,Go modules 只可以同时依赖一个模块的不同的两个或者多个大版本(主版本号不同)。比如可以同时依赖 example.com/foobar@v1.2.3example.com/foobar/v2@v2.3.4,因为他们的模块路径(module path)不同,Go modules 规定主版本号不是 v0 或者 v1 时,那么主版本号必须显式地出现在模块路径的尾部。但是,同时依赖两个或者多个小版本是不支持的。比如如果模块 A 同时直接依赖了模块 B 和模块 C,且模块 A 直接依赖的是模块 C 的 v1.0.0 版本,然后模块 B 直接依赖的是模块 C 的 v1.0.1 版本,那么最终 Go modules 会为模块 A 选用模块 C 的 v1.0.1 版本而不是模块 A 的 go.mod 文件中指明的 v1.0.0 版本。

这是因为 Go modules 认为只要主版本号不变,那么剩下的都可以直接升级采用最新的。但是如果采用了最新的结果导致项目 Break 掉了,那么 Go modules 就会 Fallback 到上一个老的版本,比如在前面的例子中就会 Fallback 到 v1.0.0 版本。

Q:在 go.sum 文件中的一个模块版本的 Hash 校验数据什么情况下会成对出现,什么情况下只会存在一行?

A:通常情况下,在 go.sum 文件中的一个模块版本的 Hash 校验数据会有两行,前一行是该模块的 ZIP 文件的 Hash 校验数据,后一行是该模块的 go.mod 文件的 Hash 校验数据。但是也有些情况下只会出现一行该模块的 go.mod 文件的 Hash 校验数据,而不包含该模块的 ZIP 文件本身的 Hash 校验数据,这个情况发生在 Go modules 判定为你当前这个项目完全用不到该模块,根本也不会下载该模块的 ZIP 文件,所以就没必要对其作出 Hash 校验保证,只需要对该模块的 go.mod 文件作出 Hash 校验保证即可,因为 go.mod 文件是用得着的,在深入挖取项目依赖的时候要用。

Q:能不能更详细地讲解一下 go.mod 文件中的 replace 动词的行为以及用法?

A:这个 replace 动词的作用是把一个“模块版本”替换为另外一个“模块版本”,这是“模块版本”和“模块版本(module path)”之间的替换,“=>”标识符前面的内容是待替换的“模块版本”的“模块路径”,后面的内容是要替换的目标“模块版本”的所在地,即路径,这个路径可以是一个本地磁盘的相对路径,也可以是一个本地磁盘的绝对路径,还可以是一个网络路径,但是这个目标路径并不会在今后你的项目代码中作为你“导入路径(import path)”出现,代码里的“导入路径”还是得以你替换成的这个目标“模块版本”的“模块路径”作为前缀。

另外需要注意,Go modules 是不支持在 “导入路径” 里写相对路径的。举个例子,如果项目 A 依赖了模块 B,比如模块 B 的“模块路径”是 example.com/b,然后它在的磁盘路径是 ~/b,在项目 A 里的 go.mod 文件中你有一行 replace example.com/b=>~/b,然后在项目 A 里的代码中的“导入路基”就是 import"example.com/b",而不是 import"~/b",剩下的工作是 Go modules 帮你自动完成了的。

然后就是我在分享中也提到了, exclude 和 replace 这两个动词只作用于当前主模块,也就是当前项目,它所依赖的那些其他模块版本中如果出现了你待替换的那个模块版本的话,Go modules 还是会为你依赖的那个模块版本去拉取你的这个待替换的模块版本。

举个例子,比如项目 A 直接依赖了模块 B 和模块 C,然后模块 B 也直接依赖了模块 C,那么你在项目 A 中的 go.mod 文件里的 replace c=>~/some/path/c 是只会影响项目 A 里写的代码中,而模块 B 所用到的还是你 replace 之前的那个 c,并不是你替换成的 ~/some/path/c 这个。

总结

在 Go1.13 发布后,接触 Go modules 和 Go module proxy 的人越来越多,经常在各种群看到各种小伙伴在咨询,包括我自己也贡献了好几枚 “坑”,因此我觉得傲飞的这一次 《Go Modules、Go Module Proxy 和 goproxy.cn》的技术分享,非常的有实践意义。如果后续大家还有什么建议或问题,欢迎随时来讨论。

最后,感谢 goproxy.cn 背后的人们(@七牛云 和 @盛傲飞)对中国 Go 语言社区的无私贡献和奉献。

我的公众号

公众号二维码

进一步阅读

查看原文

赞 65 收藏 42 评论 10

zhongpan 关注了标签 · 2019-07-21

html5

HTML5 是 HTML 下一个的主要修订版本,现在仍处于发展阶段。广义论及 HTML5 时,实际指的是包括 HTML、CSS 和 JavaScript 在内的一套技术组合。

关注 88053

zhongpan 关注了标签 · 2019-06-15

docker

an open source project to pack, ship and run any application as a lightweight container ! By Lock !

关注 9299

zhongpan 关注了用户 · 2017-06-14

极乐君 @dreawer

极乐,让技术变得更简单!

关注 156

zhongpan 关注了用户 · 2017-06-07

高阳Sunny @sunny

SegmentFault 思否 CEO
C14Z.Group Founder
Forbes China 30U30

独立思考 敢于否定

曾经是个话痨... 要做一个有趣的人!

任何问题可以给我发私信或者发邮件 sunny@sifou.com

关注 2119

认证与成就

  • 获得 2 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-03-23
个人主页被 189 人浏览