『GCTT 出品』从零开始一步步构建运行在 Kubernetes 上的服务

Posted Go语言中文网

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了『GCTT 出品』从零开始一步步构建运行在 Kubernetes 上的服务相关的知识,希望对你有一定的参考价值。

首发于 https://studygolang.com/articles/12156

如果你用 Go 写过程序,就会发现用 Go 来写服务是很简单的事。比如说,只要几行代码就可以跑起来一个 HTTP 服务。但是如果我们想让服务在生产环境运行,我们还需要添加什么呢?本文将通过写一个能在 Kubernetes 上运行的服务的例子,来讨论上述问题。

文中所有的例子可以在 这里(按标签分类) ,或者 这里(按 commit 分类) 找到。

第一步 最简单的服务

从一个最简单的应用开始:
main.go

package main
import (
   "fmt"    "net/http"
)

func
main() {    http.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) {        fmt.Fprint(w, "Hello! Your request was processed.")    })    http.ListenAndServe(":8000", nil) }

执行 go run main.go 即可运行程序。用 curl 命令 curl -i http://127.0.0.1:8000/home 可以看到程序返回值。不过目前在终端并没有多少状态信息。

第二步 添加日志

添加一个 logger 便于查看执行到哪一行、记录错误信息以及其他重要状态。本例中简便起见,会使用 Go 标准库中的 log,而线上生产环境你或许会使用到更强大的日志系统,例如: glog 或者 logrus 。

代码中有三个地方需要添加日志:服务开始时候、服务准备好可以接受请求时以及当 http.ListenAndServe 返回错误时。具体代码如下:

func main() {
    log.Print("Starting the service...")

    http.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) {
        fmt.Fprint(w, "Hello! Your request was processed.")
    })

    log.Print("The service is ready to listen and serve.")
    log.Fatal(http.ListenAndServe(":8000", nil))
}

一步步趋向完美。

第三步 添加路由器

为了让应用更加可用,需要添加一个路由器(router),路由器能够以一种简单的方式处理各种不同的 URI 和 HTTP 方法,以及匹配一些其他的规则。Go 标准库中没有包含路由器(router),本文使用 gorilla/mux 库,该库提供的路由器能够很好地和标准库 net/http 兼容。

服务中如果包含了一定数量的不同路由规则,那么最好是把路由相关的代码单独封装到几个独立的 function 或者是一个 package 中。本文中,会把规则定义和初始化路由器的代码放到 handlers package 中(这里 可以看到完整的改动)。

我们添加一个 Router 方法,该方法返回一个配置好的路由器变量,其中 home 方法处理 /home 路径的请求。个人建议处理方法和路由分开写:

handlers/handlers.go

package handlers

import (
   "github.com/gorilla/mux"
)
// Router register necessary routes and returns an instance of a router.
func Router() *mux.Router {    r := mux.NewRouter()    r.HandleFunc("/home", home).Methods("GET")
   return r }

handlers/home.go

package handlers

import (
   "fmt"    "net/http"
)
// home is a simple HTTP handler function which writes a response.
func home(w http.ResponseWriter, _ *http.Request) {    fmt.Fprint(w, "Hello! Your request was processed.") }

然后main.go中做点小改动:

package main

import (
   "log"    "net/http"    "github.com/rumyantseva/advent-2017/handlers"
)
// How to try it: go run main.go
func main() {    log.Print("Starting the service...")    router := handlers.Router()    log.Print("The service is ready to listen and serve.")    log.Fatal(http.ListenAndServe(":8000", router)) }

第四步 添加测试

这一步要开始加点测试了。我们用到了 httptest 包。Router 方法的测试代码如下:

handlers/handlers_test.go

package handlers

import (
   "net/http"    "net/http/httptest"    "testing"
)

func
TestRouter(t *testing.T) {    r := Router()    ts := httptest.NewServer(r)
   defer ts.Close()    res, err := http.Get(ts.URL + "/home")
   if err != nil {        t.Fatal(err)    }
   if res.StatusCode != http.StatusOK {        t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusOK)    }    res, err = http.Post(ts.URL+"/home", "text/plain", nil)
   if err != nil {        t.Fatal(err)    }
   if res.StatusCode != http.StatusMethodNotAllowed {        t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusMethodNotAllowed)    }    res, err = http.Get(ts.URL + "/not-exists")
   if err != nil {        t.Fatal(err)    }
   if res.StatusCode != http.StatusNotFound {        t.Errorf("Status code for /home is wrong. Have: %d, want: %d.", res.StatusCode, http.StatusNotFound)    } }

检查了 GET 请求 /home 路径是否返回 200,而 POST 请求该路径应该要返回 405。请求不存在的路由期望返回404。实际上,这样子测有点太冗余了,gorilla/mux 中已经包含类似的测试,所以测试代码可以简化下。

对于 home 来说,检查其返回得 code 和 body 值即可。

handlers/home_test.go

package handlers

import (
   "io/ioutil"    "net/http"    "net/http/httptest"    "testing"
)

func
TestHome(t *testing.T) {    w := httptest.NewRecorder()    home(w, nil)    resp := w.Result()
   if have, want := resp.StatusCode, http.StatusOK; have != want {        t.Errorf("Status code is wrong. Have: %d, want: %d.", have, want)    }    greeting, err := ioutil.ReadAll(resp.Body)    resp.Body.Close()
   if err != nil {        t.Fatal(err)    }
   if have, want := string(greeting), "Hello! Your request was processed."; have != want {        t.Errorf("The greeting is wrong. Have: %s, want: %s.", have, want)    } }

运行go test开始测试。

$ go test -v ./...
?       github.com/rumyantseva/advent-2017      [no test files]
=== RUN   TestRouter
--- PASS: TestRouter (0.00s)
=== RUN   TestHome
--- PASS: TestHome (0.00s)
PASS
ok      github.com/rumyantseva/advent-2017/handlers     0.018s

第五步 添加配置

下一个比较重要的问题是:服务需要能够是可配置的。目前的代码中,写死了监听 8000 端口,可能把端口值改成可配置的,会更有用一些。The Twelve-Factor App manifesto,这篇文章详尽阐述了如何去写好服务,文中提倡在环境变量中存放配置。后面代码展示本例如何利用上环境变量:

main.go

package main

import (
   "log"    "net/http"    "os"    "github.com/rumyantseva/advent-2017/handlers"
)

// How to try it: PORT=8000 go run main.go
func main() {    log.Print("Starting the service...")    port := os.Getenv("PORT")
   if port == "" {        log.Fatal("Port is not set.")    }    r := handlers.Router()    log.Print("The service is ready to listen and serve.")    log.Fatal(http.ListenAndServe(":"+port, r)) }

上例中,若没有设置 port 值,会返回错误。如果配置错误的话,没有必要继续执行后面代码。

第六步 添加 Makefile

前几天看过一篇关于 make 的 文章,如果想要把一些重复性高的常用的东西做成自动化,推荐看看该文。让我们看下怎么用上 make ,目前有两个操作:运行测试、编译并运行服务,把这两个操作加到 Makefile 文件中。这里我们用到 go build 命令,后面会运行编译好的二进制文件,这种方式更符合”在生产环境上运行”的目标,所以就不会用到 go run 命令了。

Makefile

APP?=advent
PORT?=8000

clean:    rm -f ${APP} build: clean    go build -o ${APP} run: build    PORT=${PORT} ./${APP}

test:    go test -v -race ./...

上例中把二进制文件名单独放到变量 APP 中,减少重复定义名称次数。

运行程序前,先删除旧的二进制文件(存在的话),然后编译代码、设置正确的环境变量并运行新生成的二进制文件,这些操作可以通过执行 make run 命令完成。

第七步 添加版本控制

这一步要添加到服务中的技巧是版本控制功能。某些场景下,知道生产环境中所使用的具体是哪个构建和 commit 以及什么时间构建的这类信息是非常有用的。

添加一个新的包 version 来保存这些信息。

version/version.go

package version

var (    
   // BuildTime is a time label of the moment when the binary was built    BuildTime = "unset"    // Commit is a last commit hash at the moment when the binary was built    Commit = "unset"    // Release is a semantic version of current build    Release = "unset"
)

程序启动时,会将这些变量打到日志中。

main.go

...
func main() {    log.Printf(
       "Starting the service...\ncommit: %s, build time: %s, release: %s",        version.Commit, version.BuildTime, version.Release,    ) ... }

也可以把这些信息添加到 home handler 中(别忘了更新对应的测试方法):

handlers/home.go

package handlers
import (
   "encoding/json"    "log"    "net/http"    "github.com/rumyantseva/advent-2017/version"
)

// home is a simple HTTP handler function which writes a response.
func home(w http.ResponseWriter, _ *http.Request) {    info := struct {        BuildTime string `json:"buildTime"`        Commit    string `json:"commit"`        Release   string `json:"release"`    }{        version.BuildTime, version.Commit, version.Release,    }    body, err := json.Marshal(info)
   if err != nil {        log.Printf("Could not encode info data: %v", err)        http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
       return    }    w.Header().Set("Content-Type", "application/json")    w.Write(body) }

通过 Go 链接器在编译时设置 BuildTimeCommit 以及 Release 变量。

先在 Makefile 中添加新变量:

Makefile

RELEASE?=0.0.1
COMMIT?=$(shell git rev-parse --short HEAD)
BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S')

COMMITBUILD_TIME(译者注:原文这里写 RELEASE ,可能有误)通过已有的命令获取,RELEASE 的赋值按照 语义化版本控制规范 来。

好,现在重写 Makefile 的 build 目标,用上上面定义的变量:

Makefile

build: clean
    go build \
        -ldflags "-s -w -X ${PROJECT}/version.Release=${RELEASE} \
        -X ${PROJECT}/version.Commit=${COMMIT} -X ${PROJECT}/version.BuildTime=${BUILD_TIME}" \
        -o ${APP}

PROJECT 变量添加到 Makefile 开头地方(减少多处定义)。

Makefile

PROJECT?=github.com/rumyantseva/advent-2017

本步所有代码变更记录可以在 这里 找到。可以多动手尝试运行下 make run 命令,看看具体是怎么工作的。

第八步 减少依赖

之前代码有个不尽如人意的点:handler 包依赖 version 包。做个简单的改动,让 home 处理器变成可配置的,减少依赖:

handlers/home.go

// home returns a simple HTTP handler function which writes a response.
func home(buildTime, commit, release string) http.HandlerFunc {
   return func(w http.ResponseWriter, _ *http.Request) {        ...    } }

同样,别忘了 改 测试代码。

第九步 添加“健康”检查功能(health checks)

某些情况下,想在 kubernetes 上跑服务,需要添加“健康”检查功能: 存活探针(liveness probe) 及就绪探针(readiness probe)。存活探针(liveness probe)目的是测试程序是否还在跑。如果存活探针(liveness probe)检测失败,服务会被重启。就绪探针(readiness probe)目的是测试程序是否准备好可以接受请求。如果就绪探针(readiness probe)检测失败,该容器会从服务负载均衡器中移除。

实现存活探针(liveness probe)的方式,可以简单写一个 handler 返回 200:

handlers/healthz.go

// healthz is a liveness probe.
func healthz(w http.ResponseWriter, _ *http.Request) {    w.WriteHeader(http.StatusOK) }

就绪探针(readiness probe)实现方式类似,不同的就是可能要等待某事件完成(例如:数据库已起来):

handlers/readyz.go

// readyz is a readiness probe.
func readyz(isReady *atomic.Value) http.HandlerFunc {
   return func(w http.ResponseWriter, _ *http.Request) {
       if isReady == nil || !isReady.Load().(bool) {            http.Error(w, http.StatusText(http.StatusServiceUnavailable), http.StatusServiceUnavailable)
           return        }        w.WriteHeader(http.StatusOK)    } }

isReady 有值并且为 true,返回 200

下面是如何使用它的例子:

handlers.go

func Router(buildTime, commit, release string) *mux.Router {
    isReady := &atomic.Value{}
    isReady.Store(false)
   go func() {        log.Printf("Readyz probe is negative by default...")        time.Sleep(10 * time.Second)        isReady.Store(true)        log.Printf("Readyz probe is positive.")    }()    r := mux.NewRouter()    r.HandleFunc("/home", home(buildTime, commit, release)).Methods("GET")    r.HandleFunc("/healthz", healthz)    r.HandleFunc("/readyz", readyz(isReady))
   return r }

设置等待 10 s 后服务可以处理请求。当然,实际业务代码不会有空等 10s 的情况,这里是模拟 cache warming(如果有用 cache)或者其他情况。

代码改动 GitHub 上可以找到。

注意:如果流量过大,服务节点的响应会不稳定。例如,存活探针(liveness probe)检测会因为超时失败。这就是为什么一些工程师不用存活探针(liveness probe)的原因。个人认为,当发现请求越来越多时候,最好是去扩容;例如可以参考 scale pods with HPA。

第十步 添加平滑关闭功能

关闭服务时,最好是不要立即中断连接、请求或者其他一些操作,而应该平滑关闭。Go 从 1.8 版本支持平滑关闭http.Server。下面看看怎么用:

main.go

func main() {
    ...
    r := handlers.Router(version.BuildTime, version.Commit, version.Release)

    interrupt := make(chan os.Signal, 1)
    signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)

    srv := &http.Server{
        Addr:    ":" + port,
        Handler: r,
    }
   go func() {        log.Fatal(srv.ListenAndServe())    }()    log.Print("The service is ready to listen and serve.")    killSignal := <-interrupt
   switch killSignal {
   case os.Interrupt:        log.Print("Got SIGINT...")
   case syscall.SIGTERM:        log.Print("Got SIGTERM...")    }    log.Print("The service is shutting down...")    srv.Shutdown(context.Background())    log.Print("Done") }

收到 SIGINTSIGTERM 任意一个系统信号,服务平滑关闭。

注意:当我在写这段代码的时候,我(作者)尝试去捕获 SIGKILL 信号。之前在不同的库中有看到过这种用法,我确认这样是行的通的。但是后来 Sandor Szücs 指出 ,不可能获取到 SIGKILL 信号。发出 SIGKILL 信号后,程序会直接结束。

第十一步 添加 Dockerfile

程序基本上可以在 Kubernetes 上跑了。这一步进行 Docker 化。

先添加一个简单的 Dockerfile,如下:

Dockerfile

FROM scratch

ENV PORT 8000
EXPOSE $PORT

COPY advent /
CMD ["/advent"]

创建了一个最小的容器,复制二进制到容器内然后运行(别忘了 PORT 配置变量)。

扩展 Makefile,使其能够构建镜像以及运行容器。同时添加 GOOSGOARCH 变量,在 build 的目标中交叉编译要用到。

Makefile

...

GOOS?=linux
GOARCH?=amd64

...

build: clean
    CGO_ENABLED=0 GOOS=${GOOS} GOARCH=${GOARCH} go build \
        -ldflags "-s -w -X ${PROJECT}/version.Release=${RELEASE} \
        -X ${PROJECT}/version.Commit=${COMMIT} -X ${PROJECT}/version.BuildTime=${BUILD_TIME}" \
        -o ${APP}

container: build
    docker build -t $(APP):$(RELEASE) .

run: container
    docker stop $(APP):$(RELEASE) || true && docker rm $(APP):$(RELEASE) || true
    docker run --name ${APP} -p ${PORT}:${PORT} --rm \
        -e "PORT=${PORT}" \
        $(APP):$(RELEASE)

...

添加了 containerrun goal,前者构建镜像,后者从容器启动程序。所有改动 这里 可以找到。

请尝试运行 make run命令,检查所有过程是否正确。

第十二步 添加 vendor

项目中依赖了外部代码(github.com/gorilla/mux),所以肯定要 加入依赖管理。如果引入 dep 的话,就只要执行 dep init

$ dep init
  Using ^1.6.0 as constraint for direct dep github.com/gorilla/mux
  Locking in v1.6.0 (7f08801) for direct dep github.com/gorilla/mux
  Locking in v1.1 (1ea2538) for transitive dep github.com/gorilla/context

会创建 Gopkg.tomlGopkg.lock 文件以及 vendor 目录。个人观点,推荐 push vendor 到 git,重要的项目尤其应该 push 上去。

第十三步 Kubernetes

最后一步,将程序部署到 Kubernets 上运行。本地环境最简单方式就是安装、配置 minikube。

Kubernetes 从 Docker registry 拉取镜像。本文中,使用公共 Docker registry—Docker Hub。Makefile 中还要添加一个变量和命令:

CONTAINER_IMAGE?=docker.io/webdeva/${APP}

...

container: build
    docker build -t $(CONTAINER_IMAGE):$(RELEASE) .

...

push: container
    docker push $(CONTAINER_IMAGE):$(RELEASE)

CONTAINER_IMAGE 变量定义了 push、pull 镜像的 Docker registry repo,路径中包含了用户名(webdeva)。如果没有 hub.docker.com 账户,请创建账户并通过 docker login 登录。这样就可以 push 镜像了。

运行 make push

$ make push
...
docker build -t docker.io/webdeva/advent:0.0.1 .
Sending build context to Docker daemon   5.25MB
...
Successfully built d3cc8f4121fe
Successfully tagged webdeva/advent:0.0.1
docker push docker.io/webdeva/advent:0.0.1
The push refers to a repository [docker.io/webdeva/advent]
ee1f0f98199f: Pushed
0.0.1: digest: sha256:fb3a25b19946787e291f32f45931ffd95a933100c7e55ab975e523a02810b04c size: 528

成功了~!然后可以在这里找到镜像。

接下来,定义必要的 Kubernetes 配置(manifest)。通常,一个服务至少需要设置 deployment、service 和 ingress 配置。默认情况,manifest 都是静态的,即其中不能使用任何变量。不过可以通过 helm 工具 创建更灵活的配置。

本例中,我们没有用 helm,但如果能定义两个变量:ServiceNameRelease 会更加实用。后面通过 sed 命令替换“变量”为实际值。

先看下 deployment 配置:

deployment.yaml

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: {{ .ServiceName }}
  labels:
    app: {{ .ServiceName }}
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 50%
      maxSurge: 1
  template:
    metadata:
      labels:
        app: {{ .ServiceName }}
    spec:
      containers:
      - name: {{ .ServiceName }}
        image: docker.io/webdeva/{{ .ServiceName }}:{{ .Release }}
        imagePullPolicy: Always
        ports:
        - containerPort: 8000
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8000
        readinessProbe:
          httpGet:
            path: /readyz
            port: 8000
        resources:
          limits:
            cpu: 10m
            memory: 30Mi
          requests:
            cpu: 10m
            memory: 30Mi
      terminationGracePeriodSeconds: 30

Kubernetes 的配置要讲清楚可以单独写一篇文章了,这里用到了容器镜像和存活探针(liveness probe)、就绪探针(readiness probe)检测功能,去哪里找镜像,以及检测模块的路径前文都有阐述。

一个经典的服务更简单:

service.yaml

apiVersion: v1
kind: Service
metadata:
  name: {{ .ServiceName }}
  labels:
    app: {{ .ServiceName }}
spec:
  ports:
  - port: 80
    targetPort: 8000
    protocol: TCP
    name: http
  selector:
    app: {{ .ServiceName }}

最后,定义下 ingress。定义从外部访问访问 Kubernetes 中服务的规则。这里假定把服务部署到到 advent.test 域上(实际不是)。

ingress.yaml

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
    ingress.kubernetes.io/rewrite-target: /
  labels:
    app: {{ .ServiceName }}
  name: {{ .ServiceName }}
spec:
  backend:
    serviceName: {{ .ServiceName }}
    servicePort: 80
  rules:
  - host: advent.test
    http:
      paths:
      - path: /
        backend:
          serviceName: {{ .ServiceName }}
          servicePort: 80

验证配置是否正确,需要安装、运行 minikube,官方文档在这里,还需要安装 kubectl 工具,用来提供配置和检验服务。

启动 minikube,启动 ingress,准备 kubectl,我们需要运行如下命令:

minikube start
minikube addons enable ingress
kubectl config use-context minikube

接下来,给 Makefile 添加新目标:安装服务到 minikube 上。

Makefile

minikube: push
    for t in $(shell find ./kubernetes/advent -type f -name "*.yaml"); do \
        cat $$t | \
            gsed -E "s/\{\{(\s*)\.Release(\s*)\}\}/$(RELEASE)/g" | \
            gsed -E "s/\{\{(\s*)\.ServiceName(\s*)\}\}/$(APP)/g"; \
        echo ---; \
    done > tmp.yaml
    kubectl apply -f tmp.yaml

上面命令“编译”所有 *.yaml 配置到一个文件。用实际值替换 ReleaseServiceName 变量(注意,这里用了 gsed 而非标准 sed), 最后运行 kubectl apply 命令安装应用到 Kubernetes 上。

验证配置是否正确:

$ kubectl get deployment
NAME      DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
advent    3         3         3            3           1d

$ kubectl get service
NAME         CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE
advent       10.109.133.147   <none>        80/TCP    1d

$ kubectl get ingress
NAME      HOSTS         ADDRESS        PORTS     AGE
advent    advent.test   192.168.64.2   80        1d

先在 /etc/host 文件添加模拟域名 advent.test,然后可以发请求测试服务了。

echo "$(minikube ip) advent.test" | sudo tee -a /etc/hosts
curl -i http://advent.test/home
HTTP/1.1 200 OK
Server: nginx/1.13.6
Date: Sun, 10 Dec 2017 20:40:37 GMT
Content-Type: application/json
Content-Length: 72
Connection: keep-alive
Vary: Accept-Encoding

{"buildTime":"2017-12-10_11:29:59","commit":"020a181","release":"0.0.5"}%

成功~!

所有步骤的代码在 这里 ,两个版本:按 commit 划分 以及 按步骤划分。如有任何疑问,请 提 issue,或者 tweet@webdeva,或者在评论区留评论。

真实生产环境上的服务其实有更大的灵活性,想知道是代码“长”啥样的么 ^_^?可以参考 takama/k8sapp ,是一个 Go 应用模板,满足了 Kubernetes 需求。


via: https://blog.gopheracademy.com/advent-2017/kubernetes-ready-service/

本文由 GCTT 原创编译,Go 中文网 荣誉推出


喜欢本文的朋友们,欢迎长按下图关注订阅号Go语言中文网,收看更多精彩内容


以上是关于『GCTT 出品』从零开始一步步构建运行在 Kubernetes 上的服务的主要内容,如果未能解决你的问题,请参考以下文章

『GCTT 出品』并行化 Golang 文件 IO

『GCTT 出品』可视化 Go 语言中的并发

『GCTT 出品』使用 Go 语言写一个即时编译器(JIT)

GCTT 出品 | 阅读挑战:Go 的堆排序

『GCTT 出品』在 golang 中如何调用私有函数(绑定隐藏的标识符)

『GCTT 出品』Donng Go 语言的优点,缺点和令人厌恶的设计