目录

在 Debian 12 上部署 SPDK:编译、验证与 NVMe-oF 实测

这篇记录了我在 Debian 12 上从源码编译 SPDK v24.09、把它跑起来、再用 fio 压一遍的过程。内容从 I/O 路径和 Reactor 模型讲起,然后是部署、验证、监控和上线前的注意事项。

malloc bdev、NBD、NVMe-oF/TCP 这几条路径都实际跑过,4.5 节有测试环境和原始数字。NVMe 直连需要把盘绑到 VFIO,我的测试环境没有可直通的盘,这部分只给步骤;生产调优和监控接入也按“建议”标注,上生产前要在自己的环境验证。


  1. SPDK 是什么
  2. 为什么要用 SPDK
  3. Debian 12 部署 SPDK
  4. 部署后验证
  5. 监控体系
  6. 版本升级与回滚
  7. 生产环境最佳实践
  8. 故障排查手册
  9. 扩展阅读
  10. 附:最小复现脚本

SPDK(Storage Performance Development Kit)是一个用户态存储开发套件,最早由 Intel 发起。它把 NVMe 驱动、块设备抽象(bdev)、NVMe-oF/iSCSI/vhost-user 这些协议和内存管理都放在用户空间,核心做法是轮询代替中断、零拷贝、无锁队列。这样做的直接结果是把 4K 随机读的软件开销压到微秒级,代价是要独占 CPU 核心。

核心能力概览:

