Kasten k10 实战系列 07 – Kanister 应用感知框架的使用

Kasten k10 实战系列 07 - Kanister 应用感知框架的使用

1. 前言

由于 Kubernetes 本身的特性,基于 Kubernetes 开发和运行的应用对于企业的商业目标非常重要。随着 Kubernetes 中有状态应用程序的部署在云原生基础架构中的越发成熟,对有状态的云原生应用如何进行备份就成为了一个让人担心的问题。实际上在生产系统运行有状态应用并不是一件容易的事情,这需要我们仔细的计划并部署,管理者需要清晰地知道数据备份到哪个位置,备份的时间计划以及备份时间窗口,以确保与应用关联的数据也被正确备份了,从而达成应用的一致性,Kanister 就是解决这此问题的有利武器。

文章目录

Kasten k10 实战系列 07 - Kanister 应用感知框架的使用

  1. 前言
  2. Kasten Kanister 介绍
  3. Kanister 的核心概念
    • 3.1 Kanister Operator 与 CRDs
    • 3.2 Blueprint 的实现方式-- Admission Webhook
  4. 启用 Kanister 之前的准备工作
    • 4.1 安装 kanctl 工具包
    • 4.2 存储桶的准备
    • 4.3 安装 MySQL
  5. 利用 Kanister 达成应用感知的备份
    • 5.1 在 K10 中启用应用感知的备份
    • 5.2 利用开源的 Kanister 框架进行应用感知的备份
  6. 总结
  7. 参考链接

Kasten 实战系列导航

2. Kasten Kanister 介绍

Kanister 是 Kasten K10 数据管理平台使用的可扩展开源框架,可用于 Kubernetes 上的应用程序级数据管理。利用 Kanister 的特性,数据库领域专家在可以轻松地共享和扩展的 Blueprint 中捕获特定于应用程序的数据管理任务。 在本文中我们将介绍有关 Kanister 备份与恢复应用程序的测试及其扩展资源与信息,以供大家参考。

目前,通过 Kanister 我们可以对如下数据库进行应用感知的备份,可以说非常全面了:

  • RDS PostgreSQL Backup
  • Logical Elasticsearch Backup
  • Logical MongoDB Backup
  • Logical MySQL Backup
  • Logical PostgreSQL Backup
  • Logical MySQL Backup for OpenShift
  • Logical MongoDB Backup on OpenShift clusters
  • Logical PostgreSQL Backup on OpenShift Clusters

3. Kanister 的核心概念

3.1 Kanister Operator 与 CRDs

Kanister Operator 部署完成后,系统中会看到三个 CRD,就是 Kanister 运行的 3 个核心组件。下面给出了每个 CR 的简要说明。

  • actionset 动作集 , 指示要由备份或还原执行的任务(操作)的资源,理解成 backup policy 或备份、还原等数据操作任务就好了。
  • buleprint 蓝图 , 定义备份和恢复等过程的模板资源, 其中定义的主要的数据操作,备份、还原、删除,还有就是使用何种应用原生的方法获取数据集。
  • profile 配置文件, 定义作为备份目的地的对象存储的位置信息的资源, 之后会定义的 Profiles 中 包括 endpoint, bucket, access_key, secret_key 等。

Kanister 部署后的三个 CRD:

$ kubectl get crd |grep kanister
actionsets.cr.kanister.io                        2021-06-25T08:14:59Z
blueprints.cr.kanister.io                        2021-06-25T08:14:59Z
profiles.cr.kanister.io                          2021-06-25T08:14:59Z

Kanister 的工作流程如下所示。
20210630172425

3.2 Blueprint 的实现方式-- Admission Webhook

就像我们在之前的章节中表述的那样,K10 在进行数据备份操作时,会通过存储的方式(原生或是CSI)进行卷快照操作,但也有需要自定义的情况。例如,保护应用程序与数据库需要从逻辑层进行备份,而这时需要使用特定于该数据库的工具进行数据操作,例如 mysql 数据库上常用的 mysqldump。

