分类 技术志 下的文章

在Kubernetes集群上部署高可用Harbor镜像仓库

关于基于Harbor的高可用私有镜像仓库,在我的博客里曾不止一次提到,在源创会2017沈阳站上,我还专门以此题目和大家做了分享。事后,很多人通过微博私信个人公众号或博客评论问我是否可以在Kubernetes集群上安装高可用的Harbor仓库,今天我就用这篇文章来回答大家这个问题。

一、Kubernetes上的高可用Harbor方案

首先,我可以肯定给出一个回答:Harbor支持在Kubernetes部署。只不过Harbor官方的默认安装并非是高可用的,而是“单点式”的。在《基于Harbor的高可用企业级私有容器镜像仓库部署实践》一文中,我曾谈到了一种在裸机或VM上的、基于Cephfs共享存储的高可用Harbor方案。在Kubernetes上部署,其高可用的思路也是类似的,可见下面这幅示意图:

img{512x368}

围绕这幅示意图,简单说明一下我们的方案:

  • 通过在Kubernetes上启动Harbor内部各组件的多个副本的方式实现Harbor服务的计算高可用;
  • 通过挂载CephFS共享存储的方式实现镜像数据高可用;
  • Harbor使用的配置数据和关系数据放在外部(External)数据库集群中,保证数据高可用和实时一致性;
  • 通过外部Redis集群实现UI组件的session共享。

方案确定后,接下来我们就开始部署。

二、环境准备

在Harbor官方的对Kubernetes支持的说明中,提到当前的Harbor on kubernetes相关脚本和配置在Kubernetes v1.6.5和Harbor v1.2.0上验证测试通过了,因此在我们的实验环境中,Kubernetes至少要准备v1.6.5及以后版本。下面是我的环境的一些信息:

Kubernetes使用v1.7.3版本:

# kubelet --version
Kubernetes v1.7.3

Docker使用17.03.2版本:

# docker version
Client:
 Version:      17.03.2-ce
 API version:  1.27
 Go version:   go1.7.5
 Git commit:   f5ec1e2
 Built:        Tue Jun 27 03:35:14 2017
 OS/Arch:      linux/amd64

Server:
 Version:      17.03.2-ce
 API version:  1.27 (minimum version 1.12)
 Go version:   go1.7.5
 Git commit:   f5ec1e2
 Built:        Tue Jun 27 03:35:14 2017
 OS/Arch:      linux/amd64
 Experimental: false

关于Harbor的相关脚本,我们直接用master branch中的,而不是v1.2.0这个release版本中的。切记!否则你会发现v1.2.0版本源码中的相关kubernetes支持脚本根本就没法工作,甚至缺少adminserver组件的相关脚本。不过Harbor相关组件的image版本,我们使用的还是v1.2.0的:

Harbor源码的版本:

commit 82d842d77c01657589d67af0ea2d0c66b1f96014
Merge pull request #3741 from wy65701436/add-tc-concourse   on Dec 4, 2017

Harbor各组件的image的版本:

REPOSITORY                      TAG                 IMAGE ID
vmware/harbor-jobservice      v1.2.0          1fb18427db11
vmware/harbor-ui              v1.2.0          b7069ac3bd4b
vmware/harbor-adminserver     v1.2.0          a18331f0c1ae
vmware/registry               2.6.2-photon    c38af846a0da
vmware/nginx-photon           1.11.13         2971c92cc1ae

除此之外,高可用Harbor使用外部的DB cluster和redis cluster,DB cluster我们采用MySQL,对于MySQL cluster,可以使用mysql galera cluster或MySQL5.7以上版本自带的Group Replication (MGR) 集群。

三、探索harbor on k8s部署脚本和配置

我们在本地创建harbor-install-on-k8s目录,并将Harbor最新源码下载到该目录下:

# mkdir harbor-install-on-k8s
# cd harbor-install-on-k8s
# wget -c https://github.com/vmware/harbor/archive/master.zip
# unzip master.zip
# cd harbor-master
# ls -F
AUTHORS  CHANGELOG.md  contrib/  CONTRIBUTING.md  docs/
LICENSE  make/  Makefile  NOTICE  partners.md  README.md
ROADMAP.md  src/  tests/  tools/  VERSION

将Harbor部署到k8s上的脚本就在make/kubernetes目录下:

