2017年十一月月 发布的文章

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}

理解Docker的多阶段镜像构建

Docker技术从2013年诞生到目前已经4年有余了。对于已经接纳和使用Docker技术在日常开发工作中的开发者而言,构建Docker镜像已经是家常便饭。但这是否意味着Docker的image构建机制已经相对完美了呢?不是的,Docker官方依旧在持续优化镜像构建机制。这不,从今年发布的Docker 17.05版本起,Docker开始支持容器镜像的多阶段构建(multi-stage build)了。

什么是镜像多阶段构建呢?直接给出概念定义太突兀,这里先卖个关子,我们先从日常开发中用到的镜像构建的方式和所遇到的镜像构建的问题说起。

一、同构的镜像构建

我们在做镜像构建时的一个常见的场景就是:应用在开发者自己的开发机或服务器上直接编译,编译出的二进制程序再打入镜像。这种情况一般要求编译环境与镜像所使用的base image是兼容的,比如说:我在Ubuntu 14.04上编译应用,并将应用打入基于ubuntu系列base image的镜像。这种构建我称之为“同构的镜像构建”,因为应用的编译环境与其部署运行的环境是兼容的:我在Ubuntu 14.04下编译出来的应用,可以基本无缝地在基于ubuntu:14.04及以后版本base image镜像(比如:16.04、16.10、17.10等)中运行;但在不完全兼容的base image中,比如centos中就可能会运行失败。

1、同构镜像构建举例

这里举个同构镜像构建的例子(后续的章节也是基于这个例子的),注意:我们的编译环境为Ubuntu 16.04 x86_64虚拟机、Go 1.8.3和docker 17.09.0-ce

我们用一个Go语言中最常见的http server作为例子:

// github.com/bigwhite/experiments/multi_stage_image_build/isomorphism/httpserver.go
package main

import (
        "net/http"
        "log"
        "fmt"
)

func home(w http.ResponseWriter, req *http.Request) {
        w.Write([]byte("Welcome to this website!\n"))
}

func main() {
        http.HandleFunc("/", home)
        fmt.Println("Webserver start")
        fmt.Println("  -> listen on port:1111")
        err := http.ListenAndServe(":1111", nil)
        if err != nil {
                log.Fatal("ListenAndServe:", err)
        }
}

编译这个程序:

# go build -o myhttpserver httpserver.go
# ./myhttpserver
Webserver start
  -> listen on port:1111

这个例子看起来很简单,也没几行代码,但背后Go net/http包在底层做了大量的事情,包括很多系统调用,能够反映出应用与操作系统的“耦合”,这在后续的讲解中会体现出来。接下来我们就来为这个程序构建一个docker image,并基于这个image来启动一个myhttpserver容器。我们选择ubuntu:14.04作为base image:

// github.com/bigwhite/experiments/multi_stage_image_build/isomorphism/Dockerfile
From ubuntu:14.04

COPY ./myhttpserver /root/myhttpserver
RUN chmod +x /root/myhttpserver

WORKDIR /root
ENTRYPOINT ["/root/myhttpserver"]

执行构建:

# docker build -t myrepo/myhttpserver:latest .
Sending build context to Docker daemon  5.894MB
Step 1/5 : FROM ubuntu:14.04
 ---> dea1945146b9
Step 2/5 : COPY ./myhttpserver /root/myhttpserver
 ---> 993e5129c081
Step 3/5 : RUN chmod +x /root/myhttpserver
 ---> Running in 104d84838ab2
 ---> ebaeca006490
Removing intermediate container 104d84838ab2
Step 4/5 : WORKDIR /root
 ---> 7afdc2356149
Removing intermediate container 450ccfb09ffd
Step 5/5 : ENTRYPOINT /root/myhttpserver
 ---> Running in 3182766e2a68
 ---> 77f315e15f14
Removing intermediate container 3182766e2a68
Successfully built 77f315e15f14
Successfully tagged myrepo/myhttpserver:latest

# docker images
REPOSITORY            TAG                 IMAGE ID            CREATED             SIZE
myrepo/myhttpserver   latest              77f315e15f14        18 seconds ago      200MB

# docker run myrepo/myhttpserver
Webserver start
  -> listen on port:1111

以上是最基本的image build方法。