如果要获得应用程序一致的备份, 则需要在启动卷快照之前停止数据库服务,最方便的做法就是利用 Admission Webhook 机制,将 Sidecar Proxy 自动注入用户 Pod , 并调用应用程序蓝图中定义的函数 ,如 mysqldump,静默数据库,然后调用卷快照,保证数据的一致性。这样的操作的好处是,用户可以快速得到一份应用一致的数据副本用于备份,同时不影响生产系统的性能。

下图中,标成蓝色的部分说明了,blueprint 调用 mysql dump 网我们定义的对象存储中写入数据的过程。

详细的情况可以查看 Github Kanister
https://github.com/kanisterio/kanister

20210701175051

Admission Webhook 的理论(option)

在 K8S中 Admission Webhook 是一种用于接收准入请求并对其进行处理的 HTTP 回调机制。Admission Webhook 有两种类型:validating 和 mutating 。

Validating webhook 可用于执行超出 OpenAPI 架构验证功能的验证,例如确保字段在常见后是不可变的,或者对向 API Server 发出请求的用户进行更高级别的权限检查。通过 Validating admission webhook,我们可以拒绝自定义准入策略的请求。

Mutating webhook 常用于默认设置,在创建时往在资源中添加未设置字段的默认值,工作原理如下图。

20210630154409

4. 启用 Kanister 之前的准备工作

  • Kanctl CLI 安装
  • 对象存储 - COS 存储桶
  • 应用程序 - mysql with statefulset
  • Kubernetes 要求:
    • Kubernetes 1.6+ with Beta APIs enabled
    • 底层基础设施中的PV, PVC 存储提供支持
    • Kanister controller 0.61.0 版本 安装在 K8S 集群 Kanister 命名空间中

4.1 安装 kanctl 工具包

Kanctl 是操作 kanister 进行备份恢复的工具集,如果你没有 k10 ,就靠它了。

安装 kanctl 工具包
https://docs.kanister.io/tooling.html#install-the-tools


$ curl https://raw.githubusercontent.com/kanisterio/kanister/master/scripts/get.sh | bash

## 4.2 存储桶的准备

存储桶是我们备份的目标,请建立存储桶,并将相关参数记录, 一会儿建立 profile 时要用。