# cd harbor-master/make
# tree kubernetes
kubernetes
├── adminserver
│   ├── adminserver.rc.yaml
│   └── adminserver.svc.yaml
├── jobservice
│   ├── jobservice.rc.yaml
│   └── jobservice.svc.yaml
├── k8s-prepare
├── mysql
│   ├── mysql.rc.yaml
│   └── mysql.svc.yaml
├── nginx
│   ├── nginx.rc.yaml
│   └── nginx.svc.yaml
├── pv
│   ├── log.pvc.yaml
│   ├── log.pv.yaml
│   ├── registry.pvc.yaml
│   ├── registry.pv.yaml
│   ├── storage.pvc.yaml
│   └── storage.pv.yaml
├── registry
│   ├── registry.rc.yaml
│   └── registry.svc.yaml
├── templates
│   ├── adminserver.cm.yaml
│   ├── jobservice.cm.yaml
│   ├── mysql.cm.yaml
│   ├── nginx.cm.yaml
│   ├── registry.cm.yaml
│   └── ui.cm.yaml
└── ui
    ├── ui.rc.yaml
    └── ui.svc.yaml

8 directories, 25 files

  • k8s-prepare脚本:根据templates下的模板文件以及harbor.cfg中的配置生成各个组件,比如registry等的最终configmap配置文件。它的作用类似于用docker-compose工具部署Harbor时的prepare脚本;
  • templates目录:templates目录下放置各个组件的配置模板文件(configmap文件模板),将作为k8s-prepare的输入;
  • pv目录:Harbor组件所使用的存储插件的配置,默认情况下使用hostpath,对于高可用Harbor而言,我们这里将使用cephfs;
  • 其他组件目录,比如:registry:这些目录中存放这各个组件的service yaml和rc yaml,用于在Kubernetes cluster启动各个组件时使用。

下面我用一个示意图来形象地描述一下配置的生成过程以及各个文件在后续Harbor组件启动中的作用:

img{512x368}

由于使用external mysql db,Harbor自带的mysql组件我们不会使用,对应的pv目录下的storage.pv.yaml和storage.pvc.yaml我们也不会去关注和使用。

四、部署步骤

1、配置和创建挂载Cephfs的pv和pvc

我们先在共享分布式存储CephFS上为Harbor的存储需求创建目录:apps/harbor-k8s,并在harbor-k8s下创建两个子目录:log和registry,分别满足jobservice和registry的存储需求:

# cd /mnt   // CephFS的根目录挂载到了/mnt下面
# mkdir -p apps/harbor-k8s/log
# mkdir -p apps/harbor-k8s/registry
# tree apps/harbor-k8s
apps/harbor-k8s
├── log
└── registry

关于CephFS的挂载等具体操作步骤,可以参见我的《Kubernetes集群跨节点挂载CephFS》一文。

接下来,创建用于k8s pv挂载cephfs的ceph-secret,我们编写一个ceph-secret.yaml文件:

//ceph-secret.yaml
apiVersion: v1
data:
  key: {base64 encoding of the ceph admin.secret}
kind: Secret
metadata:
  name: ceph-secret
type: Opaque

创建ceph-secret:

# kubectl create -f ceph-secret.yaml
secret "ceph-secret" created

最后,我们来修改pv、pvc文件并创建对应的pv和pvc资源,要修改的文件包括pv/log.xxx和pv/registry.xxx,我们的目的就是用cephfs替代原先的hostPath:

//log.pv.yaml

apiVersion: v1
kind: PersistentVolume
metadata:
  name: log-pv
  labels:
    type: log
spec:
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteMany
  cephfs:
    monitors:
      - {ceph-mon-node-ip}:6789
    path: /apps/harbor-k8s/log
    user: admin
    secretRef:
      name: ceph-secret
    readOnly: false
  persistentVolumeReclaimPolicy: Retain

//log.pvc.yaml

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: log-pvc
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 1Gi
  selector:
    matchLabels:
      type: log

// registry.pv.yaml

apiVersion: v1
kind: PersistentVolume
metadata:
  name: registry-pv
  labels:
    type: registry
spec:
  capacity:
    storage: 5Gi
  accessModes:
    - ReadWriteMany
  cephfs:
    monitors:
      - 10.47.217.91:6789
    path: /apps/harbor-k8s/registry
    user: admin
    secretRef:
      name: ceph-secret
    readOnly: false
  persistentVolumeReclaimPolicy: Retain

//registry.pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: registry-pvc
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 5Gi
  selector:
    matchLabels:
      type: registry

创建pv和pvc:

# kubectl create -f log.pv.yaml
persistentvolume "log-pv" created
# kubectl create -f log.pvc.yaml
persistentvolumeclaim "log-pvc" created
# kubectl create -f registry.pv.yaml
persistentvolume "registry-pv" created
# kubectl create -f registry.pvc.yaml
persistentvolumeclaim "registry-pvc" created
# kubectl get pvc
NAME           STATUS    VOLUME        CAPACITY   ACCESSMODES   STORAGECLASS   AGE
log-pvc        Bound     log-pv        1Gi        RWX                          31s
registry-pvc   Bound     registry-pv   5Gi        RWX                          2s
# kubectl get pv
NAME          CAPACITY   ACCESSMODES   RECLAIMPOLICY   STATUS    CLAIM                  STORAGECLASS   REASON    AGE
log-pv        1Gi        RWX           Retain          Bound     default/log-pvc                                 36s
registry-pv   5Gi        RWX           Retain          Bound     default/registry-pvc                            6s

2、创建和初始化Harbor用的数据库

我们需要在External DB中创建Harbor访问数据库所用的user(harbork8s/harbork8s)以及所使用的数据库(registry_k8s):

mysql> create user harbork8s identified  by 'harbork8s';
Query OK, 0 rows affected (0.03 sec)

mysql> GRANT ALL PRIVILEGES ON *.* TO 'harbork8s'@'%' IDENTIFIED BY 'harbork8s' WITH GRANT OPTION;
Query OK, 0 rows affected, 1 warning (0.00 sec)

# mysql> create database registry_k8s;
Query OK, 1 row affected (0.00 sec)

mysql> grant all on registry_k8s.* to 'harbork8s' identified by 'harbork8s';
Query OK, 0 rows affected, 1 warning (0.00 sec)

由于目前Harbor还不支持自动init数据库,因此我们需要为新建的registry_k8s数据库做初始化,具体的方案就是先使用docker-compose工具在本地启动一个harbor,通过mysqldump将harbor-db container中的数据表dump出来,再导入到external db中的registry_k8s中,具体操作步骤如下:

# wget -c http://harbor.orientsoft.cn/harbor-1.2.0/harbor-offline-installer-v1.2.0.tgz
# tar zxvf harbor-offline-installer-v1.2.0.tgz

进入harbor目录,修改harbor.cfg中的hostname:

hostname = hub.tonybai.com:31777

# ./prepare
# docker-compose up -d

找到harbor_db的container id: 77fde71390e7,进入容器,并将数据库registry dump出来:

# docker exec -i -t  77fde71390e7 bash
# mysqldump -u root -pxxx --databases registry > registry.dump

离开容器,将容器内导出的registry.dump copy到本地:
# docker cp 77fde71390e7:/tmp/registry.dump ./

修改registry.dump为registry_k8s.dump,修改其内容中的registry为registry_k8s,然后导入到external db:

# mysqldump -h external_db_ip -P 3306 -u harbork8s -pharbork8s
mysql> source ./registry_k8s.dump;

3、配置make/harbor.cfg

harbor.cfg是整个配置生成的重要输入,我们在k8s-prepare执行之前,先要根据我们的需要和环境对harbor.cfg进行配置:

// make/harbor.cfg
hostname = hub.tonybai.com:31777
db_password = harbork8s
db_host = {external_db_ip}
db_user = harbork8s

4、对templates目录下的configmap配置模板(*.cm.yaml)进行配置调整

  • templates/adminserver.cm.yaml:
MYSQL_HOST: {external_db_ip}
MYSQL_USR: harbork8s
MYSQL_DATABASE: registry_k8s
RESET: "true"

注:adminserver.cm.yaml没有使用harbor.cfg中的有关数据库的配置项,而是需要单独再配置一遍,这块估计将来会fix掉这个问题。

  • templates/registry.cm.yaml:
rootcertbundle: /etc/registry/root.crt
  • templates/ui.cm.yaml:

ui组件需要添加session共享。ui组件读取_REDIS_URL环境变量:

//vmware/harbor/src/ui/main.go
... ..
    redisURL := os.Getenv("_REDIS_URL")
    if len(redisURL) > 0 {
        beego.BConfig.WebConfig.Session.SessionProvider = "redis"
        beego.BConfig.WebConfig.Session.SessionProviderConfig = redisURL
    }
... ...

而redisURL的格式在beego的源码中有说明:

// beego/session/redis/sess_redis.go

// SessionInit init redis session
// savepath like redis server addr,pool size,password,dbnum
// e.g. 127.0.0.1:6379,100,astaxie,0
func (rp *Provider) SessionInit(maxlifetime int64, savePath string) error {...}

因此,我们在templates/ui.cm.yaml中添加一行:

_REDIS_URL: {redis_ip}:6379,100,{redis_password},11

jobservice.cm.yaml和nginx.cm.yaml无需改变。