能力 说明
用户态 NVMe 驱动 绕过内核 nvme 驱动,直接操作 PCIe BAR 空间完成 I/O 提交与完成
bdev 抽象层 统一块设备接口,支持 malloc / NVMe / AIO / Ceph RBD / RAID / Crypto / QoS 等多种后端,可动态组合与堆叠
多协议 Target NVMe-oF(TCP/RDMA)、iSCSI、vhost-user、NBD 等,同一进程可同时暴露多种协议
JSON-RPC 控制面 所有运行时配置通过 Unix Domain Socket 上的 JSON-RPC 完成,适合自动化编排
Reactor 线程模型 基于 DPDK EAL 的事件驱动框架,每个 Reactor 绑定一个 CPU 核心,通过 run-to-completion 模型避免线程切换开销
graph TB
    subgraph Applications
        A1["spdk_tgt"]
        A2["nvmf_tgt"]
        A3["vhost"]
        A4["iscsi_tgt"]
        A5["Custom App"]
    end
    subgraph Control Plane
        B1["JSON-RPC Server
Unix Domain Socket"] B2["rpc.py / curl"] end subgraph SPDK Framework C1["Reactor / Thread Model"] C2["Event Framework"] C3["Poller Subsystem"] end subgraph Block Device Layer - bdev D1["malloc"] D2["NVMe bdev"] D3["AIO / uring"] D4["Ceph RBD"] D5["RAID"] D6["Crypto"] D7["QoS / Rate Limit"] end subgraph Transport Protocols E1["NVMe-oF/TCP"] E2["NVMe-oF/RDMA"] E3["iSCSI"] E4["vhost-user"] E5["NBD"] end subgraph Low-Level Drivers F1["SPDK NVMe Driver
User-Space PCIe"] F2["DPDK EAL"] F3["VFIO / UIO"] end subgraph Hardware G1["NVMe SSD"] G2["NIC - RDMA / TCP"] end B2 --> B1 B1 --> A1 B1 --> A2 B1 --> A3 B1 --> A4 B1 --> A5 A1 --> C1 A2 --> C1 A3 --> C1 A4 --> C1 A5 --> C1 C1 --> C2 C2 --> C3 C3 --> D1 C3 --> D2 C3 --> D3 C3 --> D4 C3 --> D5 C3 --> D6 C3 --> D7 D5 --> D2 D6 --> D2 D7 --> D2 D2 --> F1 F1 --> F2 F2 --> F3 F3 --> G1 A1 --> E5 A2 --> E1 A2 --> E2 A4 --> E3 A3 --> E4 E1 --> D1 E1 --> D2 E2 --> D2 E3 --> D2 E4 --> D2 E5 --> D1 E1 --> G2 E2 --> G2 E3 --> G2

看图时可以从下往上:硬件层是 NVMe SSD 和网卡;SPDK 的用户态驱动通过 DPDK 和 VFIO/UIO 接管设备;bdev 层把 malloc、NVMe、AIO、RBD、RAID、Crypto、QoS 这些后端统一成块设备;Reactor 负责事件循环和轮询;最上面是 spdk_tgt、nvmf_tgt、vhost、iscsi_tgt 这些应用,JSON-RPC 负责运行时配置。

RAID、Crypto、QoS 是堆叠在其它 bdev 之上的,所以图中画成指向 NVMe bdev 的箭头。

SPDK 的性能来自两条 I/O 路径的差异:

graph TD
    subgraph Kernel I/O Path
        KA["Application
User Space"] -->|"syscall
context switch"| KB["VFS / Page Cache"] KB --> KC["Block Layer
I/O Scheduler / mq"] KC --> KD["NVMe Driver
Kernel Space"] KD -->|"MMIO doorbell"| KE["NVMe Hardware"] KE -.->|"completion IRQ"| KD end subgraph SPDK I/O Path SA["Application
User Space"] -->|"function call
no syscall"| SB["SPDK bdev API"] SB --> SC["SPDK NVMe Driver
User Space / Polling"] SC -->|"MMIO doorbell"| SE["NVMe Hardware"] SE -.->|"poll completion queue"| SC end

关键差异点:

维度 内核路径 SPDK 路径
系统调用 每次 I/O 至少 1 次 syscall(read/write/io_submit) 无 syscall,纯函数调用
中断处理 完成后触发硬件中断 -> softirq -> 唤醒进程 轮询(polling),Reactor 线程持续检查完成队列
数据拷贝 通常经过 Page Cache 或 bounce buffer 零拷贝,DMA 直接写入/读取用户态内存(hugepage)
上下文切换 用户态 <-> 内核态频繁切换 同一用户态线程完成全链路,无切换
锁竞争 内核 block layer 存在多级锁(blk-mq 有所改善) 无锁设计,每个 Reactor 独占自己的队列与资源
CPU 占用模式 空闲时释放 CPU(中断唤醒) 轮询线程持续占用 CPU 核心(即使无 I/O)

需要注意的权衡:SPDK 的轮询模式意味着即使没有 I/O 请求,绑定的 CPU 核心也会被 100% 占用。这在 I/O 密集型场景是合理的(用 CPU 换延迟),但在混合负载或低 I/O 场景下需要仔细评估 CPU 预算。

调优之前先要理解 Reactor 模型:

graph LR
    subgraph CPU Core 0
        R0["Reactor 0
Main Reactor"] R0 --> P0A["Poller: NVMe I/O"] R0 --> P0B["Poller: JSON-RPC"] R0 --> P0C["Poller: NVMe-oF Acceptor"] end subgraph CPU Core 1 R1["Reactor 1"] R1 --> P1A["Poller: NVMe-oF
Poll Group 1"] R1 --> P1B["Poller: bdev I/O"] end subgraph CPU Core 2 R2["Reactor 2"] R2 --> P2A["Poller: NVMe-oF
Poll Group 2"] R2 --> P2B["Poller: bdev I/O"] end

核心概念:

  • Reactor:绑定到单个 CPU 核心的事件循环线程,永不阻塞,以 run-to-completion 模式执行所有注册的 Poller。
  • Poller:注册在 Reactor 上的回调函数,分为 active poller(每轮都执行,如 NVMe completion queue 检查)和 timed poller(定时执行,如统计采集)。
  • SPDK Thread:逻辑线程,可运行在任意 Reactor 上。一个 Reactor 可以承载多个 SPDK Thread。
  • Message Passing:Reactor 之间通过无锁环形队列(ring)传递消息,避免共享数据结构的锁竞争。

这套模型的直接推论:

  1. 给 SPDK 分配几个核心,它就能使用几个 Reactor 并行处理 I/O;
  2. 不同 Reactor 之间的 I/O 路径完全隔离,不存在锁争用;
  3. 核心数不是越多越好,需要匹配实际 I/O 并发度和 NVMe 队列数。

企业级 NVMe 的 4K 随机读延迟已经到 5µs 左右。请求经过内核块层、中断和上下文切换后,软件开销往往比介质本身还大。下面是一组公开资料里的典型量级:

指标 硬件能力 内核 NVMe 驱动(fio ioengine=libaio) SPDK(fio ioengine=spdk)
4K 随机读延迟 ~5 µs(介质延迟) ~12-15 µs ~6-8 µs
4K 随机读 IOPS(单核) ~800K+ ~200-350K ~600-800K
4K 随机读 IOPS(多核饱和) ~1.6M+ ~800K-1M(受限于中断/锁) ~1.5M+(接近线速)
CPU 每 IOP 开销 - ~4000-6000 cycles ~1000-1500 cycles

以上数据来自公开资料中的典型量级参考(基于 Intel P5800X / Samsung PM9A3 等企业级 NVMe SSD),不是本文环境实测。实际数值因硬件型号、固件版本、内核版本、fio 参数等因素而异,部署后应以自己的实测数据为准。

SPDK 本身不是文件系统,也不是开箱即用的存储产品。它更像一套数据面积木:bdev 怎么组、协议怎么暴露、上层怎么接,都要自己决定。下面这些是社区和商业产品里比较常见的用法。

场景 典型做法 要注意的地方
NVMe-oF 存储解耦 存储节点用 nvmf_tgt 把本地 NVMe 通过 TCP/RDMA 暴露出去,计算节点用内核 nvme connect 接进来,当本地盘用 网络决定上限。1Gb 只能做功能验证,4.5 的 24K IOPS 就是例子;生产至少 25Gb,host 要做 allowlist 或 TLS
虚拟化块存储后端 SPDK 的 vhost 程序通过 vhost-user 把 virtio-blk/scsi 队列交给 QEMU,虚拟机 I/O 不走宿主机内核块层 要配 hugepage 和 CPU 绑核;vhost-user socket 的生命周期要和 QEMU 对齐
分布式存储引擎的本地盘管理 DAOS 这类引擎用 SPDK 直接管 NVMe,把盘交给用户态 I/O 栈;SPDK 自带的 blobstore/blobfs 可以做上层 要改存储引擎的 I/O 层,不是挂个盘就能用;副本和 HA 由上层负责
Kubernetes 持久卷 spdk-csi 把 SPDK 管理的卷通过 NVMe-oF 暴露给节点,CSI 负责 attach 和 mount 节点要装 nvme-tcp;网络和 multipath 要提前规划;适合高性能有状态负载
自研数据面 直接基于 bdev API 写 SPDK 应用,再叠加 QoS、RAID、crypto 等 bdev 开发和运维成本最高,适合存储厂商或大规模自研团队

实际选型时,先确定上层要什么接口:要块设备给主机或虚拟机,选 NVMe-oF;要给 QEMU 挂盘,选 vhost-user;要接 Kubernetes,选 CSI;要改存储引擎内部,才需要直接写 bdev 应用。

几个可以继续看的项目:

  • Ceph NVMe-oF Gateway:用 SPDK 把 Ceph RBD 镜像通过 NVMe-oF/TCP 暴露给客户端。
  • DAOS:HPC/AI 存储引擎,底层用 SPDK 访问 NVMe。
  • SPDK CSI:把 SPDK 卷接进 Kubernetes。
  • SPDK vhost:QEMU/KVM 的 virtio 后端。

下面这些情况不适合用 SPDK:

  • 应用要的是 POSIX 文件系统,直接 open/read/write。SPDK 不提供这些语义。
  • I/O 很稀疏,CPU 却很紧张。轮询线程会一直占着核心。
  • 只有 SATA/SAS HDD。硬件本身就是瓶颈,换 SPDK 收益有限。
  • 团队没有精力维护用户态进程、hugepage、VFIO 和绑核。这种场景先上 io_uring 更划算。

判断要不要上,可以问三个问题:现在的瓶颈是不是 I/O?能不能接受几个 CPU 核心被轮询线程占满?团队有没有精力维护用户态存储栈?前两个是“是”、第三个也有底,再考虑 SPDK。

io_uring 作为 Linux 5.x 引入的新异步 I/O 接口,同样致力于降低 I/O 路径开销,但定位不同:

维度 SPDK io_uring
运行空间 用户态,完全绕过内核 用户态提交,内核执行
设备绑定 需要将设备从内核驱动解绑(VFIO/UIO) 无需解绑,内核驱动照常工作
延迟极限 更低(无 syscall、无中断) 略高于 SPDK,但远低于 libaio
生态兼容 独立编程模型,与 POSIX I/O 不兼容 兼容现有内核 block 设备与文件系统
运维复杂度 高(hugepage、VFIO、独占 CPU 核心) 低(内核原生支持,无额外配置)
典型用户 存储厂商、SDS 引擎、NVMe-oF Target 通用高性能应用、数据库(如 io_uring 版 RocksDB)

选型上可以这么分:已有应用跑在内核文件系统或块设备上,想少改代码提升性能,先用 io_uring;要做 NVMe-oF target、vhost-user 后端,或者自己控制整个数据面,再看 SPDK。两者不是替代关系,很多存储系统会同时用到。

本节以 Debian 12(bookworm, x86_64)为例,给出可复现的从零部署步骤。默认使用 bash 与 sudo。

说明:本节和第 4 节的 malloc bdev、NBD、NVMe-oF/TCP 回环路径可以在无 NVMe 硬件的 Debian 12 VM 里复现;VFIO 绑定和 NVMe 直连需要真实设备,不在这个范围内。

硬件要求:

项目 最低要求 生产建议
CPU 架构 x86_64(支持 SSE4.2) 支持 NUMA 的多核 Xeon / EPYC
内存 4 GB(含 2 GB hugepage) 根据 bdev 数量与连接数规划,至少 16 GB
磁盘 任意(用 malloc bdev 验证) NVMe SSD(支持 VFIO)
IOMMU 可选 生产环境建议开启(Intel VT-d / AMD-Vi)
网络 可选 NVMe-oF/TCP 需千兆以上网卡;RDMA 需 RoCE/IB 网卡
# 检查发行版与内核版本
cat /etc/os-release | grep -E 'PRETTY_NAME|VERSION_ID'
uname -r

# 检查 CPU 是否支持 SSE4.2(DPDK 必需)
grep -o 'sse4_2' /proc/cpuinfo | head -1

# 检查 IOMMU 是否已启用(可选)
dmesg | grep -i -E 'DMAR|IOMMU' | head -5

# 检查现有 NVMe 设备(可选)
lspci -nn | grep -i 'Non-Volatile memory'
nvme list 2>/dev/null || echo "无 NVMe 设备或 nvme-cli 未安装"

两种方式二选一,按环境选:

方式一:手动安装最小依赖集(适合离线准备或严格管控包源的环境)

sudo apt update
sudo apt install -y \
    git build-essential meson ninja-build pkg-config \
    python3 python3-venv python3-pip python3-pyelftools \
    libnuma-dev libssl-dev libaio-dev libfuse3-dev \
    uuid-dev libncurses-dev libcunit1-dev \
    autoconf automake libtool \
    jq curl fio nvme-cli \
    nasm

各依赖用途说明:

包 用途
meson / ninja-build 构建 DPDK 子模块;SPDK 顶层仍用 ./configure + make
libnuma-dev NUMA 感知内存分配
libssl-dev TLS 支持(NVMe-oF/TCP with TLS)
libaio-dev AIO bdev 后端
libfuse3-dev NVMe CUSE 字符设备和 blobfs FUSE 挂载
python3-pyelftools DPDK 构建依赖
autoconf / automake / libtool 构建自带的 ISA-L、ISA-L-crypto 子模块时,autogen.sh 需要 autoreconf
libcunit1-dev 编译自带的单元测试 ut 时需要 CUnit 头文件
nasm ISA-L(存储加速库)汇编编译
fio / nvme-cli 性能测试与 NVMe 管理工具
uuid-dev / libncurses-dev UUID 生成 / spdk_top TUI 界面

scripts/pkgdep.sh 会把这些依赖一次装齐;手动装时,libfuse3-dev、autoconf/automake/libtool、meson/ninja-build、libcunit1-dev 这几个最容易漏,缺任何一个都会在 configure 或 make 阶段报错。

方式二:使用 SPDK 自带脚本自动安装(推荐联网环境)

# 先克隆源码(见 3.3),然后执行
sudo scripts/pkgdep.sh

该脚本会根据 /etc/os-release 自动判断发行版并安装所有已知依赖,包括 RDMA、iSCSI、FUSE 等可选组件。

如果后续要用 RDMA(RoCE v2),额外确保安装:sudo apt install -y rdma-core libibverbs-dev librdmacm-dev

固定标签是为了让每次构建拿到同一份代码,避免 HEAD 漂移。

cd /opt
sudo git clone https://github.com/spdk/spdk.git
sudo chown -R $(whoami):$(whoami) /opt/spdk
cd /opt/spdk

# 固定到稳定发布版本
git checkout v24.09
git submodule update --init --recursive

生产环境建议将源码与构建产物放在 /opt/spdk 而非 $HOME,便于多用户访问与权限管理。

# SPDK 顶层用 configure + make 构建
./configure --with-vhost --with-virtio --with-nvme-cuse
make -j$(nproc)

SPDK v24.09 顶层仍然用 ./configure + make,没有 meson.build。meson/ninja 是 DPDK 子模块的构建工具,make 会自动调用,所以依赖里要装;不要按某些文档去执行 meson setup build,这里没有对应的构建定义。

# 验证关键二进制已编译成功
ls -la build/bin/spdk_tgt build/bin/nvmf_tgt build/examples/hello_world

离线编译方案:

对于无法联网的生产环境,可在联网构建机上完成以下步骤后打包传输:

# 在联网机上
cd /opt/spdk && git checkout v24.09
git submodule update --init --recursive
# 将 /opt/spdk 整个目录打包(含子模块)
tar czf spdk-v24.09-full.tar.gz -C /opt spdk/

# 传输到离线目标机后解压编译即可

SPDK 通过 DPDK 使用 hugepage 进行 DMA 缓冲区和内部数据结构的内存分配,这是运行的硬性前提。

容量规划:

场景 建议 hugepage 数量(2MB 页) 总计
功能验证(malloc bdev) 1024 页 2 GB
单盘 NVMe + NVMe-oF Target 2048 页 4 GB
多盘 NVMe + 高连接数 4096+ 页 8+ GB

具体开销取决于 I/O 大小、队列深度、连接数和共享缓冲区配置,不能只按 bdev 数量估算。功能验证给 1-2 GB 就够;连接多、队列深时,-s 要相应加大。上线前用 spdk_top、bdev_get_iostat 和 HugePages_Free 看余量,再调整。

临时生效(测试用):

# 查看当前 hugepage 状态
grep -i huge /proc/meminfo

# 设置 1024 个 2MB hugepage(约 2GB)
sudo sysctl -w vm.nr_hugepages=1024

# 确保 hugetlbfs 已挂载
sudo mkdir -p /dev/hugepages
sudo mount -t hugetlbfs nodev /dev/hugepages 2>/dev/null || true

# 验证
grep HugePages_ /proc/meminfo

vm.nr_hugepages 是请求值,内核实际能分配多少取决于可用内存和碎片。实测在一台 3 GB 的 VM 上第一次写 1024 时,HugePages_Total 只到 454(908 MB),接着 spdk_tgt -s 1024 就报 EAL: Not enough memory available! Requested: 1024MB, available: 908MB。先 sync,再执行 echo 3 | sudo tee /proc/sys/vm/drop_caches,然后重设 vm.nr_hugepages,才能拿到 1024 页。生产环境按上面的容量规划预留内存。

持久化(生产环境):

# 写入 sysctl 配置
cat <<'EOF' | sudo tee /etc/sysctl.d/60-spdk-hugepages.conf
# SPDK hugepage configuration
vm.nr_hugepages = 2048
EOF

# 写入 fstab 确保 hugetlbfs 开机挂载
grep -q hugetlbfs /etc/fstab || \
    echo 'nodev /dev/hugepages hugetlbfs defaults,mode=1770,gid=0 0 0' | sudo tee -a /etc/fstab

sudo sysctl -p /etc/sysctl.d/60-spdk-hugepages.conf
sudo mkdir -p /dev/hugepages
sudo mount -a

# 验证
grep HugePages_ /proc/meminfo

NUMA 感知的 hugepage 分配(多路服务器):

在多 NUMA 节点的服务器上,应将 hugepage 分配到 NVMe 设备所在的 NUMA 节点,避免跨节点内存访问:

# 查看 NVMe 设备所在的 NUMA 节点
cat /sys/bus/pci/devices/0000:5e:00.0/numa_node

# 在指定节点上分配 hugepage(示例:node 0 分配 1024 页,node 1 分配 1024 页)
echo 1024 | sudo tee /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
echo 1024 | sudo tee /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages

1G hugepage 通常要在内核启动参数里预留(hugepagesz=1G default_hugepagesz=1G hugepages=N),不能像 2MB 页那样随时扩容。对 TLB miss 敏感的场景可以用 1G 页,但要提前规划好内存。

SPDK 用户态驱动需要通过 VFIO(推荐)或 UIO 机制获取 NVMe 设备的 PCIe 资源访问权限。

纯 malloc bdev 功能验证可跳过本节。

步骤一:启用 IOMMU

# Intel CPU
sudo mkdir -p /etc/default/grub.d
echo 'GRUB_CMDLINE_LINUX_DEFAULT="$GRUB_CMDLINE_LINUX_DEFAULT intel_iommu=on iommu=pt"' \
    | sudo tee /etc/default/grub.d/90-iommu.cfg

# AMD CPU(二选一)
# echo 'GRUB_CMDLINE_LINUX_DEFAULT="$GRUB_CMDLINE_LINUX_DEFAULT amd_iommu=on iommu=pt"' \
#     | sudo tee /etc/default/grub.d/90-iommu.cfg

sudo update-grub
sudo reboot

重启后验证:

dmesg | grep -i -E 'DMAR|IOMMU' | head -5
# 应看到类似 "DMAR: IOMMU enabled" 的输出

步骤二:绑定 NVMe 设备

cd /opt/spdk

# 查看当前 NVMe 设备及其驱动绑定关系
sudo scripts/setup.sh status

# 执行绑定(将所有 NVMe 设备绑定到 VFIO;注意:会解除内核 nvme 驱动的访问)
sudo HUGEMEM=4096 scripts/setup.sh

# 再次确认绑定状态
sudo scripts/setup.sh status

生产环境警告:scripts/setup.sh 默认会绑定所有 NVMe 设备。如果系统盘是 NVMe,必须通过 PCI_BLOCKED 环境变量排除:

# 排除系统盘(假设系统盘是 NVMe,PCI 地址为 0000:01:00.0;用 lspci 确认)
sudo PCI_BLOCKED="0000:01:00.0" HUGEMEM=4096 scripts/setup.sh

恢复设备到内核驱动:

sudo scripts/setup.sh reset

spdk_tgt 是 SPDK 的通用目标进程,启动后通过 JSON-RPC 动态配置 bdev、transport、subsystem 等。

基础启动:

cd /opt/spdk

# 前台启动(调试用,日志同时写到文件;Ctrl+C 停止)
sudo build/bin/spdk_tgt -m 0x3 -s 2048 -r /var/tmp/spdk.sock 2>&1 | tee /tmp/spdk_tgt.log

# 参数说明:
#   -m 0x3     : CPU 核心掩码,0x3 = 核心 0 和 1(二进制 11)
#   -s 2048    : SPDK 内存大小(由 hugepage 提供),单位 MB
#   -r <path>  : JSON-RPC Unix socket 路径

验证进程就绪(另开一个终端):

# 检查进程
pgrep -a spdk_tgt

# 查询已加载的子系统
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock framework_get_subsystems | jq '.[].subsystem'

# 查询 Reactor 列表(确认核心绑定)
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock framework_get_reactors | jq '.reactors[] | {core: .lcore, busy: .busy, idle: .idle}'

生产环境不应使用 & 后台运行进程,应通过 systemd 管理:

sudo cat > /etc/systemd/system/spdk-tgt.service <<'EOF'
[Unit]
Description=SPDK Target Daemon
After=network.target
Wants=network.target

[Service]
Type=simple
# 核心掩码根据实际 NUMA 拓扑调整
ExecStartPre=/sbin/modprobe vfio-pci
ExecStart=/opt/spdk/build/bin/spdk_tgt \
    -m 0x3 \
    -s 4096 \
    -r /var/tmp/spdk.sock \
    --wait-for-rpc
ExecStartPost=/bin/sleep 1
ExecStartPost=/opt/spdk/scripts/rpc.py -s /var/tmp/spdk.sock framework_start_init
ExecStartPost=/opt/spdk/conf/init-bdevs.sh
# 优雅停止
ExecStop=/opt/spdk/scripts/rpc.py -s /var/tmp/spdk.sock spdk_kill_instance SIGTERM
TimeoutStopSec=30
Restart=on-failure
RestartSec=5
# 安全加固
LimitMEMLOCK=infinity
LimitNOFILE=65536
LimitCORE=infinity

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now spdk-tgt
sudo systemctl status spdk-tgt

--wait-for-rpc 让进程先停在初始化前,等 framework_start_init 被调用后再启动子系统。bdev 创建要放在 framework_start_init 之后,因为 bdev RPC 依赖 bdev 子系统已经初始化;上面的 ExecStartPost 就是按这个顺序排的。

配套的初始化脚本(由上面的 ExecStartPost 调用):

#!/usr/bin/env bash
# /opt/spdk/conf/init-bdevs.sh
# 启动后通过 JSON-RPC 创建所需的 bdev 与 subsystem

set -euo pipefail

RPC="/opt/spdk/scripts/rpc.py -s /var/tmp/spdk.sock"

# 示例:创建 malloc bdev 用于测试(1024 MB,块大小 4096)
$RPC bdev_malloc_create -b Malloc0 1024 4096

# 示例:创建 NVMe-oF/TCP transport
$RPC nvmf_create_transport -t TCP -u 8192 -m 8 -c 4096

echo "SPDK bdev initialization complete."

记得 sudo chmod +x /opt/spdk/conf/init-bdevs.sh,否则 systemd 的 ExecStartPost 会报 permission denied。这个示例脚本不是幂等的:进程重启后 bdev 名字会冲突,需要先清理或改成按需创建;生产环境按实际拓扑写初始化脚本。

4.1 到 4.4 是四种从低到高的验证路径,覆盖不同硬件条件;4.5 和 4.6 给出一组在 Homelab 双 VM 上的实测数据,以及从 malloc bdev 换成真实 NVMe 的配置。

这是无需任何特殊硬件即可完成的端到端验证,通过 SPDK 创建内存块设备,经 NBD(Network Block Device)内核模块映射为标准块设备后使用 fio 测试。

下面的命令假设 3.6 的 spdk_tgt 已经在跑。只是做 malloc/NBD 验证时,启动 spdk_tgt 可以加 -u(--no-pci)跳过 PCI 探测;要接 NVMe 设备时不要加。

graph LR
    A["fio"] -->|"/dev/nbd0"| B["NBD
Kernel Module"] B -->|"NBD socket"| C["SPDK bdev
Malloc0"] C --> D["Hugepage Memory
User Space"]
cd /opt/spdk

# 1) 创建 1 GiB malloc bdev(block_size = 4096)
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_create -b Malloc0 1024 4096

# 2) 加载 NBD 内核模块(允许最多 16 个 nbd 设备)
sudo modprobe nbd nbds_max=16

# 3) 将 bdev 映射到 /dev/nbd0
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nbd_start_disk Malloc0 /dev/nbd0

# 4) 确认设备就绪
lsblk | grep nbd0

# 5) fio 基准测试:4K 随机读写混合,队列深度 32
sudo fio --name=spdk-malloc-verify \
    --filename=/dev/nbd0 \
    --ioengine=libaio \
    --direct=1 \
    --rw=randrw \
    --rwmixread=70 \
    --bs=4k \
    --iodepth=32 \
    --numjobs=1 \
    --time_based \
    --runtime=15 \
    --group_reporting

# 6) 查看 SPDK 侧 IO 统计
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_get_iostat -b Malloc0 | jq

# 7) 清理
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nbd_stop_disk /dev/nbd0
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_delete Malloc0

nbd_stop_disk 前务必确保没有进程仍在使用 /dev/nbd0(如 fio 已退出、无文件系统挂载)。可用 sudo lsof /dev/nbd0 检查。

NBD 路径要经过内核块层,这里的 fio 数字只能确认 SPDK 进程、bdev 和映射都正常,不代表 SPDK 的数据面性能。我在测试环境照上面的命令跑 15 秒,read 38.4K IOPS、write 16.5K IOPS,平均 clat 约 574µs;更多实测见 4.5。

以下需要真实 NVMe 设备和 VFIO 绑定。本文的测试环境没有可直通的 NVMe,这部分只做步骤参考。

hello_world 示例程序直接通过 SPDK 用户态 NVMe 驱动与设备交互,不依赖 spdk_tgt 进程:

cd /opt/spdk

# 向 NVMe 设备写入一段字符串再读回
sudo build/examples/hello_world

预期输出应包含:

  • SPDK 探测到的 NVMe 控制器和 namespace 信息
  • “Hello world!\n” 写入与读回一致的确认消息

要看设备枚举和 bdev 名称,用 spdk_tgt 接管控制器:

sudo build/bin/spdk_tgt -m 0x3 -s 2048 -r /var/tmp/spdk.sock &
sleep 2
RPC="sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock"
$RPC bdev_nvme_attach_controller -b Nvme0 -t pcie -a 0000:5e:00.0
$RPC bdev_get_bdevs | jq -r '.[].name'

压测可以用 SPDK 的 fio 插件或者 bdevperf。fio 插件默认不编译,需要先准备 fio 源码,再用 --with-fio=/path/to/fio 重新 configure 和 make,插件会出现在 build/fio/ 下;bdevperf 是 SPDK 自带的 bdev 压测程序,用法见 examples/bdev/bdevperf/。

v24.09 的 examples/nvme/ 里没有 perf 和 identify 这两个示例,build/examples/perf、build/examples/identify 都不存在。旧文档里的这两条命令不要直接照抄。

在同一主机上完成 NVMe-oF target 暴露与 initiator 连接的端到端验证:

graph LR
    subgraph Host - Same Machine
        A["nvme connect
Kernel Initiator"] -->|"TCP 127.0.0.1:4420"| B["nvmf_tgt
SPDK NVMe-oF Target"] B --> C["bdev: Malloc0
1 GiB"] A -.->|"/dev/nvmeXn1"| D["fio"] end
cd /opt/spdk

# 1) 停止 spdk_tgt(如在运行),启动 nvmf_tgt
sudo pkill spdk_tgt 2>/dev/null; sleep 1
sudo build/bin/nvmf_tgt -m 0x3 -s 2048 -r /var/tmp/spdk.sock &
sleep 2

# 2) 创建 TCP transport
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nvmf_create_transport \
    -t TCP \
    -u 8192 \
    -m 8 \
    -c 4096

# 3) 创建 malloc bdev
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_create -b Malloc0 1024 4096

# 4) 创建 NVMe-oF subsystem(NQN 需全局唯一)
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nvmf_create_subsystem \
    nqn.2025-10.io.spdk:cnode1 \
    -a -s SPDK00000001

# 5) 将 bdev 作为 namespace 挂载到 subsystem
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nvmf_subsystem_add_ns \
    nqn.2025-10.io.spdk:cnode1 Malloc0

# 6) 添加 TCP listener
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nvmf_subsystem_add_listener \
    nqn.2025-10.io.spdk:cnode1 \
    -t tcp -a 127.0.0.1 -s 4420

# 7) 内核 initiator 端:发现与连接
sudo modprobe nvme-tcp
sudo nvme discover -t tcp -a 127.0.0.1 -s 4420
sudo nvme connect -t tcp -n nqn.2025-10.io.spdk:cnode1 -a 127.0.0.1 -s 4420

# 8) 确认新设备出现
sleep 1
sudo nvme list
ls -la /dev/nvme*n* 2>/dev/null

# 9) fio 验证
NVME_DEV=$(ls /dev/nvme*n1 2>/dev/null | tail -1)
if [ -n "$NVME_DEV" ]; then
    sudo fio --name=nvmeof-tcp-verify \
        --filename="$NVME_DEV" \
        --ioengine=libaio \
        --direct=1 \
        --rw=randread \
        --bs=4k \
        --iodepth=32 \
        --numjobs=1 \
        --time_based \
        --runtime=10 \
        --group_reporting
fi

# 10) 清理(顺序很重要)
sudo nvme disconnect -n nqn.2025-10.io.spdk:cnode1
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nvmf_delete_subsystem nqn.2025-10.io.spdk:cnode1
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_delete Malloc0

跨主机测试时,把命令里的 127.0.0.1 换成 target 的实际 IP,listener 也要写成这个 IP。v24.09 里 listener 写成 0.0.0.0 时,另一台机器连过来会被访问控制拒绝,报 does not allow host ... to connect at this address;实测踩过这个坑,细节见 4.5。

这一节用 spdk_tgt。如果刚做完 4.3,先停掉 nvmf_tgt,再按 3.6 启动 spdk_tgt(可以加 -u 跳过 PCI)。

SPDK bdev 的核心能力之一是可堆叠组合。以下示例演示在两个 malloc bdev 上构建 RAID0,再对 RAID 设备施加 QoS 限速:

graph TB
    A["NBD /dev/nbd0
fio 测试入口"] --> B["bdev: QoS Layer
IOPS Limit: 50000"] B --> C["bdev: Raid0
Strip Size: 64K"] C --> D1["bdev: Malloc0
512 MiB"] C --> D2["bdev: Malloc1
512 MiB"]
cd /opt/spdk

# 1) 创建两个 malloc bdev
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_create -b Malloc0 512 4096
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_create -b Malloc1 512 4096

# 2) 创建 RAID0(条带大小 64K)
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_raid_create \
    -n Raid0 -z 64 -r 0 -b "Malloc0 Malloc1"

# 3) 对 Raid0 设置 QoS(限制读写合计 IOPS 为 50000)
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_set_qos_limit \
    Raid0 --rw-ios-per-sec 50000

# 4) 映射到 NBD 验证
sudo modprobe nbd nbds_max=16
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nbd_start_disk Raid0 /dev/nbd0

# 5) fio 验证 - 观察 IOPS 是否被限制在 50000 左右
sudo fio --name=raid0-qos \
    --filename=/dev/nbd0 \
    --ioengine=libaio \
    --direct=1 \
    --rw=randread \
    --bs=4k \
    --iodepth=64 \
    --numjobs=1 \
    --time_based \
    --runtime=10 \
    --group_reporting

# 6) 清理
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nbd_stop_disk /dev/nbd0
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_raid_delete Raid0
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_delete Malloc0
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_delete Malloc1

实测(2 vCPU 的 VM,NBD 入口):限制设成 20000 时,fio 读到 19.9K IOPS,QoS 生效;设成 50000 时只读到 44.6K,因为这台 VM 的 NBD 路径本身到不了 50K,此时瓶颈不在 QoS。调 QoS 前先用不限速的基线确认后端能跑多快。

前面几节是操作步骤,这一节是实际跑出来的数字。测试环境是一套 PVE 9.2 集群,两台 Debian 12 虚拟机分在两个物理节点上,中间走 1Gb 链路:

项目 目标机(target) 发起端(initiator)
PVE 节点 infra-node1 infra-node3
vCPU / 内存 2 / 3072 MB 2 / 1536 MB
虚拟机磁盘 Ceph 上的 virtio 盘 同左
网络 virtio-net,物理口 1Gb/s 同左
SPDK v24.09(DPDK 24.03.0) 内核 nvme-tcp 客户端
测试后端 malloc bdev,512 MiB —

两台 VM 都没有 NVMe 直通。malloc bdev 的数据放在 SPDK 的 hugepage 内存里,所以下面测到的是 NBD 路径和 NVMe/TCP 协议栈,不是 NVMe 介质本身的性能。

先量网络上限:iperf3 单流 10 秒,发送端 889 Mbits/s,接收端 886 Mbits/s。

本地 malloc + NBD

spdk_tgt -m 0x1 -s 1024 -u,512 MiB malloc bdev 映射到 /dev/nbd0,fio 跑 4K 随机读,QD128,每轮 30 秒,前 5 秒预热,共 3 轮:

轮次 IOPS 带宽 平均延迟 p99
1 56,045 219 MiB/s 2.27 ms 6.32 ms
2 63,305 247 MiB/s 2.01 ms 4.75 ms
3 51,567 201 MiB/s 2.47 ms 8.36 ms

中位数是 56,045 IOPS、219 MiB/s、平均延迟 2.27 ms、p99 6.32 ms,三轮的 IOPS 范围是 51,567 到 63,305。波动和 VM 共享 CPU、NBD 单队列都有关系。这个量级只能说明 SPDK 进程、bdev、NBD 映射都工作正常,不能拿来评价 SPDK 的 IOPS。要看数据面性能,用 4.2 的 NVMe 直连,或者 fio 的 SPDK ioengine 直接压 bdev。

跨主机 NVMe-oF/TCP

target 启动 nvmf_tgt -m 0x1 -s 1024 -u,把同一个 512 MiB malloc bdev 通过 TCP 暴露出去;initiator 用内核 nvme connect 接上,在 /dev/nvme0n1 上跑同样的 4K 随机读 QD128,也是 3 轮:

轮次 IOPS 带宽 平均延迟 p99
1 24,634 96.2 MiB/s 5.16 ms 10.29 ms
2 24,726 96.6 MiB/s 5.14 ms 9.90 ms
3 24,187 94.5 MiB/s 5.26 ms 11.86 ms

中位数是 24,634 IOPS、96.2 MiB/s,约 807 Mbits/s,相当于 iperf3 接收端带宽的 91%。瓶颈在 1Gb 链路上,剩下约 9% 是 NVMe/TCP 和 TCP/IP 的开销。平均延迟从本地 NBD 的 2 ms 量级涨到 5 ms,主要来自网络往返和队列排队;这组数字不代表 NVMe-oF 在 10Gb/25Gb 网络或 RDMA 下的延迟。

一个访问控制的坑

target 的 listener 如果写成 -a 0.0.0.0,initiator 从另一台机器连过来时,v24.09 的 spdk_nvmf_subsystem_listener_allowed 会拿连接的实际地址去比对 listener 地址,比对失败,target 日志报:

Subsystem 'nqn.2025-10.io.spdk:cnode1' does not allow host 'nqn...' to connect at this address.

这次 subsystem 开了 -a(allow any host),所以不是 host NQN 被拒,而是 listener 地址对不上。把 listener 改成 target 的具体 IP(示例是 10.0.0.10)就能连上。回环测试两边都用 127.0.0.1,不会碰到这个问题。

我在测试机上照上面的回环命令跑 4K 随机读、QD32、10 秒,得到 47.1K IOPS,平均 clat 659µs。回环不经过物理网络,这组数字只用于确认 target 和 initiator 配置正确;跨主机数据见 4.5。

这组测试对应的是存储解耦场景的最小验证。把 malloc bdev 换成真实 NVMe、把 1Gb 换成 25/100Gb,就是下一节要说的落地形态。

4.5 用 malloc bdev 代替了真实盘。生产里把 bdev 换成 NVMe,拓扑不变:

graph LR
    subgraph Compute Node
        A["应用 / 文件系统"]
        B["内核 nvme-tcp initiator"]
    end
    subgraph Network
        C["25/100Gb TCP"]
    end
    subgraph Storage Node
        D["SPDK nvmf_tgt"]
        E["bdev: Nvme0n1"]
        F["NVMe SSD"]
    end
    A --> B
    B -->|"NVMe-oF/TCP"| C
    C --> D
    D --> E
    E --> F

这台测试环境没有可直通的 NVMe,下面的 NVMe bdev 部分只核对了 RPC 语法,没有在真实盘上跑过。上生产前先找一块可以丢数据的盘走一遍。

存储节点上,先把 NVMe 绑到 VFIO(见 3.5),再启动 nvmf_tgt:

cd /opt/spdk
sudo build/bin/nvmf_tgt -m 0x3 -s 4096 -r /var/tmp/spdk.sock &
sleep 2
RPC="sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock"

# 1) 创建 TCP transport
$RPC nvmf_create_transport -t TCP -u 8192 -m 8 -c 4096

# 2) 接管 NVMe 控制器(-a 是 PCI 地址,用 lspci 确认)
$RPC bdev_nvme_attach_controller -b Nvme0 -t pcie -a 0000:5e:00.0

# 3) 确认 namespace bdev 出现,通常是 Nvme0n1
$RPC bdev_get_bdevs | jq -r '.[].name'

创建 subsystem 时不要用 -a,把计算节点的 host NQN 加进 allowlist:

# 在计算节点上取 host NQN,把输出的字符串记下来
nvme show-hostnqn
# 输出示例:nqn.2014-08.org.nvmexpress:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# 在存储节点上
RPC="sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock"
HOSTNQN="nqn.2014-08.org.nvmexpress:uuid:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
SUB=nqn.2025-10.io.example:storage0
$RPC nvmf_create_subsystem "$SUB" -s SPDK00000000000001
$RPC nvmf_subsystem_add_host "$SUB" "$HOSTNQN"
$RPC nvmf_subsystem_add_ns "$SUB" Nvme0n1
$RPC nvmf_subsystem_add_listener "$SUB" -t tcp -a 10.0.0.10 -s 4420

计算节点接进来:

sudo modprobe nvme-tcp
sudo nvme discover -t tcp -a 10.0.0.10 -s 4420
sudo nvme connect -t tcp -n nqn.2025-10.io.example:storage0 -a 10.0.0.10 -s 4420
nvme list-subsys
lsblk

之后把它当本地块设备用:mkfs、mount,或者直接给数据库和存储引擎。上线前有几件事要确认:

  • 网络:1Gb 只能做功能验证,4.5 的 24K IOPS 就是 1Gb 的天花板。要跑满 NVMe,至少 25Gb,最好 100Gb。
  • 多路径:内核 NVMe 自带 multipath,可以连同一 target 的多个 listener 或端口。SPDK 只做 target,不做副本;目标机挂了就是挂了,HA 要在上层做。
  • 安全:生产用 host allowlist,不要 -a;把 initiator 的 /etc/nvme/hostnqn 固定下来并纳入配置管理,否则重装后 NQN 变化会连不上。需要加密时给 host 配 PSK,listener 加 --secure-channel。
  • 监控:把 bdev_get_iostat、nvmf_get_stats、spdk_top 接进 Prometheus,至少盯住延迟、队列深度和 hugepage 余量。

SPDK 自带 JSON-RPC 统计接口和 spdk_top。DPDK 的 telemetry 在 SPDK 启动时默认关闭(EAL 参数里有 --no-telemetry),所以监控主要走 JSON-RPC。生产环境可以把这些指标接进 Prometheus + Grafana。

graph LR
    subgraph SPDK Process
        A["spdk_tgt / nvmf_tgt"]
        B["JSON-RPC
/var/tmp/spdk.sock"] end subgraph Collection Layer D["spdk_exporter.sh
cron / systemd timer"] E["spdk_top
Interactive TUI"] end subgraph Monitoring Stack F["node_exporter
textfile collector"] G["Prometheus"] H["Grafana"] I["AlertManager"] end A --> B B --> D B --> E D -->|"write .prom file"| F F --> G G --> H G --> I
# bdev 层 IO 统计(最常用)
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_get_iostat | jq

# 输出结构(关键字段):
# {
#   "tick_rate": 2200000000,          <- CPU TSC 频率
#   "ticks": 123456789,               <- 当前 tick
#   "bdevs": [{
#     "name": "Malloc0",
#     "bytes_read": 1048576,
#     "bytes_written": 2097152,
#     "num_read_ops": 256,
#     "num_write_ops": 512,
#     "read_latency_ticks": 12345,    <- 累计延迟 ticks,除以 num_read_ops 再换算
#     "write_latency_ticks": 23456,
#     ...
#   }]
# }

# NVMe-oF subsystem 连接信息
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nvmf_get_stats | jq

# Reactor 线程负载
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock framework_get_reactors | jq

平均延迟换算:latency_us = read_latency_ticks / num_read_ops / tick_rate * 1000000。read_latency_ticks 是累计值,不是单次 I/O 的延迟。

下面的 exporter 是模板,本文环境没有接入真实的 Prometheus/node_exporter;指标名和采集路径请先在测试环境确认。

sudo cat > /usr/local/bin/spdk_exporter.sh <<'SCRIPT'
#!/usr/bin/env bash
# SPDK Prometheus Exporter - textfile collector
# 每次执行生成 .prom 文件供 node_exporter 采集

set -euo pipefail

SOCK="/var/tmp/spdk.sock"
RPC="/opt/spdk/scripts/rpc.py -s $SOCK"
OUT="/var/lib/node_exporter/textfile_collector/spdk.prom"
TMP=$(mktemp)

# 检查 SPDK 进程是否存活
if ! $RPC framework_get_reactors >/dev/null 2>&1; then
    echo '# SPDK process not running' > "$TMP"
    echo 'spdk_up 0' >> "$TMP"
    mv "$TMP" "$OUT"
    exit 0
fi

echo 'spdk_up 1' > "$TMP"

# bdev IO 统计
IOSTAT=$($RPC bdev_get_iostat 2>/dev/null || echo '{}')
TICK_RATE=$(echo "$IOSTAT" | jq -r '.tick_rate // 1')

echo "$IOSTAT" | jq -r --argjson tr "$TICK_RATE" '
    .bdevs[]? |
    "spdk_bdev_bytes_read{bdev=\"\(.name)\"} \(.bytes_read // 0)",
    "spdk_bdev_bytes_written{bdev=\"\(.name)\"} \(.bytes_written // 0)",
    "spdk_bdev_read_ops_total{bdev=\"\(.name)\"} \(.num_read_ops // 0)",
    "spdk_bdev_write_ops_total{bdev=\"\(.name)\"} \(.num_write_ops // 0)",
    "spdk_bdev_read_latency_us{bdev=\"\(.name)\"} \((if .num_read_ops > 0 then (.read_latency_ticks / .num_read_ops / $tr * 1000000) else 0 end))",
    "spdk_bdev_write_latency_us{bdev=\"\(.name)\"} \((if .num_write_ops > 0 then (.write_latency_ticks / .num_write_ops / $tr * 1000000) else 0 end))"
' >> "$TMP"

# Reactor 负载统计
$RPC framework_get_reactors 2>/dev/null | jq -r '
    .reactors[]? |
    "spdk_reactor_busy_ticks{core=\"\(.lcore)\"} \(.busy // 0)",
    "spdk_reactor_idle_ticks{core=\"\(.lcore)\"} \(.idle // 0)"
' >> "$TMP"

mv "$TMP" "$OUT"
chmod 0644 "$OUT"
SCRIPT

sudo chmod +x /usr/local/bin/spdk_exporter.sh
sudo mkdir -p /var/lib/node_exporter/textfile_collector

使用 systemd timer 替代 cron(更精确、可观测):

# 创建 service 单元
sudo cat > /etc/systemd/system/spdk-exporter.service <<'EOF'
[Unit]
Description=SPDK Prometheus Metrics Exporter

[Service]
Type=oneshot
ExecStart=/usr/local/bin/spdk_exporter.sh
User=root
EOF

# 创建 timer 单元(每 15 秒采集一次)
sudo cat > /etc/systemd/system/spdk-exporter.timer <<'EOF'
[Unit]
Description=Run SPDK exporter every 15 seconds

[Timer]
OnBootSec=30
OnUnitActiveSec=15

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now spdk-exporter.timer

# 验证
sudo systemctl list-timers | grep spdk
cat /var/lib/node_exporter/textfile_collector/spdk.prom

spdk_top 是 SPDK 自带的实时 TUI,连到运行中的进程看 Thread、Poller 和核心负载:

cd /opt/spdk

# -r 指定 JSON-RPC socket,默认就是 /var/tmp/spdk.sock
sudo build/bin/spdk_top -r /var/tmp/spdk.sock

界面里按 Tab 切换:

视图 内容
Threads 各 SPDK Thread 的 busy/idle 百分比、Poller 数量
Pollers 各 Poller 的类型(active/timed/paused)、执行次数、周期
Cores 各 Reactor 核心的负载分布

要脚本化的话直接用 JSON-RPC:framework_get_reactors 看每个 Reactor 的 busy/idle,bdev_get_iostat 看每个 bdev 的 IOPS 和延迟。DPDK telemetry 默认关闭,不要指望在 /var/run/dpdk/*/telemetry 找到可用的端点。

指标 告警条件 说明
spdk_up == 0 SPDK 进程不可达
Reactor busy 比率 > 95% 持续 5 分钟 Reactor 过载,需增加核心或优化连接分布
bdev read/write latency P99 超过基线(本地 NVMe 可从 100µs 起,NVMe-oF 按网络基线放宽) 延迟异常,检查 NVMe 健康或 QoS 配置
hugepage free < 10% 总量 hugepage 即将耗尽,需扩容或排查泄漏
NVMe-oF 连接数 突变 > 20% 客户端大规模连接/断开,检查网络或客户端状态

SPDK 是用户态进程,升级就是编译新版本、停旧进程、起新进程。需要关注两件事:JSON-RPC 接口兼容性,以及停机和恢复要多久。

graph TD
    A["阅读 Release Notes
确认 API 变更"] --> B["在测试环境编译新版本"] B --> C["运行回归测试
malloc + NVMe-oF 验证"] C --> D{"测试通过?"} D -->|"Yes"| E["生产环境:编译新版本
保留旧版本二进制"] D -->|"No"| F["修复脚本/配置
适配新 API"] F --> C E --> N["更新 service 指向新目录"] N --> G["维护窗口:停止旧进程"] G --> H["启动新版本进程"] H --> I["执行初始化脚本
JSON-RPC 配置"] I --> J["端到端验证"] J --> K{"验证通过?"} K -->|"Yes"| L["完成升级"] K -->|"No"| M["回滚:停止新进程
启动旧版本二进制"]

并行编译,不停服准备:

cd /opt/spdk
git fetch --tags

# 查看可用版本
git tag -l 'v25.*' | sort -V

# 以 v25.09 为例,换成你要升级的 tag
# 在不同目录编译新版本(不影响正在运行的旧版本)
sudo cp -a /opt/spdk /opt/spdk-v25.09
cd /opt/spdk-v25.09
git checkout v25.09
git submodule update --init --recursive
./configure --with-vhost --with-virtio --with-nvme-cuse
make -j$(nproc)

# 验证新版本编译成功
build/bin/spdk_tgt --version

# 把 systemd 指向新目录,但先不重启,等维护窗口再切
sudo sed -i 's|/opt/spdk/|/opt/spdk-v25.09/|g' /etc/systemd/system/spdk-tgt.service
sudo systemctl daemon-reload

SPDK 的 JSON-RPC 接口在大版本间可能有变更(参数重命名、方法废弃等)。升级前务必对比:

# 导出当前版本的 RPC 方法列表
cd /opt/spdk
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock rpc_get_methods | jq -r '.[]' | sort > /tmp/rpc_methods_old.txt

# 编译新版本后启动临时实例,导出其方法列表
cd /opt/spdk-v25.09
sudo build/bin/spdk_tgt -m 0x4 -s 512 -r /var/tmp/spdk_test.sock &
sleep 2
sudo python3 scripts/rpc.py -s /var/tmp/spdk_test.sock rpc_get_methods | jq -r '.[]' | sort > /tmp/rpc_methods_new.txt
sudo pkill -f "spdk_tgt.*spdk_test.sock"

# 对比差异
diff /tmp/rpc_methods_old.txt /tmp/rpc_methods_new.txt
# 回滚只需切换二进制路径
sudo systemctl stop spdk-tgt

# 把 ExecStart、rpc.py、init-bdevs.sh 的路径一起换回旧版本
sudo sed -i 's|/opt/spdk-v25.09|/opt/spdk|g' /etc/systemd/system/spdk-tgt.service
sudo systemctl daemon-reload
sudo systemctl start spdk-tgt

# 验证
sudo python3 /opt/spdk/scripts/rpc.py -s /var/tmp/spdk.sock framework_get_subsystems | jq '.[0]'

以下是生产建议,本文环境没有逐条实测;上生产前请先在测试环境验证。

在多路服务器上,NVMe 设备、网卡与 CPU/内存之间的 NUMA 拓扑直接影响延迟。核心原则:让 SPDK Reactor 绑定的 CPU 核心、hugepage 内存、NVMe 设备三者在同一 NUMA 节点。

# 1) 确认 NVMe 设备的 NUMA 节点
cat /sys/bus/pci/devices/0000:5e:00.0/numa_node
# 输出示例: 1

# 2) 确认 NUMA 节点对应的 CPU 核心
lscpu | grep -i numa
# NUMA node0 CPU(s): 0-15,32-47
# NUMA node1 CPU(s): 16-31,48-63

# 3) 选择 NUMA node 1 的核心用于 SPDK(如核心 16-19)
# 核心掩码计算: cores 16,17,18,19 -> 0xF0000
sudo build/bin/spdk_tgt -m 0xF0000 -s 4096 -r /var/tmp/spdk.sock

# 4) 确保 hugepage 分配在 node 1
echo 2048 | sudo tee /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages

先在 node0 上把 nr_hugepages 写 0 释放已分配的页,再往 node1 分配;两边写死的数字加起来不能超过物理内存。

角色 建议核心数 说明
Main Reactor 1 处理 JSON-RPC、管理面、Acceptor
I/O Reactor(NVMe-oF) N 每个 Reactor 处理一组连接,N 取决于并发连接数与目标吞吐
NVMe I/O 通道 与 Reactor 数一致 每个 Reactor 自动创建对应的 NVMe I/O Queue Pair
系统预留 2-4 留给 OS 内核、监控采集、SSH 等

单 Reactor 能跑多少 4K IOPS,取决于 bdev 类型、协议和 I/O 大小。malloc bdev 可以到几十万,NVMe-oF/TCP 往往先撞到网卡或 TCP 栈。不要照搬固定数字,用 fio 在目标环境压一遍再定核心数。连接数可以从每 Reactor 32-64 个开始观察,RDMA 下单 Reactor 能承载的吞吐更高,核心数可以少一些。

# TCP Transport 关键参数
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock nvmf_create_transport \
    -t TCP \
    -u 8192 \
    -m 8 \
    -c 4096 \
    -n 4096 \
    -q 128

# 参数说明:
# -u (io_unit_size)     : 单次 I/O 传输单元大小,建议 8192-131072
# -m (max_io_qpairs_per_ctrlr) : 每个控制器最大 I/O 队列对数
# -c (in_capsule_data_size)    : capsule 内嵌数据大小,小 I/O 场景可增大以减少 RTT
# -n (num_shared_buffers)      : 共享缓冲区数量,高并发场景需增大
# -q (max-queue-depth) : 每个队列最大未完成 I/O 数
cat <<'EOF' | sudo tee /etc/sysctl.d/61-spdk-network.conf
# TCP buffer sizes for NVMe-oF/TCP
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 87380 16777216

# Increase connection backlog
net.core.somaxconn = 4096
net.core.netdev_max_backlog = 8192

# Disable TCP slow start after idle (对 NVMe-oF 重要)
net.ipv4.tcp_slow_start_after_idle = 0
EOF

sudo sysctl -p /etc/sysctl.d/61-spdk-network.conf
# 1) JSON-RPC socket 权限限制(仅 root 和 spdk 组可访问)
sudo groupadd -r spdk 2>/dev/null || true
sudo chgrp spdk /var/tmp/spdk.sock
sudo chmod 0660 /var/tmp/spdk.sock

# 2) 如果要以非 root 用户运行,在 service 里配 User= 和所需 capabilities:
# CapabilityBoundingSet=CAP_SYS_ADMIN CAP_IPC_LOCK CAP_NET_ADMIN CAP_SYS_RAWIO
# AmbientCapabilities=CAP_SYS_ADMIN CAP_IPC_LOCK CAP_NET_ADMIN CAP_SYS_RAWIO
# 实际需要的 capability 随版本和驱动变化,先在测试环境确认。

# 3) 启用 NVMe-oF/TLS(PSK)
# PSK 是预先共享的密钥,不需要证书:
# python3 scripts/rpc.py nvmf_subsystem_add_host <subnqn> <hostnqn> --psk /etc/spdk/psk.txt
# python3 scripts/rpc.py nvmf_subsystem_add_listener <subnqn> -t tcp -a <ip> -s 4420 --secure-channel

停止顺序会影响数据安全。下面这个脚本按先断连接、再删 subsystem、最后停进程的顺序来:

#!/usr/bin/env bash
# /opt/spdk/conf/graceful-shutdown.sh
# 适用于本文的 malloc + NBD + NVMe-oF 拓扑。真实环境的 bdev 类型更多,删除步骤要按类型替换。

set -euo pipefail
RPC="/opt/spdk/scripts/rpc.py -s /var/tmp/spdk.sock"

echo "Step 1: 移除所有 NVMe-oF listener..."
$RPC nvmf_get_subsystems | jq -r '
    .[] | select(.nqn != "nqn.2014-08.org.nvmexpress.discovery") |
    .nqn as $nqn | .listeners[]? |
    "\($nqn) \(.trtype) \(.traddr) \(.trsvcid)"
' | while read -r sub trtype traddr trsvcid; do
    echo "  $sub $trtype $traddr $trsvcid"
    $RPC nvmf_subsystem_remove_listener "$sub" -t "$trtype" -a "$traddr" -s "$trsvcid" || true
done

echo "Step 2: 等待 initiator 断开或 I/O 排空..."
sleep 15

echo "Step 3: 删除 subsystem..."
$RPC nvmf_get_subsystems | jq -r '.[].nqn | select(. != "nqn.2014-08.org.nvmexpress.discovery")' |
while read -r sub; do
    $RPC nvmf_delete_subsystem "$sub"
done

echo "Step 4: 停止 NBD 映射..."
$RPC nbd_get_disks | jq -r '.[].nbd_device' 2>/dev/null |
while read -r nbd; do
    $RPC nbd_stop_disk "$nbd"
done

echo "Step 5: 删除 bdev(按实际类型替换命令)..."
$RPC bdev_malloc_delete Malloc0 2>/dev/null || true
# 真实 NVMe:$RPC bdev_nvme_detach_controller Nvme0
# RAID:$RPC bdev_raid_delete Raid0

echo "Step 6: 终止进程..."
$RPC spdk_kill_instance SIGTERM
现象 可能原因 排查命令 解决方案
configure 报 --with-nvme-cuse requires libfuse3 缺 libfuse3-dev dpkg -s libfuse3-dev sudo apt install libfuse3-dev
configure 报 autoreconf: not found 缺 autotools command -v autoreconf sudo apt install autoconf automake libtool
make 报 meson: not found 缺 DPDK 构建工具 command -v meson ninja sudo apt install meson ninja-build
make 报 CUnit/Basic.h: No such file 缺 CUnit 头文件 dpkg -s libcunit1-dev sudo apt install libcunit1-dev
EAL: Not enough memory available hugepage 实际分配不足 grep HugePages_ /proc/meminfo 先 drop caches 再重设,或增加内存
NVMe-oF 连接被拒 does not allow host ... at this address listener 写了 0.0.0.0,或 host 不在 allowlist target 日志、nvmf_get_subsystems listener 写具体 IP,或用 nvmf_subsystem_add_host
spdk_tgt 启动立即退出 hugepage 不足 grep HugePages_ /proc/meminfo 增加 vm.nr_hugepages
spdk_tgt 启动报 EAL: No free hugepages hugepage 被其他进程占用 sudo fuser /dev/hugepages/* 释放或增加 hugepage
spdk_tgt 报 Cannot bind to core X 指定核心不存在或被隔离 lscpu, cat /sys/devices/system/cpu/online 调整 -m 核心掩码
rpc.py 连接失败 socket 路径不对或进程未启动 ls -la /var/tmp/spdk*.sock 确认 -r 参数与进程状态
VFIO 绑定失败 IOMMU 未启用 dmesg | grep -i iommu 配置 GRUB 启用 IOMMU(见 3.5)
NVMe-oF 连接超时 防火墙、IP 不通或 listener 未添加 ss -tlnp | grep 4420 检查 listener 配置与网络
fio 报错 No such device NBD 设备未就绪 lsblk | grep nbd 确认 nbd_start_disk 已完成
延迟异常高 跨 NUMA 访问或 CPU 过载 numastat, Reactor busy 比率 调整 NUMA 亲和(见 7.1)
内存持续增长 hugepage 泄漏(bdev 未正确删除) grep HugePages_ /proc/meminfo 对比 确保所有 bdev/subsystem 正确清理
# 运行时调整日志级别(无需重启)
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock log_set_flag bdev
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock log_set_level DEBUG

# 查看当前日志标志
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock log_get_flags | jq

# 恢复默认
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock log_set_level NOTICE
sudo python3 scripts/rpc.py -s /var/tmp/spdk.sock log_clear_flag bdev
# 确保核心转储已启用
ulimit -c unlimited
echo '/tmp/core.%e.%p' | sudo tee /proc/sys/kernel/core_pattern

# 如果 spdk_tgt 崩溃,使用 gdb 分析
gdb /opt/spdk/build/bin/spdk_tgt /tmp/core.spdk_tgt.<pid>
# (gdb) bt        <- 查看调用栈
# (gdb) thread apply all bt  <- 所有线程调用栈
资源 地址 说明
SPDK 官方文档 https://spdk.io/doc/ API 参考、架构说明、示例代码
SPDK GitHub https://github.com/spdk/spdk 源码、Issues、Release Notes
SPDK JSON-RPC 参考 https://spdk.io/doc/jsonrpc.html 所有 RPC 方法的详细参数说明
NVMe 规范 https://nvmexpress.org/specifications/ NVMe Base / NVMe-oF 协议规范
DPDK 文档 https://doc.dpdk.org/ EAL、hugepage、VFIO 底层原理
fio 文档 https://fio.readthedocs.io/ I/O 基准测试工具完整参考
SPDK NVMe-oF https://spdk.io/doc/nvmf.html target/initiator 配置与协议细节
SPDK vhost https://spdk.io/doc/vhost.html QEMU/KVM virtio 后端
SPDK blobstore https://spdk.io/doc/blob.html blobstore/blobfs 设计与 API
Ceph NVMe-oF Gateway https://github.com/ceph/ceph-nvmeof 基于 SPDK 的 RBD NVMe-oF 网关
DAOS https://github.com/daos-stack/daos 用 SPDK 管理 NVMe 的 HPC/AI 存储
SPDK CSI https://github.com/spdk/spdk-csi Kubernetes CSI 驱动
nvme-cli man nvme 内核 NVMe 管理工具手册

以下脚本在任意 Debian 12 主机上从零完成 SPDK 编译、启动、malloc bdev 创建与 fio 验证,不需要 NVMe 硬件,不修改内核启动参数:

想在 Homelab 里验证,开一台 Debian 12 VM 就够:4 vCPU、8 GB 内存、32 GB 磁盘。nbd 和 nvme-tcp 使用 Debian 自带模块;如果 modprobe nbd 找不到模块,先装 linux-modules-extra-$(uname -r)。VFIO/NVMe 直连需要额外的 NVMe 和 IOMMU;虚拟磁盘上的 fio 数字只能验证功能,不能代表 NVMe 性能。

#!/usr/bin/env bash
# spdk-quickstart.sh - Debian 12 SPDK 最小可复现验证
# 用法: sudo bash spdk-quickstart.sh

set -euo pipefail

echo "=== [1/7] Install dependencies ==="
apt update
apt install -y git build-essential meson ninja-build pkg-config \
    python3 python3-venv python3-pip \
    python3-pyelftools libnuma-dev libssl-dev libaio-dev libfuse3-dev \
    uuid-dev libncurses-dev libcunit1-dev autoconf automake libtool \
    jq curl fio nasm

echo "=== [2/7] Clone and build SPDK v24.09 ==="
cd /opt
[ -d spdk ] || git clone https://github.com/spdk/spdk.git
cd spdk
git checkout v24.09
git submodule update --init --recursive
./configure
make -j$(nproc)

echo "=== [3/7] Configure hugepages ==="
sysctl -w vm.nr_hugepages=1024
if [ "$(awk '/HugePages_Total/{print $2}' /proc/meminfo)" -lt 1024 ]; then
    # 内存碎片或 page cache 占得多时,内核可能只分配一部分
    sync
    echo 3 > /proc/sys/vm/drop_caches
    sysctl -w vm.nr_hugepages=1024
fi
mkdir -p /dev/hugepages
mount -t hugetlbfs nodev /dev/hugepages 2>/dev/null || true
grep HugePages_ /proc/meminfo
if [ "$(awk '/HugePages_Total/{print $2}' /proc/meminfo)" -lt 1024 ]; then
    echo "hugepage 分配不足,需要 1024 页(2GB)" >&2
    exit 1
fi

echo "=== [4/7] Start spdk_tgt ==="
# -u 跳过 PCI,纯 malloc 验证不需要 VFIO
build/bin/spdk_tgt -m 0x1 -s 1024 -u -r /var/tmp/spdk.sock &
SPDK_PID=$!
sleep 2
echo "spdk_tgt PID: $SPDK_PID"

echo "=== [5/7] Create malloc bdev and NBD mapping ==="
python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_create -b Malloc0 512 4096
modprobe nbd nbds_max=16
python3 scripts/rpc.py -s /var/tmp/spdk.sock nbd_start_disk Malloc0 /dev/nbd0
sleep 1
lsblk | grep nbd0

echo "=== [6/7] Run fio benchmark ==="
fio --name=spdk-verify \
    --filename=/dev/nbd0 \
    --ioengine=libaio \
    --direct=1 \
    --rw=randrw \
    --rwmixread=70 \
    --bs=4k \
    --iodepth=32 \
    --numjobs=1 \
    --time_based \
    --runtime=10 \
    --group_reporting

echo "=== [7/7] Cleanup ==="
python3 scripts/rpc.py -s /var/tmp/spdk.sock nbd_stop_disk /dev/nbd0
python3 scripts/rpc.py -s /var/tmp/spdk.sock bdev_malloc_delete Malloc0
kill $SPDK_PID 2>/dev/null; wait $SPDK_PID 2>/dev/null || true

echo "=== SPDK quickstart verification PASSED ==="

这篇的内容可以分成三块。原理部分讲 SPDK 为什么快、快在哪里,以及 Reactor 模型怎么影响调优;部署部分给出一套在 Debian 12 上能复现的编译、hugepage、VFIO 和 systemd 步骤;验证部分从 malloc/NBD 一路到跨主机 NVMe-oF,4.5 节有 1Gb 链路上的实测数字。

如果你的应用已经跑在内核文件系统上,只是想让 I/O 快一点,先看 io_uring。要做 NVMe-oF target、vhost-user 后端,或者自己控制数据面,再考虑 SPDK。它带来的性能要用独占 CPU、hugepage、VFIO 和更高的运维复杂度来换,先把这些成本算清楚,再决定要不要上。