接下来,我们可能会遇到如下需求:
* 搭建一个Go程序的构建环境有时候是很耗时的,尤其是对那些依赖很多第三方开源包的Go应用来说,下载包就需要很长时间。我们最好将这些易变的东西统统打包到一个用于Go程序构建的builder image中;
* 我们看到上面我们构建出的myrepo/myhttpserver image的SIZE是200MB,这似乎有些过于“庞大”了。虽然每个主机node上的docker有cache image layer的能力,但我们还是希望能build出更加精简短小的image。

2、借助golang builder image

Docker Hub上提供了一个带有go dev环境的官方golang image repository,我们可以直接使用这个golang builder image来辅助构建我们的应用image;对于一些对第三方包依赖较多的Go应用,我们也可以以这个golang image为base image定制我们自己的专用builder image。

我们基于golang:latest这个base image构建我们的golang-builder image,我们编写一个Dockerfile.build用于build golang-builder image:

// github.com/bigwhite/experiments/multi_stage_image_build/isomorphism/Dockerfile.build
FROM golang:latest

WORKDIR /go/src
COPY httpserver.go .

RUN go build -o myhttpserver ./httpserver.go

在同目录下构建golang-builder image:

# docker build -t myrepo/golang-builder:latest -f Dockerfile.build .
Sending build context to Docker daemon  5.895MB
Step 1/4 : FROM golang:latest
 ---> 1a34fad76b34
Step 2/4 : WORKDIR /go/src
 ---> 2361824677d3
Removing intermediate container 01d8f4e9f0c4
Step 3/4 : COPY httpserver.go .
 ---> 1ff14bb0bc56
Step 4/4 : RUN go build -o myhttpserver ./httpserver.go
 ---> Running in 37a1b76b7b9e
 ---> 2ac5347bb923
Removing intermediate container 37a1b76b7b9e
Successfully built 2ac5347bb923
Successfully tagged myrepo/golang-builder:latest

REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
myrepo/golang-builder   latest              2ac5347bb923        3 minutes ago       739MB

接下来,我们就基于golang-builder中已经build完毕的myhttpserver来构建我们最终的应用image:

# docker create --name appsource myrepo/golang-builder:latest
# docker cp appsource:/go/src/myhttpserver ./
# docker rm -f appsource
# docker rmi myrepo/golang-builder:latest
# docker build -t myrepo/myhttpserver:latest .

这段命令的逻辑就是从基于golang-builder image启动的容器appsource中将已经构建完毕的myhttpserver拷贝到主机当前目录中,然后删除临时的container appsource以及上面构建的那个golang-builder image;最后的步骤和第一个例子一样,基于本地目录中的已经构建完的myhttpserver构建出最终的image。为了方便,你也可以将这一系列命令放到一个Makefile中去。

3、使用size更小的alpine image

builder image并不能帮助我们为最终的应用image“减重”,myhttpserver image的Size依旧停留在200MB。要想“减重”,我们需要更小的base image,我们选择了alpineAlpine image的size不到4M,再加上应用的size,最终应用Image的Size估计可以缩减到20M以下。

结合builder image,我们只需将Dockerfile的base image改为alpine:latest:

// github.com/bigwhite/experiments/multi_stage_image_build/isomorphism/Dockerfile.alpine

From alpine:latest

COPY ./myhttpserver /root/myhttpserver
RUN chmod +x /root/myhttpserver

WORKDIR /root
ENTRYPOINT ["/root/myhttpserver"]

构建alpine版应用image:

# docker build -t myrepo/myhttpserver-alpine:latest -f Dockerfile.alpine .
Sending build context to Docker daemon  6.151MB
Step 1/5 : FROM alpine:latest
 ---> 053cde6e8953
Step 2/5 : COPY ./myhttpserver /root/myhttpserver
 ---> ca0527a62d39
Step 3/5 : RUN chmod +x /root/myhttpserver
 ---> Running in 28d0a8a577b2
 ---> a3833af97b5e
Removing intermediate container 28d0a8a577b2
Step 4/5 : WORKDIR /root
 ---> 667345b78570
Removing intermediate container fa59883e9fdb
Step 5/5 : ENTRYPOINT /root/myhttpserver
 ---> Running in adcb5b976ca3
 ---> 582fa2aedc64
Removing intermediate container adcb5b976ca3
Successfully built 582fa2aedc64
Successfully tagged myrepo/myhttpserver-alpine:latest

# docker images
REPOSITORY                   TAG                 IMAGE ID            CREATED             SIZE
myrepo/myhttpserver-alpine   latest              582fa2aedc64        4 minutes ago       16.3MB

