使用NVMe云盘多重挂载及Reservation实现应用间的数据共享

支持NVMe(Non-Volatile Memory Express)协议的ESSD云盘称为NVMe云盘。NVMe云盘支持多重挂载能力,最多可以同时挂载到16个ECS实例上;同时也基于多重挂载实现了符合NVMe协议规范的Reservation功能。这些特性可以帮助您实现应用的多个副本间的数据共享以提升数据读写性能。本文通过简单的示例介绍如何在ACK集群中使用NVMe云盘多重挂载及Reservation功能。

阅读前提示

为了让您更好地使用NVMe云盘多重挂载及Reservation功能,建议您在阅读本文档之前,了解以下内容:

应用场景

云盘多重挂载主要有以下应用场景:

  • 数据共享

    NVMe最简单的应用场景为数据共享,当数据被写入云盘后,其他节点均可以访问该数据,从而有效节省成本并提升读写性能。例如,在云上容器镜像场景,同一套系统的镜像通常相似,因此多个不同实例可以读取加载同一份镜像。

    image
  • 高可用故障转移

    业务高可用是共享盘最常见的应用场景之一。传统基于SAN的数据库,例如Oracle RAC、SAP HANA以及云原生高可用数据库等场景中,实际业务使用过程中可能存在单点故障,确保故障情况下业务连续性是高可用系统的核心能力,在云上存储和网络具备极高的可用性。而计算节点则经常受断电、宕机、硬件故障等影响,所以业务通常搭建主备模式解决计算的高可用问题。

    例如数据库场景,当主库故障时迅速切换到备库对外提供服务,实例切换后,可以通过NVMe PR命令释放旧实例的写入权限,从而确保旧实例不再写入数据确保数据一致性。如图所示,故障转移流程说明如下:

    说明

    PR(PersistentReservation)属于NVMe协议的一部分,PR可精确地控制某个云盘的读写权限,从而确保计算端按照预期写入数据。更多信息,请参见NVMe PR协议

    1. 数据库主实例1故障,导致业务停止。

    2. 下发NVMe PR命令,禁止数据库实例1继续写入数据,允许数据库实例2写入数据。

    3. 数据库实例2通过日志回放等方式恢复到和数据库实例1一致的状态。

    4. 切换数据库实例2为主实例,继续对外提供服务。

    image
  • 分布式缓存加速

    开启多重挂载功能的云盘具备较高的IOPS和吞吐性能,可以为其他中低速的存储系统提供性能加速能力。例如数据湖场景,数据湖通常基于OSS搭建,可同时被多个客户端访问,同时具备较高的顺序读吞吐和追加写吞吐能力,但是其顺序读写吞吐和延迟较差,其随机读写性能较差。通过在计算节点上挂载高速云盘作为缓存,可以极大地提升数据湖等场景的访问性能。

    image
  • 机器学习

    在分布式机器学习训练中,将样本标注写入后,会将数据集分割成小块分发到多个计算节点上并行处理。云盘多重挂载使得每个计算节点都能直接访问共享的存储资源,无需通过网络频繁传输数据,减少了数据传输的延迟,从而加速了模型训练过程。云盘的高性能与多重挂载功能相结合,为机器学习场景提供了一个高效、灵活的存储解决方案,特别是针对需要高速数据访问和处理的大规模模型训练任务,能够显著提升整个机器学习流程的效率和效果。

    image

使用限制

  • 单个NVMe云盘支持同时挂载到同一可用区内的最多16个ECS实例。

  • ACK仅支持通过volumeDevices的方式挂载可从多个节点读写的云盘,即不能通过文件系统访问。

  • 更多使用限制,请参见多重挂载使用限制