5、对各组件目录下的xxx.rc.yaml和xxx.svc.yaml配置模板进行配置调整

  • adminserver/adminserver.rc.yaml
replicas: 3
  • adminserver/adminserver.svc.yaml

不变。

  • jobservice/jobservice.rc.yaml、jobservice/jobservice.svc.yaml

不变。

  • nginx/nginx.rc.yaml
replicas: 3
  • nginx/nginx.svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  type: NodePort
  ports:
    - name: http
      port: 80
      nodePort: 31777
      protocol: TCP
  selector:
    name: nginx-apps
  • registry/registry.rc.yaml
replicas: 3
mountPath: /etc/registry

这里有一个严重的bug,即registry.rc.yaml中configmap的默认mount路径:/etc/docker/registry与registry的docker image中的registry配置文件的路径/etc/registry不一致,这将导致我们精心配置的registry的configmap根本没有发挥作用,数据依然在memory中,而不是在我们配置的Cephfs中。这样一旦registry container退出,仓库的image数据就会丢失。同时也无法实现数据的高可用。因此,我们将mountPath都改为与registry image的一致,即:/etc/registry目录。

  • registry/registry.svc.yaml

不变。

  • ui/ui.rc.yaml
replicas: 3
  • ui/ui.svc.yaml
- name: _REDIS_URL
             valueFrom:
               configMapKeyRef:
                 name: harbor-ui-config
                 key: _REDIS_URL

6、执行k8s-prepare

执行k8s-prepare,生成各个组件的configmap文件:

# ./k8s-prepare
# git status
 ... ...

    adminserver/adminserver.cm.yaml
    jobservice/jobservice.cm.yaml
    mysql/mysql.cm.yaml
    nginx/nginx.cm.yaml
    registry/registry.cm.yaml
    ui/ui.cm.yaml

7、启动Harbor组件

  • 创建configmap
# kubectl apply -f jobservice/jobservice.cm.yaml
configmap "harbor-jobservice-config" created
# kubectl apply -f nginx/nginx.cm.yaml
configmap "harbor-nginx-config" created
# kubectl apply -f registry/registry.cm.yaml
configmap "harbor-registry-config" created
# kubectl apply -f ui/ui.cm.yaml
configmap "harbor-ui-config" created
# kubectl apply -f adminserver/adminserver.cm.yaml
configmap "harbor-adminserver-config" created

# kubectl get cm
NAME                        DATA      AGE
harbor-adminserver-config   42        14s
harbor-jobservice-config    8         16s
harbor-nginx-config         3         16s
harbor-registry-config      2         15s
harbor-ui-config            9         15s
  • 创建harbor各组件对应的k8s service
# kubectl apply -f jobservice/jobservice.svc.yaml
service "jobservice" created
# kubectl apply -f nginx/nginx.svc.yaml
service "nginx" created
# kubectl apply -f registry/registry.svc.yaml
service "registry" created
# kubectl apply -f ui/ui.svc.yaml
service "ui" created
# kubectl apply -f adminserver/adminserver.svc.yaml
service "adminserver" created

# kubectl get svc
NAME               CLUSTER-IP      EXTERNAL-IP   PORT(S)
adminserver        10.103.7.8      <none>        80/TCP
jobservice         10.104.14.178   <none>        80/TCP
nginx              10.103.46.129   <nodes>       80:31777/TCP
registry           10.101.185.42   <none>        5000/TCP,5001/TCP
ui                 10.96.29.187    <none>        80/TCP
  • 创建rc,启动各个组件pods
# kubectl apply -f registry/registry.rc.yaml
replicationcontroller "registry-rc" created
# kubectl apply -f jobservice/jobservice.rc.yaml
replicationcontroller "jobservice-rc" created
# kubectl apply -f ui/ui.rc.yaml
replicationcontroller "ui-rc" created
# kubectl apply -f nginx/nginx.rc.yaml
replicationcontroller "nginx-rc" created
# kubectl apply -f adminserver/adminserver.rc.yaml
replicationcontroller "adminserver-rc" created

#kubectl get pods
NAMESPACE     NAME                  READY     STATUS    RESTARTS   AGE
default       adminserver-rc-9pc78  1/1       Running   0          3m
default       adminserver-rc-pfqtv  1/1       Running   0          3m
default       adminserver-rc-w55sx  1/1       Running   0          3m
default       jobservice-rc-d18zk   1/1       Running   1          3m
default       nginx-rc-3t5km        1/1       Running   0          3m
default       nginx-rc-6wwtz        1/1       Running   0          3m
default       nginx-rc-dq64p        1/1       Running   0          3m
default       registry-rc-6w3b7     1/1       Running   0          3m
default       registry-rc-dfdld     1/1       Running   0          3m
default       registry-rc-t6fnx     1/1       Running   0          3m
default       ui-rc-0kwrz           1/1       Running   1          3m
default       ui-rc-kzs8d           1/1       Running   1          3m
default       ui-rc-vph6d           1/1       Running   1          3m

