Zookeeper分布式锁

文章目录

一、MAC安装

  1. 安装zookeeper

brew install zookeeper

配置文件目录:

/usr/local/etc/zookeeper

其他文件目录:

/usr/local/Cellar/zookeeper

  1. 修改配置文件
java 复制代码
// 心跳时间 单位:毫秒
tickTime=2000
// 初始通信时限
initLimit=10
// 同步通信时限
syncLimit=5
// 数据目录,存储内存数据库序列化后的快照路径,也是唯一需要修改的地方
dataDir=/usr/local/Cellar/zookeeper/3.7.0_1/data
// 端口
clientPort=2181

只需要修改dataDir目录就行,然后重启

brew services restart zookeeper

  1. 进入zookeeper客户端

cd /usr/local/Cellar/zookeeper

//找到对应bin目录

cd bin

// 进入脚本即 zkCli

zkCli

二、Zookeeper

类似Linux的目录结构

一). 基本指令

  • ls 查看子节点

ls (path) 查看某一路径的子节点

  • get 查看节点的数据

  • create 创建某节点,不能越级创建,只能一层层目录创建

    也可以加参数,这样直接就在该节点下存储了数据

    如果要修改该节点的数据值,不能用create再次覆盖,而要用set

  • delete 删除某节点

    如果要删除掉的节点有子节点,那就用deleteall

  • set 修改已存在的某节点(只能修改已存在的节点)

    已存在的节点成功修改数据。

二). Znode节点类型

  1. 永久节点 create /path content

  2. 临时节点 create -e /path content

    只要客户端程序断开连接,会自动删除

  3. 永久序列化节点 create -s /path content

    同样的一个节点可创建多个,每个节点后自动生成序列号

  4. 临时序列化节点 create -s -e /path content (-s -e 顺序前后都可)

    会自动生成序列号,只要客户端程序断开连接会自动删除

三) 节点的事件监听 一次性

  1. 节点创建 nodeCreated

    stat -w /xx 监听/xx目录节点的创建

    A先开启一个对/watch的监听

    B创建/watch目录
    再看A

  2. 节点删除 nodeDeleted

    stat -w /xx 监听/xx目录节点的删除

    重新监听该节点的删除(由于节点监听是一次性的)

  3. 节点数据变化 NodeDataChanged

    get -w /xx

A监听/bb节点的数据变化

B修改节点数据

再看A

  1. 子节点变化 NodeChildrenChanged
    ls -w /xx

三) zookeeper的java客户端

官方提供、ZkClient、Curator

简单客户端实现

  • 引入pom.xml
java 复制代码
		<dependency>
			<groupId>org.apache.zookeeper</groupId>
			<artifactId>zookeeper</artifactId>
			<version>3.7.0</version>
		</dependency>
  1. 代码实现 新建ZkTest
java 复制代码
package com.distributed.lock.zk;

import org.apache.zookeeper.WatchedEvent;
import org.apache.zookeeper.Watcher;
import org.apache.zookeeper.ZooKeeper;

import java.io.IOException;

public class ZkTest {
    public static void main(String[] args) throws InterruptedException {
        ZooKeeper zooKeeper = null;
        try {
            zooKeeper = new ZooKeeper("127.0.0.1:2181", 30000, new Watcher() {
                @Override
                public void process(WatchedEvent watchedEvent) {
                    System.out.println("获取链接了");
                }
            });
            System.out.println("一顿操作");
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            if(zooKeeper != null){
                zooKeeper.close();
            }
        }
    }
}
  • 启动测试:

提示jar包重复依赖

把pom.xml新添的zookeeper依赖修改下:

java 复制代码
		<dependency>
			<groupId>org.apache.zookeeper</groupId>
			<artifactId>zookeeper</artifactId>
			<version>3.7.0</version>
			<exclusions>
				<exclusion>
					<groupId>org.slf4j</groupId>
					<artifactId>slf4j-log4j12</artifactId>
				</exclusion>
			</exclusions>
		</dependency>

再启动测试:

发现问题,先一顿操作,再获取链接,且获取了两次。

  1. 先输出了一顿操作
    添加CountDowLatch, 等获取链接后再开始操作

CountDownLatch:允许一个或者多个线程去等待其他线程完成操作

CountDownLatch接收一个int型参数,表示要等待的工作线程的个数。

await(): 使当前线程进入同步队列进行等待,直到latch的值被减到0或者当前线程被中断,当前线程就会被唤醒。

countDown(): 使latch的值减1,如果减到了0,则会唤醒所有等待在这个latch上的线程。

