用LVM dm-cache + ZSwap + Zram 改造石头盘VPS

大类
CloudResource
Env
技术标签
云服务-白嫖
环境增强
优先级
Medium
状态
Maintaining
开始日期
Sep 10, 2025
最后更新
Sep 12, 2025
Public
Public

初见端倪

假如你有一台VPS:
  • 顺序读写速度其实还可以,但是读写太多经常会直接卡死整个整个系统几十秒
  • 内存大,6核32GB内存
怎么让这台VPS焕发活力?
 

磁盘缓存调优

虽然Linux的page cache已经给磁盘做了一层cache了,但是这个cache可能被其他应用的内存占用顶掉。
问题来了:
  • 应用由于写不进盘,数据堆在内存里,导致内存占用激增
  • 内存占用激增导致page cache被清除
  • page cache清除导致盘的io压力激增,进一步写不进盘
 
这下一根筋变两头堵了(
 
所以我们的思路是专门弄一片内存给磁盘做缓存,并区分需要缓存和不需要缓存的磁盘区域。

对比目前磁盘缓存的方案

  • bcache
    • ✔功能多,可调的参数也多
    • ✔甚至是基于PID做writeback时的带宽限制
    • ❌不支持动态开启和关闭
  • LVM dm-cache
    • ❌功能少
    • ✔与LVM整合,配置还算方便
    • ✔支持动态开关
 
考虑到希望动态开关,所以选了LVM,后来发现这是个灾难的选择(但是已经投入了太多时间改不了了)

内存block device方案选择

这些方案都是预期由ssd给hdd做cache,所以都要求cache是个block device,所以我们应当把内存转为block device。
有两种方案:brd vs zram
  • brd:简单粗暴,没有压缩
  • zram:高级,有压缩,但是默认没法被lvm识别
 
在VPS上,内存就是金钱,所以我们肯定选能压缩的zram :)

开始配置

LVM dm-cache可以动态切换,方便我们在线测试配置。
  • 先是直接想用zram0加成pv,结果发现lvm拒绝。查找一番后发现修改/etc/lvm/lvm.conf的 type = [ { “zram”, 255 } ] 即可
  • 虽然能加pv了,但是加不进pool,因为硬盘是512b block,而zram统一是4K block
  • 查询发现无法修改zram的block size,所以只好用losetup --sector-size再转为512b block
  • 最后终于可以挂上了
 

噩梦降临

重启后发现initramfs里面寄了,因为挂不上root。
显然是因为重启后内存盘消失了,所以自然会报错。这样就可以恢复正常,然后挂载到sysroot就行了
lvm lvconvert --uncache --yes vg0/rootlv lvm vgreduce --removemissing lvm vgchange -ay vg0
但是总不能每次启动都这样吧?需要好好配一下initramfs
 

搭上框架

干这种事情首选就是用dracut,因为模块化不用担心改错东西,大不了直接删掉我们的模块就能恢复原状。
装好dracut后先开rd.debug rd.shell rd.timeout=15 kernel cmdline参数开始initramfs的调试。
用GPT糊了一版,发现果然用不了。先是缺命令的问题,这个简单自己按照需求补inst_multiple就行了。主要还是运行时机的问题,脚本运行的时候,lvm模块已经在挂载了。
  • 一开始是pre-mount阶段,总是不执行我们脚本
  • 然后换成了pre-trigger,变成检测不到设备,然后等超时了才能检测到
 
notion image
 
  • pre-mount不行是因为lvm模块在Mainloop里面负责查找lvm并vgchange -ay让设备显示出来,如果他失败,他根本就不会到后面的stage,导致一直卡在mainloop。
  • pre-trigger失败是因为这个时候根本就没有到udev阶段,所有的设备都不可见。
 
