在一台VPS上部署k8s手记

2024年11月13日 作者 unix2go

如下是我用一台vps部署k8s,以及在k8s上运行基于mysql的web程序的全过程。

安装minikube

商业k8s集群都是庞然大物,通常都是几个master节点,和几百个work节点组成。但我这里只是测试部署,没必要搞那么复杂,就用minikube好了。

Minikube是k8s的官方项目,它允许在一个vps上进行部署,从而创建k8s模拟环境。它的主页:

https://minikube.sigs.k8s.io/docs/start

在安装之前,你需要一个vps,虽然主页写着最低要求是2c/2g/20g,但考虑到系统自身占用的资源,我建议是2c/4g/40g这种配置。

在vps里,需要预先安装docker服务。我的是ubuntu 22.04系统,安装docker也很简单,参考digitalocean的这篇文档:

https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-20-04

装了docker后,参考minikube的官方文档,把minikube环境安装上。这个安装也是十分简单的,执行如下命令。

$ curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
$ sudo install minikube-linux-amd64 /usr/local/bin/minikube

然后启动minukube:

$ minikube start

在系统里设置好别名如下,这个是k8s的管理客户端,经常要用到的。

alias k='minikube kubectl --'
alias kubectl='minikube kubectl --'

试着执行如下命令,看kubectl客户端是否正常工作。

$ k get node
NAME       STATUS   ROLES           AGE   VERSION
minikube   Ready    control-plane   20d   v1.26.1

如上输出,就说明ok了。

部署mysql pod

Mysql有官方的docker镜像,所以搬到k8s上挺简单的。不过我这里设计复杂一点,使用了3个mysql实例,一方面用于负载均衡,另一方面用于故障转移。其中一个mysql宕机,不至于影响业务的运行。当然,我的微型站点根本不需要这样的配置,只是演习而已。

创建一个custo-mysql目录,在里面创建k8s的yaml配置文件,共有3个文件:

secret.yaml  service.yaml  statefulset.yaml

第一个文件用来放mysql的root密码,第二个文件用来放mysql的service配置,第三个文件用来放mysql的部署结构。

它们各自的内容如下。第一个文件:

apiVersion: v1
kind: Secret
metadata: 
    name: mysecret
type: Opaque
data:
  ROOT_PASSWORD: bXlwYXNzd2Q=

注意mysql的root密码,是base64 encode过的,原始密码就是mypasswd(演示目的)。

这个secret.yaml文件在执行后,实际将ROOT_PASSWORD注入容器的环境变量,容器里的mysql启动后就自动把root密码设置为这个变量。

第二个文件:

apiVersion: v1
kind: Service
metadata:
  name: custo-mysql
  labels:
    app: custo-mysql
spec:
  clusterIP: None
  selector:
    app: custo-mysql
  ports:
    - name: tcp
      protocol: TCP
      port: 3306

这个是mysql的service文件。所谓service,在k8s里的作用是服务发现和接口暴露。也就是说,k8s里的其他容器,要跟mysql容器进行通信,就必须用到service接口。Service如果有clusterIP,那么k8s就会生成一个负载均衡ip,其他容器对mysql的请求,会到达这个负载均衡ip。但是,mysql集群通常是一个状态服务,不能随机负载均衡,所以这里clusterIP设置为none,表示直接使用mysql容器的ip,而不是生成另外一个负载均衡ip。

注意下selector那里的配置,app:custo-mysql是一个标签,service会根据这个标签找到后端对应的mysql实例,这里不能搞错了。

第三个文件:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: custo-mysql
spec:
  replicas: 3
  serviceName: custo-mysql
  selector:
    matchLabels:
      app: custo-mysql
  template:
    metadata:
      labels:
        app: custo-mysql
    spec:
      terminationGracePeriodSeconds: 10
      containers:
        - name: mysql
          image: mysql:5.7
          ports:
            - name: tpc
              protocol: TCP
              containerPort: 3306
          env:
            - name: MYSQL_ROOT_PASSWORD
              valueFrom: 
               secretKeyRef: 
                key: ROOT_PASSWORD
                name: mysecret
          volumeMounts:
            - name: custo-data
              mountPath: /var/lib/mysql
  volumeClaimTemplates:
    - metadata:
        name: custo-data
      spec:
        storageClassName: standard
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 1Gi

这是mysql的部署文件。注意部署对象不是deployment,而是statefulset。这是因为mysql是一个状态服务,k8s重启它后,要保持之前的状态,比如恢复数据。如果不恢复到之前的状态,数据就丢了。