那就是先初始化

java 复制代码
CountDownLatch countDownLatch = new CountDownLatch(1);

获取链接后

java 复制代码
countDownLatch.countDown();

在一顿操作的前面加上await(),直到获取了链接才准开始操作

java 复制代码
 countDownLatch.await();
  1. 监听到了两次获取链接

获取链接时加个参数看看

java 复制代码
System.out.println("获取链接了" + watchedEvent);

很明显,是关闭的时候watcher也监听到了。

watchedEvent有多个状态

判断下状态是不是我要的,再输出"获取链接了"即可。即:只监听想监听到的状态。

java 复制代码
Event.KeeperState state = watchedEvent.getState();
if(Event.KeeperState.SyncConnected.equals(state)){
  	System.out.println("获取到链接了" + watchedEvent);
}else if(Event.KeeperState.Closed.equals(state)) {
  	System.out.println("关闭链接了" + watchedEvent);
}

重启测试:

成功。

CRUD操作

  1. 节点新增
java 复制代码
String create(String path, byte[] data, List<ACL> acl, CreateMode createMode) 

path 路径

data 数据 要求是字节,字符串后加getBytes()

acl 权限 OPEN_ACL_UNSAFE 所有人都可以操作即可

createMode 创建类型

CreateMode.PERSISTENT 永久节点

CreateMode.EPHEMERAL 临时节点

CreateMode.PERSISTENT_SEQUENTIAL 永久序列化节点

CreateMode.EPHEMERAL_SEQUENTIAL 临时序列化节点

java 复制代码
zooKeeper.create("/ustc","hello zookeeper".getBytes(), 
	ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);

启动成功,查看zookeeper

  1. 节点查询
java 复制代码
			// 判断节点是否存在
            Stat stat = zooKeeper.exists("/ustc",false);
            if(stat != null){
                System.out.println("当前节点存在");
            }else{
                System.out.println("当前节点不存在");
            }

            // 获取当前节点中的数据内容
            byte[] data = zooKeeper.getData("/ustc",false,stat);
            System.out.println("当前节点内容" + new String(data));

            // 获取当前子节点
            List<String> children = zooKeeper.getChildren("/ustc",false);
            System.out.println("当前子节点:" + children);
  1. 节点更新
java 复制代码
// 更新:版本号必须和当前节点的版本号一致,否则更新失败。也可以指定为-1,代表不关心版本号
    zooKeeper.setData("/ustc","bye bye".getBytes(), stat.getVersion());
  1. 删除节点
java 复制代码
// 删除
	zooKeeper.delete("/ustc", -1);

监听

监听子节点

java 复制代码
// 获取当前子节点
   List<String> children = zooKeeper.getChildren("/ustc",true);

同时保证main方法不能结束,最后一行添加

java 复制代码
 System.in.read();

启动成功,去修改节点值看能否监听到.

这种监听方式只能生效一次。

也可以自己加个new watcher(),不过也是只生效一次。

想一直生效可以把它提取出来当作方法,然后最后递归的调用自己。

java 复制代码
// 监听当前子节点
            List<String> children = zooKeeper.getChildren("/ustc", new Watcher() {
                @Override
                public void process(WatchedEvent watchedEvent) {
                    System.out.println("当前子节点发生变化....");
                }
            });

三、Zookeeper分布式锁

1. 独占排它锁 自旋锁(其他线程在不停重试)

zookeeper的create方法无法覆盖新的路径,只能成功一次 ,可以依靠这个特效来实现独占排它锁

多个线程去create一个path的时候,只有一个线程可以create成功,也就相当于抢到了锁,其他线程只能不停的尝试create,而抢到锁的线程A,在执行完后释放锁,即delete path,就会有下一个线程create成功。

为了避免出现,抢到锁后客户端宕机,导致锁无法释放,即拿锁线程不会delete path,create的节点设置为临时节点,这样当客户端宕机时,节点也跟着自动销毁,也就解锁了。

代码实现

  • ZkDistributedLock
java 复制代码
public class ZkDistributedLock implements Lock {

    private ZooKeeper zooKeeper;
    private String lockName;