五、验证与Troubleshooting

1、docker cli访问

由于harbor默认使用了http访问,因此在docker login前先要将我们的仓库地址加到/etc/docker/daemon.json的insecure-registries中:

///etc/docker/daemon.json
{
  "insecure-registries": ["hub.tonybai.com:31777"]
}

systemctl daemon-reload and restart后,我们就可以通过docker login登录新建的仓库了(初始密码:Harbor12345):

 docker login hub.tonybai.com:31777
Username (admin): admin
Password:
Login Succeeded

2、docker push & pull

我们测试上传一个busybox image:

# docker pull busybox
Using default tag: latest
latest: Pulling from library/busybox
0ffadd58f2a6: Pull complete
Digest: sha256:bbc3a03235220b170ba48a157dd097dd1379299370e1ed99ce976df0355d24f0
Status: Downloaded newer image for busybox:latest
# docker tag busybox:latest hub.tonybai.com:31777/library/busybox:latest
# docker push hub.tonybai.com:31777/library/busybox:latest
The push refers to a repository [hub.tonybai.com:31777/library/busybox]
0271b8eebde3: Preparing
0271b8eebde3: Pushing [==================================================>] 1.338 MB
0271b8eebde3: Pushed
latest: digest: sha256:179cf024c8a22f1621ea012bfc84b0df7e393cb80bf3638ac80e30d23e69147f size: 527

下载刚刚上传的busybox:

# docker pull hub.tonybai.com:31777/library/busybox:latest
latest: Pulling from library/busybox
414e5515492a: Pull complete
Digest: sha256:179cf024c8a22f1621ea012bfc84b0df7e393cb80bf3638ac80e30d23e69147f
Status: Downloaded newer image for hub.tonybai.com:31777/library/busybox:latest

3、访问Harbor UI

在浏览器中打开http://hub.tonybai.com:31777,用admin/Harbor12345登录,如果看到下面页面,说明安装部署成功了:

img{512x368}

六、参考资料


微博:@tonybai_cn
微信公众号:iamtonybai
github.com: https://github.com/bigwhite

微信赞赏:
img{512x368}

Goroutine调度实例简要分析

前两天一位网友在微博私信我这样一个问题:

抱歉打扰您咨询您一个关于Go的问题:对于goroutine的概念我是明了的,但很疑惑goroutine的调度问题, 根据《Go语言编程》一书:“当一个任务正在执行时,外部没有办法终止它。要进行任务切换,只能通过由该任务自身调用yield()来主动出让CPU使用权。” 那么,假设我的goroutine是一个死循环的话,是否其它goroutine就没有执行的机会呢?我测试的结果是这些goroutine会轮流执行。那么除了syscall时会主动出让cpu时间外,我的死循环goroutine 之间是怎么做到切换的呢?

我在第一时间做了回复。不过由于并不了解具体的细节,我在答复中做了一个假定,即假定这位网友的死循环带中没有调用任何可以交出执行权的代码。事后,这位网友在他的回复后道出了死循环goroutine切换的真实原因:他在死循环中调用了fmt.Println

事后总觉得应该针对这个问题写点什么? 于是就构思了这样一篇文章,旨在循着这位网友的思路通过一些例子来step by step演示如何分析go schedule。如果您对Goroutine的调度完全不了解,那么请先读一读这篇前导文 《也谈goroutine调度器》

一、为何在deadloop的参与下,多个goroutine依旧会轮流执行

我们先来看case1,我们顺着那位网友的思路来构造第一个例子,并回答:“为何在deadloop的参与下,多个goroutine依旧会轮流执行?”这个问题。下面是case1的源码:

//github.com/bigwhite/experiments/go-sched-examples/case1.go
package main

import (
    "fmt"
    "time"
)

func deadloop() {
    for {
    }
}

func main() {
    go deadloop()
    for {
        time.Sleep(time.Second * 1)
        fmt.Println("I got scheduled!")
    }
}

在case1.go中,我们启动了两个goroutine,一个是main goroutine,一个是deadloop goroutine。deadloop goroutine顾名思义,其逻辑是一个死循环;而main goroutine为了展示方便,也用了一个“死循环”,并每隔一秒钟打印一条信息。在我的macbook air上运行这个例子(我的机器是两核四线程的,runtime的NumCPU函数返回4):