16.3MB,Size的确降下来了!我们基于该image启动一个容器,看应用运行是否有什么问题:

# docker run myrepo/myhttpserver-alpine:latest
standard_init_linux.go:185: exec user process caused "no such file or directory"

容器启动失败了!为什么呢?因为alpine image并非ubuntu环境的同构image。我们在下面详细说明。

二、异构的镜像构建

我们的image builder: myrepo/golang-builder:latest是基于golang:latest这个image。golang base image有两个模板:Dockerfile-debain.template和Dockerfile-alpine.template。而golang:latest是基于debian模板的,与ubuntu兼容。构建出来的myhttpserver对动态共享链接库的情况如下:

 # ldd myhttpserver
    linux-vdso.so.1 =>  (0x00007ffd0c355000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007ffa8b36f000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffa8afa5000)
    /lib64/ld-linux-x86-64.so.2 (0x000055605ea5d000)

debian系的linux distribution使用了glibc。但alpine则不同,alpine使用的是musl libc的实现,因此当我们运行上面的那个容器时,加载器因找不到myhttpserver依赖的libc.so.6而失败退出。

这种构建环境与运行环境不兼容的情况我这里称之为“异构的镜像构建”。那么如何解决这个问题呢?我们继续看:

1、静态构建

在主流编程语言中,Go的移植性已经是数一数二的了,尤其是Go 1.5之后,Go将runtime中的C代码都用Go重写了,对libc的依赖已经降到最低了,但仍有一些feature提供了两个版本的实现:C实现和Go实现。并且默认情况下,即在CGO_ENABLED=1的情况下,程序和预编译的标准库都采用了C的实现。关于这方面的详细论述请参见我之前写的《也谈Go的可移植性》一文,这里就不赘述了。于是采用了不同libc实现的debian系和alpine系自然存在不兼容的情况。要解决这个问题,我们首先考虑对Go程序进行静态构建,然后将静态构建后的Go应用放入alpine image中。

我们修改一下Dockerfile.build,在编译Go源文件时加上CGO_ENABLED=0:

// github.com/bigwhite/experiments/multi_stage_image_build/heterogeneous/Dockerfile.build

FROM golang:latest

WORKDIR /go/src
COPY httpserver.go .

RUN CGO_ENABLED=0 go build -o myhttpserver ./httpserver.go

构建这个builder image:

# docker build -t myrepo/golang-static-builder:latest -f Dockerfile.build .
Sending build context to Docker daemon  4.096kB
Step 1/4 : FROM golang:latest
 ---> 1a34fad76b34
Step 2/4 : WORKDIR /go/src
 ---> 593cd9692019
Removing intermediate container ee005d487ad5
Step 3/4 : COPY httpserver.go .
 ---> a095eb69e716
Step 4/4 : RUN CGO_ENABLED=0 go build -o myhttpserver ./httpserver.go
 ---> Running in d9f3b3a6c36c
 ---> c06fe8dccbad
Removing intermediate container d9f3b3a6c36c
Successfully built c06fe8dccbad
Successfully tagged myrepo/golang-static-builder:latest

# docker images
REPOSITORY                     TAG                 IMAGE ID            CREATED             SIZE
myrepo/golang-static-builder   latest              c06fe8dccbad        31 seconds ago      739MB

接下来,我们再基于golang-static-builder中已经build完毕的静态连接的myhttpserver来构建我们最终的应用image:

# docker create --name appsource myrepo/golang-static-builder:latest
# docker cp appsource:/go/src/myhttpserver ./
# ldd myhttpserver
    not a dynamic executable
# docker rm -f appsource
# docker rmi myrepo/golang-static-builder:latest
# docker build -t myrepo/myhttpserver-alpine:latest -f Dockerfile.alpine .

运行新image:

# docker run myrepo/myhttpserver-alpine:latest
Webserver start
  -> listen on port:1111

Note: 我们可以用strace来证明静态连接时Go只使用的是Go自己的runtime实现,而并未使用到libc.a中的代码:

# CGO_ENABLED=0 strace -f go build httpserver.go 2>&1 | grep open | grep -o '/.*\.a'  > go-static-build-strace-file-open.txt

打开go-static-build-strace-file-open.txt文件查看文件内容,你不会找到libc.a这个文件(在Ubuntu下,一般libc.a躺在/usr/lib/x86_64-linux-gnu/下面),这说明go build根本没有尝试去open libc.a文件并获取其中的符号定义。