    // 根节点
    private static final String ROOT_PATH = "/locks";
    public ZkDistributedLock(ZooKeeper zooKeeper, String lockName){
        this.zooKeeper = zooKeeper;
        this.lockName = lockName;
        try {
            // 保证根节点存在
            if(zooKeeper.exists(ROOT_PATH,false) == null){
                zooKeeper.create(ROOT_PATH,null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        // 创建znode节点过程
        try {
            // 临时节点,避免zk客户端获取到锁后,宕机带来的死锁问题,相当于redis的过期时间
            this.zooKeeper.create(ROOT_PATH + "/" + lockName, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
            return true;
        } catch (Exception e) {
            // 出问题后一直递归调用,不停尝试
            try {
                Thread.sleep(100);
                this.tryLock();
            } catch (InterruptedException ex) {
                throw new RuntimeException(ex);
            }

        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {
        // 删除znode节点
        try {
            this.zooKeeper.delete(ROOT_PATH+"/"+lockName,-1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public Condition newCondition() {
        return null;
    }
}
  • ZkClient
java 复制代码
@Component
public class ZkClient {

    public ZooKeeper zooKeeper;
    /**
     * 在无参构造后立刻执行
     * 获取联建,项目启动时
      */
    @PostConstruct
    public void init(){
        CountDownLatch countDownLatch = new CountDownLatch(1);
        try {
            zooKeeper = new ZooKeeper("127.0.0.1:2181", 30000, new Watcher() {
                @Override
                public void process(WatchedEvent watchedEvent) {
                    Event.KeeperState state = watchedEvent.getState();
                    if (Event.KeeperState.SyncConnected.equals(state) && Event.EventType.None.equals(watchedEvent.getType())) {
                        System.out.println("获取到链接了" + watchedEvent);
                    } else if (Event.KeeperState.Closed.equals(state)) {
                        System.out.println("关闭链接了" + watchedEvent);
                    } else {
                        System.out.println("节点事件....");
                    }
                    countDownLatch.countDown();
                }
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

    /**
     * 释放zk的链接
     */
    @PreDestroy
    public void destroy(){
        if(zooKeeper != null){
            try {
                zooKeeper.close();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public ZkDistributedLock getLock(String lockName){
        return new ZkDistributedLock(zooKeeper,lockName);
    }

}
  • StockService
java 复制代码
 public void deduct() {
        ZkDistributedLock lock = zkClient.getLock("lock");
        lock.lock();

        try{
            // 1。 查询库存
            String stockStr = redisTemplate.opsForValue().get("stock");
            if (!StringUtil.isNullOrEmpty(stockStr)) {
                int stock = Integer.parseInt(stockStr);
                // 2。 判断条件是否满足
                if (stock > 0) {
                    // 3 更新redis
                    redisTemplate.opsForValue().set("stock", String.valueOf(stock - 1));
                }
            }
        }finally {
            lock.unlock();
        }
    }

简单来说,就是create实现排它锁,加锁是create临时节点,解锁就是delete临时节点.

jmeter测试:

并发量高达700,库存也成功清0

2. 阻塞锁 临时序列化节点(自旋太耗性能)

思路:通过创建临时序列化节点,每个线程创建的节点序列号依次递增。

最小的节点序列号抢到锁,比如0,然后1监听比自己小的序列化节点也就是0,2监听比自己小的节点1,如此下去,相当于阻塞着排队等待。同时也是公平锁。

1)所有请求要求获取锁时,给每一个请求创建临时序列化节点

2)获取当前节点的前置节点(比自己序列号小1),如果前置节点为空,则获取锁成功。否则监听前置节点。

把根节点的孩子节点全部拿到,然后通过排序比较,拿到前置节点。

3)获取锁成功后执行业务操作,然后释放当前节点的锁。

代码实现

java 复制代码
public class ZkDistributedLock implements Lock {

    private ZooKeeper zooKeeper;
    private String lockName;
    // 前置节点路径
    private String currentNodePath;

    // 根节点
    private static final String ROOT_PATH = "/locks";
    public ZkDistributedLock(ZooKeeper zooKeeper, String lockName){
        this.zooKeeper = zooKeeper;
        this.lockName = lockName;
        try {
            // 保证根节点存在
            if(zooKeeper.exists(ROOT_PATH,false) == null){
                zooKeeper.create(ROOT_PATH,null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    /**
     *
     * @return true 成功获取锁
     */
    @Override
    public boolean tryLock() {
        // 创建znode节点过程
        try {
            // 所有请求要求获取锁时,给每一个请求创建临时序列化节点,currentNodePath返回的是全路径
            currentNodePath = this.zooKeeper.create(ROOT_PATH + "/" + lockName + "-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            // 获取前置节点,若前置节点为空,则获取锁成功,否则监听前置节点
            String preNode = this.getPreNode();
            // 因为获取前置节点的操作不具有原子性,故再次判断zk中的前置节点是否存在
            if(preNode != null){
                // 利用闭锁思想,实现阻塞功能
                CountDownLatch countDownLatch = new CountDownLatch(1);

                if(this.zooKeeper.exists(ROOT_PATH + "-" + preNode, new Watcher() {
                    @Override
                    public void process(WatchedEvent watchedEvent) {
                        // 监听是否存在,若不存在则触发
                        countDownLatch.countDown();
                    }
                }) == null){
                    // 若前置节点不存在了,我就是第一个,直接获取锁
                    return true;
                }
                // 不能直接返回true,需要等待监听到存在的状态变化
                countDownLatch.await();
            }

            return true;
        } catch (Exception e) {

        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {
        // 删除znode节点
        try {
            this.zooKeeper.delete(ROOT_PATH+"/"+lockName,-1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public Condition newCondition() {
        return null;
    }

    private String getPreNode(){
        // 获取根节点下的所有节点
        try {
            List<String> children = this.zooKeeper.getChildren(ROOT_PATH,false);
            Assert.notEmpty(children,"根节点下没有子节点!");
            // 删选出lockName对应的子节点
            List<String> nodes = children.stream().filter(
                    node ->
                        StringUtils.startsWithIgnoreCase(node,lockName+"-")
            ).collect(Collectors.toList());
            Assert.notEmpty(children,"没有该锁的子节点!");

            // 排好队
            Collections.sort(nodes);

            // 获取当前节点的下标
            // 获取当前节点的名称 currentNodePath是全路径 但是children不是
            String currentNode =  StringUtil.substringAfter(currentNodePath,'/');
            int index = Collections.binarySearch(nodes,currentNode);
            if(index < 0){
                throw new IllegalMonitorStateException("没有当前锁的子节点");
            }else if(index > 0){
                return nodes.get(index-1); // 若不是最小的节点,返回前置节点
            }
            return null; // 如果当前节点没有前置节点,即是第一个节点,返回null

        } catch (Exception e) {
            throw new RuntimeException("非法操作");
        }

    }
}

Jmeter测试

并发达到了600,略低于独占排它锁。库存也成功清0。

3. 可重入锁

思路:通过ThreadLocal保存线程的入锁次数

如果ThreadLocal中数值>0,说明已经入锁,可重入,直接+1,抢锁成功

如果ThreadLocal无数值,那就按照之前的步骤去抢锁。

在抢到锁后ThreadLocal数值设置为1。

解锁的时候将ThreadLocal数值-1,如果是0就delete删除节点,即释放锁。

java 复制代码
package com.distributed.lock.zk;

import cn.hutool.core.lang.Assert;
import io.netty.util.internal.StringUtil;
import org.apache.zookeeper.*;
import org.springframework.util.StringUtils;

import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.stream.Collectors;


public class ZkDistributedLock implements Lock {

    private ZooKeeper zooKeeper;
    private String lockName;
    // 前置节点路径
    private String currentNodePath;

    // 根节点
    private static final String ROOT_PATH = "/locks";

    public static final ThreadLocal<Integer> THREAD_LOCAL = new ThreadLocal<>();
    public ZkDistributedLock(ZooKeeper zooKeeper, String lockName){
        this.zooKeeper = zooKeeper;
        this.lockName = lockName;
        try {
            // 保证根节点存在
            if(zooKeeper.exists(ROOT_PATH,false) == null){
                zooKeeper.create(ROOT_PATH,null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
            }
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }


    @Override
    public void lock() {
        this.tryLock();
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    /**
     *
     * @return true 成功获取锁
     */
    @Override
    public boolean tryLock() {
        // 创建znode节点过程
        try {
            //判断ThreadLocal中是否已经有锁,如果有锁直接重入(+1),返回true,重入成功
            Integer num = THREAD_LOCAL.get();
            if( num != null || num > 0){
                THREAD_LOCAL.set(num+1);
                return true;
            }
            // 所有请求要求获取锁时,给每一个请求创建临时序列化节点,currentNodePath返回的是全路径
            currentNodePath = this.zooKeeper.create(ROOT_PATH + "/" + lockName + "-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            // 获取前置节点,若前置节点为空,则获取锁成功,否则监听前置节点
            String preNode = this.getPreNode();
            // 因为获取前置节点的操作不具有原子性,故再次判断zk中的前置节点是否存在
            if(preNode != null){
                // 利用闭锁思想,实现阻塞功能
                CountDownLatch countDownLatch = new CountDownLatch(1);

                if(this.zooKeeper.exists(ROOT_PATH + "-" + preNode, new Watcher() {
                    @Override
                    public void process(WatchedEvent watchedEvent) {
                        // 监听是否存在,若不存在则触发
                        countDownLatch.countDown();
                    }
                }) == null){
                    // 若前置节点不存在了,我就是第一个,直接获取锁
                    THREAD_LOCAL.set(1);
                    return true;
                }
                // 不能直接返回true,需要等待监听到存在的状态变化
                countDownLatch.await();
            }
            THREAD_LOCAL.set(1);
            return true;
        } catch (Exception e) {

        }
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {
        // 删除znode节点
        try {
            // 可重入锁,先删除Thread_Local中的数值
            THREAD_LOCAL.set(THREAD_LOCAL.get()-1);
            if(THREAD_LOCAL.get() == 0){
                this.zooKeeper.delete(ROOT_PATH+"/"+lockName,-1);
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } catch (KeeperException e) {
            throw new RuntimeException(e);
        }

    }

    @Override
    public Condition newCondition() {
        return null;
    }

    private String getPreNode(){
        // 获取根节点下的所有节点
        try {
            List<String> children = this.zooKeeper.getChildren(ROOT_PATH,false);
            Assert.notEmpty(children,"根节点下没有子节点!");
            // 删选出lockName对应的子节点
            List<String> nodes = children.stream().filter(
                    node ->
                        StringUtils.startsWithIgnoreCase(node,lockName+"-")
            ).collect(Collectors.toList());
            Assert.notEmpty(children,"没有该锁的子节点!");

            // 排好队
            Collections.sort(nodes);

            // 获取当前节点的下标
            // 获取当前节点的名称 currentNodePath是全路径 但是children不是
            String currentNode =  StringUtil.substringAfter(currentNodePath,'/');
            int index = Collections.binarySearch(nodes,currentNode);
            if(index < 0){
                throw new IllegalMonitorStateException("没有当前锁的子节点");
            }else if(index > 0){
                return nodes.get(index-1); // 若不是最小的节点,返回前置节点
            }
            return null; // 如果当前节点没有前置节点,即是第一个节点,返回null

        } catch (Exception e) {
            throw new RuntimeException("非法操作");
        }

    }
}

测试后

吞吐量高达700,库存也成功清0。

4. 和redis分布式锁对比

  1. Zookeeper分布式锁:

    独占排它

    阻塞锁

    可重入

  2. redis分布式锁

  • 独占排它互斥

    redis: setnx

    zk: 节点不重复

  • 防死锁:客户端获取到锁后服务器宕机

    redis: 过期时间

    zk-临时节点:一旦客户端服务器宕机,链接就会关闭,此时zk心跳检测不到客户端程序,删除对应的临时节点

  • 可重入

    redis: hash脚本+lua脚本

    zk: ThreadLocal保存入锁次数/节点数据/concurrentHashMap

  • 防误删

    redis: 唯一ID

    zk: 每个请求线程创建一个唯一的序列化节点

  • 原子性

    redis:

    zk: 创建、删除、查询及监听具备原子性

  • 自动续期

    redis: Timer定时器

    zk: 临时节点,没有过期时间,不需要自动续期

  • 单机故障问题

    redis: 会,主从需要时间

    zk: 一般都是集群部署,偏向于一致性的集群

  • 阻塞锁

    zk: 临时节点序列化,监听上一个小的节点,实现排队阻塞,实现公平锁

    但redis需要不停自旋尝试,是非公平锁

相关推荐
Algorithm157610 分钟前
云原生相关的 Go 语言工程师技术路线(含博客网址导航)
开发语言·云原生·golang
MZWeiei14 分钟前
Zookeeper的选举机制
大数据·分布式·zookeeper
MZWeiei14 分钟前
Zookeeper基本命令解析
大数据·linux·运维·服务器·zookeeper
学计算机的睿智大学生15 分钟前
Hadoop集群搭建
大数据·hadoop·分布式
一路狂飙的猪15 分钟前
RabbitMQ的工作模型
分布式·rabbitmq
miss writer1 小时前
Redis分布式锁释放锁是否必须用lua脚本?
redis·分布式·lua
m0_748254881 小时前
DataX3.0+DataX-Web部署分布式可视化ETL系统
前端·分布式·etl
字节程序员2 小时前
Jmeter分布式压力测试
分布式·jmeter·压力测试
年薪丰厚2 小时前
如何在K8S集群中查看和操作Pod内的文件?
docker·云原生·容器·kubernetes·k8s·container
zhangj11252 小时前
K8S Ingress 服务配置步骤说明
云原生·容器·kubernetes