本教程展示了在 Kubernetes 上使用 StatefulSets,PodDisruptionBudgets 和 PodAntiAffinity 特性运行 Apache Zookeeper。

准备开始

在开始本教程前,你应该熟悉以下 Kubernetes 概念:Pods、Cluster DNS、Headless Services、PersistentVolumes、PersistentVolume Provisioning、StatefulSets、PodDisruptionBudgets、PodAntiAffinity、kubectl CLI。
你需要一个至少包含四个节点的集群,每个节点至少 2 CPUs 和 4 GiB 内存。在本教程中你将会 cordon 和 drain 集群的节点。这意味着集群节点上所有的 Pods 将会被终止并移除。这些节点也会暂时变为不可调度。在本教程中你应该使用一个独占的集群,或者保证你造成的干扰不会影响其它租户。
本教程假设你的集群配置为动态的提供 PersistentVolumes。如果你的集群没有配置成这样,在开始本教程前,你需要手动准备三个 20 GiB 的卷。

教程目标

在学习本教程后,你将熟悉下列内容:如何使用 StatefulSet 部署一个 ZooKeeper ensemble、如何一致性配置 ensemble、如何在 ensemble 中 分布 ZooKeeper 服务器的部署、如何在计划维护中使用 PodDisruptionBudgets 确保服务可用性。

ZooKeeper 基础

Apache ZooKeeper 是一个分布式的开源协调服务,用于分布式系统。ZooKeeper 允许你读取、写入数据和发现数据更新。数据按层次结构组织在文件系统中,并复制到 ensemble(一个 ZooKeeper 服务器的集合) 中所有的 ZooKeeper 服务器。对数据的所有操作都是原子的和顺序一致的。ZooKeeper 通过 Zab 一致性协议在 ensemble 的所有服务器之间复制一个状态机来确保这个特性。

ensemble 使用 Zab 协议选举一个 leader,在选举出 leader 前不能写入数据。一旦选举出了 leader,ensemble 使用 Zab 保证所有写入被复制到一个 quorum,然后这些写入操作才会被确认并对客户端可用。如果没有遵照加权 quorums,一个 quorum 表示包含当前 leader 的 ensemble 的多数成员。例如,如果 ensemble 有3个服务器,一个包含 leader 的成员和另一个服务器就组成了一个 quorum。如果 ensemble 不能达成一个 quorum,数据将不能被写入。

ZooKeeper 在内存中保存它们的整个状态机,但是每个改变都被写入一个在存储介质上的持久 WAL(Write Ahead Log)。当一个服务器故障时,它能够通过回放 WAL 恢复之前的状态。为了防止 WAL 无限制的增长,ZooKeeper 服务器会定期的将内存状态快照保存到存储介质。这些快照能够直接加载到内存中,所有在这个快照之前的 WAL 条目都可以被安全的丢弃。

创建一个 ZooKeeper Ensemble

下面的清单包含一个 Headless Service, 一个 Service, 一个 PodDisruptionBudget, 和一个 StatefulSet。

apiVersion: v1
kind: Service
metadata:name: zk-hslabels:app: zk
spec:ports:- port: 2888name: server- port: 3888name: leader-electionclusterIP: Noneselector:app: zk
---
apiVersion: v1
kind: Service
metadata:name: zk-cslabels:app: zk
spec:ports:- port: 2181name: clientselector:app: zk
---
apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:name: zk-pdb
spec:selector:matchLabels:app: zkmaxUnavailable: 1
---
apiVersion: apps/v1
kind: StatefulSet
metadata:name: zk
spec:selector:matchLabels:app: zkserviceName: zk-hsreplicas: 3updateStrategy:type: RollingUpdatepodManagementPolicy: OrderedReadytemplate:metadata:labels:app: zkspec:affinity:podAntiAffinity:requiredDuringSchedulingIgnoredDuringExecution:- labelSelector:matchExpressions:- key: "app"operator: Invalues:- zktopologyKey: "kubernetes.io/hostname"containers:- name: kubernetes-zookeeperimagePullPolicy: Alwaysimage: "k8s.gcr.io/kubernetes-zookeeper:1.0-3.4.10"resources:requests:memory: "1Gi"cpu: "0.5"ports:- containerPort: 2181name: client- containerPort: 2888name: server- containerPort: 3888name: leader-electioncommand:- sh- -c- "start-zookeeper \--servers=3 \--data_dir=/var/lib/zookeeper/data \--data_log_dir=/var/lib/zookeeper/data/log \--conf_dir=/opt/zookeeper/conf \--client_port=2181 \--election_port=3888 \--server_port=2888 \--tick_time=2000 \--init_limit=10 \--sync_limit=5 \--heap=512M \--max_client_cnxns=60 \--snap_retain_count=3 \--purge_interval=12 \--max_session_timeout=40000 \--min_session_timeout=4000 \--log_level=INFO"readinessProbe:exec:command:- sh- -c- "zookeeper-ready 2181"initialDelaySeconds: 10timeoutSeconds: 5livenessProbe:exec:command:- sh- -c- "zookeeper-ready 2181"initialDelaySeconds: 10timeoutSeconds: 5volumeMounts:- name: datadirmountPath: /var/lib/zookeepersecurityContext:runAsUser: 1000fsGroup: 1000volumeClaimTemplates:- metadata:name: datadirspec:accessModes: [ "ReadWriteOnce" ]resources:requests:storage: 10Gi

打开一个命令行终端,使用 kubectl apply 创建这个清单:kubectl apply -f https://k8s.io/examples/application/zookeeper/zookeeper.yaml
这个操作创建了 zk-hs Headless Service、zk-cs Service、zk-pdb PodDisruptionBudget 和 zk StatefulSet。

service/zk-hs created
service/zk-cs created
poddisruptionbudget.policy/zk-pdb created
statefulset.apps/zk created

使用 kubectl get 查看 StatefulSet 控制器创建的 Pods:kubectl get pods -w -l app=zk
一旦 zk-2 Pod 变成 Running 和 Ready 状态,使用 CRTL-C 结束 kubectl。