2、使用alpine golang builder

我们的Go应用运行在alpine based的container中,我们可以使用alpine golang builder来构建我们的应用(无需静态链接)。前面提到过golang有alpine模板:

REPOSITORY                   TAG                 IMAGE ID            CREATED             SIZE
golang                       alpine              9e3f14138abd        7 days ago          269MB

alpine版golang builder的Dockerfile内容如下:

//github.com/bigwhite/experiments/multi_stage_image_build/heterogeneous/Dockerfile.alpine.build

FROM golang:alpine

WORKDIR /go/src
COPY httpserver.go .

RUN go build -o myhttpserver ./httpserver.go

后续的操作与前面golang builder的操作并不二致:利用alpine golang builder构建我们的应用,并将其打入alpine image,这里就不赘述了。

三、多阶段镜像构建:提升开发者体验

在Docker 17.05以前,我们都是像上面那样构建镜像的。你会发现即便采用异构image builder模式,我们也要维护两个Dockerfile,并且还要在docker build命令之外执行一些诸如从容器内copy应用程序、清理build container和build image等的操作。Docker社区看到了这个问题,于是实现了多阶段镜像构建机制(multi-stage)。

我们先来看一下针对上面例子,multi-stage build所使用Dockerfile:

//github.com/bigwhite/experiments/multi_stage_image_build/multi_stages/Dockerfile

FROM golang:alpine as builder

WORKDIR /go/src
COPY httpserver.go .

RUN go build -o myhttpserver ./httpserver.go

From alpine:latest

WORKDIR /root/
COPY --from=builder /go/src/myhttpserver .
RUN chmod +x /root/myhttpserver

ENTRYPOINT ["/root/myhttpserver"]

看完这个Dockerfile的内容,你的第一赶脚是不是把之前的两个Dockerfile合并在一块儿了,每个Dockerfile单独作为一个“阶段”!事实也是这样,但这个Docker也多了一些新的语法形式,用于建立各个“阶段”之间的联系。针对这样一个Dockerfile,我们应该知道以下几点:

  • 支持Multi-stage build的Dockerfile在以往的多个build阶段之间建立内在连接,让后一个阶段构建可以使用前一个阶段构建的产物,形成一条构建阶段的chain;
  • Multi-stages build的最终结果仅产生一个image,避免产生冗余的多个临时images或临时容器对象,这正是我们所需要的:我们只要结果。

我们来使用multi-stage来build一下上述例子:

# docker build -t myrepo/myhttserver-multi-stage:latest .
Sending build context to Docker daemon  3.072kB
Step 1/9 : FROM golang:alpine as builder
 ---> 9e3f14138abd
Step 2/9 : WORKDIR /go/src
 ---> Using cache
 ---> 7a99431d1be6
Step 3/9 : COPY httpserver.go .
 ---> 43a196658e09
Step 4/9 : RUN go build -o myhttpserver ./httpserver.go
 ---> Running in 9e7b46f68e88
 ---> 90dc73912803
Removing intermediate container 9e7b46f68e88
Step 5/9 : FROM alpine:latest
 ---> 053cde6e8953
Step 6/9 : WORKDIR /root/
 ---> Using cache
 ---> 30d95027ee6a
Step 7/9 : COPY --from=builder /go/src/myhttpserver .
 ---> f1620b64c1ba
Step 8/9 : RUN chmod +x /root/myhttpserver
 ---> Running in e62809993a22
 ---> 6be6c28f5fd6
Removing intermediate container e62809993a22
Step 9/9 : ENTRYPOINT /root/myhttpserver
 ---> Running in e4000d1dde3d
 ---> 639cec396c96
Removing intermediate container e4000d1dde3d
Successfully built 639cec396c96
Successfully tagged myrepo/myhttserver-multi-stage:latest

# docker images
REPOSITORY                       TAG                 IMAGE ID            CREATED             SIZE
myrepo/myhttserver-multi-stage   latest              639cec396c96        About an hour ago   16.3MB

我们来Run一下这个image:

# docker run myrepo/myhttserver-multi-stage:latest
Webserver start
  -> listen on port:1111

四、小结

多阶段镜像构建可以让开发者通过一个Dockerfile,一次性地、更容易地构建出size较小的image,体验良好并且更容易接入CI/CD等自动化系统。不过当前多阶段构建仅是在Docker 17.05及之后的版本中才能得到支持。如果想学习和实践这方面功能,但又没有环境,可以使用play-with-docker提供的实验环境。