所以,换成了initqueue/settled,用更高hook优先级的抢在lvm模块之前把lvm初始化好才可以。
脚本的流程:
  1. 清理残留的cache pool:首先关闭cache、删除可能创建到一半的pool、删除已经不存在的pv
  1. 创建zram:通过zramctl自动选一个未使用的zramX设备,然后配置
  1. 创建loop:用losetup -f --sector-size 512 创建一个正确blocksize的设备
  1. 用loop初始化pv,然后按照流程启动cache pool
 
中间还有一堆坑,比如如果不开busybox,那就缺各种命令,如果开了busybox,那么busybox里面已经有了的文件就不会再拷贝了,原因是:
  • dracut inst的逻辑:如果前面的模块已经放了对应文件,那么不会覆盖,([ -e $initdir/$XXX ] continue; )
  • 然而busybox用的ln_r安装命令却没有这个检查,永远会生效
所以只好复制一份busybox我们自己定制一下,如果initdir里面已经有了对应命令,则不执行ln_r 发

脑残操作

调试initramfs的时候,一不小心把整个rootlv删了。。
情景还原:
  • 没执行 lvconvert --uncache,直接执行了vgreduce --removemissing
  • 正常情况下,如果真的只是移除无关痛痒的pv,是不会让人手动确认的。这次突然确认了,但我想都没想就直接yes了。
  • 发现rootlv被整个删除了。。。