NAME      READY     STATUS    RESTARTS   AGE
zk-0      0/1       Pending   0          0s
zk-0      0/1       Pending   0         0s
zk-0      0/1       ContainerCreating   0         0s
zk-0      0/1       Running   0         19s
zk-0      1/1       Running   0         40s
zk-1      0/1       Pending   0         0s
zk-1      0/1       Pending   0         0s
zk-1      0/1       ContainerCreating   0         0s
zk-1      0/1       Running   0         18s
zk-1      1/1       Running   0         40s
zk-2      0/1       Pending   0         0s
zk-2      0/1       Pending   0         0s
zk-2      0/1       ContainerCreating   0         0s
zk-2      0/1       Running   0         19s
zk-2      1/1       Running   0         40s

StatefulSet 控制器创建了3个 Pods,每个 Pod 包含一个 ZooKeeper 服务器。

促成 Leader 选举

由于在匿名网络中没有用于选举 leader 的终止算法,Zab 要求显式的进行成员关系配置,以执行 leader 选举。Ensemble 中的每个服务器都需要具有一个独一无二的标识符,所有的服务器均需要知道标识符的全集,并且每个标识符都需要和一个网络地址相关联。
使用 kubectl exec 获取 zk StatefulSet 中 Pods 的主机名:for i in 0 1 2; do kubectl exec zk-$i -- hostname; done
StatefulSet 控制器基于每个 Pod 的序号索引为它们各自提供一个唯一的主机名。主机名采用 <statefulset name>-<ordinal index>的形式。由于 zk StatefulSet 的 replicas 字段设置为3,这个 Set 的控制器将创建3个 Pods,主机名为:zk-0、zk-1 和 zk-2。

zk-0
zk-1
zk-2

ZooKeeper ensemble 中的服务器使用自然数作为唯一标识符,每个服务器的标识符都保存在服务器的数据目录中一个名为 myid 的文件里。
检查每个服务器的 myid 文件的内容:for i in 0 1 2; do echo "myid zk-$i";kubectl exec zk-$i -- cat /var/lib/zookeeper/data/myid; done
由于标识符为自然数并且序号索引是非负整数,你可以在序号上加 1 来生成一个标识符。

myid zk-0
1
myid zk-1
2
myid zk-2
3

获取 zk StatefulSet 中每个 Pod 的 FQDN (Fully Qualified Domain Name,正式域名):for i in 0 1 2; do kubectl exec zk-$i -- hostname -f; done
zk-hs Service 为所有 Pods 创建了一个 domain:zk-hs.default.svc.cluster.local。

zk-0.zk-hs.default.svc.cluster.local
zk-1.zk-hs.default.svc.cluster.local
zk-2.zk-hs.default.svc.cluster.local

Kubernetes DNS 中的 A 记录将 FQDNs 解析成为 Pods 的 IP 地址。如果 Pods 被调度,这个 A 记录将会使用 Pods 的新 IP 地址更新,但 A 记录的名称不会改变。
ZooKeeper 在一个名为 zoo.cfg 的文件中保存它的应用配置。使用 kubectl exec 在 zk-0 Pod 中查看 zoo.cfg 文件的内容:kubectl exec zk-0 -- cat /opt/zookeeper/conf/zoo.cfg
文件底部为 server.1、server.2 和 server.3,其中的 1、2和3分别对应 ZooKeeper 服务器的 myid 文件中的标识符。它们被设置为 zk StatefulSet 中的 Pods 的 FQDNs。

clientPort=2181
dataDir=/var/lib/zookeeper/data
dataLogDir=/var/lib/zookeeper/log
tickTime=2000
initLimit=10
syncLimit=2000
maxClientCnxns=60
minSessionTimeout= 4000
maxSessionTimeout= 40000
autopurge.snapRetainCount=3
autopurge.purgeInterval=0
server.1=zk-0.zk-hs.default.svc.cluster.local:2888:3888
server.2=zk-1.zk-hs.default.svc.cluster.local:2888:3888
server.3=zk-2.zk-hs.default.svc.cluster.local:2888:3888

达成一致

一致性协议要求每个参与者的标识符唯一。在 Zab 协议里任何两个参与者都不应该声明相同的唯一标识符。对于让系统中的进程协商哪些进程已经提交了哪些数据而言,这是必须的。如果有两个 Pods 使用相同的序号启动,这两个 ZooKeeper 服务器会将自己识别为相同的服务器。

kubectl get pods -w -l app=zk
NAME      READY     STATUS    RESTARTS   AGE
zk-0      0/1       Pending   0          0s
zk-0      0/1       Pending   0         0s
zk-0      0/1       ContainerCreating   0         0s
zk-0      0/1       Running   0         19s
zk-0      1/1       Running   0         40s
zk-1      0/1       Pending   0         0s
zk-1      0/1       Pending   0         0s
zk-1      0/1       ContainerCreating   0         0s
zk-1      0/1       Running   0         18s
zk-1      1/1       Running   0         40s
zk-2      0/1       Pending   0         0s
zk-2      0/1       Pending   0         0s
zk-2      0/1       ContainerCreating   0         0s
zk-2      0/1       Running   0         19s
zk-2      1/1       Running   0         40s

每个 Pod 的 A 记录仅在 Pod 变成 Ready状态时被录入。因此,ZooKeeper 服务器的 FQDNs 只会解析到一个 endpoint,而那个 endpoint 将会是一个唯一的 ZooKeeper 服务器,这个服务器声明了配置在它的 myid 文件中的标识符。

zk-0.zk-hs.default.svc.cluster.local
zk-1.zk-hs.default.svc.cluster.local
zk-2.zk-hs.default.svc.cluster.local

这保证了 ZooKeepers 的 zoo.cfg 文件中的 servers 属性代表了一个正确配置的 ensemble。

server.1=zk-0.zk-hs.default.svc.cluster.local:2888:3888
server.2=zk-1.zk-hs.default.svc.cluster.local:2888:3888
server.3=zk-2.zk-hs.default.svc.cluster.local:2888:3888