前提条件

  • 已创建ACK托管集群,且集群为1.20及以上版本。具体操作,请参见创建ACK托管集群

  • 已安装csi-plugin和csi-provisioner组件,且组件为v1.24.10-7ae4421-aliyun及以上版本。关于csi-plugin和csi-provisioner组件的升级操作,请参见管理CSI组件

  • 集群至少包含2个在同一可用区的且支持使用多重挂载功能的节点,支持的实例规格族详见多重挂载使用限制

  • 已准备好业务应用且符合以下要求,然后将应用打包至容器镜像中用于在ACK集群中部署。

    • 应用支持同时从多个副本中访问同一云盘中的数据。

    • 应用能自行通过标准的NVMe Reservation等功能确保数据的一致性。

计费说明

云盘多重挂载功能不会产生额外费用,支持NVMe协议的相关资源仍保持各资源原有的计费方式。关于云盘相关计费的更多信息,请参见计费说明

应用示例

本文使用下方应用示例的源代码和Dockerfile,将其构建后上传至镜像仓库以便后续在集群中部署。该示例应用中的多个副本共同管理一个租约,但仅有一个副本持有该租约。若该副本无法正常工作,其他副本将自动抢占该租约。编写应用注意事项如下:

  • 示例中使用O_DIRECT打开块设备进行读写,避免任何缓存对测试的影响。

  • 示例中使用Linux内核提供的Reservation简化接口,应用也可使用以下两种方法执行与Reservation相关的命令,以下方法需要特权。

    • C代码:ioctl(fd, NVME_IOCTL_IO_CMD, &cmd);

    • 命令行工具:nvme-cli

  • 关于NVMe Reservation功能的详细信息,请参见NVMe Specification

展开查看应用示例的源代码

#define _GNU_SOURCE
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/pr.h>
#include <signal.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <time.h>
#include <unistd.h>

const char *disk_device = "/dev/data-disk";
uint64_t magic = 0x4745D0C5CD9A2FA4;

void panic(const char *restrict format, ...) {
    va_list args;
    va_start(args, format);
    vfprintf(stderr, format, args);
    va_end(args);
    exit(EXIT_FAILURE);
}

struct lease {
    uint64_t magic;
    struct timespec acquire_time;
    char holder[64];
};

volatile bool shutdown = false;
void on_term(int signum) {
    shutdown = true;
}

struct lease *lease;
const size_t lease_alloc_size = 512;

void acquire_lease(int disk_fd) {
    int ret;

    struct pr_registration pr_reg = {
        .new_key = magic,
        .flags = PR_FL_IGNORE_KEY,
    };
    ret = ioctl(disk_fd, IOC_PR_REGISTER, &pr_reg);
    if (ret != 0)
        panic("failed to register (%d): %s\n", ret, strerror(errno));

    struct pr_preempt pr_pre = {
        .old_key = magic,
        .new_key = magic,
        .type  = PR_WRITE_EXCLUSIVE,
    };
    ret = ioctl(disk_fd, IOC_PR_PREEMPT, &pr_pre);
    if (ret != 0)
        panic("failed to preempt (%d): %s\n", ret, strerror(errno));

    // register again in case we preempted ourselves
    ret = ioctl(disk_fd, IOC_PR_REGISTER, &pr_reg);
    if (ret != 0)
        panic("failed to register (%d): %s\n", ret, strerror(errno));
    fprintf(stderr, "Register as key %lx\n", magic);


    struct pr_reservation pr_rev = {
        .key   = magic,
        .type  = PR_WRITE_EXCLUSIVE,
    };
    ret = ioctl(disk_fd, IOC_PR_RESERVE, &pr_rev);
    if (ret != 0)
        panic("failed to reserve (%d): %s\n", ret, strerror(errno));

    lease->magic = magic;
    gethostname(lease->holder, sizeof(lease->holder));

    while (!shutdown) {
        clock_gettime(CLOCK_MONOTONIC, &lease->acquire_time);
        ret = pwrite(disk_fd, lease, lease_alloc_size, 0);
        if (ret < 0)
            panic("failed to write lease: %s\n", strerror(errno));
        fprintf(stderr, "Refreshed lease\n");
        sleep(5);
    }
}