还好,经过查询,lvm每一次操作都会再/etc/lvm/archive下面留备份。
美滋滋尝试用vgcfgrestore恢复,结果这个命令在缺失对应设备的情况下拒绝恢复。。
或许造一个uuid相同的盘就可以了?还好archive是纯文本,直接看就可以看到uuid
notion image
pvcreate --uuid 9I2Clh-DnnE-Ozds-gk29-tUDI-NpSY-vbs6U2 \ --restorefile /etc/lvm/archive/vg0_xxxxxx.vg \ /dev/loop7
现在有了这个pv了,总可以成功恢复了吧?结果还是不行。vgcfgrestore还是提示pv1是missing状态,不能恢复。
心一横,直接sed -i把archive的 flags = ["MISSING"] 替换为空了,然后vgcfgrestore果然就好了(
 

最终脚本

  • /usr/lib/dracut/modules.d/70zram/module-setup.sh
#!/bin/bash check() { return 0; } depends() { echo lvm; return 0; } installkernel() { # 需要 zram、loop、dm-cache instmods zram loop dm-mod dm-cache dm-cache-smq } install() { # 工具:含 losetup、zramctl 和 LVM 全家桶 echo "PATH during install: $PATH" >>/tmp/dracut-debug.log ls -al /usr/sbin/losetup >>/tmp/dracut-debug.log 2>&1 command -v losetup >>/tmp/dracut-debug.log 2>&1 || echo "losetup not found" >>/tmp/dracut-debug.log inst_multiple zramctl losetup \ lvm lvs vgs pvs \ pvscan vgscan lvscan \ vgchange lvchange \ lvconvert \ vgreduce \ vgcfgbackup vgcfgrestore \ pvcreate \ dmsetup wipefs blockdev awk sed tee grep udevadm # 带上 zram/loop 的 LVM types drop-in(如果已创建) if [ -f /etc/lvm/lvm.conf ]; then inst_simple /etc/lvm/lvm.conf \ /etc/lvm/lvm.conf fi # 挂载 root 之前执行 inst_hook initqueue/settled 00 "$moddir/zram-cache.sh" }
  • /usr/lib/dracut/modules.d/70zram/zram-cache.sh
#!/bin/sh # 阶段:initqueue/settled(00) # 策略:一次触发只检查一次;成功或放弃时写锁,并激活 VG set -eu # ===== 日志 ===== LOG_DIR=/run/initramfs LOG_FILE=$LOG_DIR/70zram.log LOCK_DONE=$LOG_DIR/70zram.done mkdir -p "$LOG_DIR" exec > >(tee -a "$LOG_FILE") 2>&1 set -x ts() { date +%H:%M:%S 2>/dev/null || echo time; } log() { echo "[70zram $(ts)] $*"; } # 已完成就直接返回 [ -f "$LOCK_DONE" ] && { log "already done; skip"; exit 0; } # “完成并激活”帮助函数 finish_and_activate() { touch "$LOCK_DONE" lvm vgscan --mknodes || true lvm vgchange -ay || true } # ===== 参数 ===== VG="vg0"; LV="rootlv" for tok in $(cat /proc/cmdline); do case "$tok" in rd.lvm.lv=*) p="${tok#rd.lvm.lv=}"; VG="${p%%/*}"; LV="${p#*/}";; esac done WAIT_VG="${VG}" for tok in $(cat /proc/cmdline); do case "$tok" in rd.zram.waitvg=*) WAIT_VG="${tok#rd.zram.waitvg=}";; esac done CACHE_SZ="${CACHE_SZ:-2G}" META_SZ="${META_SZ:-128M}" CACHE_LV="${CACHE_LV:-cache_data}" META_LV="${META_LV:-cache_meta}" log "Target: ${VG}/${LV}; wait-vg=${WAIT_VG}; meta=${META_LV}:${META_SZ}" # ===== A) 只检查一次:VG 是否可见,不可见就等下一轮 ===== lvm vgscan --mknodes || true if ! lvm vgdisplay "$WAIT_VG" >/dev/null 2>&1; then log "VG ${WAIT_VG} not visible yet; next settled will retry." exit 0 fi log "VG ${WAIT_VG} visible." lvm lvremove "${VG}/${CACHE_LV}" || true lvm lvremove "${VG}/${META_LV}" || true lvm lvconvert --yes --uncache "$VG/$LV" || true lvm vgreduce --removemissing "$VG" || true # 已经是 cached:完成并激活 if lvm lvs --noheadings -o lv_attr "$VG/$LV" 2>/dev/null | grep -q "C"; then log "${VG}/${LV} already cached; mark done & activate." finish_and_activate exit 0 fi # ===== B) zram -> loop(512B) ===== modprobe zram || true command -v zramctl >/dev/null 2>&1 || { log "zramctl missing; done & activate without cache."; finish_and_activate; exit 0; } ZDEV="$(zramctl -f -s "$CACHE_SZ" -a lz4 2>/dev/null)" || { log "zramctl failed; done & activate without cache."; finish_and_activate; exit 0; } log "zram: ${ZDEV}" modprobe loop || true command -v losetup >/dev/null 2>&1 || { log "losetup missing; done & activate without cache."; finish_and_activate; exit 0; } LOOPDEV="$(losetup --sector-size=512 -fP --show "$ZDEV" 2>/dev/null)" || { log "losetup failed; done & activate without cache."; finish_and_activate; exit 0; } log "loop on zram: ${LOOPDEV}" ln -sf "$(basename "$LOOPDEV")" /dev/cachebrd udevadm settle || true [ -b "$LOOPDEV" ] || { log "loop not block; done & activate without cache."; finish_and_activate; exit 0; } SZ="$(blockdev --getsize64 "$LOOPDEV" 2>/dev/null || echo 0)" [ "$SZ" -gt 0 ] || { log "loop size=0; done & activate without cache."; finish_and_activate; exit 0; } log "loop size: $SZ bytes" # ===== C) PV/VG/LV/cache 幂等构建 ===== wipefs -a "$LOOPDEV" || true if ! lvm pvcreate -ff -y "$LOOPDEV"; then log "pvcreate failed (likely existing PV); continue." fi lvm vgscan --mknodes || true if ! lvm vgdisplay "$VG" >/dev/null 2>&1; then log "VG $VG disappeared; done & activate without cache." finish_and_activate exit 0 fi if ! lvm vgextend "$VG" "$LOOPDEV"; then log "vgextend failed (already present?); continue." fi # meta LV 固定大小 if ! lvm lvs "$VG/$META_LV" >/dev/null 2>&1; then log "create $VG/$META_LV on $LOOPDEV size $META_SZ" lvm lvcreate -n "$META_LV" -L "$META_SZ" "$VG" "$LOOPDEV" fi # data LV 吃满该 PV if ! lvm lvs "$VG/$CACHE_LV" >/dev/null 2>&1; then log "create $VG/$CACHE_LV on $LOOPDEV use 100%FREE" lvm lvcreate -n "$CACHE_LV" -l 100%FREE "$VG" "$LOOPDEV" fi # 组 cache-pool(幂等) if ! lvm lvs -o lv_attr --noheadings "$VG/$CACHE_LV" 2>/dev/null | grep -q "C"; then log "convert $VG/$CACHE_LV + $VG/$META_LV to cache-pool" lvm lvconvert --yes --type cache-pool --poolmetadata "$VG/$META_LV" "$VG/$CACHE_LV" fi # 绑定 rootlv(writethrough) log "attach cache to $VG/$LV (writethrough)" if ! lvm lvconvert --yes --type cache --cachepool "$VG/$CACHE_LV" --cachemode writeback "$VG/$LV"; then log "attach failed; done & activate without cache." finish_and_activate exit 0 fi log "cache attached; done & activate." finish_and_activate exit 0
  • /usr/lib/dracut/modules.d/99busybox_post/module-setup.sh
#!/bin/bash # called by dracut check() { require_binaries busybox || return 1 return 255 } # called by dracut depends() { return 0 } # called by dracut install() { local _i _path _busybox local _progs=() _busybox=$(find_binary busybox) inst "$_busybox" /usr/bin/busybox for _i in $($_busybox --list); do [[ ${_i} == busybox ]] && continue _progs+=("${_i}") done echo busybox >>/tmp/dracut-debug.log 2>&1 for _i in "${_progs[@]}"; do _path=$(find_binary "$_i") [ -z "$_path" ] && continue [ -e "$initdir/$_path" ] && continue ln_r /usr/bin/busybox "$_path" done }
  • /usr/lib/dracut/modules.d/99misty-utils/module-setup.sh
#!/bin/bash check() { return 0 } depends() { echo bash echo busybox_post return 0 } install() { for cmd in lsblk blkid fdisk sfdisk cfdisk parted wipefs \ mkfs mkfs.ext4 mkfs.xfs mkfs.vfat mkfs.btrfs \ mount umount losetup dd hexdump \ lvm pvcreate pvscan vgcreate vgscan vgchange lvcreate lvremove lvs vgs pvs \ tee wc sort uniq grep find xargs \ vim nano awk \ pv \ ; do inst $(command -v $cmd) || true done }

关机时的配合writeback脚本

  • /etc/systemd/system/lvm-uncache.service
[Unit] Description=Flush and uncache all dm-cache LVs before shutdown DefaultDependencies=no Before=shutdown.target umount.target After=local-fs.target [Service] Type=oneshot RemainAfterExit=yes ExecStart=/bin/true ExecStop=/usr/local/sbin/lvm-uncache-all.sh [Install] WantedBy=shutdown.target
  • /usr/local/sbin/lvm-uncache-all.sh
#!/bin/bash # Flush and uncache all dm-cache LVs before shutdown set -euo pipefail echo "[lvm-uncache] scanning for cached volumes..." # 找出所有处于 cached 状态的 LV CACHED_LVS=$(lvs --noheadings -o lv_name,vg_name,lv_attr | awk '$3 ~ /^C/ {print $2"/"$1}') if [[ -z "$CACHED_LVS" ]]; then echo "[lvm-uncache] no cached LV found." exit 0 fi for lv in $CACHED_LVS; do echo "[lvm-uncache] uncache $lv ..." lvconvert --yes --uncache "$lv" || echo "[lvm-uncache] failed: $lv" done echo "[lvm-uncache] done."
 

内存配置调优

光有磁盘缓存可不行,想要拉满使用,还是应该配置swap。
参考目前的各种方案选择,选择了zswap + zram swap + hdd swap方案。

方案原理

zswap是第一层,它本身就可以工作的很好,我选择将1/4的内存都划分给zswap管理。
然而zswap一旦被击穿,由于我们的盘实在是太石头了,会导致长时间的卡死(因为zswap被打穿的时候,必然也是IO负载非常大的时候)
所以这就需要第二层zram swap。zram swap作为高priority的swapfile会优先被使用,降低突然一下就被打到hdd,但是却只需要读很少的page的情况。
最后就是最底层的hdd了, 这是作为last resort,否则应用就要oom了。

方案配置

  • zswap很好配,直接在kernel cmdline里面选就可以了。
  • zram swap需要自己用systemd想办法配,
    • 我交给gpt写的tmd上来就直接hardcode zram0,导致我的cache pool直接报错
    • 还好一开始测试的时候写的cache mode不是writeback而是writethrough,否则我的盘就整个都废了
    • 调教了一下gpt写出来的脚本就对了
    • /etc/systemd/system/zram-swap.service
    • [Unit] Description=Setup zram swap via script DefaultDependencies=no Before=swap.target After=local-fs.target [Service] Type=oneshot ExecStart=/usr/local/bin/setup-zram-swap.sh [Install] WantedBy=swap.target
    • /usr/local/bin/setup-zram-swap.sh
    • #!/bin/bash # setup-zram-swap.sh # 指定 zram swap 大小和优先级,支持重复调用时跳过 set -euo pipefail SIZE_GB=8 # swap 大小 (GB) PRIORITY=200 # swap 优先级 LABEL=zram_swap # 如果已有对应 label 的 swap 挂载,就直接退出 if swapon --show=NAME,LABEL | grep -q "$LABEL"; then echo "[*] Swap with label '$LABEL' is already active, skip." swapon --show --output exit 0 fi # 加载模块 modprobe zram # 找到一个空闲的 zram 设备并配置 dev=$(zramctl --find --algorithm zstd --size ${SIZE_GB}G) # 初始化为 swap 并启用 mkswap -L "$LABEL" "$dev" swapon -p "$PRIORITY" "$dev" echo "[+] Created $SIZE_GB GB zram swap on $dev with priority $PRIORITY" swapon --show --output
  • hdd swap直接用fstab自动挂就好啦
 

方案实测

  • dm-cache:2G zram
  • zswap:zswap.enabled=1 zswap.compressor=lz4 zswap.max_pool_percent=25
  • zram swap:8G,priority 200
  • hdd swap:8G,priority 10
 

磁盘缓存效果

业务上对数据做了冷热分离,root上配了cache pool,downcache需要高频写入的则直通
TARGET SOURCE FSTYPE OPTIONS / /dev/mapper/vg0-rootlv ext4 rw,relatime,errors=remount-ro ├─/home/misty/download/cache /dev/mapper/vg0-downcache ext4 rw,noatime,nodiratime,nobarrier
实测rootlv的缓存命中率极高,保证了执行命令、写入日志都不会卡死,只有写入延迟不敏感的业务跑在外面。
notion image
 

内存优化效果

实际使用时,swapon信息如下
# root @ mistytest in ~ [8:27:44] C:130 $ swapon NAME TYPE SIZE USED PRIO /dev/zram1 partition 8G 1011.4M 200 /dev/dm-5 partition 8G 208M 10
可以看出zswap扛住了绝大部分的请求,zram进一步收尾,最终落到hdd上的非常少

综合效果

观察12小时中的几个低谷,可以看到D掉的时间极少,频率约为2.5小时一次,相比于配置前的长时间卡顿效果非常好
notion image
命令执行、ls也比之前顺畅许多