当服务器使用 Zab 协议尝试提交一个值的时候,它们会达成一致并成功提交这个值(如果 leader 选举成功并且至少有两个 Pods 处于 Running 和 Ready状态),或者将会失败(如果没有满足上述条件中的任意一条)。当一个服务器承认另一个服务器的代写时不会有状态产生。

Ensemble 健康检查

最基本的健康检查是向一个 ZooKeeper 服务器写入一些数据,然后从另一个服务器读取这些数据。
使用 zkCli.sh 脚本在 zk-0 Pod 上写入 world 到路径 /hello。

kubectl exec zk-0 zkCli.sh create /hello world
WATCHER::WatchedEvent state:SyncConnected type:None path:null
Created /hello

从 zk-1 Pod 获取数据:kubectl exec zk-1 zkCli.sh get /hello
你在 zk-0 创建的数据在 ensemble 中所有的服务器上都是可用的。

WATCHER::WatchedEvent state:SyncConnected type:None path:null
world
cZxid = 0x100000002
ctime = Thu Dec 08 15:13:30 UTC 2016
mZxid = 0x100000002
mtime = Thu Dec 08 15:13:30 UTC 2016
pZxid = 0x100000002
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 5
numChildren = 0

准备持久存储

如同在 ZooKeeper 基础 一节所提到的,ZooKeeper 提交所有的条目到一个持久 WAL,并周期性的将内存快照写入存储介质。对于使用一致性协议实现一个复制状态机的应用来说,使用 WALs 提供持久化是一种常用的技术,对于普通的存储应用也是如此。
使用 kubectl delete 删除 zk StatefulSet。

kubectl delete statefulset zk
statefulset.apps "zk" deleted

观察 StatefulSet 中的 Pods 变为终止状态。
kubectl get pods -w -l app=zk
当 zk-0 完全终止时,使用 CRTL-C 结束 kubectl。

zk-2      1/1       Terminating   0         9m
zk-0      1/1       Terminating   0         11m
zk-1      1/1       Terminating   0         10m
zk-2      0/1       Terminating   0         9m
zk-2      0/1       Terminating   0         9m
zk-2      0/1       Terminating   0         9m
zk-1      0/1       Terminating   0         10m
zk-1      0/1       Terminating   0         10m
zk-1      0/1       Terminating   0         10m
zk-0      0/1       Terminating   0         11m
zk-0      0/1       Terminating   0         11m
zk-0      0/1       Terminating   0         11m

重新应用 zookeeper.yaml 中的代码清单。
kubectl apply -f https://k8s.io/examples/application/zookeeper/zookeeper.yaml
zk StatefulSet 将会被创建。由于清单中的其他 API 对象已经存在,所以它们不会被修改。
观察 StatefulSet 控制器重建 StatefulSet 的 Pods:kubectl get pods -w -l app=zk
一旦 zk-2 Pod 处于 Running 和 Ready 状态,使用 CRTL-C 停止 kubectl命令。

NAME      READY     STATUS    RESTARTS   AGE
zk-0      0/1       Pending   0          0s
zk-0      0/1       Pending   0         0s
zk-0      0/1       ContainerCreating   0         0s
zk-0      0/1       Running   0         19s
zk-0      1/1       Running   0         40s
zk-1      0/1       Pending   0         0s
zk-1      0/1       Pending   0         0s
zk-1      0/1       ContainerCreating   0         0s
zk-1      0/1       Running   0         18s
zk-1      1/1       Running   0         40s
zk-2      0/1       Pending   0         0s
zk-2      0/1       Pending   0         0s
zk-2      0/1       ContainerCreating   0         0s
zk-2      0/1       Running   0         19s
zk-2      1/1       Running   0         40s

从 zk-2 Pod 中获取你在健康检查中输入的值:kubectl exec zk-2 zkCli.sh get /hello
尽管 zk StatefulSet 中所有的 Pods 都已经被终止并重建过,ensemble 仍然使用原来的数值提供服务。

WATCHER::WatchedEvent state:SyncConnected type:None path:null
world
cZxid = 0x100000002
ctime = Thu Dec 08 15:13:30 UTC 2016
mZxid = 0x100000002
mtime = Thu Dec 08 15:13:30 UTC 2016
pZxid = 0x100000002
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 5
numChildren = 0

zk StatefulSet 的 spec 中的 volumeClaimTemplates 字段标识了将要为每个 Pod 准备的 PersistentVolume。

volumeClaimTemplates:- metadata:name: datadirannotations:volume.alpha.kubernetes.io/storage-class: anythingspec:accessModes: [ "ReadWriteOnce" ]resources:requests:storage: 20Gi

StatefulSet 控制器为 StatefulSet 中的每个 Pod 生成一个 PersistentVolumeClaim。
获取 StatefulSet 的 PersistentVolumeClaims。
kubectl get pvc -l app=zk
当 StatefulSet 重新创建它的 Pods时,Pods 的 PersistentVolumes 会被重新挂载。

NAME           STATUS    VOLUME                                     CAPACITY   ACCESSMODES   AGE
datadir-zk-0   Bound     pvc-bed742cd-bcb1-11e6-994f-42010a800002   20Gi       RWO           1h
datadir-zk-1   Bound     pvc-bedd27d2-bcb1-11e6-994f-42010a800002   20Gi       RWO           1h
datadir-zk-2   Bound     pvc-bee0817e-bcb1-11e6-994f-42010a800002   20Gi       RWO           1h

StatefulSet 的容器 template 中的 volumeMounts 一节使得 PersistentVolumes 被挂载到 ZooKeeper 服务器的数据目录。

volumeMounts:- name: datadirmountPath: /var/lib/zookeeper

当 zk StatefulSet 中的一个 Pod 被(重新)调度时,它总是拥有相同的 PersistentVolume,挂载到 ZooKeeper 服务器的数据目录。即使在 Pods 被重新调度时,所有对 ZooKeeper 服务器的 WALs 的写入和它们的全部快照都仍然是持久的。

确保一致性配置