![20210701191637](https://mars-blog-1257130361.cos.ap-chengdu.myqcloud.com/20210701191637.png)

## 4.3 安装 MySQL

用 bitnami 部署 MySQL 应用, 应用名为 mysql-release。

加入 bitnami docker 镜像库

$ helm repo add bitnami https://charts.bitnami.com/bitnami

更新 helm repo 设置

$ helm repo update

安装 MySQL database

$ kubectl create namespace mysql-test
$ helm install mysql-release bitnami/mysql --namespace mysql-test \
--set auth.rootPassword='Start123' \
--set primary.persistence.size=10Gi # <- 默认 8Gi 无法部署到 TKE CBS,改成10Gi


> [bitnami mysql 参数](https://artifacthub.io/packages/helm/bitnami/mysql)
> https://artifacthub.io/packages/helm/bitnami/mysql

用以下命令验证 Password 是否指定正确,如果在安装中没有指定 password, 可通过以下命令获取 root password

可通过以下命令获取 root password

$ kubectl get secret [YOUR_RELEASE_NAME] --namespace [YOUR_NAMESPACE] -o jsonpath="{.data.mysql-root-password}" | base64 --decode


部署完成,了解 MySql 部署的详细情况

$ kubectl get statefulset,pod,pvc,svc -n mysql-test
NAME READY AGE
statefulset.apps/mysql-release 1/1 7h9m

NAME READY STATUS RESTARTS AGE
pod/mysql-release-0 1/1 Running 0 7h9m

NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
persistentvolumeclaim/data-mysql-release-0 Bound pvc-20019012-4622-4925-b445-0566088c7254 20Gi RWO cbs-csi 7h9m

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/mysql-release ClusterIP 172.16.255.192 <none> 3306/TCP 7h9m


# 5.  利用 Kanister 达成应用感知的备份
## 5.1 在 K10 中启用应用感知的备份

很显然 Kansten K10 是个重视体验的商用软件,它已经将 Kanister 功能集成, 默认地 Kanister controller 已经部署到 Kasten K10 namespace 当中,所有的 ActionSet 被 K10 接管,只需要 apply 相应的 Blueprint 即可。

### 5.1.1 建立 secret 与 profile  

- **建立 secret**

$ kubectl create secret generic k10-cos-secret \
--namespace kasten-io \
--type secrets.kanister.io/aws \
--from-literal=aws_access_key_id=AKID4B6mCN79Ava3IJef4GCzZuzoRovmj2OW \
--from-literal=aws_secret_access_key=2uYx74pLbhgT1KUR9bopb8PXT9alkUzF
secret/k10-cos-secret created

$ kubectl get secret -n kasten-io
NAME TYPE DATA AGE
default-token-72fwn kubernetes.io/service-account-token 3 6d15h
k10-cluster-passphrase Opaque 1 6d15h
k10-cos-secret secrets.kanister.io/aws 2 53m


- **建立 profile**

```yaml
apiVersion: config.kio.kasten.io/v1alpha1
kind: Profile
metadata:
  name: kanister-profile
  namespace: kasten-io
spec:
  type: Kanister
  kanister:
    credential:
      secretType: AwsAccessKey
      secret:
        apiVersion: v1
        kind: Secret
        name: k10-cos-secret
        namespace: kasten-io
    location:
      type: ObjectStore
      objectStore:
        name: kanister-1257130361
        objectStoreType: S3
        endpoint: https://cos.ap-chengdu.myqcloud.com
        region: ap-chengdu

# 建立 profile 
$ kubectl apply -f kanister-profile.yaml 
profile.config.kio.kasten.io/kanister-profile created   

#  确保初始化成功
$ kubectl get profiles.config.kio.kasten.io --namespace kasten-io 
NAME               STATUS
cos1               Success
kanister-profile   Success

5.1.2 建立 MySQL 应用

$ kubectl create namespace mysql
namespace/mysql created

$ helm repo add 

$ helm repo add bitnami https://charts.bitnami.com/bitnami
$ helm repo list 
NAME            URL                               
kasten          https://charts.kasten.io/         
bitnami         https://charts.bitnami.com/bitnami
kanister        http://charts.kanister.io 

$ helm install mysql-release bitnami/mysql --namespace mysql \
   --set auth.rootPassword='Start123' \
   --set primary.persistence.size=10Gi    # <- 默认 8Gi 无法部署到 TKE CBS,改成10Gi

$ kubectl get pvc,po,svc -n mysql 
NAME                                         STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
persistentvolumeclaim/data-mysql-release-0   Bound    pvc-edf41c52-79a0-4b4d-b3fb-47e881a84a8f   10Gi       RWO            cbs-csi        4m19s

NAME                  READY   STATUS    RESTARTS   AGE
pod/mysql-release-0   1/1     Running   0          4m19s

NAME                             TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/mysql-release            ClusterIP   172.16.255.33   <none>        3306/TCP   4m19s
service/mysql-release-headless   ClusterIP   None            <none>        3306/TCP   4m19s

$ kubectl get statefulset -n mysql 
NAME            READY   AGE
mysql-release   1/1     3m6s

下面的命令将在 K10 命名空间中安装 MySQL Blueprint,并在 MySQL 部署中添加注释,以便让 K10 在对这个MySQL实例执行操作时使用Blueprint。

$ kubectl --namespace kasten-io apply -f \
    https://raw.githubusercontent.com/kanisterio/kanister/0.58.0/examples/stable/mysql/blueprint-v2/mysql-blueprint.yaml
blueprint.cr.kanister.io/mysql-blueprint created

$ kubectl get blueprint --namespace kasten-io  
NAME                                 AGE
k10-namespace-generic-volume-2.0.6   122m
mysql-blueprint                      2s

$ kubectl --namespace mysql annotate statefulset/mysql-release \
    kanister.kasten.io/blueprint=mysql-blueprint
statefulset.apps/mysql-release annotated

$ kubectl get statefulset mysql-release -n mysql -o yaml 

apiVersion: v1
items:
- apiVersion: apps/v1
  kind: StatefulSet
  metadata:
    annotations:
      kanister.kasten.io/blueprint: mysql-blueprint
...     

5.1.3 在 K10中 建立 MySQL 应用的备份

设置 sidecar inject 参数

$ helm get values k10 --output yaml --namespace=kasten-io > k10_val.yaml  
$ helm upgrade k10 kasten/k10 --namespace=kasten-io -f k10_val.yaml \                                                                                                                                                   ✔  1112  
    --set injectKanisterSidecar.enabled=true

在 K10中 建立 MySQL 应用的备份
20210702091219

5.2 利用开源的 Kanister 框架进行应用感知的备份

Kanister 开源框架让一切组件动作都变得更加清晰,当然这也是需要一定的技术基础的。Kanister 开源框架以免费的方式让更多的客户与合作伙伴受益,开源的目的是让更多数据类型应用的开发者使用蓝图,使其数据操作变得更顺畅。

5.2.1 Kanister Operator 安装与设置

首先,创建并移动到 Kanister 命名空间

$ kubectl create ns kanister
$ kubens kanister

然后使用 Helm 设置 Kanister Operator

$ helm repo add kanister http://charts.kanister.io
$ helm install myrelease --namespace kanister kanister/kanister-operator --set image.tag=0.61.0

确认 kanister Operator 已启动

$ kubectl get deploy,pod
NAME                                          READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/myrelease-kanister-operator   1/1     1            1           64m
NAME                                               READY   STATUS    RESTARTS   AGE
pod/myrelease-kanister-operator-6bbcd59494-cq5h7   1/1     Running   0     

这样就完成了 Kanister Operator 设置, Kanister 还部署了以下三个 CRD:

$ kubectl get crd |grep kanister
actionsets.cr.kanister.io                        2021-06-25T08:14:59Z
blueprints.cr.kanister.io                        2021-06-25T08:14:59Z
profiles.cr.kanister.io                          2021-06-25T08:14:59Z

下面给出了每个 CR 的简要说明。

  • actionset , 动作集,指示要由备份或还原执行的任务(操作)的资源。
  • buleprint , 蓝图, 定义备份和恢复等过程的模板资源
  • profile , 存储配置, 定义作为备份目的地的对象存储的位置信息的资源

Kanister 使用以上三个 CR 来执行备份/恢复。

5.2.2 创建用于备份的对象存储 Profile

$ kanctl create profile s3compliant --access-key A****@#@#@#@#@#***W \ 
    --secret-key 2****@#@#@#@#@#***F \
    --bucket kanister-1257130361 --region ap-chengdu \
    --endpoint https://cos.ap-chengdu.myqcloud.com \
    --namespace mysql-test

secret 's3-secret-yixwyd' created
profile 's3-profile-fbvpv' created  

# 查看 profiles 要牢记这个 profile 的名字
kubectl get profiles.cr.kanister.io -n mysql-test  
NAME               AGE
s3-profile-6xtr6   16h
s3-profile-fbvpv   5s

5.2.3 建立 Blueprint 蓝图

# 下载应用蓝图
$ wget https://raw.githubusercontent.com/kanisterio/kanister/0.58.0/examples/stable/mysql/blueprint-v2/mysql-blueprint.yaml

# 建立 Blueprint 蓝图
$ kubectl create -f mysql-blueprint.yaml -n kanister

# 查看 blueprint
$ kubectl get blueprint -n kanister                  
NAME              AGE
mysql-blueprint   18h

5.2.4 建立 MySQL 数据集

# 登录到 MySQL Docker
$ kubectl exec -ti $(kubectl get pods -n mysql-test --selector=app.kubernetes.io/instance=mysql-release -o=jsonpath='{.items[0].metadata.name}') -n mysql-test -- bash

# 登录 MySQL
$ mysql --user=root --password=Start123

# 建立 "test" 数据库, 并使用这个库
mysql> CREATE DATABASE test;
Query OK, 1 row affected (0.00 sec)

mysql> USE test;
Database changed

# 创建 "pets" 表,
mysql> CREATE TABLE pets (name VARCHAR(20), owner VARCHAR(20), species VARCHAR(20), sex CHAR(1), birth DATE, death DATE);
Query OK, 0 rows affected (0.02 sec)

# 插入一行数据
mysql> INSERT INTO pets VALUES ('Puffball','Diane','hamster','f','1999-03-30',NULL);
Query OK, 1 row affected (0.01 sec)

# 查看 "pets" 表中的数据
mysql> SELECT * FROM pets;
+----------+-------+---------+------+------------+-------+
| name     | owner | species | sex  | birth      | death |
+----------+-------+---------+------+------------+-------+
| Puffball | Diane | hamster | f    | 1999-03-30 | NULL  |
+----------+-------+---------+------+------------+-------+
1 row in set (0.00 sec)

5.2.5 创建 ActionSets 保护 MySQL 应用

现在我们可以使用这个应用程序的 ActionSet 对 MySQL 数据进行备份,在与 controller 相同的 namespace 中创建一个ActionSet

# 找到 刚刚建立的 profile 

kubectl get profiles.cr.kanister.io -n mysql-test 
NAME               AGE
s3-profile-fbvpv   2m

kanctl create actionset --action backup --namespace kanister --blueprint mysql-blueprint --statefulset mysql-test/mysql-release --profile mysql-test/s3-profile-fbvpv --secrets mysql=mysql-test/mysql-release
actionset backup-mpvqf created

# 查看 actionset 完成情况

$ kubectl --namespace kanister get actionsets.cr.kanister.io
NAME                 AGE
backup-mpvqf         1m

$ kubectl describe actionset -n kanister backup 
~~~
Events:
  Type    Reason           Age   From                 Message
  ----    ------           ----  ----                 -------
  Normal  Started Action   19m   Kanister Controller  Executing action backup
  Normal  Started Phase    19m   Kanister Controller  Executing phase dumpToObjectStore
  Normal  Ended Phase      19m   Kanister Controller  Completed phase dumpToObjectStore
  Normal  Update Complete  19m   Kanister Controller  Updated ActionSet &#039;backup-mpvqf&#039; Status->complete</code></pre>
<p>查看 S3 存储桶上的备份信息</p>
<pre><code>$ kubectl describe actionset backup-mpvqf -n kanister |grep s3path 
        s3path:  /mysql-backups/mysql-test/mysql-release/2021-07-01T07-24-37/dump.sql.gz</code></pre>
<h3>5.2.6 模拟灾难 删除 MySQL 数据</h3>
<pre><code># 登录 Docker, MySQL 库
$ kubectl exec -ti $(kubectl get pods -n mysql-test --selector=app.kubernetes.io/instance=mysql-release -o=jsonpath=&#039;{.items[0].metadata.name}&#039;) -n mysql-test -- bash

$ mysql --user=root --password=Start123
# Drop the test database
$ mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| test               |
+--------------------+
5 rows in set (0.00 sec)

mysql> DROP DATABASE test;
Query OK, 1 row affected (0.03 sec)

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
4 rows in set (0.00 sec)</code></pre>
<h3>5.2.7 还原 MySQL 数据</h3>
<p>要恢复丢失的数据, 我匀需要使用之前创建的备份集。利用 kanctl 命令工具,帮助创建用于Restore 的 actionset</p>
<pre><code>$ kanctl --namespace kanister create actionset --action restore --from "backup-mpvqf"
restore-backup-cfnms-lcw5t created

$ kubectl describe actionset -n kanister restore
~~~
Events:
  Type    Reason           Age   From                 Message
  ----    ------           ----  ----                 -------
  Normal  Started Action   6s    Kanister Controller  Executing action restore
  Normal  Started Phase    6s    Kanister Controller  Executing phase restoreFromBlobStore
  Normal  Ended Phase      2s    Kanister Controller  Completed phase restoreFromBlobStore
  Normal  Update Complete  2s    Kanister Controller  Updated ActionSet 'restore-backup-cfnms-lcw5t' Status->complete

一旦 ActionSet 状态为 “complete”,您可以看到数据已经成功恢复到 MySQL

5.2.8 查看 mysql 数据

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| test               |
+--------------------+
5 rows in set (0.00 sec)

mysql> USE test;
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 TABLES;
+----------------+
| Tables_in_test |
+----------------+
| pets           |
+----------------+
1 row in set (0.00 sec)

mysql> SELECT * FROM pets;
+----------+-------+---------+------+------------+-------+
| name     | owner | species | sex  | birth      | death |
+----------+-------+---------+------+------------+-------+
| Puffball | Diane | hamster | f    | 1999-03-30 | NULL  |
+----------+-------+---------+------+------------+-------+
1 row in set (0.00 sec)

5.2.9 ActionSet 的维护与续操作

可以使用以下命令清理备份操作创建的Artifacts:

$ kanctl --namespace kanister create actionset --action delete --from backup-mpvqf --namespacetargets kanister
actionset delete-backup-mpvqf-rm47s created

# 查看 ActionSet 情况
$ kubectl --namespace kanister describe actionset delete-backup-glptq-cq6bw

Events:
  Type    Reason           Age   From                 Message
  ----    ------           ----  ----                 -------
  Normal  Started Action   46s   Kanister Controller  Executing action delete
  Normal  Started Phase    46s   Kanister Controller  Executing phase deleteFromBlobStore
  Normal  Ended Phase      44s   Kanister Controller  Completed phase deleteFromBlobStore
  Normal  Update Complete  44s   Kanister Controller  Updated ActionSet 'delete-backup-mpvqf-rm47s' Status->complete

删除 CRs

# 删除 Blueprint 和 CR Profile
$ kubectl delete blueprints.cr.kanister.io mysql-blueprint -n kanister

$ kubectl get profiles.cr.kanister.io -n mysql-test
NAME               AGE
s3-profile-6xtr6   16h
s3-profile-fbvpv   16h
s3-profile-jcm89   14h

# 这是只是删除定义,在 S3 桶上的数据还在
$ kubectl delete profiles.cr.kanister.io s3-profile-jcm89 -n mysql-test                                                                                                                     ✔  942  05:47:22
profile.cr.kanister.io "s3-profile-jcm89" deleted

5.2.10 Troubleshooting

如果您在使用上述命令时遇到任何问题,您可以使用以下命令查看控制器的日志, 比如我们查看一下以上删除ActionSet的日志

$ kubectl --namespace kanister logs -l app=kanister-operator |grep delete-backup-mpvqf-rm47s

~~~
time="2021-07-01T13:09:29.432733894Z" level=info msg="Executing action delete" ActionSet=delete-backup-mpvqf-rm47s File=pkg/controller/controller.go Function="github.com/kanisterio/kanister/pkg/controller.(*Controller).runAction" Line=379 cluster_name=aee8ee9a-7e11-4d6d-a338-8b959d3acf23 hostname=myrelease-kanister-operator-6bbcd59494-cq5h7
~~~

# 我们也可以检查活动的事件
$ kubectl describe actionset delete-backup-mpvqf-rm47s -n kanister

7. 总结

这一次,我们验证了Kanister对Kubernetes Native备份/恢复的操作。 Kanister 是一种工具,它通过为每个应用程序准备一个名为 Blueprint 的模板,以某种方式为每个应用程序备份/恢复。如果您的应用程序中已有蓝图,则可以轻松使用它。然而,仅仅因为蓝图可用而过度自信是危险的。我们建议您在使用 Blueprint 之前始终检查它的内容,以了解正在执行的处理类型。

参考链接

https://github.com/kanisterio/kanister

https://itnext.io/backup-and-restore-of-k8s-using-k10-and-kanister-mutating-web-hooks-with-minio-a1511a79638e

Backup and Restore of K8s using K10 and Kanister(Mutating Web Hooks) with Minio
https://itnext.io/backup-and-restore-of-k8s-using-k10-and-kanister-mutating-web-hooks-with-minio-a1511a79638e

Kubernetes NativeなバックアップツールKanisterの検証
https://qiita.com/ysakashita/items/79e0d45257b59772fd77#%E6%84%9F%E6%83%B3

Writing a very basic kubernetes mutating admission webhook
https://medium.com/ovni/writing-a-very-basic-kubernetes-mutating-admission-webhook-398dbbcb63ec

https://kanister.io
Workflow orchestration:- http://github.com/kanisterio

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注