$go run case1.go
I got scheduled!
I got scheduled!
I got scheduled!
... ...

从运行结果输出的日志来看,尽管有deadloop goroutine的存在,main goroutine仍然得到了调度。其根本原因在于机器是多核多线程的(硬件线程哦,不是操作系统线程)。Go从1.5版本之后将默认的P的数量改为 = CPU core的数量(实际上还乘以了每个core上硬线程数量),这样case1在启动时创建了不止一个P,我们用一幅图来解释一下:

img{512x368}

我们假设deadloop Goroutine被调度与P1上,P1在M1(对应一个os kernel thread)上运行;而main goroutine被调度到P2上,P2在M2上运行,M2对应另外一个os kernel thread,而os kernel threads在操作系统调度层面被调度到物理的CPU core上运行,而我们有多个core,即便deadloop占满一个core,我们还可以在另外一个cpu core上运行P2上的main goroutine,这也是main goroutine得到调度的原因。

Tips: 在mac os上查看你的硬件cpu core数量和硬件线程总数量:

$sysctl -n machdep.cpu.core_count
2
$sysctl -n machdep.cpu.thread_count
4

二、如何让deadloop goroutine以外的goroutine无法得到调度?

如果我们非要deadloop goroutine以外的goroutine无法得到调度,我们该如何做呢?一种思路:让Go runtime不要启动那么多P,让所有用户级的goroutines在一个P上被调度。

三种办法:

  • 在main函数的最开头处调用runtime.GOMAXPROCS(1);
  • 设置环境变量export GOMAXPROCS=1后再运行程序
  • 找一个单核单线程的机器^0^(现在这样的机器太难找了,只能使用云服务器实现)

我们以第一种方法为例:

//github.com/bigwhite/experiments/go-sched-examples/case2.go
package main

import (
    "fmt"
    "runtime"
    "time"
)

func deadloop() {
    for {
    }
}

func main() {
    runtime.GOMAXPROCS(1)
    go deadloop()
    for {
        time.Sleep(time.Second * 1)
        fmt.Println("I got scheduled!")
    }
}

运行这个程序后,你会发现main goroutine的”I got scheduled”字样再也无法输出了。这里的调度原理可以用下面图示说明:

img{512x368}

deadloop goroutine在P1上被调度,由于deadloop内部逻辑没有给调度器任何抢占的机会,比如:进入runtime.morestack_noctxt。于是即便是sysmon这样的监控goroutine,也仅仅是能给deadloop goroutine的抢占标志位设为true而已。由于deadloop内部没有任何进入调度器代码的机会,Goroutine重新调度始终无法发生。main goroutine只能躺在P1的local queue中徘徊着。

三、反转:如何在GOMAXPROCS=1的情况下,让main goroutine得到调度呢?

我们做个反转:如何在GOMAXPROCS=1的情况下,让main goroutine得到调度呢?听说在Go中 “有函数调用,就有了进入调度器代码的机会”,我们来试验一下是否属实。我们在deadloop goroutine的for-loop逻辑中加上一个函数调用:

// github.com/bigwhite/experiments/go-sched-examples/case3.go
package main

import (
    "fmt"
    "runtime"
    "time"
)

func add(a, b int) int {
    return a + b
}

func deadloop() {
    for {
        add(3, 5)
    }
}

func main() {
    runtime.GOMAXPROCS(1)
    go deadloop()
    for {
        time.Sleep(time.Second * 1)
        fmt.Println("I got scheduled!")
    }
}

我们在deadloop goroutine的for loop中加入了一个add函数调用。我们来运行一下这个程序,看是否能达成我们的目的:

$ go run case3.go

“I got scheduled!”字样依旧没有出现在我们眼前!也就是说main goroutine没有得到调度!为什么呢?其实所谓的“有函数调用,就有了进入调度器代码的机会”,实际上是go compiler在函数的入口处插入了一个runtime的函数调用:runtime.morestack_noctxt。这个函数会检查是否扩容连续栈,并进入抢占调度的逻辑中。一旦所在goroutine被置为可被抢占的,那么抢占调度代码就会剥夺该Goroutine的执行权,将其让给其他goroutine。但是上面代码为什么没有实现这一点呢?我们需要在汇编层次看看go compiler生成的代码是什么样子的。

查看Go程序的汇编代码有许多种方法:

  • 使用objdump工具:objdump -S go-binary
  • 使用gdb disassemble
  • 构建go程序同时生成汇编代码文件:go build -gcflags ‘-S’ xx.go > xx.s 2>&1
  • 将Go代码编译成汇编代码:go tool compile -S xx.go > xx.s
  • 使用go tool工具反编译Go程序:go tool objdump -S go-binary > xx.s