如同在 促成 leader 选举 和 达成一致 小节中提到的,ZooKeeper ensemble 中的服务器需要一致性的配置来选举一个 leader 并形成一个 quorum。它们还需要 Zab 协议的一致性配置来保证这个协议在网络中正确的工作。在这次的样例中,我们通过直接将配置写入代码清单中来达到该目的。
获取 zk StatefulSet。

kubectl get sts zk -o yaml
…
command:- sh- -c- "start-zookeeper \--servers=3 \--data_dir=/var/lib/zookeeper/data \--data_log_dir=/var/lib/zookeeper/data/log \--conf_dir=/opt/zookeeper/conf \--client_port=2181 \--election_port=3888 \--server_port=2888 \--tick_time=2000 \--init_limit=10 \--sync_limit=5 \--heap=512M \--max_client_cnxns=60 \--snap_retain_count=3 \--purge_interval=12 \--max_session_timeout=40000 \--min_session_timeout=4000 \--log_level=INFO"
…

用于启动 ZooKeeper 服务器的命令将这些配置作为命令行参数传给了 ensemble。你也可以通过环境变量来传入这些配置。

配置日志

zkGenConfig.sh 脚本产生的一个文件控制了 ZooKeeper 的日志行为。ZooKeeper 使用了 Log4j 并默认使用基于文件大小和时间的滚动文件追加器作为日志配置。
从 zk StatefulSet 的一个 Pod 中获取日志配置。
kubectl exec zk-0 cat /usr/etc/zookeeper/log4j.properties
下面的日志配置会使 ZooKeeper 进程将其所有的日志写入标志输出文件流中。

zookeeper.root.logger=CONSOLE
zookeeper.console.threshold=INFO
log4j.rootLogger=${zookeeper.root.logger}
log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender
log4j.appender.CONSOLE.Threshold=${zookeeper.console.threshold}
log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout
log4j.appender.CONSOLE.layout.ConversionPattern=%d{ISO8601} [myid:%X{myid}] - %-5p [%t:%C{1}@%L] - %m%n

这是在容器里安全记录日志的最简单的方法。由于应用的日志被写入标准输出,Kubernetes 将会为你处理日志轮转。Kubernetes 还实现了一个智能保存策略,保证写入标准输出和标准错误流的应用日志不会耗尽本地存储媒介。
使用 kubectl logs 从一个 Pod 中取回最后几行日志。kubectl logs zk-0 --tail 20
使用 kubectl logs 或者从 Kubernetes Dashboard 可以查看写入到标准输出和标准错误流中的应用日志。

2016-12-06 19:34:16,236 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@827] - Processing ruok command from /127.0.0.1:52740
2016-12-06 19:34:16,237 [myid:1] - INFO  [Thread-1136:NIOServerCnxn@1008] - Closed socket connection for client /127.0.0.1:52740 (no session established for client)
2016-12-06 19:34:26,155 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxnFactory@192] - Accepted socket connection from /127.0.0.1:52749
2016-12-06 19:34:26,155 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@827] - Processing ruok command from /127.0.0.1:52749
2016-12-06 19:34:26,156 [myid:1] - INFO  [Thread-1137:NIOServerCnxn@1008] - Closed socket connection for client /127.0.0.1:52749 (no session established for client)
2016-12-06 19:34:26,222 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxnFactory@192] - Accepted socket connection from /127.0.0.1:52750
2016-12-06 19:34:26,222 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@827] - Processing ruok command from /127.0.0.1:52750
2016-12-06 19:34:26,226 [myid:1] - INFO  [Thread-1138:NIOServerCnxn@1008] - Closed socket connection for client /127.0.0.1:52750 (no session established for client)
2016-12-06 19:34:36,151 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxnFactory@192] - Accepted socket connection from /127.0.0.1:52760
2016-12-06 19:34:36,152 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@827] - Processing ruok command from /127.0.0.1:52760
2016-12-06 19:34:36,152 [myid:1] - INFO  [Thread-1139:NIOServerCnxn@1008] - Closed socket connection for client /127.0.0.1:52760 (no session established for client)
2016-12-06 19:34:36,230 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxnFactory@192] - Accepted socket connection from /127.0.0.1:52761
2016-12-06 19:34:36,231 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@827] - Processing ruok command from /127.0.0.1:52761
2016-12-06 19:34:36,231 [myid:1] - INFO  [Thread-1140:NIOServerCnxn@1008] - Closed socket connection for client /127.0.0.1:52761 (no session established for client)
2016-12-06 19:34:46,149 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxnFactory@192] - Accepted socket connection from /127.0.0.1:52767
2016-12-06 19:34:46,149 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@827] - Processing ruok command from /127.0.0.1:52767
2016-12-06 19:34:46,149 [myid:1] - INFO  [Thread-1141:NIOServerCnxn@1008] - Closed socket connection for client /127.0.0.1:52767 (no session established for client)
2016-12-06 19:34:46,230 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxnFactory@192] - Accepted socket connection from /127.0.0.1:52768
2016-12-06 19:34:46,230 [myid:1] - INFO  [NIOServerCxn.Factory:0.0.0.0/0.0.0.0:2181:NIOServerCnxn@827] - Processing ruok command from /127.0.0.1:52768
2016-12-06 19:34:46,230 [myid:1] - INFO  [Thread-1142:NIOServerCnxn@1008] - Closed socket connection for client /127.0.0.1:52768 (no session established for client)

Kubernetes 支持与 Stackdriver 和 Elasticsearch and Kibana 的整合以获得复杂但更为强大的日志功能。 对于集群级别的日志输出与整合,可以考虑部署一个 sidecar 容器。

配置非特权用户

在容器中允许应用以特权用户运行这条最佳实践是值得商讨的。如果你的组织要求应用以非特权用户运行,你可以使用 SecurityContext 控制运行容器入口点的用户。
zk StatefulSet 的 Pod 的 template 包含了一个 SecurityContext。

securityContext:runAsUser: 1000fsGroup: 1000

在 Pods 的容器内部,UID 1000 对应用户 zookeeper,GID 1000对应用户组 zookeeper。
从 zk-0 Pod 获取 ZooKeeper 进程信息。
kubectl exec zk-0 – ps -elf
由于 securityContext 对象的 runAsUser 字段被设置为1000而不是 root,ZooKeeper 进程将以 zookeeper 用户运行。