这个文件包含的内容比较多,关键点说明如下,请自己对照着看。

  • 配置了3个实例副本。
  • 标签选择器是app:custo-mysql
  • 使用mysql 5.7版本,这是官方支持的5.x系列最低要求。
  • 服务端口是3306
  • 从环境变量里获取到root密码(第一个文件里设置)。
  • 申请到一个永久存储,大小为1g,绑定到/var/lib/mysql目录(数据持久目录)。

上述最后一点很重要,这是mysql状态服务的关键。因为k8s里的容器可以被随意kill,删掉一个后,k8s会启动一个新的容器用来代替。那么新起来的容器,必须同样加载这个数据目录,mysql数据才不会丢失。

上述三个配置文件,位于custo-mysql目录里,用kubectl命名加载进集群如下。

$ k apply -f *.yaml

然后看一下pod和service是否正常:

$ k get pod -l app=custo-mysql
NAME            READY   STATUS    RESTARTS      AGE
custo-mysql-0   1/1     Running   0             9h
custo-mysql-1   1/1     Running   0             61m
custo-mysql-2   1/1     Running   1 (36h ago)   4d5h

$ k get service -l app=custo-mysql
NAME          TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
custo-mysql   ClusterIP   None         <none>        3306/TCP   4d6h

如上输出就ok了。你也可以仔细看一下这个部署的详细情况,执行如下命令。

$ k describe sts/custo-mysql

导入数据到mysql

上一节创建的3个mysql,理论上可以配置成一主二从,需要人工去设置mysql replication。我懒得弄,就在每个库里都创建了对等的表和数据,应用查询任何一个库,都可以得到对等的结果。

如何访问这三个mysql呢?请参考如下脚本(名为mysql.sh)。

#!/bin/bash

arg=$1
k='minikube kubectl --'

if [ $arg -eq 0 ];then
  $k exec -it custo-mysql-0 -- mysql -uroot -pmypasswd
  
elif [ $arg -eq 1 ];then
  $k exec -it custo-mysql-1 -- mysql -uroot -pmypasswd

elif [ $arg -eq 2 ];then
  $k exec -it custo-mysql-2 -- mysql -uroot -pmypasswd
fi

在vps里运行./mysql.sh 0就访问第一个实例,运行./mysql.sh 1就访问第二个实例,运行./mysql.sh 2就访问第三个实例。如下所示。

$ ./mysql.sh 0
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 60
Server version: 5.7.41 MySQL Community Server (GPL)

Copyright (c) 2000, 2023, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> use mytest;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed

mysql> show create table books;
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                                           |
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| books | CREATE TABLE `books` (
  `id` int(11) DEFAULT NULL,
  `name` varchar(64) DEFAULT NULL,
  `year` int(11) DEFAULT NULL,
  `publisher` varchar(64) DEFAULT NULL,
  `labels` varchar(32) DEFAULT NULL,
  `price` float DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=latin1 |
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

如上内容表示,登陆mysql后,我创建了mytest数据库,以及在这个库里,创建了books数据表。表的结构也给出来了,照抄即可(create table books语句)。

需要在三个数据库都创建同样的库和表结构。这三个库的ip,可以用get pod列出来如下。

$ k get pod -o wide -l app=custo-mysql
NAME            READY   STATUS    RESTARTS      AGE    IP            NODE       NOMINATED NODE   READINESS GATES
custo-mysql-0   1/1     Running   0             9h     10.244.0.79   minikube   <none>           <none>
custo-mysql-1   1/1     Running   0             74m    10.244.0.96   minikube   <none>           <none>
custo-mysql-2   1/1     Running   1 (37h ago)   4d5h   10.244.0.76   minikube   <none>           <none>

当然,你也可以用kubectl exec命令,先登陆其中一个mysql容器,在容器里再访问mysql服务,如下所示。

$ k exec custo-mysql-0 -it -- bash
bash-4.2# 
bash-4.2# mysql -uroot -hcusto-mysql -pmypasswd
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 62
Server version: 5.7.41 MySQL Community Server (GPL)

Copyright (c) 2000, 2023, Oracle and/or its affiliates.

你还可以运行kubectl logs命令,查看容器的日志输出,如下所示。

$ k logs custo-mysql-0 
2023-03-07 03:12:46+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 5.7.41-1.el7 started.
2023-03-07 03:12:46+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'

既然能够登陆mysql服务,那么导入数据就不难了。刚才已经创建了数据表,那么把测试数据,导入到这个表里即可。我随机生成了一份测试数据,是10万条IT书籍的记录。

制作web应用镜像

数据库已经有了,接下来就创建一个应用服务,它根据用户的输入关键字,查询数据库,并返回结果。这个应用服务我简单写了个cgi脚本(名为books.cgi),内容如下:

#!/usr/bin/perl
use strict;
use CGI;
use MySQL::mycrud;

my $q = CGI->new;

if (defined $q->param("submit") ) {

  my $key = $q->param("key");
  $key =~ s/^\s+|\s+$//g;

  if ($key =~ /[^a-zA-Z0-9\s]/ or length($key) > 64 or length($key) < 3 ) {
    print $q->header;
    print "invalid keyword";
    exit;
  }

  my $db = MySQL::mycrud->new('mytest','custo-mysql','3306','root','mypasswd');
  my $rr = $db->get_rows("select * from books where name like '%$key%' or publisher like '%$key%' or labels like '%$key%' limit 100");

  print $q->header(
    -type => 'text/html',
  );

  print "<table>";
  print "<tr><td>ID</td><td>Name</td><td>Year</td><td>Publisher</td><td>Labels</td><td>Price</td></tr>\n";

  for my $r (@$rr) { # each element is a hash ref
    print "<tr>";
    print "<td>$r->{id}</td>";
    print "<td>$r->{name}</td>";
    print "<td>$r->{year}</td>";
    print "<td>$r->{publisher}</td>";
    print "<td>$r->{labels}</td>";
    print "<td>$r->{price}</td>";
    print "</tr>\n";
  }
  print "</table>";

} else {
    print $q->header(
    -type => 'text/html',
          );

  print <<HTML_EOF;
<!DOCTYPE html>
<html>
<body>

<h2>search books on k8s</h2>

<form action="/cgi-bin/books.cgi" method="post">
  <label>keyword:</label><br>
  <input type="text" name="key" value="database"><br><br>
  <input type="submit" name="submit" value="Submit">
</form>

</body>
</html>
HTML_EOF
}

cgi运行在哪里呢?当然最合适的还是apache httpd服务器。所以,我要创建一个包含apache2的镜像(image),并将这个cgi脚本放到镜像里。

如何创建docker image,这个我就不重点讲了,请参考我的另一篇笔记:

https://notes.199903.xyz/create-my-ubuntu-docker-image-for-k8s

简言之要准备好Dockerfile,这个文件的内容如下:

FROM ubuntu
RUN apt update && apt -y install \
  make cpanminus  curl mysql-client libdbd-mysql-perl apache2
RUN cpanm MySQL::mycrud
RUN cpanm CGI
RUN a2enmod cgi
RUN mkdir -p /var/www/cgi-bin
COPY books.conf /etc/apache2/sites-enabled/
COPY books.cgi /var/www/cgi-bin/
RUN chmod 755 /var/www/cgi-bin/books.cgi
RUN rm -f /etc/apache2/sites-enabled/000-default.conf
CMD ["/usr/sbin/apachectl", "-D", "FOREGROUND"]

镜像里Web服务器的配置文件(books.conf)内容如下:

<VirtualHost *:80>

  ServerName books.hostcache.com
	ServerAdmin webmaster@localhost
	DocumentRoot /var/www/html

    ScriptAlias /cgi-bin/ /var/www/cgi-bin/
    <Directory /var/www/cgi-bin>
        AllowOverride None
        Options +ExecCGI
        AddHandler cgi-script .cgi .pl
        Require all granted
    </Directory>

	ErrorLog ${APACHE_LOG_DIR}/error.log
	CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>

上述3个文件(Dockerfile, books.cgi, books.conf)放在同一个目录里(名为books),然后针对这个目录,执行如下docker build即可。

docker build -t books books/
docker tag books geekml/books
docker push geekml/books

build成功后,会将image提交到docker hub里,后面k8s就可以直接从docker hub拉取镜像生成容器了。具体image的制作过程,还请参考前述博客。

创建web应用pod

跟创建mysql pod一样,同样使用一个yaml配置文件,内容如下:

apiVersion: v1
kind: Service
metadata:
  name: books
  labels:
    app: books
spec:
  ports:
    - port: 80
  selector:
    app: books
  type: LoadBalancer
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: books
  labels:
    app: books
spec:
  replicas: 3
  selector:
    matchLabels:
      app: books
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: books
    spec:
      containers:
      - image: geekml/books
        name: books
        ports:
        - containerPort: 80
          name: books

上述配置我挑重点说明如下,请对照mysql的配置进行对比。

  • 针对web服务定义了一个service,同样用来做服务发现。它的类型是LoadBalancer,而不是clusterIP。
  • 针对web服务定义了Deployment,包含了部署结构。请注意这里是deployment对象,而不是statefulset,因为web服务是个无状态服务。
  • Web服务包含3个副本。
  • Web服务的标签选择器是app:books
  • Web服务使用的镜像是geekml/books,就是我之前上传到docker hub的镜像。

关于service的类型,务必仔细弄清楚,我简要说明如下。

  • Service默认类型是clusterIP,也就是生成一个负载均衡ip,用来轮询到后台pod的ip。
  • 如果clusterIP设置为none,那么就不生成负载均衡ip,而是直接用pod的ip。对于状态服务(比如mysql),它的clusterIP需要设置为none,也就是不需要轮询。
  • 如果service类型是LoadBalancer,它不但会生成负载均衡ip,还会生成一个外部ip。vps使用这个外部ip,可以访问到k8s集群的服务。

我们打开另一个容器,登入k8s集群,查看mysql和web服务两个service的ip。如下所示。

$ k run devbox -it --rm --image geekml/myubuntu -- bash
If you don't see a command prompt, try pressing enter.

root@devbox:/# host custo-mysql
custo-mysql.default.svc.cluster.local has address 10.244.0.96
custo-mysql.default.svc.cluster.local has address 10.244.0.79
custo-mysql.default.svc.cluster.local has address 10.244.0.76

root@devbox:/# host books
books.default.svc.cluster.local has address 10.96.210.217

这里很明显,3个mysql实例的clusterIP就是它们自己。而3个web实例的clusterIP是一个独立的负载均衡ip。

我在配置里对web服务使用了LoadBalancer,因为需要从vps上访问这个web服务,而且web服务是无状态的,随便轮询。

那么web服务的外部访问地址是什么呢?运行如下命令查看:

$ minikube service books
|-----------|-------|-------------|---------------------------|
| NAMESPACE | NAME  | TARGET PORT |            URL            |
|-----------|-------|-------------|---------------------------|
| default   | books |          80 | http://192.168.49.2:32075 |
|-----------|-------|-------------|---------------------------|
🎉  Opening service default/books in default browser...
👉  http://192.168.49.2:32075

好了,把如上k8s的外部服务地址(http://192.168.49.2:32075),加入到vps上web服务器的反向代理,就万事大吉。当然,这里也可以用k8s的Ingress规则来做反向代理。

我的vps上也装了apache2,就懒得去弄nginx反向代理,直接用apache的mod_proxy模块。关于如何配置apache的反向代理,请参考digitalocean的文档:

https://www.digitalocean.com/community/tutorials/how-to-use-apache-as-a-reverse-proxy-with-mod_proxy-on-ubuntu-16-04

apache2反向代理配置文件如下:

<VirtualHost *:80>
    ProxyPreserveHost On

    ProxyPass / http://192.168.49.2:32075/
    ProxyPassReverse / http://192.168.49.2:32075/
</VirtualHost>

配置完后,重启apache2。再设置一个域名,指向vps的ip,这里的测试域名是books.hostcache.com。然后,你打开如下url,就可以访问到k8s上运行的web服务了。

http://books.hostcache.com/cgi-bin/books.cgi

我们再看一下3个web服务器和3个数据库服务器组成的k8s集群:

$ k get pod -l app=books
NAME                    READY   STATUS    RESTARTS   AGE
books-fd4f9cccd-cs985   1/1     Running   0          3h3m
books-fd4f9cccd-rl2r5   1/1     Running   0          3h3m
books-fd4f9cccd-rwnrf   1/1     Running   0          3h3m

$ k get pod -l app=custo-mysql
NAME            READY   STATUS    RESTARTS      AGE
custo-mysql-0   1/1     Running   0             10h
custo-mysql-1   1/1     Running   0             128m
custo-mysql-2   1/1     Running   1 (38h ago)   4d6h

你可以随便删掉一个mysql,或者一个web服务,k8s都会自动重启一个新的来代替。

$ k delete pod custo-mysql-1

在故障转移和高可用方面,k8s真的挺强的,所以k8s很适合运行微服务。在商业环境里,数以万计的微服务运行在k8s上,它们经常重启或者漂移,但是不影响整体稳定性。