int timespec_compare(const struct timespec *a, const struct timespec *b) {
    if (a->tv_sec < b->tv_sec)
        return -1;
    if (a->tv_sec > b->tv_sec)
        return 1;
    if (a->tv_nsec < b->tv_nsec)
        return -1;
    if (a->tv_nsec > b->tv_nsec)
        return 1;
    return 0;
}

int main() {
    assert(lease_alloc_size >= sizeof(struct lease));
    lease = aligned_alloc(512, lease_alloc_size);
    if (lease == NULL)
        panic("failed to allocate memory\n");

    // char *reg_key_str = getenv("REG_KEY");
    // if (reg_key_str == NULL)
    //     panic("REG_KEY env not specified");

    // uint64_t reg_key = atoll(reg_key_str) | (magic << 32);
    // fprintf(stderr, "Will register as key %lx", reg_key);


    int disk_fd = open(disk_device, O_RDWR|O_DIRECT);
    if (disk_fd < 0)
        panic("failed to open disk: %s\n", strerror(errno));

    // setup signal handler
    struct sigaction sa = {
        .sa_handler = on_term,
    };
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGINT, &sa, NULL);

    struct timespec last_active_local;
    struct timespec last_active_remote;

    int ret = pread(disk_fd, lease, lease_alloc_size, 0);
    if (ret < 0)
        panic("failed to read lease: %s\n", strerror(errno));

    if (lease->magic != magic) {
        // new disk, no lease
        acquire_lease(disk_fd);
    } else {
        // someone else has the lease
        while (!shutdown) {
            struct timespec now;
            clock_gettime(CLOCK_MONOTONIC, &now);
            if (timespec_compare(&lease->acquire_time, &last_active_remote)) {
                fprintf(stderr, "Remote %s refreshed lease\n", lease->holder);
                last_active_remote = lease->acquire_time;
                last_active_local = now;
            } else if (now.tv_sec - last_active_local.tv_sec > 20) {
                // remote is dead
                fprintf(stderr, "Remote is dead, preempting\n");
                acquire_lease(disk_fd);
                break;
            }
            sleep(5);
            int ret = pread(disk_fd, lease, lease_alloc_size, 0);
            if (ret < 0)
                panic("failed to read lease: %s\n", strerror(errno));
        }
    }

    close(disk_fd);
}
#!/bin/bash

set -e

DISK_DEVICE="/dev/data-disk"
MAGIC=0x4745D0C5CD9A2FA4

SHUTDOWN=0
trap "SHUTDOWN=1" SIGINT SIGTERM

function acquire_lease() {
    # racqa:
    # 0: aquire
    # 1: preempt

    # rtype:
    # 1: write exclusive

    nvme resv-register $DISK_DEVICE --iekey --nrkey=$MAGIC
    nvme resv-acquire $DISK_DEVICE --racqa=1 --rtype=1 --prkey=$MAGIC --crkey=$MAGIC
    # register again in case we preempted ourselves
    nvme resv-register $DISK_DEVICE --iekey --nrkey=$MAGIC
    nvme resv-acquire $DISK_DEVICE --racqa=0 --rtype=1 --prkey=$MAGIC --crkey=$MAGIC

    while [[ $SHUTDOWN -eq 0 ]]; do
        echo "$MAGIC $(date +%s) $HOSTNAME" | dd of=$DISK_DEVICE bs=512 count=1 oflag=direct status=none
        echo "Refreshed lease"
        sleep 5
    done
}

LEASE=$(dd if=$DISK_DEVICE bs=512 count=1 iflag=direct status=none)

if [[ $LEASE != $MAGIC* ]]; then
    # new disk, no lease
    acquire_lease
