从零开始写一个运行在Kubernetes上的服务程序
2021/5/21 12:29:54
本文主要是介绍从零开始写一个运行在Kubernetes上的服务程序,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!
王晓轩 译 分布式实验室
这是一篇对于Go语言和Kubernetes新手来说再适合不过的文章了。文中详细介绍了从代码编写到用容器的方式在Kubernetes集群中发布,一步一步,一行一行,有例子有说明,解释透彻,贯穿始末,值得每一个容器爱好者和Go语言程序员去阅读和学习。
也许你已经尝试过了Go语言,也许你已经知道了可以很容易的用Go语言去写一个服务程序。没错!我们仅仅需要几行代码[1]就可以用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[4]或者logrus[5]。
比如,如果我们想记录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[6],它们兼容Go语言的标准库net/http。
如果你的服务程序需要处理大量的不同路由规则,你可以把所有相关的路由放在各自的函数中,甚至是package里。现在我们就在例子中,把路由的初始化和规则放到handlers package里(点这里[7]有所有的更改)。
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)) }
第四步 测试
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 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步 配置
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
几天以前有一篇文章[8]介绍make工具,如果你有一些重复性比较强的工作,那么使用它就大有帮助。现在我们来看一看我们的应用程序如何使用它。当前,我们有两个操作,测试和编译并运行。我们对Makefile文件进行了如下修改[9]。但是我们用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的值。
第7步 版本控制
下一步,我们将为我们的程序加入版本控制。因为有的时候,它对我们知道正在生产环境中运行和编译的代码非常有帮助。(译者注:就是说,我们在生产环境中运行的代码,有的时候我们自己都不知道对这个代码进行和什么样的提交和修改,有了版本控制,就可以显示出这个版本的变化和历史记录)。
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在编译中去设置BuildTime、Commit和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')
这里面的COMMIT和RELEASE可以在命令行中提供,也可以用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
所有的变化都可以在这里[10]找到,现在可以用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[11]。这么做的目的是为了知道容器里的应用程序是否还在运行。如果liveness探测失败,这个服务程序将会被重启,如果readness探测失败,说明服务还没有准备好。
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[12]找到。
第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") }
这里,我们会捕获系统信号,如果发现有SIGKILL,SIGINT或者SIGTERM,我们将优雅的关闭程序。
第11步 Dockerfile
我们的应用程序马上就以运行在Kubernetes里了,现在我们把它容器化。
下面是一个最简单的Dockerfile:Dockerfile
FROM scratch ENV PORT 8000 EXPOSE $PORT COPY advent / CMD ["/advent"]
我们创建了一个最简单的容器,复制程序并且运行它(当然不会忘记设置PORT这个环境变量)。
我们再对Makefile进行一下修改,让他能够产生容器镜像,并且运行一个容器。在这里为了交叉编译,定义环境变量GOOS 和GOARCH在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) ...
我们还增加了container段去产生一个容器的镜像,并且在run段运去以容器的方式运行我们的程序。所有的变化可以从这里[13]找到。
现在我们终于可以用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.toml和Gopkg.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
...
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
现在你看它可以工作了,从这里[14]可以找到这个镜像。
现在我们来定义一些Kubernetes里需要的配置文件。通常情况下,对于一个简单的服务程序,我们需要定一个deployment,一个service和一个ingress。默认情况下所有的配置都是静态的,即配置文件里不能使用变量。希望以后可以使用helm来创建一份灵活的配置。
在这个例子里,我们不会使用helm,虽然这个工具可以定义一些变量ServiceName和Release,它给我们的部署带来了很多灵活性。以后,我们会使用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,它的官方文档在[这里[15]。我们还需要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文件的配置信息都合并成一个临时文件,然后替换变量Release和ServiceName(这里要注意一下,我使用的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"}%
看,它工作了!
从这里[16]你可找到所有的步骤,这里[17]是提交的历史,这里[18]是最后的结果。如果你还有任何的疑问,请创建一个issue或者通过twitter:@webdeva或者是留一条comment。
相关链接:
https://github.com/rumyantseva/advent-2017/commit/76864ab0587dd9
https://github.com/rumyantseva/advent-2017/tree/all-steps
https://github.com/rumyantseva/advent-2017/commits/master
https://github.com/golang/glog
https://github.com/sirupsen/logrus
https://github.com/gorilla/mux
https://github.com/rumyantseva/advent-2017/commit/1a61e7952e227e33eaab81404d7bff9278244080
https://blog.gopheracademy.com/advent-2017/make
https://github.com/rumyantseva/advent-2017/commit/90966780ba6656f8dc0aebd166938c9adcbe0514
https://github.com/rumyantseva/advent-2017/commit/eaa4ff224b32fb343f5eac2a1204cc3806a22efd
https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/
https://github.com/rumyantseva/advent-2017/commit/e73b996f8522b736c150e53db059cf041c7c3e64
https://github.com/rumyantseva/advent-2017/commit/909fef6d585c85c5e16b5b0e4fdbdf080893b679
https://hub.docker.com/r/webdeva/advent/tags/
https://github.com/kubernetes/minikube#installation
https://github.com/rumyantseva/advent-2017
https://github.com/rumyantseva/advent-2017/commits/master
https://github.com/rumyantseva/advent-2017/tree/all-steps
https://github.com/takama/k8sapp
原文链接:https://blog.gopheracademy.com/advent-2017/kubernetes-ready-service
这篇关于从零开始写一个运行在Kubernetes上的服务程序的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2024-12-20/kubernetes 1.32版本更新解读:新特性和变化一目了然
- 2024-12-19拒绝 Helm? 如何在 K8s 上部署 KRaft 模式 Kafka 集群?
- 2024-12-16云原生周刊:Kubernetes v1.32 正式发布
- 2024-12-13Kubernetes上运行Minecraft:打造开发者平台的例子
- 2024-12-12深入 Kubernetes 的健康奥秘:探针(Probe)究竟有多强?
- 2024-12-10运维实战:K8s 上的 Doris 高可用集群最佳实践
- 2024-12-022024年最好用的十大Kubernetes工具
- 2024-12-02OPA守门人:Kubernetes集群策略编写指南
- 2024-11-26云原生周刊:K8s 严重漏洞
- 2024-11-15在Kubernetes (k8s) 中搭建三台 Nginx 服务器怎么实现?-icode9专业技术文章分享