F S UID        PID  PPID  C PRI  NI ADDR SZ WCHAN  STIME TTY          TIME CMD
4 S zookeep+     1     0  0  80   0 -  1127 -      20:46 ?        00:00:00 sh -c zkGenConfig.sh && zkServer.sh start-foreground
0 S zookeep+    27     1  0  80   0 - 1155556 -    20:46 ?        00:00:19 /usr/lib/jvm/java-8-openjdk-amd64/bin/java -Dzookeeper.log.dir=/var/log/zookeeper -Dzookeeper.root.logger=INFO,CONSOLE -cp /usr/bin/../build/classes:/usr/bin/../build/lib/*.jar:/usr/bin/../share/zookeeper/zookeeper-3.4.9.jar:/usr/bin/../share/zookeeper/slf4j-log4j12-1.6.1.jar:/usr/bin/../share/zookeeper/slf4j-api-1.6.1.jar:/usr/bin/../share/zookeeper/netty-3.10.5.Final.jar:/usr/bin/../share/zookeeper/log4j-1.2.16.jar:/usr/bin/../share/zookeeper/jline-0.9.94.jar:/usr/bin/../src/java/lib/*.jar:/usr/bin/../etc/zookeeper: -Xmx2G -Xms2G -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.local.only=false org.apache.zookeeper.server.quorum.QuorumPeerMain /usr/bin/../etc/zookeeper/zoo.cfg

默认情况下,当 Pod 的 PersistentVolume 被挂载到 ZooKeeper 服务器的数据目录时,它只能被 root 用户访问。这个配置将阻止 ZooKeeper 进程写入它的 WAL 及保存快照。
在 zk-0 Pod 上获取 ZooKeeper 数据目录的文件权限。
kubectl exec -ti zk-0 – ls -ld /var/lib/zookeeper/data
由于 securityContext 对象的 fsGroup 字段设置为1000,Pods 的 PersistentVolumes 的所有权属于 zookeeper 用户组,因而 ZooKeeper 进程能够成功的读写数据。
drwxr-sr-x 3 zookeeper zookeeper 4096 Dec 5 20:45 /var/lib/zookeeper/data

管理 ZooKeeper 进程

ZooKeeper documentation 文档指出“你将需要一个监管程序用于管理每个 ZooKeeper 服务进程(JVM)”。在分布式系统中,使用一个看门狗(监管程序)来重启故障进程是一种常用的模式。
更新 Ensemble
zk StatefulSet 的更新策略被设置为了 RollingUpdate。
你可以使用 kubectl patch 更新分配给每个服务器的 cpus 的数量。
kubectl patch sts zk --type=‘json’ -p=’[{“op”: “replace”, “path”: “/spec/template/spec/containers/0/resources/requests/cpu”, “value”:“0.3”}]’
statefulset.apps/zk patched
使用 kubectl rollout status 观测更新状态。

kubectl rollout status sts/zk
waiting for statefulset rolling update to complete 0 pods at revision zk-5db4499664...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
waiting for statefulset rolling update to complete 1 pods at revision zk-5db4499664...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
waiting for statefulset rolling update to complete 2 pods at revision zk-5db4499664...
Waiting for 1 pods to be ready...
Waiting for 1 pods to be ready...
statefulset rolling update complete 3 pods at revision zk-5db4499664...

这项操作会逆序地依次终止每一个 Pod,并用新的配置重新创建。这样做确保了在滚动更新的过程中 quorum 依旧保持工作。
使用 kubectl rollout history 命令查看历史或先前的配置。

kubectl rollout history sts/zk
statefulsets "zk"
REVISION
1
2

使用 kubectl rollout undo 命令撤销这次的改动。
kubectl rollout undo sts/zk
statefulset.apps/zk rolled back

处理进程故障

Restart Policies 控制 Kubernetes 如何处理一个 Pod 中容器入口点的进程故障。对于 StatefulSet 中的 Pods 来说,Always 是唯一合适的 RestartPolicy,这也是默认值。你应该绝不覆盖 stateful 应用的默认策略。
检查 zk-0 Pod 中运行的 ZooKeeper 服务器的进程树。
kubectl exec zk-0 – ps -ef
作为容器入口点的命令的 PID 为 1,Zookeeper 进程是入口点的子进程,PID 为27。

UID        PID  PPID  C STIME TTY          TIME CMD
zookeep+     1     0  0 15:03 ?        00:00:00 sh -c zkGenConfig.sh && zkServer.sh start-foreground
zookeep+    27     1  0 15:03 ?        00:00:03 /usr/lib/jvm/java-8-openjdk-amd64/bin/java -Dzookeeper.log.dir=/var/log/zookeeper -Dzookeeper.root.logger=INFO,CONSOLE -cp /usr/bin/../build/classes:/usr/bin/../build/lib/*.jar:/usr/bin/../share/zookeeper/zookeeper-3.4.9.jar:/usr/bin/../share/zookeeper/slf4j-log4j12-1.6.1.jar:/usr/bin/../share/zookeeper/slf4j-api-1.6.1.jar:/usr/bin/../share/zookeeper/netty-3.10.5.Final.jar:/usr/bin/../share/zookeeper/log4j-1.2.16.jar:/usr/bin/../share/zookeeper/jline-0.9.94.jar:/usr/bin/../src/java/lib/*.jar:/usr/bin/../etc/zookeeper: -Xmx2G -Xms2G -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.local.only=false org.apache.zookeeper.server.quorum.QuorumPeerMain /usr/bin/../etc/zookeeper/zoo.cfg

在一个终端观察 zk StatefulSet 中的 Pods。
kubectl get pod -w -l app=zk
在另一个终端杀掉 Pod zk-0 中的 ZooKeeper 进程。
kubectl exec zk-0 – pkill java
ZooKeeper 进程的终结导致了它父进程的终止。由于容器的 RestartPolicy 是 Always,父进程被重启。

NAME      READY     STATUS    RESTARTS   AGE
zk-0      1/1       Running   0          21m
zk-1      1/1       Running   0          20m
zk-2      1/1       Running   0          19m
NAME      READY     STATUS    RESTARTS   AGE
zk-0      0/1       Error     0          29m
zk-0      0/1       Running   1         29m
zk-0      1/1       Running   1         29m

如果你的应用使用一个脚本(例如 zkServer.sh)来启动一个实现了应用业务逻辑的进程,这个脚本必须和子进程一起结束。这保证了当实现应用业务逻辑的进程故障时,Kubernetes 会重启这个应用的容器。

存活性测试

你的应用配置为自动重启故障进程,但这对于保持一个分布式系统的健康来说是不够的。许多场景下,一个系统进程可以是活动状态但不响应请求,或者是不健康状态。你应该使用 liveness probes 来通知 Kubernetes 你的应用进程处于不健康状态,需要被重启。
zk StatefulSet 的 Pod 的 template 一节指定了一个存活探针。

 livenessProbe:exec:command:- sh- -c- "zookeeper-ready 2181"initialDelaySeconds: 15timeoutSeconds: 5

这个探针调用一个简单的 bash 脚本,使用 ZooKeeper 的四字缩写 ruok 来测试服务器的健康状态。

OK=$(echo ruok | nc 127.0.0.1 $1)
if [ "$OK" == "imok" ]; thenexit 0
elseexit 1
fi

在一个终端窗口观察 zk StatefulSet 中的 Pods。
kubectl get pod -w -l app=zk
在另一个窗口中,从 Pod zk-0 的文件系统中删除 zookeeper-ready 脚本。
kubectl exec zk-0 – rm /usr/bin/zookeeper-ready
当 ZooKeeper 进程的存活探针探测失败时,Kubernetes 将会为你自动重启这个进程,从而保证 ensemble 中不健康状态的进程都被重启。

kubectl get pod -w -l app=zk
NAME      READY     STATUS    RESTARTS   AGE
zk-0      1/1       Running   0          1h
zk-1      1/1       Running   0          1h
zk-2      1/1       Running   0          1h
NAME      READY     STATUS    RESTARTS   AGE
zk-0      0/1       Running   0          1h
zk-0      0/1       Running   1         1h
zk-0      1/1       Running   1         1h

就绪性测试

就绪不同于存活。如果一个进程是存活的,它是可调度和健康的。如果一个进程是就绪的,它应该能够处理输入。存活是就绪的必要非充分条件。在许多场景下,特别是初始化和终止过程中,一个进程可以是存活但没有就绪的。
如果你指定了一个就绪探针,Kubernetes将保证在就绪检查通过之前,你的应用不会接收到网络流量。
对于一个 ZooKeeper 服务器来说,存活即就绪。因此 zookeeper.yaml 清单中的就绪探针和存活探针完全相同。

  readinessProbe:exec:command:- sh- -c- "zookeeper-ready 2181"initialDelaySeconds: 15timeoutSeconds: 5

虽然存活探针和就绪探针是相同的,但同时指定它们两者仍然重要。这保证了 ZooKeeper ensemble 中只有健康的服务器能接收网络流量。

容忍节点故障

ZooKeeper 需要一个 quorum 来提交数据变动。对于一个拥有 3 个服务器的 ensemble来说,必须有两个服务器是健康的,写入才能成功。在基于 quorum 的系统里,成员被部署在故障域之间以保证可用性。为了防止由于某台机器断连引起服务中断,最佳实践是防止应用的多个示例在相同的机器上共存。
默认情况下,Kubernetes 可以把 StatefulSet 的 Pods 部署在相同节点上。对于你创建的 3 个服务器的 ensemble 来说,如果有两个服务器并存于相同的节点上并且该节点发生故障时,ZooKeeper 服务将中断,直至至少一个 Pods 被重新调度。
你应该总是提供额外的容量以允许关键系统进程在节点故障时能够被重新调度。如果你这样做了,服务故障就只会持续到 Kubernetes 调度器重新调度某个 ZooKeeper 服务器为止。但是,如果希望你的服务在容忍节点故障时无停服时间,你应该设置 podAntiAffinity。
获取 zk Stateful Set 中的 Pods 的节点。

for i in 0 1 2; do kubectl get pod zk-$i --template {{.spec.nodeName}}; echo ""; done

zk StatefulSe 中所有的 Pods 都被部署在不同的节点。

kubernetes-node-cxpk
kubernetes-node-a5aq
kubernetes-node-2g2d

这是因为 zk StatefulSet 中的 Pods 指定了 PodAntiAffinity。

  affinity:podAntiAffinity:requiredDuringSchedulingIgnoredDuringExecution:- labelSelector:matchExpressions:- key: "app"operator: Invalues:- zktopologyKey: "kubernetes.io/hostname"

requiredDuringSchedulingIgnoredDuringExecution 告诉 Kubernetes 调度器,在以 topologyKey 指定的域中,绝对不要把带有键为 app,值为 zk 的标签的两个 Pods 调度到相同的节点。topologyKey kubernetes.io/hostname 表示这个域是一个单独的节点。使用不同的 rules、labels 和 selectors,你能够通过这种技术把你的 ensemble 分布在不同的物理、网络和电力故障域之间。

存活管理

在本节中你将会 cordon 和 drain 节点。如果你是在一个共享的集群里使用本教程,请保证不会影响到其他租户
上一小节展示了如何在节点之间分散 Pods 以在计划外的节点故障时保证服务存活。但是你也需要为计划内维护引起的临时节点故障做准备。
获取你集群中的节点。
kubectl get nodes
使用 kubectl cordon cordon 你的集群中除4个节点以外的所有节点。
kubectl cordon
获取 zk-pdb PodDisruptionBudget。
kubectl get pdb zk-pdb
max-unavailable 字段指示 Kubernetes 在任何时候,zk StatefulSet 至多有一个 Pod 是不可用的。

NAME      MIN-AVAILABLE   MAX-UNAVAILABLE   ALLOWED-DISRUPTIONS   AGE
zk-pdb    N/A             1                 1

在一个终端观察 zk StatefulSet 中的 Pods。
kubectl get pods -w -l app=zk
在另一个终端获取 Pods 当前调度的节点。

for i in 0 1 2; do kubectl get pod zk-$i --template {{.spec.nodeName}}; echo ""; done
kubernetes-node-pb41
kubernetes-node-ixsl
kubernetes-node-i4c4

使用 kubectl drain 来 cordon 和 drain zk-0 Pod 调度的节点。

kubectl drain $(kubectl get pod zk-0 --template {{.spec.nodeName}}) --ignore-daemonsets --force --delete-local-data
node "kubernetes-node-pb41" cordonedWARNING: Deleting pods not managed by ReplicationController, ReplicaSet, Job, or DaemonSet: fluentd-cloud-logging-kubernetes-node-pb41, kube-proxy-kubernetes-node-pb41; Ignoring DaemonSet-managed pods: node-problem-detector-v0.1-o5elz
pod "zk-0" deleted
node "kubernetes-node-pb41" drained

由于你的集群中有4个节点, kubectl drain 执行成功,`zk-0 被调度到其它节点。

NAME      READY     STATUS    RESTARTS   AGE
zk-0      1/1       Running   2          1h
zk-1      1/1       Running   0          1h
zk-2      1/1       Running   0          1h
NAME      READY     STATUS        RESTARTS   AGE
zk-0      1/1       Terminating   2          2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Pending   0         0s
zk-0      0/1       Pending   0         0s
zk-0      0/1       ContainerCreating   0         0s
zk-0      0/1       Running   0         51s
zk-0      1/1       Running   0         1m

在第一个终端持续观察 StatefulSet 的 Pods并 drain zk-1 调度的节点。

kubectl drain $(kubectl get pod zk-1 --template {{.spec.nodeName}}) --ignore-daemonsets --force --delete-local-data "kubernetes-node-ixsl" cordoned
WARNING: Deleting pods not managed by ReplicationController, ReplicaSet, Job, or DaemonSet: fluentd-cloud-logging-kubernetes-node-ixsl, kube-proxy-kubernetes-node-ixsl; Ignoring DaemonSet-managed pods: node-problem-detector-v0.1-voc74
pod "zk-1" deleted
node "kubernetes-node-ixsl" drained

zk-1 Pod 不能被调度。由于 zk StatefulSet 包含了一个防止 Pods 共存的 PodAntiAffinity 规则,而且只有两个节点可用于调度,这个 Pod 将保持在 Pending 状态。

kubectl get pods -w -l app=zk
NAME      READY     STATUS    RESTARTS   AGE
zk-0      1/1       Running   2          1h
zk-1      1/1       Running   0          1h
zk-2      1/1       Running   0          1h
NAME      READY     STATUS        RESTARTS   AGE
zk-0      1/1       Terminating   2          2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Pending   0         0s
zk-0      0/1       Pending   0         0s
zk-0      0/1       ContainerCreating   0         0s
zk-0      0/1       Running   0         51s
zk-0      1/1       Running   0         1m
zk-1      1/1       Terminating   0         2h
zk-1      0/1       Terminating   0         2h
zk-1      0/1       Terminating   0         2h
zk-1      0/1       Terminating   0         2h
zk-1      0/1       Pending   0         0s
zk-1      0/1       Pending   0         0s

继续观察 stateful set 的 Pods 并 drain zk-2 调度的节点。

kubectl drain $(kubectl get pod zk-2 --template {{.spec.nodeName}}) --ignore-daemonsets --force --delete-local-data
node "kubernetes-node-i4c4" cordonedWARNING: Deleting pods not managed by ReplicationController, ReplicaSet, Job, or DaemonSet: fluentd-cloud-logging-kubernetes-node-i4c4, kube-proxy-kubernetes-node-i4c4; Ignoring DaemonSet-managed pods: node-problem-detector-v0.1-dyrog
WARNING: Ignoring DaemonSet-managed pods: node-problem-detector-v0.1-dyrog; Deleting pods not managed by ReplicationController, ReplicaSet, Job, or DaemonSet: fluentd-cloud-logging-kubernetes-node-i4c4, kube-proxy-kubernetes-node-i4c4
There are pending pods when an error occurred: Cannot evict pod as it would violate the pod's disruption budget.
pod/zk-2

使用 CRTL-C 终止 kubectl。
你不能 drain 第三个节点,因为删除 zk-2 将和 zk-budget 冲突。然而这个节点仍然保持 cordoned。
使用 zkCli.sh 从 zk-0 取回你的健康检查中输入的数值。
kubectl exec zk-0 zkCli.sh get /hello
由于遵守了 PodDisruptionBudget,服务仍然可用。

WatchedEvent state:SyncConnected type:None path:null
world
cZxid = 0x200000002
ctime = Wed Dec 07 00:08:59 UTC 2016
mZxid = 0x200000002
mtime = Wed Dec 07 00:08:59 UTC 2016
pZxid = 0x200000002
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 5
numChildren = 0

使用 kubectl uncordon 来取消对第一个节点的隔离。
kubectl uncordon kubernetes-node-pb41
node “kubernetes-node-pb41” uncordoned
zk-1 被重新调度到了这个节点。等待 zk-1 变为 Running 和 Ready 状态。

kubectl get pods -w -l app=zk
NAME      READY     STATUS    RESTARTS   AGE
zk-0      1/1       Running   2          1h
zk-1      1/1       Running   0          1h
zk-2      1/1       Running   0          1h
NAME      READY     STATUS        RESTARTS   AGE
zk-0      1/1       Terminating   2          2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Terminating   2         2h
zk-0      0/1       Pending   0         0s
zk-0      0/1       Pending   0         0s
zk-0      0/1       ContainerCreating   0         0s
zk-0      0/1       Running   0         51s
zk-0      1/1       Running   0         1m
zk-1      1/1       Terminating   0         2h
zk-1      0/1       Terminating   0         2h
zk-1      0/1       Terminating   0         2h
zk-1      0/1       Terminating   0         2h
zk-1      0/1       Pending   0         0s
zk-1      0/1       Pending   0         0s
zk-1      0/1       Pending   0         12m
zk-1      0/1       ContainerCreating   0         12m
zk-1      0/1       Running   0         13m
zk-1      1/1       Running   0         13m

尝试 drain zk-2 调度的节点。
kubectl drain $(kubectl get pod zk-2 --template {{.spec.nodeName}}) --ignore-daemonsets --force --delete-local-data
输出:

node "kubernetes-node-i4c4" already cordoned
WARNING: Deleting pods not managed by ReplicationController, ReplicaSet, Job, or DaemonSet: fluentd-cloud-logging-kubernetes-node-i4c4, kube-proxy-kubernetes-node-i4c4; Ignoring DaemonSet-managed pods: node-problem-detector-v0.1-dyrog
pod "heapster-v1.2.0-2604621511-wht1r" deleted
pod "zk-2" deleted
node "kubernetes-node-i4c4" drained

这次 kubectl drain 执行成功。
Uncordon 第二个节点以允许 zk-2 被重新调度。
kubectl uncordon kubernetes-node-ixsl
node “kubernetes-node-ixsl” uncordoned
你可以同时使用 kubectl drain 和 PodDisruptionBudgets 来保证你的服务在维护过程中仍然可用。如果使用了 drain 来隔离节点并在节点离线之前排出了 pods,那么表达了 disruption budget 的服务将会遵守该 budget。你应该总是为关键服务分配额外容量,这样它们的 Pods 就能够迅速的重新调度。

清理现场

使用 kubectl uncordon 解除你集群中所有节点的隔离。
你需要删除在本教程中使用的 PersistentVolumes 的持久存储媒介。请遵循必须的步骤,基于你的环境、存储配置和准备方法,保证回收所有的存储。

k8s部署zookeeper集群 运行 ZooKeeper, 一个 CP 分布式系统相关推荐

  1. k8s部署kafka集群

    前言 环境:centos 7.9 k8s集群.zookeeper集群 本篇将在k8s中部署kafka集群,kafka依赖zookeeper集群,zookeeper集群我们已经搭建好了,可以参考http ...

  2. docker 部署rabbitmq,k8s部署rabbitmq集群,跟踪和监控rabbitmq

    全栈工程师开发手册 (作者:栾鹏) 架构系列文章 rabbit原理和架构可以参考https://blog.csdn.net/luanpeng825485697/article/details/8208 ...

  3. K8S 部署rabbitmq集群

    K8S 部署rabbitmq集群 版本介绍 名称 版本 k8s 1.18 rabbitmq 3.8 命名空间:rabbitmq 我这里已经建立 configmap 配置文件 [root@k8s-mas ...

  4. kubernetes入门到精通(二):k8s部署Tomcat集群,基于NTFS协议的文件集群共享,Service提供负载均衡,端口转发工具Rinetd配置外部访问

    首先,配置 Docker 镜像加速服务 登录阿里云账号,进入控制台 -> 容器镜像服务 (不需要有阿里云的服务器,只要注册账号即可) 在两台 node 节点上配置好阿里云的镜像加速. 重启一下 ...

  5. k8s部署redis集群

    K8S部署Redis Cluster集群 kubernetes部署单节点redis: https://www.cnblogs.com/zisefeizhu/p/14282299.html Redis ...

  6. k8s部署eureka集群

    在k8s集群中通过CICD进行部署Eureka集群 一.前言 普通后端如果想要同时起多个服务来进行负载均衡,可以通过部署Deployment并调整Pod的数量,然后交由Service来代理这些Pod, ...

  7. 【华为云CCE】 k8s部署nacos集群

    业务场景: 华为CCE,根据自己的nacos镜像,部署nacos集群.由于公司使用的是华为云CCE内网环境,不能通过外网下载镜像,只能根据公司自己的镜像进行部署.看了网上华为云CCE部署nacos集群 ...

  8. k8s部署mysql集群(statefulset):主写从读、一主多从集群

    安装nfs server和client端都执行 yum install nfs-utils rpcbind -y systemctl start nfs systemctl start rpcbind ...

  9. k8s部署rabbitmq集群(使用statefulset部署)

    本例使用Storage Class(存储类)来作为集群数据的持久化后端,使用nfs作为后端存储. 1.创建namespace: # ns.yaml --- apiVersion: v1 kind: N ...

最新文章

  1. java对cookie的操作
  2. Makefile for OpenCV
  3. html数据复制到剪切板
  4. Datawhale 一周年,生日快乐!
  5. cas登录后怎么直接到我们系统_当我们购买服务器后,那么服务器的操作系统该怎么选择呢?...
  6. Linux二进制程序安装使用
  7. py 的 第 12 天
  8. lomboz连接mysql数据库_『在线等』 lomboz-eclipse连接mysql,连接失败
  9. 开篇记(好记性不如烂笔头)
  10. 在计算机中正确的邮件写信格式,电子邮件格式怎么写
  11. 关闭compactos_compact 命令详解 – 原来文件还能这样压缩!
  12. [大忌(。_。)]清明节外出
  13. Python爬取人民网夜读文案
  14. 管理者做好团队建设必看的书推荐
  15. 网络舆情分析-part1
  16. 详解小程序的蓝牙模块
  17. GitHub 热点速览 Vol.18:刷 LeetCode 的正确姿势
  18. 基于python的灰色预测模型
  19. 100天精通Python(数据分析篇)——第75天:Pandas数据预处理之数据标准化
  20. 某校2018专硕编程题-方阵求和

热门文章

  1. 如何选择Java培训机构?
  2. 基于javaMail发送邮件的实现
  3. 计算机专业会学linux编程吗,计算机专业学编程,ubuntu与deepin和centos相比,哪款系统更合适...
  4. Default encoder for format image2 (codec png) is probably disabled. Please choose an encoder manuall
  5. D3.js的v5版本入门教程(第一章)—— 如何在项目中使用D3.js
  6. Logo tools
  7. 动态范围测试—imatest
  8. LeetCode Weekly Contest 25 之 545.Boundary of Binary Tree
  9. JQuery字符串文本替换并且加样式
  10. 学生管理系统(总结)