img{512x368}
Play with Docker labs

以上所有示例代码可以在这里下载到。


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

Hello,Termux

程序员或多或少都有一颗Geek(极客)的心^0^。- Tony Bai

折腾开始。

这一切都源于前不久将手机换成了Xiaomi的MIX2。因为青睐开放的系统(相对于水果公司系统的封闭,当然Mac笔记本除外^0^),我长期使用Android平台的手机。但之前被三星Note3手机的“大屏”搞的不是很舒服,这两年一直用5寸及以下的手机,因为单手操作体验良好。MIX2的所谓“全面屏”概念又让我回归到了大屏时代。

除了大屏,现在手机“豪华”的硬件配置也让人惊叹:高通骁龙835,8核,最高主频 2.45GHz;6GB以上的LPDDR4x的双通道大内存,怪不得微软和高通都开始合作生产基于高通ARM处理器的Win10笔记本了,这配置支撑在笔记本上办公+浏览网页绰绰有余。不过对于不怎么玩游戏的我而言,这种配置仅仅用作手机日常功能有些浪费。于是有了“mobile coding”的想法和需求,至少现在是这样想的,冲动也好,伪需求也好,先实现了再说。

一、神器Termux,不仅仅是一个terminal emulator

所谓”mobile coding”不仅仅是要通过手机ssh到服务器端进行coding,还要支持在手机上搭建一个dev环境。dev环境这个需求是以往我安装的ConnectBot等ssh client端工具所无法提供的,而其他一些terminal工具,诸如Terminal Emulator for Android仅仅提供一些shell命令的支持,适合于那些喜爱使用命令行对Android机器进行管理的”administrator”们,但对dev环境的搭建支持有限的。于是神器Termux登场了。

Termux是什么?Termux首先是一个Android terminal emulator,可以像那些terminal工具一样,提供基本的shell操作命令;除此之外更为重要的是它不仅仅是一个terminal emulator。Termux提供了一套模拟的Linux环境,你可以在无需root、无需root、无需root的情况下,像在PC linux环境下一样进行各种Linux操作,包括使用apt工具进行安装包管理、定制shell、访问网络、编写源码、编译和运行程序,甚至将手机作为反向代理、负载均衡服务器或是Web服务器,又或是做一些羞羞的hack行为等。

1、安装

Termux仅支持Android 5.0及以上版本(估计现在绝大多数android机都满足这一条件)。在国内建议使用F-Droid安装Termux(先下载安装F-Droid,再在F-Droid内部搜索Termux,然后点击安装),国内的各种安装助手很少有对这个工具的支持。或是到apk4fun下载Termux的apk包(size非常小)到手机中安装(安装时需要连接着网络)。当前Termux的最新版本为0.54

在桌面点击安装后的Termux图标,我们就启动了一个Termux应用,见下图:

img{512x368}

2、Termux初始环境探索

Mix2手机的Android系统使用的是Android 7.1.1版本,桌面Launcher用的是MIUI 9.1稳定版,默认的shell是bash。通过Termux,我们可以查看Android 7.1.1.使用的Linux内核版本如下:

$uname -a
Linux localhost 4.4.21-perf-g6a9ee37d-06186-g2b2a77b #1 SMP PREEMPT Thu Oct 26 14:55:45 CST 2017 aarch64 Android

可以看出Linux内核是4.4.21,采用的CPU arch family是ARM aarch64

我再来看一下Termux提供的常见目录结构:

Home路径:

$cd ~/
$pwd
/data/data/com.termux/files/home

//或者通过环境变量HOME获取:

$echo $HOME
/data/data/com.termux/files/home

长期使用Linux的朋友可能会发现,这个HOME路径好是奇怪,一般的标准Linux发行版,比如Ubuntu都是在”/home”下放置用户目录,但termux环境中HOME路径却是一个奇怪的位置。在Termux官方Wiki中,我们得到的答案是:Termux是一个prefixed system。

这个prefix的含义我理解颇有些类似于我们在使用configure脚本时指定的–prefix参数的含义。我们在执行configure脚本时,如果不显式地给–prefix传入值,那么make install后,包将被install在标准位置;否则将被install在–prefix值所指定的位置。

prefixed system意味着Termux中所有binaries、libraries、configs都不是放在标准的位置,比如:/usr/bin、/bin、/usr/lib、/etc等下面。Termux expose了一个特殊的环境变量:PREFIX(类似于configure –prefix参数选项):

$echo $PREFIX
/data/data/com.termux/files/usr

$cd $PREFIX
$ls -F
bin/  etc/  include/  lib/  libexec/  share/  tmp/  var/

是不是有些似曾相识?但Termux的$PREFIX路径与标准linux的根路径下的目录结构毕竟还存在差别,但有着对应关系,这种对应关系大致是:

Termux的$PREFIX/bin  <=>  标准Linux环境的 /bin和/usr/bin
Termux的$PREFIX/lib  <=>  标准Linux环境的 /lib和/usr/lib
Termux的$PREFIX/var  <=>  标准Linux环境的 /var
Termux的$PREFIX/etc  <=>  标准Linux环境的 /etc

因此,基本可以认为Termux的$PREFIX/就对应于标准Linux的/路径。

3、更新源和包管理

Termux的牛逼之处在于它基于debian的APT包管理工具进行软件包的安装、管理和卸载,就像我们在Ubuntu下所做的那样,非常方便。

Termux自己维护了一个源,提供各种专门为termux定制的包:

# The main termux repository:
#deb [arch=all,aarch64] http://termux.net stable main

同时,termux-packages项目为开发者和爱好者提供了构建工具和脚本,通过这些工具和脚本,我们可以将自己需要的软件包编译为可以在termux运行的版本,并补充到Termux的源之中。我大致测试了一下官方这个源还是可用的,虽然初始连接的响应很缓慢。

国内清华大学维护了一个Termux的镜像源,你可以通过编辑 /data/data/com.termux/files/usr/etc/apt/sources.list文件或执行apt edit-sources命令编辑源(在Shell配置中添加export EDITOR=vi后,apt edit-sources才能启动编辑器进行编辑):

# The main termux repository:
#deb [arch=all,aarch64] http://termux.net stable main
deb [arch=all,aarch64] http://mirrors.tuna.tsinghua.edu.cn/termux stable main

剩下的操作与Ubuntu上的一模一样,无非apt update后,利用apt install安装你想要的包。目前Termux源中都有哪些包呢?可以通过apt list命令查看:

$apt list
Listing... Done
aapt/stable 7.1.2.33-1 aarch64
abduco/stable 0.6 aarch64
abook/stable 0.6.0pre2-1 aarch64
ack-grep/stable 2.18 all
alpine/stable 2.21 aarch64
angband/stable 4.1.0 aarch64
apache2/stable 2.4.29 aarch64
apache2-dev/stable 2.4.29 aarch64
apksigner/stable 0.4 all
apr/stable 1.6.3 aarch64
apr-dev/stable 1.6.3 aarch64
apr-util/stable 1.6.1 aarch64
apr-util-dev/stable 1.6.1 aarch64
apt/stable,now 1.2.12-3 aarch64 [installed]
apt-transport-https/stable 1.2.12-3 aarch64
... ...
zile/stable 2.4.14 aarch64
zip/stable 3.0-1 aarch64
zsh/stable,now 5.4.2-1 aarch64 [installed]

查看是否有需要更新的包列表:

$apt list --upgradable

以安装golang为例:

$apt install golang
....
$go version
go version go1.9.2 android/arm64

img{512x368}

Termux源中的包似乎更新的很勤奋,Go 1.9.2才发布没多久,这里已经是最新版本了,这点值得赞一个!

二、开发环境搭建

我的目标是mobile coding,需要在Termux上搭建一个dev环境,以Go环境为例。

1、sshd

在搭建和配置阶段,如果直接通过Android上的软键盘操作,即便屏再大,那个体验也是较差的。我们最好通过PC连到termux上去安装和配置,这就需要我们在Termux上搭建一个sshd server。下面是步骤:

$apt install openssh
$sshd

就这么简单,一个sshd的server就在termux的后台启动起来了。由于Termux没有root权限,无法listen数值小于1024的端口,因此termux上sshd默认的listen端口是8022。另外termux上的sshd server不支持用户名+密码的方式进行登录,只能用免密登录的方式,即将PC上的~/.ssh/id_rsa.pub写入termux上的~/.ssh/authorized_keys文件中。关于免密登录的证书生成方法和导入方式,网上资料已经汗牛充栋,这里就不赘述了。导入PC端的id_rsa.pub后,PC就可以通过下面命令登录termux了:

$ssh 10.88.46.79  -p 8022
Welcome to Termux!

Wiki:            https://wiki.termux.com
Community forum: https://termux.com/community
IRC channel:     #termux on freenode
Gitter chat:     https://gitter.im/termux/termux
Mailing list:    termux+subscribe@groups.io

Search packages:   pkg search <query>
Install a package: pkg install <package>
Upgrade packages:  pkg upgrade
Learn more:        pkg help

其中10.88.46.79是手机的wlan0网卡的IP地址,可以在termux中使用ip addr命令获得:

$ip addr show wlan0
34: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 3000
    ... ...
    inet 10.88.46.79/20 brd 10.88.47.255 scope global wlan0
       valid_lft forever preferred_lft forever
    ... ...

2、定制shell

Termux支持多种主流Shell,默认的Shell是Bash。很多开发者喜欢zsh + oh-my-zsh的组合,Termux也是支持的,安装起来也是非常简单的:

$ apt install git
$ apt install zsh
$ git clone git://github.com/robbyrussell/oh-my-zsh.git ~/.oh-my-zsh
$ cp ~/.oh-my-zsh/templates/zshrc.zsh-template ~/.zshrc
$ chsh zsh

与在PC上安装和配置zsh和oh-my-zsh没什么两样,你完全可以按照你在PC上的风格定制zsh的Theme等,我用的就是默认theme,所以也无需做太多变化,顶多定制一下PROMPT(~/.oh-my-zsh/themes/robbyrussell.zsh-theme中的PROMPT变量)的格式^0^。

3、安装vim-go

在terminal内进行Go开发,vim-go是必备之神器。vim-go以及相关自动补齐、snippet插件安装在不同平台上都是大同小异的,之前写过两篇《Golang开发环境搭建-Vim篇》和《vim-go更新小记》,大家可以参考。

不过这里有一个较为关键的问题,那就是Termux官方源中的vim 8.0缺少了对python和lua的支持:

 $vim --version|grep py
+cryptv          +linebreak       -python          +viminfo
+cscope          +lispindent      -python3         +vreplace
$vim --version|grep lua
+dialog_con      -lua             +rightleft       +windows

而一些插件又恰需要这些内置的支持,比如ultisnips需要vim自带py支持;neocomplete又依赖vim的lua支持。这样如果你还想要补齐和snippet特性,你就需要在Termux下面自己编译Vim的源码了(configure时加上对python和lua的支持)。

4、中文支持

无论是PC还是Termux使用的都是UTF8的内码格式,但是在安装完vim-go后,我试着用vim编辑一些简单的源码,发现在vim中输入的中文都是乱码。这里通过一个配置解决了该问题:

//~/.vimrc

添加一行:

set enc=utf8

至于其中的原理,可以参见我N年前写的《也谈VIM字符集编码设置》一文。

三、键盘适配

现阶段,写代码还是需要键盘输入的(憧憬未来^0^)。

1、软键盘

使用原生自带的默认软键盘在terminal中用vim进行coding,那得多执着啊,尤其是在vim大量使用ESC键的情况下(我都没找到原生键盘中ESC键在哪里:()。不过Termux倒是很具包容心,为原生软键盘提供了扩展支持:用两个上下音量键协助你输入一些原生键盘上没有或者难于输入的符号,比如(全部的模拟按键列表参见这里):

清理屏幕:用volume down + L 来模拟 ctrl + L
结束前台程序:用volume down + C 来模拟 ctrl + C
ESC:用volume up + E 来模拟
F1-F9: 用volume up + 1 ~ 9 来模拟

据网友提示:volume up + Q键可以打开扩展键盘键,包括ESC、CTRL、ALT等,感谢。

这样仅能满足临时的需要,要想更有效率的输入,我们需要Hacker’s Keyboard。顾名思义,Hacker’s Keyboard可以理解为专为Coding(无论出于何种目的)的人准备的。和Termux一样,你可以从F-droid安装该工具。启动该app后,app界面上有明确的使用说明,如果依旧不明确,还可以查看这篇图文并茂的文章:《How to Use Hacker’s Keyboard》。默认情况下,横屏时Hacker’s keyboard会使用”Full 5-row layout”,即全键盘,竖屏时,则是4-row layout。你可以通过“系统设置”中的“语言和输入法”配置中对其进行设置,让Hacker’s keyboard无论在横屏还是竖屏都采用全键盘(我们屏幕够大^0^):

img{512x368}
横屏

img{512x368}
竖屏

Hacker’s Keyboard无法支持中文输入,这点是目前的缺憾,不过我个人写代码时绝少使用中文,该问题忽略不计。

2、外接蓝牙键盘

Hacker’s Keyboard虽然一定程度提升了Coding时的输入效率,但也仅是权宜之计,长时间大规模通过软键盘输入依旧不甚可取,外接键盘是必须的。对于手机而言,目前最好的外接连接方式就是蓝牙。蓝牙键盘市面上现在有很多种,我选择了老牌大厂logitechK480。这款键盘缺点是便携性差点、按键有些硬,但按键大小适中;而那些超便携的蓝牙键盘普遍键帽太小,长时间Coding的体验是个问题。

img{512x368}

Termux对外接键盘的支持也是很好的,除了常规输入,通过键盘组合键Ctrl+Alt与其他字母的组合实现各种控制功能,比如:

ctrl + alt + c => 实现创建一个新的session;
ctrl + alt + 上箭头/下箭头 => 实现切换到上一个/下一个session的窗口;
ctrl + alt + f => 全屏
ctrl + alt +v => 粘贴
ctrl + alt + +/- => 实现窗口字体的放大/缩小

不过,外接键盘和Hacker’s keyboard有一个相同的问题,那就是针对Termux无法输入中文。我尝试了百度、搜狗等输入法,无论如何切换(正常在其他应用中,通过【shift + 空格】实现中英文切换)均只是输入英文。

四、存储

到目前为止,我们提到的路径都在termux的私有的内部存储(private internal storage)路径下,这类存储的特点是termux应用内部的、私有的,一旦termux被卸载,这些数据也将不复存在。Android下还有另外两种存储类型:shared internal storage和external storage。所谓shared internal storage是手机上所有App可以共享的存储空间,放在这个空间内的数据不会因为App被卸载掉而被删除掉;而外部存储(external storage)主要是指外部插入的SD Card的存储空间。

默认情况下,Termux只支持private internal storage,意味着你要做好数据备份,否则一旦误卸载termux,数据可就都丢失了;数据可以用git进行管理,并sync到云端。

Termux提供了一个名为termux-setup-storage的工具,可以让你在Termux下访问和使用shared internal storage和external storage;该工具是termux-tools的一部分,你可以通过apt install termux-tools来安装这些工具。

执行termux-setup-storage(注意:这个命令只能在手机上执行才能弹出授权对话框,通过远程ssh登录后执行没有任何效果)时,手机会弹出一个对话框,让你确认授权:

img{512x368}

一旦授权,termux-setup-storage就会在HOME目录下建立一个storage目录,该目录下的结构如下:

➜  /data/data/com.termux/files/home $tree storage
storage
├── dcim -> /storage/emulated/0/DCIM
├── downloads -> /storage/emulated/0/Download
├── movies -> /storage/emulated/0/Movies
├── music -> /storage/emulated/0/Music
├── pictures -> /storage/emulated/0/Pictures
└── shared -> /storage/emulated/0

6 directories, 0 files

我们看到在我的termux下,termux-setup-storage在storage下建立了6个符号链接,其中shared指向shared internal storage的根目录,即/storage/emulated/0;其余几个分别指向shared下的若干功能目录,比如:相册、音乐、电影、下载等。我的手机没有插SD卡,可能也不支持(市面上大多数手机都已经不支持了),如果插了一张SD卡,那么termux-setup-storage还会在storage目录下j建立一个符号链接指向在external storage上的一个termux private folder。

现在你就可以把数据放在shared internal storage和external storage上了,当然你也可以在Termux下自由访问shared internal storage上的数据了。

五、小结

Termux还设计了支持扩展的Addon机制,支持通过各种Addon来丰富Termux功能,提升其能力,这些算是高级功能,在这篇入门文章里就先不提及了。好了,接下来我就可以开始我的mobile coding了,充分利用碎片时间。后续在使用Termux+k480的过程中如果遇到什么具体的问题,我再来做针对性的解析。


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




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

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

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


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

比特币:


以太币:


如果您喜欢通过微信App浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:



本站Powered by Digital Ocean VPS。

选择Digital Ocean VPS主机,即可获得10美元现金充值,可免费使用两个月哟!

著名主机提供商Linode 10$优惠码:linode10,在这里注册即可免费获得。

阿里云推荐码:1WFZ0V立享9折!

View Tony Bai's profile on LinkedIn


文章

评论

  • 正在加载...

分类

标签

归档











更多