else
    last_active_remote=-1
    last_active_local=-1
    while [[ $SHUTDOWN -eq 0 ]]; do
        now=$(date +%s)
        read -r magic timestamp holder < <(echo $LEASE)
        if [ "$last_active_remote" != "$timestamp" ]; then
            echo "Remote $holder refreshed the lease"
            last_active_remote=$timestamp
            last_active_local=$now
        elif (($now - $last_active_local > 10)); then
            echo "Remote is dead, preempting"
            acquire_lease
            break
        fi
        sleep 5
        LEASE=$(dd if=$DISK_DEVICE bs=512 count=1 iflag=direct status=none)
    done
fi

下文部署所用YAML文件仅适用于C语言版本,Bash版本部署时需要在YAML中为容器授权:

securityContext:
  capabilities:
    add: ["SYS_ADMIN"]

展开查看Dockerfile

C语言版本的Dockerfile:

# syntax=docker/dockerfile:1.4

FROM buildpack-deps:bookworm as builder

COPY lease.c /usr/src/nvme-resv/
RUN gcc -o /lease -O2 -Wall /usr/src/nvme-resv/lease.c

FROM debian:bookworm-slim

COPY --from=builder --link /lease /usr/local/bin/lease
ENTRYPOINT ["/usr/local/bin/lease"]

Bash版本的Dockerfile:

# syntax=docker/dockerfile:1.4
FROM debian:bookworm-slim

RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources && \
    rm -f /etc/apt/apt.conf.d/docker-clean && \
    apt-get update && \
    apt-get install -y nvme-cli

COPY --link lease.sh /usr/local/bin/lease
ENTRYPOINT ["/usr/local/bin/lease"]

步骤一:部署应用并配置多重挂载

创建名为alicloud-disk-shared的StorageClass,并开启云盘的多重挂载功能。

创建名为data-disk的PVC,并设置accessModesReadWriteManyvolumeModeBlock

创建名为lease-test的StatefulSet应用,使用本文应用示例的镜像。

  1. 使用以下内容,创建lease.yaml文件。

    请将以下YAML中容器镜像地址替换为您实际应用的镜像地址。

    重要
    • 由于NVMe Reservation在节点维度生效,同一节点上的多个Pod可能会互相干扰,所以本示例中通过podAntiAffinity以避免多个Pod调度到同一个节点上。

    • 如果您的集群中包括其他不使用NVMe协议的节点,您需要自行设置亲和性,以确保将Pod调度到使用NVMe协议的节点上。

    展开查看lease.yaml文件

    apiVersion: storage.k8s.io/v1
    kind: StorageClass
    metadata:
      name: alicloud-disk-shared
    parameters:
      type: cloud_essd
      multiAttach: "true"
    provisioner: diskplugin.csi.alibabacloud.com
    reclaimPolicy: Delete
    volumeBindingMode: WaitForFirstConsumer
    ---
    apiVersion: v1
    kind: PersistentVolumeClaim
    metadata:
      name: data-disk
    spec:
      accessModes: [ "ReadWriteMany" ]
      storageClassName: alicloud-disk-shared
      volumeMode: Block
      resources:
        requests:
          storage: 20Gi
    ---
    apiVersion: apps/v1
    kind: StatefulSet
    metadata:
      name: lease-test
    spec:
      replicas: 2
      serviceName: lease-test
      selector:
        matchLabels:
          app: lease-test
      template:
        metadata:
          labels:
            app: lease-test
        spec:
          affinity:
            podAntiAffinity:
              requiredDuringSchedulingIgnoredDuringExecution:
              - labelSelector:
                  matchExpressions:
                  - key: app
                    operator: In
                    values:
                    - lease-test
                topologyKey: "kubernetes.io/hostname"
          containers:
          - name: lease
            image: <IMAGE OF APP>   # 替换为您应用的镜像地址。
            volumeDevices:
            - name: data-disk
              devicePath: /dev/data-disk  
          volumes:
          - name: data-disk
            persistentVolumeClaim:
              claimName: data-disk

    参数

    使用多重挂载功能配置说明

    普通挂载配置说明

    StorageClass

    parameters.multiAttach

    设置为true,以开启云盘的多重挂载功能。

    无需配置

    PVC

    accessModes

    ReadWriteMany

    ReadWriteOnce

    volumeMode

    Block

    Filesystem

    存储卷挂载方式

    volumeDevices:直接通过块设备访问云盘中的数据。

    volumeMounts:主要用于挂载文件系统类型的Volume。

  2. 执行以下命令,部署应用。

    kubectl apply -f lease.yaml

步骤二:验证多重挂载及Reservation效果

为了确保NVMe云盘的数据一致性,您可以在应用中通过Reservation控制读写权限,如果一个Pod进行写操作,其他Pod就只能进行读操作。

多个节点可读写同一个云盘

执行以下命令,查看Pod日志。

kubectl logs -l app=lease-test --prefix -f

预期输出:

[pod/lease-test-0/lease] Register as key 4745d0c5cd9a2fa4
[pod/lease-test-0/lease] Refreshed lease
[pod/lease-test-0/lease] Refreshed lease
[pod/lease-test-1/lease] Remote lease-test-0 refreshed lease
[pod/lease-test-0/lease] Refreshed lease
[pod/lease-test-1/lease] Remote lease-test-0 refreshed lease
[pod/lease-test-0/lease] Refreshed lease
[pod/lease-test-1/lease] Remote lease-test-0 refreshed lease
[pod/lease-test-0/lease] Refreshed lease
[pod/lease-test-1/lease] Remote lease-test-0 refreshed lease

预期输出表明,Pod lease-test-1可以即时读取到Pod lease-test-0的写入的内容。

NVMe Reservation创建成功

  1. 执行以下命令,获取云盘ID。

    kubectl get pvc data-disk -ojsonpath='{.spec.volumeName}'
  2. 登录两个节点中的任意一个节点,执行以下命令确认NVMe Reservation是否创建成功。

    请替换以下代码中2zxxxxxxxxxxx为您上一步获取到的云盘ID中d-之后的内容。

    nvme resv-report -c 1 /dev/disk/by-id/nvme-Alibaba_Cloud_Elastic_Block_Storage_2zxxxxxxxxxxx

    预期输出:

    NVME Reservation status:
    
    gen       : 3
    rtype     : 1
    regctl    : 1
    ptpls     : 1
    regctlext[0] :
      cntlid     : ffff
      rcsts      : 1
      rkey       : 4745d0c5cd9a2fa4
      hostid     : 4297c540000daf4a4*****

    预期输出表明,NVMe Reservation已创建成功。

通过Reservation可阻断异常节点的写入IO

  1. 登录Pod lease-test-0所在的节点上执行以下命令,暂停该进程用于模拟故障场景。

    pkill -STOP -f /usr/local/bin/lease
  2. 等待30秒后,执行以下命令,再次查看日志。

    kubectl logs -l app=lease-test --prefix -f

    预期输出:

    [pod/lease-test-1/lease] Remote lease-test-0 refreshed lease
    [pod/lease-test-1/lease] Remote is dead, preempting
    [pod/lease-test-1/lease] Register as key 4745d0c5cd9a2fa4
    [pod/lease-test-1/lease] Refreshed lease
    [pod/lease-test-1/lease] Refreshed lease
    [pod/lease-test-1/lease] Refreshed lease

    预期输出表明,此时Pod lease-test-1已接管,持有租约成为服务的主节点。

  3. 再次登录Pod lease-test-0所在的节点上执行以下命令,恢复之前暂停的进程。

    pkill -CONT -f /usr/local/bin/lease
  4. 执行以下命令,再次查看日志。

    kubectl logs -l app=lease-test --prefix -f

    预期输出:

    [pod/lease-test-0/lease] failed to write lease: Invalid exchange

    预期输出表明,Pod lease-test-0将无法再写入该云盘,容器lease自动重启。说明其写入IO的操作已成功被Reservation阻断。

相关文档

如果您的NVMe云盘空间不满足要求或磁盘已满,请参见扩容云盘存储卷