我们这里使用最后一种方法:利用go tool objdump反编译(并结合其他输出的汇编形式):

$go build -o case3 case3.go
$go tool objdump -S case3 > case3.s

打开case3.s,搜索main.add,我们居然找不到这个函数的汇编代码,而main.deadloop的定义如下:

TEXT main.deadloop(SB) github.com/bigwhite/experiments/go-sched-examples/case3.go
        for {
  0x1093a10             ebfe                    JMP main.deadloop(SB)

  0x1093a12             cc                      INT $0x3
  0x1093a13             cc                      INT $0x3
  0x1093a14             cc                      INT $0x3
  0x1093a15             cc                      INT $0x3
   ... ...
  0x1093a1f             cc                      INT $0x3

我们看到deadloop中对add的调用也消失了。这显然是go compiler执行生成代码优化的结果,因为add的调用对deadloop的行为结果没有任何影响。我们关闭优化再来试试:

$go build -gcflags '-N -l' -o case3-unoptimized case3.go
$go tool objdump -S case3-unoptimized > case3-unoptimized.s

打开 case3-unoptimized.s查找main.add,这回我们找到了它:

TEXT main.add(SB) github.com/bigwhite/experiments/go-sched-examples/case3.go
func add(a, b int) int {
  0x1093a10             48c744241800000000      MOVQ $0x0, 0x18(SP)
        return a + b
  0x1093a19             488b442408              MOVQ 0x8(SP), AX
  0x1093a1e             4803442410              ADDQ 0x10(SP), AX
  0x1093a23             4889442418              MOVQ AX, 0x18(SP)
  0x1093a28             c3                      RET

  0x1093a29             cc                      INT $0x3
... ...
  0x1093a2f             cc                      INT $0x3

deadloop中也有了对add的显式调用:

TEXT main.deadloop(SB) github.com/bigwhite/experiments/go-sched-examples/case3.go
  ... ...
  0x1093a51             48c7042403000000        MOVQ $0x3, 0(SP)
  0x1093a59             48c744240805000000      MOVQ $0x5, 0x8(SP)
  0x1093a62             e8a9ffffff              CALL main.add(SB)
        for {
  0x1093a67             eb00                    JMP 0x1093a69
  0x1093a69             ebe4                    JMP 0x1093a4f
... ...

不过我们这个程序中的main goroutine依旧得不到调度,因为在main.add代码中,我们没有发现morestack函数的踪迹,也就是说即便调用了add函数,deadloop也没有机会进入到runtime的调度逻辑中去。

不过,为什么Go compiler没有在main.add函数中插入morestack的调用呢?那是因为add函数位于调用树的leaf(叶子)位置,compiler可以确保其不再有新栈帧生成,不会导致栈分裂或超出现有栈边界,于是就不再插入morestack。而位于morestack中的调度器的抢占式检查也就无法得以执行。下面是go build -gcflags ‘-S’方式输出的case3.go的汇编输出:

"".add STEXT nosplit size=19 args=0x18 locals=0x0
     TEXT    "".add(SB), NOSPLIT, $0-24
     FUNCDATA        $0, gclocals·54241e171da8af6ae173d69da0236748(SB)
     FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
     MOVQ    "".b+16(SP), AX
     MOVQ    "".a+8(SP), CX
     ADDQ    CX, AX
     MOVQ    AX, "".~r2+24(SP)
    RET

我们看到nosplit字样,这就说明add使用的栈是固定大小,不会再split,且size为24字节。

关于在for loop中的leaf function是否应该插入morestack目前还有一定争议,将来也许会对这样的情况做特殊处理。

既然明白了原理,我们就在deadloop和add之间加入一个dummy函数,见下面case4.go代码:

//github.com/bigwhite/experiments/go-sched-examples/case4.go
package main

import (
    "fmt"
    "runtime"
    "time"
)

//go:noinline
func add(a, b int) int {
    return a + b
}

func dummy() {
    add(3, 5)
}

func deadloop() {
    for {
        dummy()
    }
}

func main() {
    runtime.GOMAXPROCS(1)
    go deadloop()
    for {
        time.Sleep(time.Second * 1)
        fmt.Println("I got scheduled!")
    }
}

执行该代码:

$go run case4.go
I got scheduled!
I got scheduled!
I got scheduled!

Wow! main goroutine果然得到了调度。我们再来看看go compiler为程序生成的汇编代码:

$go build -gcflags '-N -l' -o case4 case4.go
$go tool objdump -S case4 > case4.s

TEXT main.add(SB) github.com/bigwhite/experiments/go-sched-examples/case4.go
func add(a, b int) int {
  0x1093a10             48c744241800000000      MOVQ $0x0, 0x18(SP)
        return a + b
  0x1093a19             488b442408              MOVQ 0x8(SP), AX
  0x1093a1e             4803442410              ADDQ 0x10(SP), AX
  0x1093a23             4889442418              MOVQ AX, 0x18(SP)
  0x1093a28             c3                      RET

  0x1093a29             cc                      INT $0x3
  0x1093a2a             cc                      INT $0x3
... ...

TEXT main.dummy(SB) github.com/bigwhite/experiments/go-sched-examples/case4.s
func dummy() {
  0x1093a30             65488b0c25a0080000      MOVQ GS:0x8a0, CX
  0x1093a39             483b6110                CMPQ 0x10(CX), SP
  0x1093a3d             762e                    JBE 0x1093a6d
  0x1093a3f             4883ec20                SUBQ $0x20, SP
  0x1093a43             48896c2418              MOVQ BP, 0x18(SP)
  0x1093a48             488d6c2418              LEAQ 0x18(SP), BP
        add(3, 5)
  0x1093a4d             48c7042403000000        MOVQ $0x3, 0(SP)
  0x1093a55             48c744240805000000      MOVQ $0x5, 0x8(SP)
  0x1093a5e             e8adffffff              CALL main.add(SB)
}
  0x1093a63             488b6c2418              MOVQ 0x18(SP), BP
  0x1093a68             4883c420                ADDQ $0x20, SP
  0x1093a6c             c3                      RET

  0x1093a6d             e86eacfbff              CALL runtime.morestack_noctxt(SB)
  0x1093a72             ebbc                    JMP main.dummy(SB)

  0x1093a74             cc                      INT $0x3
  0x1093a75             cc                      INT $0x3
  0x1093a76             cc                      INT $0x3
.... ....

我们看到main.add函数依旧是leaf,没有morestack插入;但在新增的dummy函数中我们看到了CALL runtime.morestack_noctxt(SB)的身影。

四、为何runtime.morestack_noctxt(SB)放到了RET后面?

在传统印象中,morestack是放在函数入口处的。但实际编译出来的汇编代码中(见上面函数dummy的汇编),runtime.morestack_noctxt(SB)却放在了RET的后面。解释这个问题,我们最好来看一下另外一种形式的汇编输出(go build -gcflags ‘-S’方式输出的格式):

"".dummy STEXT size=68 args=0x0 locals=0x20
        0x0000 00000 TEXT    "".dummy(SB), $32-0
        0x0000 00000 MOVQ    (TLS), CX
        0x0009 00009 CMPQ    SP, 16(CX)
        0x000d 00013 JLS     61
        0x000f 00015 SUBQ    $32, SP
        0x0013 00019 MOVQ    BP, 24(SP)
        0x0018 00024 LEAQ    24(SP), BP
        ... ...
        0x001d 00029 MOVQ    $3, (SP)
        0x0025 00037 MOVQ    $5, 8(SP)
        0x002e 00046 PCDATA  $0, $0
        0x002e 00046 CALL    "".add(SB)
        0x0033 00051 MOVQ    24(SP), BP
        0x0038 00056 ADDQ    $32, SP
        0x003c 00060 RET
        0x003d 00061 NOP
        0x003d 00061 PCDATA  $0, $-1
        0x003d 00061 CALL    runtime.morestack_noctxt(SB)
        0x0042 00066 JMP     0

我们看到在函数入口处,compiler插入三行汇编:

        0x0000 00000 MOVQ    (TLS), CX  // 将TLS的值(GS:0x8a0)放入CX寄存器
        0x0009 00009 CMPQ    SP, 16(CX)  //比较SP与CX+16的值
        0x000d 00013 JLS     61 // 如果SP > CX + 16,则jump到61这个位置

这种形式输出的是标准Plan9的汇编语法,资料很少(比如JLS跳转指令的含义),注释也是大致猜测的。如果跳转,则进入到 runtime.morestack_noctxt,从 runtime.morestack_noctxt返回后,再次jmp到开头执行。

为什么要这么做呢?按照go team的说法,是为了更好的利用现代CPU的“static branch prediction”,提升执行性能。

五、参考资料

文中的代码可以点击这里下载。


微博:@tonybai_cn
微信公众号:iamtonybai
github.com: https://github.com/bigwhite

微信赞赏:
img{512x368}

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats