从零开始写一个运行在Kubernetes上的服务程序


【编者的话】这是一篇对于Go语言和Kubernetes新手来说再适合不过的文章了。文中详细介绍了从代码编写到用容器的方式在Kubernetes集群中发布,一步一步,一行一行,有例子有说明,解释透彻,贯穿始末,值得每一个容器爱好者和Go语言程序员去阅读和学习。

也许你已经尝试过了Go语言,也许你已经知道了可以很容易的用Go语言去写一个服务程序。没错!我们仅仅需要几行代码就可以用Go语言写出一个http的服务程序。但是如果我们想把它放到生产环境里,我们还需要准备些什么呢?让我用一个准备放在Kubernetes上的服务程序来举例说明一下。

你可以从这里找到这篇章中使用的例子,跟随我们一步一步的进行。

第1步 最简单的http服务程序

下面就是这个程序:

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 -i http://127.0.0.1:8000/home。但是当我们运行这个应用的时候,我们找不到任何关于状态的信息。

第2步 增加日志

首先,增加日志功能可以帮助我们了解程序现在处于一个什么样的状态,并记录错误(译者注:如果有错误的话)等其他一些重要信息。在这个例子里我们使用Go语言标准库里最简单的日志模块,但是如果是跑在Kubernetes上的服务程序,你可能还需要一些额外的库,比如glog或者logrus

比如,如果我们想记录3种情况:当程序启动的时候,当程序启动完成,可以对外提供服务的时候,当http.listenAndServe 返回出错的时候。所以我们程序如下:

main.go
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))


第3步 增加一个路由

现在,如果我们写一个真正实用的程序,我们也许需要增加一个路由,根据规则去响应不同的URL和处理HTTP的方法。在Go语言的标准库中没有路由,所以我们需要引用gorilla/mux,它们兼容Go语言的标准库net/http

如果你的服务程序需要处理大量的不同路由规则,你可以把所有相关的路由放在各自的函数中,甚至是package里。现在我们就在例子中,把路由的初始化和规则放到handlers package里(点这里有所有的更改)。

现在我们增加一个Router函数,它返回一个配置好的路由和能够处理/homehome函数。就我个人习惯,我把它们分成两个文件:

handler/handers.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/handles_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方法返回200。另一方面,如果我们发出POST,我们期待返回405。最后,增加一个如果访问错误的404。实际上,这个例子有有一点“冗余”了,因为路由作为 gorilla/mux的一部分已经处理好了,所以其实你不需要处理这么多情况。

对于home合理的检查一下响应码和返回值:

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 tests来检查代码的正确性:
$ 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

第5步 配置

下一个问题就是如何去配置我们的服务程序。因为现在它只能监听8000端口,如果能配置这个端口,我们的服务程序会更有价值。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))


在这个例子里,如果没有设置端口,应用程序会退出并返回一个错误。因为如果配置错误了,就没有必要再继续执行了。

第6步 Makefile

几天以前有一篇文章介绍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

第7步 版本控制

下一步,我们将为我们的程序加入版本控制。因为有的时候,它对我们知道正在生产环境中运行和编译的代码非常有帮助。(译者注:就是说,我们在生产环境中运行的代码,有的时候我们自己都不知道对这个代码进行和什么样的提交和修改,有了版本控制,就可以显示出这个版本的变化和历史记录)。

为了存储这些信息,我们增加一个新的package -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和test也增加上版本控制信息:

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 linker在编译中去设置BuildTimeCommitRelease变量。

Makefile增加一些变量:

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

这里面的COMMITRELEASE可以在命令行中提供,也可以用semantic version设置RELEASE`。

现在我们为了那些变量重写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} 

我也在Makefile文件的开始部分定义了PROJECT变量去避免做一些重复性的事。

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


所有的变化都可以在这里找到,现在可以用make run去运行它了。

第8步 减少一些依赖

这里有一些代码里我不喜欢的地方:handlepakcage依赖于versionpackage。这个很容易修改:我们需要让home 处理变得可以配置。

handler/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) {
    ...
}


别忘了同时去修改测试和必须的环境变量。

第9步 健康检查

在某些情况下,我们需要经常对运行在Kubernetes里的服务程序进行健康检查:liveness and readiness probes。这么做的目的是为了知道容器里的应用程序是否还在运行。如果liveness探测失败,这个服务程序将会被重启,如果readness探测失败,说明服务还没有准备好。

为了支持readness探测,我们需要实现一个简单的处理函数,去返回 200

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


readness探测方法一般和上面类似,但是我们需要经常去增加一些等待的事件(比如我们的应用已经连上了数据库)等:

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

现在我们看看怎么使用:

handles.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秒后把服务程序标记成可用,当然在真正的环境里,不可能会等待10秒,我这么做仅仅是为了报出警报去模拟程序要等待一个时间完成之后才能可用。

所有的修改都可以从这个GitHub找到。

第10步 程序优雅的关闭

当服务需要被关闭的停止的时候,最好不要立刻就断开所有的链接和终止当前的操作,而是尽可能的去完成它们。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, os.Kill, 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.Kill:
    log.Print("Got SIGKILL...")
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")


这里,我们会捕获系统信号,如果发现有SIGKILLSIGINT或者SIGTERM,我们将优雅的关闭程序。

第11步 Dockerfile

我们的应用程序马上就以运行在Kubernetes里了,现在我们把它容器化。

下面是一个最简单的Dockerfile:

Dockerfile
FROM scratch

ENV PORT 8000
EXPOSE $PORT

COPY advent /
CMD ["/advent"]

我们创建了一个最简单的容器,复制程序并且运行它(当然不会忘记设置PORT这个环境变量)。

我们再对Makefile进行一下修改,让他能够产生容器镜像,并且运行一个容器。在这里为了交叉编译,定义环境变量GOOSGOARCHbuild段。

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)

...

我们还增加了container段去产生一个容器的镜像,并且在run段运去以容器的方式运行我们的程序。所有的变化可以从这里找到。

现在我们终于可以用make run去检验一下整个过程了。

第12步 发布

在我们的项目里,我们还依赖一个外部的包(github.com/gorilla/mux)。而且,我们需要为生产环境里的
readness安装依赖管理。所以我们用了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,个人认为,我会把vendor放到git上去,特别是对与那些比较重要的项目来说。

第13步 Kubernetes

这也是最后一步了。运行一个应用程序到Kubernetes上。最简单的方法就是在本地去安装和配置一个minikube(这是一个单点的kubernetes测试环境)。

Kubernetes从容器仓库拉去镜像。在我们的例子里,我们会用公共容器仓库——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变量用来定义一个镜像的名字,我们用这个镜像存放我们的服务程序。如你所见,在这个例子里包含了我的用户名(webdeva)。如果你在hub.docker.com上没有账户,那你就先得创建一个,然后用docker login命令登陆,这个时候你就可以推送你的镜像了。

现在我们试一下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里需要的配置文件。通常情况下,对于一个简单的服务程序,我们需要定一个deployment,一个service和一个ingress。默认情况下所有的配置都是静态的,即配置文件里不能使用变量。希望以后可以使用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和readness探针。

一个典型的service看起来更简单:

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

现在我们可以发送一个http的请求到我们的服务上,但是首先还是要把域名adventtest增加到/etc/host文件里:
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"}%

看,它工作了!

这里你可找到所有的步骤,这里是提交的历史,这里是最后的结果。如果你还有任何的疑问,请创建一个issue或者通过twitter:@webdeva或者是留一条comment。

创建一个在生产环境中灵活的服务程序对你来说也许很有意思。在这个例子里可以去看一下takama/k8sapp,一个用Go语言写的能够运行在Kubernetes上面的应用程序模版。

非常感谢 Natalie PistunovichPaul BrousseauSandor Szücs等人的的建议和审核。

原文链接:Write a Kubernetes-ready service from zero step-by-step(翻译:王晓轩)

0 个评论

要回复文章请先登录注册