普通视图

发现新文章,点击刷新页面。
昨天以前首页

JuiceFS 元数据引擎五探:元数据备份与恢复(2024)

2024年10月10日 08:00

Fig. TiKV backup with different CLI tools (and their problems).

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 JuiceFS 元数据备份方式

再复习下 JuiceFS 架构,如下图所示:

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

JuiceFS 的元数据都存储在元数据引擎(例如,TiKV)里, 因此元数据的备份有两种实现方式:

  1. 从上层备份:JuiceFS client 扫描 volume,将 volume 内所有元数据备份;
  2. 元数据引擎(例如 TiKV)备份。

下面分别看看这两种方式。

2 JuiceFS 自带方式(volume 级别)

2.1 juicefs dump 手动备份 volume metadata

对指定 volume 进行备份,

$ juicefs dump tikv://ip:2379/foo-dev foo-dev-dump.json
<INFO>: Meta address: tikv://<ip>:2379/foo-dev [interface.go:406]
<WARNING>: Secret key is removed for the sake of safety [tkv.go:2571]
           Scan keys count: 357806 / 357806 [===========================]  done
      Dumped entries count: 122527 / 122527 [===========================]  done
<INFO>: Dump metadata into dump succeed [dump.go:76]

生成的是一个 JSON 文件,包含了 volume 的所有元数据信息,

{  "Setting": {
    "Name": "foo-dev",
    "UUID": "ca95c258",
    "Storage": "OSS",
    "Bucket": "http://<url>",
    "AccessKey": "ak",
    "BlockSize": 4096,
    "Capacity": 0,
    "Inodes": 0,
    "MetaVersion": 0,
    "MinClientVersion": "",
    "MaxClientVersion": "",
  },
  "Counters": {
    "usedSpace": 6164512768,
    "usedInodes": 5010,
    "nextInodes": 10402,
    "nextChunk": 25001,
    "nextSession": 118,
  },
  "FSTree": {
    "attr": {"inode":1,"type":"directory","mode":511,"atime":1645791488,"mtime":1652433235,"ctime":1652433235,"mtimensec":553010494,"ctimensec":553010494,"nlink":2,"length":0},
    "xattrs": [{"name":"lastBackup","value":"2024-05-30T13:50:25+08:00"}],
    "entries": {
      "001eb8b": {
        "attr": {...},
        "chunks": [{"index":0,"slices":[{"chunkid":15931,"size":32,"len":32}]}]
        ...
      }
   }
  },
}

其中,volume 中的所有文件和目录信息都描述在 FSTree 字段中。

2.2 juicefs mount --backup-meta <duration> 自动备份

juicefs client 默认会自动备份 volume 的元数据,

  • 备份间隔通过 --backup-meta {duration} 选项控制,默认 1h
  • 备份文件在对象存储的 meta 特殊目录中,该目录在挂载点中不可见,用对象存储的文件浏览器可以查看和管理,
  • 多 client 挂载同一个 volume 也不会发生备份冲突,因为 JuiceFS 维护了一个全局的时间戳,确保同一时刻只有一个客户端执行备份操作,但是,
  • 当文件数太多(默认达到 100w)且备份频率为默认值 1h 时,为避免备份开销太大,JuiceFS 会自动停止元数据备份,并打印相应的告警。

2.3 juicefs load 从元数据备份文件恢复

$ juicefs load tikv://<ip>:2379/foo-dev-new foo-dev-dump.json

2.4 限制及问题

根据官方文档,以上两种方式都有一些限制或问题:

  • 导出过程中如果业务仍在写入,导出的文件可能不可用。如果对一致性有更高要求,需要在导出前停写。
  • 对规模较大的 volume,直接在线上进行导出可能会影响业务稳定性。

另外,以上方式都是 volume 级别的备份,如果要备份整个 JuiceFS 集群,需要逐个 volume 备份,比较麻烦。 下面再看看直接从元数据引擎进行备份的方式。

3 从 TiKV 层面对 JuiceFS 元数据进行备份

这里假设 JuiceFS 的元数据引擎是 TiKV。

3.1 TiKV backup/restore 原理

从上层来说,很简单:

  1. 发请求给 TiKV 集群的管理者 PD,让它对集群的所有数据进行备份;

  2. 接下来,PD 会发请求给集群的所有 TiKV 节点,通知它们各自进行备份

    • TiKV 是按 region 进行多副本存储的,因此只需要一个副本进行备份就行了,
    • 在当前的设计里面就是让每个 region 的 leader 副本进行备份,
  3. TiKV region leaders 把这个 region 内的数据写到指定位置。可以是本地磁盘或分布式存储。

3.2 备份工具 TiDB br 和 TiKV tikv-br

理论上,有两个工具可能实现以上效果,它们分别来自 TiDB 和 TiKV 社区,

Fig. TiKV backup with different CLI tools (and their problems).

  1. br:以前是个独立项目(图中 A.1),后来合到 tidb 仓库里了(图中 B.1),

    这个工具主要是给 TiDB 备份用的(虽然底层备份的是 TiDB 的 TiKV), 所以需要一些 TiDB 知识(上下文),例如 db/table 都是 TiDB 才有的概念。 理论上,它也能备份独立部署的 TiKV 集群(“不依赖 TiDB 的 TiKV”), 所以加了 raw/txn 支持,但不是 TiDB 社区的重点,所以目前还是 experimental 特性,且用下来有 bug。

  2. tikv-br:是个独立项目,应该是当时 TiKV 作为独立项目推进时,想搞一个配套的独立备份工具, 但目前看起来跟 TiKV 社区一样已经不活跃了,它也没法对 txn 进行备份(JuiceFS 用的 txnkv 接口)。

至少对于 5.x TiKV 集群,测试下来以上哪个工具都无法完成备份: 有的工具备份和恢复都提示完成,看起来是成功的,但实际上是失败的,JuiceFS 挂载时才能发现。

最后,我们是基于目前(2024.09)最新的 TiDB br,修改了两个地方,才成功完成 TiKV 的备份与恢复。

3.3 基于 TiDB br 对 JuiceFS TiKV 集群进行备份与恢复的步骤

之所以要强调 “JuiceFS TiKV 集群”,是因为 JuiceFS 用的 txnkv 接口,这个比较特殊; 如果是 rawkv 接口,那 tikv 自带的备份工具 tikv-br 也许就能用了(没测过)。

  1. (可选)关闭 TiKV MVCC GC;
  2. br 执行备份,

    br-dev 是我们基于最新 master(202409)改过的版本。

     $ ./br-dev backup txn \
             --ca /tmp/pki/root.crt --cert /tmp/pki/pd.crt --key /tmp/pki/pd.key \
             --pd https://$pd_addr \
             --s3.endpoint $s3_addr \
             --storage $storage_path \
             --log-file /var/log/tikv/br.log \
             --ratelimit $bw_limit_per_node \
             --log-level debug \
             --check-requirements=false
    

    可以设置限速等参数,避免备份占用的 CPU/Memory/DiskIO/… 过大。根据 db size 等等因素,备份的耗时是可估算的,下面拿一个真实集群的备份为例:

    • 每个 TiKV 的 DB size:监控能看到,一般每个节点的 DB size 都差不太多,这里是 25GB per TiKV node;
    • MVCC 保留了数据的多个版本:假设平均保留两个版本,那就是 DB size * 2
    • 限速带宽:设置为 30MB/s,这个带宽不算大,不会是磁盘和网络瓶颈,因此可以全速运行

    根据以上参数,估算耗时:25GB * 2 / 30MBps = 1700s = 28min

    Fig. TiKV backup resource usage with br --ratelimit=30MB/s.

    可以看到跟预估的差不多。资源销毁方面:

    • CPU 利用率比平时翻倍
    • 其中两台机器的 CPU 数量比较少,所以会比其他节点更明显。
  3. 检查备份

    如果是备份到 S3,可以用 s3cmd 或 web 控制台查看,

     $ s3cmd du s3://{bucket}/<backup>/
     295655971082   18513 objects s3://{bucket}/<backup>/
    

    290GB 左右,比监控看到的 DB size 大一倍,因为保留了 MVCC 多版本。 大多少倍与 MVCC GC 间隔有密切关系, 比如写或更新很频繁的场景,1h 和 3h 的 MVCC 数据量就差很多了。

  4. br 恢复:将备份数据恢复到一个新的 JuiceFS TiKV 集群,

     $ ./br-dev restore txn \
             --ca /tmp/pki/root.crt --cert /tmp/pki/pd.crt --key /tmp/pki/pd.key \
             --pd https://$pd_addr \
             --s3.endpoint $s3_addr \
             --storage $storage_path \
             --log-file /var/log/tikv/br.log \
             --ratelimit $bw_limit_per_node \
             --log-level debug \
             --check-requirements=false
    

    可能的问题:ratelimit 好像不起作用,全速恢复,网络带宽打的很高。

  5. JuiceFS client 挂载,验证恢复成功

    用 juicefs 挂载目录,指定新 TiKV 集群的 PD 地址,

     $ juicefs mount tikv://<new-pd-ip>:2379/<volume name> /tmp/test
    
     $ cd /tmp/test && ls
     # 原来 volume 内的文件都在
    

3.4 TiDB br 备份逻辑

感兴趣的可以看看 br 源码的备份逻辑,

3.4.1 RunBackupTxn()

// tidb br/pkg/task/backup_txn.go

// RunBackupTxn starts a backup task inside the current goroutine.
func RunBackupTxn(c context.Context, g glue.Glue, cmdName string, cfg *TxnKvConfig) error {
    mgr := NewMgr(ctx, g, cfg.PD, cfg.TLS, GetKeepalive(&cfg.Config), cfg.CheckRequirements, false)
    client := backup.NewBackupClient(ctx, mgr)

    backupRanges := make([]rtree.Range, 0, 1)

    // current just build full txn range to support full txn backup
    minStartKey := []byte{}
    maxEndKey := []byte{}
    backupRanges = append(backupRanges, rtree.Range{
        StartKey: minStartKey,
        EndKey:   maxEndKey,
    })

    // Backup
    req := backuppb.BackupRequest{
        ClusterId:        client.GetClusterID(),
        StartVersion:     0,
        EndVersion:       client.GetCurrentTS(ctx), // gets a new timestamp (TSO) from PD
        RateLimit:        cfg.RateLimit,
        Concurrency:      cfg.Concurrency,
        StorageBackend:   client.GetStorageBackend(),
        IsRawKv:          false,
    }

    ranges, schemas, policies := client.BuildBackupRangeAndSchema(mgr.GetStorage(), cfg.TableFilter, backupTS, isFullBackup(cmdName))

    // StartWriteMetasAsync writes four kind of meta into backupmeta.
    // 1. file
    // 2. schema
    // 3. ddl
    // 4. rawRange( raw kv )
    metaWriter := metautil.NewMetaWriter(client.GetStorage(), metautil.MetaFileSize, false, metautil.MetaFile, &cfg.CipherInfo)
    metaWriter.StartWriteMetasAsync(ctx, metautil.AppendDataFile)

    // Start TiKV backup
    client.BackupRanges(ctx, backupRanges, req, 1, nil, metaWriter, progressCallBack)

    // Backup has finished
    metaWriter.Update(func(m *backuppb.BackupMeta) {
        m.StartVersion = req.StartVersion
        m.EndVersion = req.EndVersion
        m.IsRawKv = false
        m.IsTxnKv = true
        m.ClusterId = req.ClusterId
        m.ClusterVersion = mgr.GetClusterVersion(ctx)
        m.BrVersion = brVersion
        m.ApiVersion = client.GetApiVersion()
    })
    metaWriter.FinishWriteMetas(ctx, metautil.AppendDataFile)
    metaWriter.FlushBackupMeta(ctx)
}

几点说明:

  1. KV 的 backup range 是全量(start/end key 都是空);
  2. MVCC 的 start/end version 分别是 0 和当前 PD 最新的 TSO;

3.4.2 调用栈

BackupRanges // make a backup of the given key ranges.
  |-mainBackupLoop := &MainBackupLoop
  |     BackupSender:       &MainBackupSender{},
  |     BackupReq:          request,
  |     Concurrency:        concurrency,
  |     GlobalProgressTree: &globalProgressTree,
  |     ReplicaReadLabel:   replicaReadLabel,
  |     GetBackupClientCallBack: func(ctx , storeID uint64, reset bool) (backuppb.BackupClient, error) {
  |         return bc.mgr.GetBackupClient(ctx, storeID)
  |     },
  | }
  |-bc.RunLoop(ctx, mainBackupLoop) // infinite loop to backup ranges on all tikv stores
      |-for {
          inCompleteRanges = iter.GetIncompleteRanges() // 还未完成备份的 key 范围
          loop.BackupReq.SubRanges = getBackupRanges(inCompleteRanges)
          allStores := bc.getBackupStores(mainCtx, loop.ReplicaReadLabel)
          for _, store := range allStores {
            cli := loop.GetBackupClientCallBack(mainCtx, storeID, reset)
            loop.SendAsync(round, storeID, loop.BackupReq, loop.Concurrency, cli, ch, loop.StateNotifier)
                  |-go startBackup(storeID, request, cli, concurrency, respCh)
                        |-for i, req := range reqs {
                            doSendBackup(ectx, backupCli, bkReq, ...)
                              |-ctx, timerecv := StartTimeoutRecv(pctx, TimeoutOneResponse)
                              |-bCli := client.Backup(ctx, &req) // protobuf grpc method
                              |-for {
                              |-    resp := bCli.Recv()
                              |-    timerecv.Refresh()
                              |-    respFn(resp)
                              |-}
          }
        }

3.4.3 tikv-server 备份代码

// components/backup/src/service.rs

impl<H> Backup for Service<H>
{
    fn backup(
        req: BackupRequest,
        mut sink: ServerStreamingSink<BackupResponse>,
    ) {
        if let Err(status) = match Task::new(req, tx) {
            Ok((task, c)) => {
                self.scheduler.schedule(task)
            }
        }

        let send_task = async move {
            let mut s = rx.map(|resp| Ok((resp, WriteFlags::default())));
            sink.send_all(&mut s).await?;
        }

        ctx.spawn(send_task);
    }
}
/// Backup Task.
pub struct Task {
    request: Request,
    pub(crate) resp: UnboundedSender<BackupResponse>,
}

// components/backup/src/endpoint.rs
impl Task {
    /// Create a backup task based on the given backup request.
    pub fn new(
        req: BackupRequest,
        resp: UnboundedSender<BackupResponse>,
    ) -> Result<(Task, Arc<AtomicBool>)> {
        let speed_limit = req.get_rate_limit();
        let limiter = Limiter::new(if speed_limit > 0  else f64::INFINITY });
        let cf = name_to_cf(req.get_cf())

        let task = Task {
            request: Request {
                start_key: req.get_start_key().to_owned(),
                end_key: req.get_end_key().to_owned(),
                sub_ranges: req.get_sub_ranges().to_owned(),
                start_ts: req.get_start_version().into(),
                end_ts: req.get_end_version().into(),
                backend: req.get_storage_backend().clone(),
                limiter,
                is_raw_kv: req.get_is_raw_kv(),
                dst_api_ver: req.get_dst_api_version(),
                cf,
                replica_read: req.get_replica_read(),
                resource_group_name: .get_resource_group_name().to_owned(),
                }),
            },
            resp,
        };
    }
}

// components/backup/src/endpoint.rs
BackupRanges -> BackupWriterBuilder -> S3Uploader

self.writer.put(&data_key_write, value) -> s3 put

参考资料

  1. 官方文档:元数据备份和恢复, juicefs.com

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎四探:元数据大小评估、限流与限速的设计思考(2024)

2024年9月22日 08:00

Fig. JuiceFS upload/download data bandwidth control.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 元数据存储在哪儿?文件名到 TiKV regions 的映射

1.1 pd-ctl region 列出所有 region 信息

$ pd-ctl.sh region | jq .
{
  "regions": [
    {
      "id": 11501,
      "start_key": "6161616161616161FF2D61692D6661742DFF6261636B7570FD41FFCF68030000000000FF4900000000000000F8",
      "end_key": "...",
      "epoch": {
        "conf_ver": 23,
        "version": 300
      },
      "peers": [
        {
          "id": 19038,
          "store_id": 19001,
          "role_name": "Voter"
        },
        ...
      ],
      "leader": {
        "id": 20070,
        "store_id": 20001,
        "role_name": "Voter"
      },
      "written_bytes": 0,
      "read_bytes": 0,
      "written_keys": 0,
      "read_keys": 0,
      "approximate_size": 104,
      "approximate_keys": 994812
    },
  ]
}

1.2 tikv-ctl region-properties 查看 region 属性详情

$ ./tikv-ctl.sh region-properties -r 23293
mvcc.min_ts: 438155461254971396
mvcc.max_ts: 452403302095650819
mvcc.num_rows: 1972540
mvcc.num_puts: 3697509
mvcc.num_deletes: 834889
mvcc.num_versions: 4532503
mvcc.max_row_versions: 54738
num_entries: 4549844
num_deletes: 17341
num_files: 6
sst_files: 001857.sst, 001856.sst, 002222.sst, 002201.sst, 002238.sst, 002233.sst
region.start_key: 6e6772...
region.end_key: 6e6772...
region.middle_key_by_approximate_size: 6e6772...

1.3 tikv-ctl --to-escaped:从 region 的 start/end key 解码文件名范围

如上,每个 region 都会有 start_key/end_key 两个属性, 这里面编码的就是这个 region 内存放是元数据的 key 范围。我们挑一个来解码看看:

$ tikv-ctl.sh --to-escaped '6161616161616161FF2D61692D6661742DFF6261636B7570FD41FFCF68030000000000FF4900000000000000F8'
aaaaaaaa\377-ai-fat-\377backup\375A\377\317h\003\000\000\000\000\000\377I\000\000\000\000\000\000\000\370

再 decode 一把会更清楚:

$ tikv-ctl.sh --decode 'aaaaaaaa\377-ai-fat-\377backup\375A\377\317h\003\000\000\000\000\000\377I\000\000\000\000\000\000\000\370'
aaaaaaaa-ai-fat-backup\375A\317h\003\000\000\000\000\000I

对应的是一个名为 aaaaaaa-ai-fat-backup 的 volume 内的一部分元数据。

1.4 filename -> region:相关代码

这里看一下从文件名映射到 TiKV region 的代码。

PD 客户端代码,

    // GetRegion gets a region and its leader Peer from PD by key.
    // The region may expire after split. Caller is responsible for caching and
    // taking care of region change.
    // Also, it may return nil if PD finds no Region for the key temporarily,
    // client should retry later.
    GetRegion(ctx , key []byte, opts ...GetRegionOption) (*Region, error)

// GetRegion implements the RPCClient interface.
func (c *client) GetRegion(ctx , key []byte, opts ...GetRegionOption) (*Region, error) {
    options := &GetRegionOp{}
    for _, opt := range opts {
        opt(options)
    }
    req := &pdpb.GetRegionRequest{
        Header:      c.requestHeader(),
        RegionKey:   key,
        NeedBuckets: options.needBuckets,
    }
    serviceClient, cctx := c.getRegionAPIClientAndContext(ctx, options.allowFollowerHandle && c.option.getEnableFollowerHandle())
    resp := pdpb.NewPDClient(serviceClient.GetClientConn()).GetRegion(cctx, req)
    return handleRegionResponse(resp), nil
}

PD 服务端代码,

func (h *regionHandler) GetRegion(w http.ResponseWriter, r *http.Request) {
    rc := getCluster(r)
    vars := mux.Vars(r)
    key := url.QueryUnescape(vars["key"])
    // decode hex if query has params with hex format
    paramsByte := [][]byte{[]byte(key)}
    paramsByte = apiutil.ParseHexKeys(r.URL.Query().Get("format"), paramsByte)

    regionInfo := rc.GetRegionByKey(paramsByte[0])
    b := response.MarshalRegionInfoJSON(r.Context(), regionInfo)

    h.rd.Data(w, http.StatusOK, b)
}

// GetRegionByKey searches RegionInfo from regionTree
func (r *RegionsInfo) GetRegionByKey(regionKey []byte) *RegionInfo {
    region := r.tree.search(regionKey)
    if region == nil {
        return nil
    }
    return r.getRegionLocked(region.GetID())
}

返回的是 region info,

// RegionInfo records detail region info for api usage.
// NOTE: This type is exported by HTTP API. Please pay more attention when modifying it.
// easyjson:json
type RegionInfo struct {
    ID          uint64              `json:"id"`
    StartKey    string              `json:"start_key"`
    EndKey      string              `json:"end_key"`
    RegionEpoch *metapb.RegionEpoch `json:"epoch,omitempty"`
    Peers       []MetaPeer          `json:"peers,omitempty"` // https://github.com/pingcap/kvproto/blob/master/pkg/metapb/metapb.pb.go#L734

    Leader            MetaPeer      `json:"leader,omitempty"`
    DownPeers         []PDPeerStats `json:"down_peers,omitempty"`
    PendingPeers      []MetaPeer    `json:"pending_peers,omitempty"`
    CPUUsage          uint64        `json:"cpu_usage"`
    WrittenBytes      uint64        `json:"written_bytes"`
    ReadBytes         uint64        `json:"read_bytes"`
    WrittenKeys       uint64        `json:"written_keys"`
    ReadKeys          uint64        `json:"read_keys"`
    ApproximateSize   int64         `json:"approximate_size"`
    ApproximateKeys   int64         `json:"approximate_keys"`
    ApproximateKvSize int64         `json:"approximate_kv_size"`
    Buckets           []string      `json:"buckets,omitempty"`

    ReplicationStatus *ReplicationStatus `json:"replication_status,omitempty"`
}

// GetRegionFromMember implements the RPCClient interface.
func (c *client) GetRegionFromMember(ctx , key []byte, memberURLs []string, _ ...GetRegionOption) (*Region, error) {
    for _, url := range memberURLs {
        conn := c.pdSvcDiscovery.GetOrCreateGRPCConn(url)
        cc := pdpb.NewPDClient(conn)
        resp = cc.GetRegion(ctx, &pdpb.GetRegionRequest{
            Header:    c.requestHeader(),
            RegionKey: key,
        })
        if resp != nil {
            break
        }
    }

    return handleRegionResponse(resp), nil
}

2 JuiceFS 集群规模与元数据大小(engine size)

2.1 二者的关系

一句话总结:并没有一个线性的关系

2.1.1 文件数量 & 平均文件大小

TiKV engine size 的大小,和集群的文件数量每个文件的大小都有关系。 例如,同样是一个文件,

  1. 小文件可能对应一条 TiKV 记录;
  2. 大文件会被拆分,对应多条 TiKV 记录。

2.1.2 MVCC GC 快慢

GC 的勤快与否也会显著影响 DB size 的大小。第三篇中有过详细讨论和验证了,这里不再赘述,

Fig. TiKV DB size soaring in a JuiceFS cluster, caused by TiKV GC lagging.

2.2 两个集群对比

  • 集群 1:~1PB 数据,以小文件为主,~30K regions,~140GB TiKV engine size (3 replicas);
  • 集群 2:~7PB 数据,以大文件为主,~800 regions,~3GB TiKV engine size (3 replicas);

如下面监控所示,虽然集群 2 的数据量是前者的 7 倍,但元数据只有前者的 1/47

Fig. TiKV DB sizes and region counts of 2 JuiceFS clusters: cluster-1 with ~1PB data composed of mainly small files, cluster-2 with ~7PB data composed of mainly large files.

3 限速(上传/下载数据带宽)设计

限速(upload/download bandwidth)本身是属于数据平面(data)的事情,也就是与 S3、Ceph、OSS 等等对象存储关系更密切。

但第二篇中已经看到,这个限速的配置信息是保存在元数据平面(metadata)TiKV 中 —— 具体来说就是 volume 的 setting 信息; 此外,后面讨论元数据请求限流(rate limiting)时还需要参考限速的设计。所以,这里我们稍微展开讲讲。

3.1 带宽限制:--upload-limit/--download-limit

  • --upload-limit,单位 Mbps
  • --download-limit,单位 Mbps

3.2 JuiceFS 限速行为

  1. 如果 juicefs mount 挂载时指定了这两个参数,就会以指定的参数为准;
  2. 如果 juicefs mount 挂载时没指定,就会以 TiKV 里面的配置为准,

    • juicefs client 里面有一个 refresh() 方法一直在监听 TiKV 里面的 Format 配置变化,
    • 当这俩配置发生变化时(可以通过 juicefs config 来修改 TiKV 中的配置信息),client 就会把最新配置 reload 到本地(本进程)
    • 这种情况下,可以看做是中心式配置的客户端限速,工作流如下图所示,

Fig. JuiceFS upload/download data bandwidth control.

3.3 JuiceFS client reload 配置的调用栈

juicefs mount 时注册一个 reload 方法,

mount
 |-metaCli.OnReload
    |-m.reloadCb = append(m.reloadCb, func() {
                                        updateFormat(c)(fmt) //  fmt 是从 TiKV 里面拉下来的最新配置
                                        store.UpdateLimit(fmt.UploadLimit, fmt.DownloadLimit)
                                      })

然后有个后台任务一直在监听 TiKV 里面的配置,一旦发现配置变了就会执行到上面注册的回调方法,

refresh()
  for {
        old := m.getFormat()
        format := m.Load(false) // load from tikv

        if !reflect.DeepEqual(format, old) {
            cbs := m.reloadCb
            for _, cb := range cbs {
                cb(format)
        }
  }

4 限流(metadata 请求)设计

4.1 为什么需要限流?

如下图所示,

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

  • 限速保护的是 5;
  • 限流保护的是 3 & 4

下面我们通过实际例子看看可能会打爆 3 & 4 的几种场景。

4.2 打爆 TiKV API 的几种场景

4.2.1 mlocate (updatedb) 等扫盘工具

一次故障复盘

下面的监控,左边是 TiKV 集群的请求数量,右边是 node CPU 利用率(主要是 PD leader 在用 CPU),

Fig. PD CPU soaring caused by too much requests.

大致时间线,

  • 14:30 开始,kv_get 请求突然飙升,导致 PD leader 节点的 CPU 利用率大幅飙升;
  • 14:40 介入调查,确定暴增的请求来自同一个 volume,但这个 volume 被几十个用户的 pod 挂载, 能联系到的用户均表示 14:30 没有特殊操作;
  • 14:30~16:30 继续联系其他用户咨询使用情况 + 主动排查;期间删掉了几个用户暂时不用的 pod,减少挂载这个 volume 的 juicefs client 数量,请求量有一定下降;
  • 16:30 定位到请求来源
    • 确定暴增的请求不是用户程序读写导致的
    • 客户端大部分都 ubuntu 容器(AI 训练),
    • 使用的是同一个容器镜像,里面自带了一个 daily 的定时 mlocate 任务去扫盘磁盘,

这个扫盘定时任务的时间是每天 14:30,因此把挂载到容器里的 JuiceFS volume 也顺带扫了。 确定这个原因之后,

  • 16:40 开始,逐步强制停掉(pkill -f updatedb.mlocate) 并禁用(mv /etc/cron.daily/mlocate /tmp/)这些扫盘任务, 看到请求就下来了,PD CPU 利用率也跟着降下来了;
  • 第二天早上 6:00 又发生了一次(凌晨 00:00 其实也有一次),后来排查发生是还有几个基础镜像也有这个任务,只是 daily 时间不同。

juicefs mount 时会自动禁用 mlocate,但 CSI 部署方式中部分失效

其实官方已经注意到了 mlocate,所以 juicefs mount 的入口代码就专门有检测,开了之后就自动关闭,

// cmd/mount_unix.go

func mountMain(v *vfs.VFS, c *cli.Context) {
    if os.Getuid() == 0 {
        disableUpdatedb()
          |-path := "/etc/updatedb.conf"
          |-file := os.Open(path)
          |-newdata := ...
          |-os.WriteFile(path, newdata, 0644)
    }
    ...

但是,在 K8s CSI 部署方式中,这个代码是部分失效的

Fig. JuiceFS K8s CSI deployment

JuiceFS per-node daemon 在创建 mount pod 时,会把宿主机的 /etc/updatedb.conf 挂载到 mount pod 里面, 所以它能禁掉宿主机上的 mlocate,

  volumes:
  - hostPath:
      path: /etc/updatedb.conf
      type: FileOrCreate
    name: updatedb

但正如上一小结的例子看到的,业务 pod 里如果开了 updatedb,它就管不到了。 而且业务容器很可能是同一个镜像启动大量 pod,挂载同一个 volume,所以扫描压力直线上升

4.2.2 版本控制工具

类似的工具可能还有版本控制工具(git、svn)、编程 IDE(vscode)等等,威力可能没这么大,但排查时需要留意。

4.3 需求:对元数据引擎的保护能力

以上 case,包括上一篇看到的用户疯狂 update 文件的 case,都暴露出同一个问题: JuiceFS 缺少对元数据引擎的保护能力。

4.3.1 现状:JuiceFS 目前还没有

社区版目前(2024.09)是没有的,企业版不知道有没有。

下面讨论下如果基于社区版,如何加上这种限流能力。

4.4 客户端限流方案设计

Fig. JuiceFS upload/download data bandwidth control.

基于 JuiceFS 已有的设计,再参考其限速实现,其实加上一个限流能力并不难,代码也不多:

  1. 扩展 Format 结构体,增加限流配置;
  2. juicefs format|config 增加配置项,允许配置具体限流值;这会将配置写到元数据引擎里面的 volume setting
  3. juicefs mount 里面解析 setting 里面的限流配置,传给 client 里面的 metadata 模块;
  4. metadata 模块做客户端限流,例如针对 txnkv 里面的不到 10 个方法,在函数最开始的地方增加一个限流检查,allow 再继续,否则就等待。

这是一种(中心式配置的)客户端限流方案。

4.5 服务端限流方案设计

在 TiKV 集群前面挡一层代理,在代理上做限流,属于服务端限流

参考资料

  1. 图解 JuiceFS CSI 工作流:K8s 创建带 PV 的 Pod 时,背后发生了什么(2024)

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎三探:从实践中学习 TiKV 的 MVCC 和 GC(2024)

2024年9月22日 08:00

Fig. TiKV MVCC GC mechanisms.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 概念与实测

1.1 MVCC(多版本并发控制)

来自 wikipedia 的定义

Multiversion concurrency control (MCC or MVCC), is a non-locking concurrency control method commonly used by database management systems to provide concurrent access to the database and in programming languages to implement transactional memory.

TiKV 支持 MVCC,当更新数据时,旧的数据不会被立即删掉,而是新老同时保留,以时间戳来区分版本。 官方有几篇很不错的博客 [1,3]。

下面进行一个简单测试来对 MVCC 有一个初步的直观认识。

1.1.2 TiKV MVCC 测试

参考上一篇,新创建一个新 volume,里面什么文件都没有,有 8 条记录

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep "key:" | wc -l
8

然后进入这个 volume 的挂载目录,在里面创建一个文件

$ cd <mount dir>
$ echo 1 > foo.txt

再次扫描这个 volume 对应的所有 keys,

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep "key:" | wc -l
16

可以看到变成 16 条记录,比之前多了 8 条。内容如下,依稀能看出大部分条目的用途 (行末的注释是本文加的),

key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfoo.tx\377t\000\000\000\000\000\000\000\370 # foo.txt
key: zfoo-dev\375\377A\002\000\000\000\000\000\000\377\000C\000\000\000\000\000\000\375
key: zfoo-dev\375\377A\002\000\000\000\000\000\000\377\000I\000\000\000\000\000\000\371
key: zfoo-dev\375\377ClastCle\377anupFile\377s\000\000\000\000\000\000\000\370                         # lastCleanupFile
key: zfoo-dev\375\377ClastCle\377anupSess\377ions\000\000\000\000\373                                  # lastCleanupSessions
key: zfoo-dev\375\377CtotalIn\377odes\000\000\000\000\373                                              # totalInodes
key: zfoo-dev\375\377CusedSpa\377ce\000\000\000\000\000\000\371                                        # UsedSpace
key: zfoo-dev\375\377U\001\000\000\000\000\000\000\377\000\000\000\000\000\000\000\000\370

接下来继续更新这个文件 1000 次(每次都是一个整数,由于文件内容极小,不会导致 TiKV 的 region split 等行为),

$ for n in {1..1000}; do echo $n > bar.txt; done

再次查看元数据条目数量:

$ tikv-ctl.sh scan --from 'zfoo' --to 'zfop' | grep key | wc -l
59

多了 43 条。多的条目大致长这样:

key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\231\000\000\000\000\000\000\000\3777\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\233\000\000\000\000\000\000\000\377j\000\000\000\000\000\000\000\370
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\234\000\000\000\000\000\000\000\377\235\000\000\000\000\000\000\000\370
...
key: zfoo-dev\375\377L\000\000\000\000f\356\221\377\271\000\000\000\000\000\000\003\377\362\000\000\000\000\000\000\000\370

TiKV supports MVCC, which means that there can be multiple versions for the same row stored in RocksDB. All versions of the same row share the same prefix (the row key) but have different timestamps as a suffix.

https://tikv.org/deep-dive/key-value-engine/rocksdb/

下面我们再看看执行以上文件更新操作期间,juicefs 客户端的日志。

1.1.2 JuiceFS client 日志

在执行以上 for 循环期间,JuiceFS client 的日志,

$ juicefs mount ...
...
<DEBUG>: PUT chunks/0/0/170_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
<DEBUG>: PUT chunks/0/0/171_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
<DEBUG>: PUT chunks/0/0/172_0_4 (req_id: "xx", err: <nil>, cost: 32.002516ms) [cached_store.go:669]
...

这个似乎对应的就是以上多出来的条目。

1.1.3 小结

本节的例子让我们看到,虽然 volume 里面从头到尾只有一个文件, 但随着我们不断覆盖这个文件内的值,元数据引擎 TiKV 内的条目数量就会持续增加。 多出来的这些东西,对应的就是这份数据的多个版本,也就是 MVCC 里面 multi-version 的表现。

显然,没有冲突的话,只保留最后一个版本就行了,其他版本都可以删掉 —— 这就是垃圾回收(GC)的作用。

1.2 GC(垃圾回收)

垃圾回收 (GC) 的功能是清理 MVCC 留下的旧版本。比如同一份数据保存了 1000 个版本,那原则上前面大部分版本都可以清掉了,只保留最新的一个或几个。

那如何判断哪些版本可以安全地清掉呢?TiKV 引入了一个时间戳概念: safepoint

GC is a process to clean up garbage versions (versions older than the configured lifetime) of each row.

https://tikv.org/deep-dive/key-value-engine/rocksdb/

1.3 Safepoint(可安全删除这个时间戳之前的版本)

In order to ensure the correctness of all read and write transactions, and make sure the GC mechanism works, TiKV/TiDB introduced the concept of safe-point. There is a guarantee that all active transactions and future transactions’ timestamp is greater than or equal to the safe-point. It means old versions whose commit-ts is less than the safe-point can be safely deleted by GC. [3]

2 TiKV MVCC GC

以上看到,TiKV 有 GC 功能,但由于其“历史出身”,也存在一些限制。

2.1 历史:从 TiDB 里面拆分出来,功能不完整

TiKV 是从 TiDB 里面拆出来的一个产品,并不是从一开始就作为独立产品设计和开发的。 这导致的一个问题是:MVCC GC 功能在使用上有点蹩脚:

  1. 默认情况下,靠底层 RocksDB 的 compaction 触发 GC,这周触发周期不确定且一般比较长;
  2. TiKV+PD 也内置了另一种 GC 方式,但并不会自己主动去做,而是将 GC 接口暴露出来,靠 TiDB 等在使用 TiKV 的更上层组件来触发(见下节的图);
  3. tikv-ctl/pd-ctl 等等命令行工具也都没有提供 GC 功能,这导致 TiKV 的运维很不方便,比如有问题想快速手动触发时用不了。

下面具体看看 TiKV 中的 GC 设计。

2.2 TiKV GC 设计和配置项

Fig. TiKV MVCC GC mechanisms.

2.2.1 设计:两种 GC 触发方式

  1. 被动 GC:TiKV 底层的 RocksDB compact 时进行垃圾回收。
    • 通过 tikv-server 的 enable-compaction-filter 配置项控制;
    • 默认启用
    • 触发 RocksDB compaction 时才能进行 GC。
    • tikv-ctl compact/compact-cluster 可以手动触发这种 compact,进而 GC。
  2. 半主动 GC:内置了 GC worker,
    • 定期获取 PD 里面的 gc safepoint,然后进行 GC;会占用一些 CPU/IO 资源;
    • PD 不会主动更新这个 gc safepoint,一般是由在使用 TiKV 的更外围组件来更新的,例如 TiDB、JuiceFS 等等;
    • 所以本文把这种方式称为“半主动”。

2.2.2 tikv-server 启动日志中的 GC 配置信息

tikv-server.log

[INFO] [server.rs:274] ["using config"] [config="{..., "enable-compaction-filter":true, ...}"]
[INFO] [compaction_filter.rs:138] ["initialize GC context for compaction filter"]
[INFO] [gc_worker.rs:786] ["initialize compaction filter to perform GC when necessary"]

2.2.3 tikv-ctl compact/compact-cluster 触发被动 GC 例子

# compact-cluster 必须要指定 --pd 参数,因为针对是整个集群。指定 --host 会失败,但没有提示错在哪,TiKV 的命令行工具经常这样
$ tikv-ctl.sh compact-cluster --from 'zfoo' --to 'zfop' 

$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop'
store:"192.168.1.1:20160" compact db:Kv cf:default range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!

$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c default  # 很快
$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c lock     # 很快
store:"192.168.1.1:20160" compact db:Kv cf:lock range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!
$ tikv-ctl.sh compact --from 'zfoo' --to 'zfop' -c write    # 非常慢
store:"192.168.1.1:20160" compact db:Kv cf:write range:[[122, 122, 121, 110], [122, 122, 121, 111]) success!

# 还可以指定本地 TiKV 数据路径直接 compact
# -d: specify the RocksDB that performs compaction. default: kv. Valid values: {kv, raft}
$ tikv-ctl --data-dir /path/to/tikv compact -d kv

2.2.4 小结

“半主动方式”需要外围组件去更新 PD 中的 gc safepoint 信息,这样下面的 TiKV 才会去执行 GC 操作。作为两个具体例子,我们接下来看看 TiDB 和 JuiceFS 在使用 TiKV 时,分别是怎么去更新这个信息的。

2.3 TiDB 中触发 TiKV GC 的方式

TiDB 有 GC 相关的配置和 worker,会按照配置去触发底层的 TiKV GC,

Fig. TiDB SQL layer overview. GC worker is outside of TiKV. Image Source: pingcap.com

更多信息可以参考 [3,4]。

2.4 JuiceFS 触发 TiKV GC 的方式

TiKV 作为元数据引擎时,JuiceFS 并没有使用 TiDB,而是直接使用的 TiKV(和 PD), 所以就需要 JuiceFS client 来触发这个 GC (因为不考虑 CSI 部署方式的话,JuiceFS 就一个客户端组件,也没有其他 long running 服务来做这个事情了)。

Fig. Typical JuiceFS cluster.

2.4.1 定期更新 gc safepoint 的代码

JuiceFS v1.0.4+ 客户端会周期性地设置 PD 中的 gc safepoint,默认是 now-3h,也就是可以删除 3 小时之前的旧版本数据,

// pkg/meta/tkv_tikv.go

func (c *tikvClient) gc() {
    if c.gcInterval == 0 {
        return
    }

    safePoint := c.client.GC(context.Background(), oracle.GoTimeToTS(time.Now().Add(-c.gcInterval)))
}

2.4.2 配置:META URL \?gc-interval=1h

这个 gc-interval 可在 juicefs 挂载卷时加到 TiKV URL 中,

  • 默认值:3h
  • 最小值:1h,设置的值小于这个值会打印一条 warning,然后强制设置为 1h。

juicefs client 挂载时显式设置 gc-interval

$ juicefs mount tikv://localhost:2379\?gc-interval=1h ~/mnt/jfs
<INFO>: Meta address: tikv://localhost:2379?gc-interval=1h [interface.go:491]
<INFO>: TiKV gc interval is set to 1h0m0s [tkv_tikv.go:84]
...

2.4.3 juicefs gc 手动触发 TiKV GC

还可以通过 juicefs gc 子命令来主动触发 TiKV GC。这个例子中设置的时间太短,可以看到被强制改成了允许的最小值 1h

$ juicefs gc tikv://<ip>:2379/foo-dev\?gc-interval=1m --delete
...
<WARNING>: TiKV gc-interval (1m0s) is too short, and is reset to 1h [tkv_tikv.go:133]
<INFO>: TiKV gc interval is set to 1h0m0s [tkv_tikv.go:138]
Cleaned pending slices: 0                      0.0/s
 Pending deleted files: 0                      0.0/s
  Pending deleted data: 0.0 b   (0 Bytes)      0.0 b/s
 Cleaned pending files: 0                      0.0/s
  Cleaned pending data: 0.0 b   (0 Bytes)      0.0 b/s
         Cleaned trash: 0                      0.0/s
Cleaned detached nodes: 0                      0.0/s
         Listed slices: 2047                   4930.4/s
          Trash slices: 2026                   55423.8/s
            Trash data: 7.7 KiB (7883 Bytes)   211.8 KiB/s
  Cleaned trash slices: 0                      0.0/s
    Cleaned trash data: 0.0 b   (0 Bytes)      0.0 b/s
       Scanned objects: 2047/2047 [===========================================]  18138.6/s used: 113.115519ms
         Valid objects: 21                     187.2/s
            Valid data: 85.0 b  (85 Bytes)     758.0 b/s
     Compacted objects: 2026                   18064.2/s
        Compacted data: 7.7 KiB (7883 Bytes)   68.6 KiB/s
        Leaked objects: 0                      0.0/s
           Leaked data: 0.0 b   (0 Bytes)      0.0 b/s
       Skipped objects: 0                      0.0/s
          Skipped data: 0.0 b   (0 Bytes)      0.0 b/s
<INFO>: scanned 2047 objects, 21 valid, 2026 compacted (7883 bytes), 0 leaked (0 bytes), 0 delslices (0 bytes), 0 delfiles (0 bytes), 0 skipped (0 bytes) [gc.go:379]

2.5 外挂组件 github.com/tikv/migration/gc-worker

代码仓库,是个在 TiKV 之上的组件, 从 PD 获取 service safepoint 信息,然后计算 gc safepoint 并更新到 PD,从而触发 TiKV GC。

3 GC 不及时导致的问题一例

这里挑一个典型的问题讨论下。

3.1 问题现象

3.1.1 监控:TiKV db size 暴增,磁盘空间不断减小

如下面监控所示,

Fig. TiKV DB size soaring in a JuiceFS cluster, caused by TiKV GC lagging.

  • TiKV DB size 暴增;
  • TiKV region 分布出现显著变量,总数量也有一定程度上升;
  • TiKV node 可用磁盘空间不断下降。

3.1.2 tikv-server 错误日志:failed to split region

查看 tikv-server 日志,看到一直在刷下面这样的 warning/error:

[WARN] [split_observer.rs:73] ["invalid key, skip"] [err="\"key 6E677... should be in (6E677..., 6E677...)\""] [index=0] [region_id=39179938]
[ERROR] [split_observer.rs:136] ["failed to handle split req"] [err="\"no valid key found for split.\""] [region_id=39179938]
[WARN] [peer.rs:2971] ["skip proposal"] [error_code=KV:Raftstore:Coprocessor] [err="Coprocessor(Other(\"[components/raftstore/src/coprocessor/split_observer.rs:141]: no valid key found for split.\"))"] [peer_id=39179939] [region_id=39179938]

也就是 region split 失败。

3.2 问题排查

  1. 根据日志报错,网上搜到一些帖子,初步了解问题背景(JuiceFS/TiKV 新人,接触没多久);
  2. 对报错日志进行分析,发现:

    • 报错集中在几十个 regiongrep "failed to handle split req" tikv.log | awk '{print $NF}' | sort | uniq -c | sort -n -k1,1),相对总 region 数量很少;
    • pd-ctl region-properties -r <region> 看,发现 start/end key 都来自同一个 volume(命令行操作见下一篇);
    • 根据 volume 监控看,只有一个客户端 set 请求非常高,每秒 400 次请求,而这个 volume 只有几个 GB,可以说非常小;
  3. tikv-ctl mvcc -k <key> 查看有问题的 key,发现超时了,报错说文件(元数据)太大

结合以上三点,判断是某个或少数几个文件的 MVCC 版本太多,导致 TiKV split region 失败,进而不断累积垃圾数据。

3.3 问题根因

以上,猜测直接原因是这个用户 非正常使用 JuiceFS疯狂更新文件,也就是我们 1.1 中例子的极端版。 这导致部分文件的历史版本极其多,TiKV 在 auto split region 时失败。网上也有一些类似的 case(大部分是 TiDB 用户)。

但本质上,还是因为 TiKV 的 GC 太滞后,

  1. 被动 GC(RocksDB compact 方式)的频率不可控,跟集群所有客户端的总 write/update/delete 行为有关;
  2. JuiceFS 的主动 GC 频率太慢,跟不上某些文件的版本增长速度。

    • JuiceFS 默认 now-3h,最小 now-1h,也就是至少会保留一个小时内的所有版本(实际上我们是有个外部服务在定期更新 PD 的 gc safepoint,但也是设置的 now-1h);
    • 根据监控看,异常的 juicefs client 每秒有 400+ set 请求,一个小时就是 144w 次的更新(这些请求更新的文件很集中)。

3.4 解决方式

  1. 写了个程序,允许以非常小的粒度去更新 PD 的 gc safepoint,例如 now-5m, 也就是最多保留最近 5 分钟内的版本,其他的都删掉;这一步下去就有效果了,先稳住了,DB 不再增长,开始缓慢下降;
  2. 通知用户去处理那个看起来异常的客户端(我们没权限登录用户的机器,客户端不可控,这是另一个问题了)。

1+2,DB 开始稳步下降,最终完全恢复正常。

3.5 问题小结

对于 TiKV 这种 MVCC 的元数据引擎来说,JuiceFS 的一条元数据可能会保留多个版本,老版本什么时候删掉很大程度上依赖外部 GC 触发。 如果 GC 间隔太长 + 文件更新太频繁,单条元数据极端情况下就可以占几个 GB,这时候不仅 DB size 暴大,还会导致 TiKV split region 工作不正常。

4 问题讨论

前面看到,JuiceFS 支持配置 TiKV 的 GC 间隔,但从管理和运维层面,这里面也有几个问题可以探讨。

4.1 允许的最小 GC 间隔太大

目前最小是 now-1h,极端情况会导致第 3 节中的问题,TiKV DB size 暴增,集群被打爆。

4.2 GC 配置放在客户端,增加了用户的认知负担和学习成本

  • 用户必需感知 TiKV gc 这个东西,增加认知成本和使用负担;

    用户只是用 JuiceFS volume 读写文件,原则上没有必要去知道 JuiceFS 集群用什么元数据引擎, 甚至还必现了解这种元数据引擎的 GC 知识,后者都是 JuiceFS 集群管理员需要关心和解决的;

  • 用户如果没有配置,就只完全依赖 RocksDB compaction 来 GC,更容易触发版本太多导致的问题。

4.3 管理员运维困境

用户一旦没有显式配置 gc-interval(使用很大的默认值),TiKV 可能就被打爆, 这种情况下用户不知道,管理员知道但可能没短平快的解决办法(不一定有权限管理用户的机器)。

4.4 小结

对集群管理员来说,更好的方式可能是,

  1. 有个(内部或外部)服务,可以按管理员的需求随时和/或定时去 GC;
  2. 用户侧完全不用感知这个事情;
  3. 有 Meta 操作的限流能力(可以隔离有问题的 volume 或 client),下一篇讨论。

参考资料

  1. MVCC in TiKV, pingcap.com, 2016
  2. JuiceFS 元数据引擎最佳实践:TiKV, juicefs.com
  3. Deep Dive into Distributed Transactions in TiKV and TiDB, medium.com, 2024
  4. MVCC garbage collection, TiDB doc, 2024

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎再探:开箱解读 TiKV 中的 JuiceFS 元数据(2024)

2024年9月12日 08:00

Fig. JuiceFS upload/download data bandwidth control.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



有了第一篇的铺垫,本文直接进入正题。

  • 首先创建一个 volume,然后在其中做一些文件操作,然后通过 tikv-ctl 等工具在 TiKV 中查看对应的元数据。
  • 有了这些基础,我们再讨论 JuiceFS metadata key 和 TiKV 的编码格式。

之前有一篇类似的,开箱解读 etcd 中的 Cilium 元数据: What’s inside Cilium Etcd (kvstore)

1 创建一个 volume

创建一个名为 foo-dev 的 JuiceFS volume。

1.1 JuiceFS client 日志

用 juicefs client 的 juicefs format 命令创建 volume,

$ juicefs format --storage oss --bucket <bucket> --access-key <key> --secret-key <secret key> \
  tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev foo-dev

<INFO>: Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev
<INFO>: Data use oss://xxx/foo-dev/
<INFO>: Volume is formatted as {
  "Name": "foo-dev",
  "UUID": "ec843b",
  "Storage": "oss",
  "BlockSize": 4096,
  "MetaVersion": 1,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
}
  • 对象存储用的是阿里云 OSS;
  • TiKV 地址指向的是 PD 集群地址,上一篇已经介绍过,2379 是 PD 接收客户端请求的端口;

1.2 JuiceFS client 中的 TiKV/PD client 初始化/调用栈

下面我们进入 JuiceFS 代码,看看 JuiceFS client 初始化和连接到元数据引擎的调用栈:

mount
 |-metaCli = meta.NewClient
 |-txnkv.NewClient(url)                                          // github.com/juicedata/juicefs: pkg/meta/tkv_tikv.go
 |  |-NewClient                                                  // github.com/tikv/client-go:    txnkv/client.go
 |     |-pd.NewClient                                            // github.com/tikv/client-go:    tikv/kv.go
 |          |-NewClient                                          // github.com/tikv/pd:           client/client.go
 |             |-NewClientWithContext                            // github.com/tikv/pd:           client/client.go
 |                |-createClientWithKeyspace                     // github.com/tikv/pd:           client/client.go
 |                   |-c.pdSvcDiscovery = newPDServiceDiscovery  // github.com/tikv/pd:           client/pd_xx.go
 |                   |-c.setup()                                 // github.com/tikv/pd:           client/pd_xx.go
 |                       |-c.pdSvcDiscovery.Init()
 |                       |-c.pdSvcDiscovery.AddServingURLSwitchedCallback
 |                       |-c.createTokenDispatcher()
 |-metaCli.NewSession
    |-doNewSession
       |-m.setValue(m.sessionKey(m.sid), m.expireTime())  // SE
       |-m.setValue(m.sessionInfoKey(m.sid), sinfo)       // SI

这里面连接到 TiKV/PD 的代码有点绕,

  • 传给 juicefs client 的是 PD 集群地址
  • 但代码使用的是 tikv 的 client-go 包,创建的是一个 tikv transaction client
  • 这个 tikv transaction client 里面会去创建 pd client 连接到 PD 集群,

所以,架构上看 juicefs 是直连 PD,但实现上并没有直接创建 pd client, 也没有直接使用 pd 的库。

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

1.3 tikv-ctl 查看空 volume 的系统元数据

现在再把目光转到 TiKV。看看这个空的 volume 在 TiKV 中对应哪些元数据:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop'
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000I\000\000\000\000\000\000\371  # attr?
key: zfoo-dev\375\377ClastCle\377anupSess\377ions\000\000\000\000\373                    # lastCleanupSessions
key: zfoo-dev\375\377CnextChu\377nk\000\000\000\000\000\000\371                          # nextChunk
key: zfoo-dev\375\377CnextIno\377de\000\000\000\000\000\000\371                          # nextInode
key: zfoo-dev\375\377CnextSes\377sion\000\000\000\000\373                                # nextSession
key: zfoo-dev\375\377SE\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371  # session
key: zfoo-dev\375\377SI\000\000\000\000\000\000\377\000\001\000\000\000\000\000\000\371  # sessionInfo
key: zfoo-dev\375\377setting\000\376                                                     # setting

以上就是我们新建的 volume foo-dev 的所有 entry 了。 也就是说一个 volume 创建出来之后,默认就有这些 JuiceFS 系统元数据

TiKV 中的每个 key 都经过了两层编码(JuiceFS 和 TiKV),我们后面再介绍编码规则。 就目前来说,根据 key 中的字符还是依稀能看出每个 key 是干啥用的, 为方便起见直接注释在上面每行的最后了。比如,下面两个 session 相关的 entry 就是上面调用栈最后两个创建的:

  • session
  • sessionInfo

1.4 例子:tikv-ctl mvcc 解码 volume setting 元数据

TiKV 中的每个 entry 都是 key/value。现在我们尝试解码最后一个 entry,key 是 zfoo-dev\375\377setting\000\376, 我们来看看它的 value —— 也就是它的内容 —— 是什么

$ value_hex=$(./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377setting\000\376' --show-cf=default | awk '/default cf value:/ {print $NF}')
$ value_escaped=$(./tikv-ctl.sh --to-escaped $value_hex)
$ echo -e $value_escaped | sed 's/\\"/"/g' | jq .

输出:

{
  "Name": "foo-dev",
  "UUID": "1ce2973b",
  "Storage": "S3",
  "Bucket": "http://xx/bucket",
  "AccessKey": "xx",
  "SecretKey": "xx",
  "BlockSize": 4096,
  "MetaVersion": 1,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
}

可以看到是个 JSON 结构体。这其实就是这个 volume 的配置信息。如果对 JuiceFS 代码有一定了解, 就会看出来它对应的其实就是 type Format 这个 struct。

1.4.1 对应 JuiceFS Format 结构体

// https://github.com/juicedata/juicefs/blob/v1.2.0/pkg/meta/config.go#L72

type Format struct {
    Name             string
    UUID             string
    Storage          string
    StorageClass     string `json:",omitempty"`
    Bucket           string
    AccessKey        string `json:",omitempty"`
    SecretKey        string `json:",omitempty"`
    SessionToken     string `json:",omitempty"`
    BlockSize        int
    Compression      string `json:",omitempty"`
    Shards           int    `json:",omitempty"`
    HashPrefix       bool   `json:",omitempty"`
    Capacity         uint64 `json:",omitempty"`
    Inodes           uint64 `json:",omitempty"`
    UploadLimit      int64  `json:",omitempty"` // Mbps
    DownloadLimit    int64  `json:",omitempty"` // Mbps
    ...
}

2 将 volume 挂载(mount)到机器

接下来我们找一台机器,把这个 volume 挂载上去,这样就能在这个 volume 里面读写文件了。

2.1 JuiceFS client 挂载日志

$ juicefs mount --verbose --backup-meta 0 tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev /tmp/foo-dev
<INFO>:  Meta address: tikv://192.168.1.1:2379,192.168.1.2:2379,192.168.1.3:2379/foo-dev [interface.go:406]
<DEBUG>: Creating oss storage at endpoint http://<url> [object_storage.go:154]
<INFO>:  Data use oss://xx/foo-dev/ [mount.go:497]
<INFO>:  Disk cache (/var/jfsCache/ec843b85/): capacity (10240 MB), free ratio (10%), max pending pages (15) [disk_cache.go:94]
<DEBUG>: Scan /var/jfsCache/ec843b85/raw to find cached blocks [disk_cache.go:487]
<DEBUG>: Scan /var/jfsCache/ec843b85/rawstaging to find staging blocks [disk_cache.go:530]
<DEBUG>: Found 8 cached blocks (32814 bytes) in /var/jfsCache/ec843b85/ with 269.265µs [disk_cache.go:515]
<INFO>:  Create session 4 OK with version: 1.2.0 [base.go:279]
<INFO>:  Prometheus metrics listening on 127.0.0.1:34849 [mount.go:165]
<INFO>:  Mounting volume foo-dev at /tmp/foo-dev ... [mount_unix.go:203]
<INFO>:  OK, foo-dev is ready at /tmp/foo-dev [mount_unix.go:46]

可以看到成功挂载到了本机路径 /tmp/foo-dev/

2.2 查看挂载信息

$ mount | grep juicefs
JuiceFS:foo-dev on /tmp/foo-dev type fuse.juicefs (rw,relatime,user_id=0,group_id=0,default_permissions,allow_other)

$ cd /tmp/foo-dev
$ ls # 空目录

2.3 查看 JuiceFS 隐藏(系统)文件

新建的 volume 里面其实有几个隐藏文件:

$ cd /tmp/foo-dev
$ ll
-r-------- 1 root root  .accesslog
-r-------- 1 root root  .config
-r--r--r-- 1 root root  .stats
dr-xr-xr-x 2 root root  .trash/

2.3.1 .accesslog

可以通过 cat 这个文件看到一些 JuiceFS client 底层的操作日志,我们一会会用到。

2.3.2 .config

包括 Format 在内的一些 volume 配置信息:

$ cat .config
{
 "Meta": {
  "Strict": true,
  "Retries": 10,
  "CaseInsensi": false,
  "ReadOnly": false,
  "NoBGJob": false,
  "OpenCache": 0,
  "Heartbeat": 12000000000,
  "MountPoint": "/tmp/foo-dev",
  "Subdir": "",
  "CleanObjFileLever": 1
 },
 "Format": {
  "Name": "foo-dev",
  "UUID": "ec843b85",
  "Storage": "oss",
  "Bucket": "http://<url>",
  "UploadLimit": 0,
  "DownloadLimit": 0,
  ...
 },
 "Chunk": {
  "CacheDir": "/var/jfsCache/ec843b85",
  "CacheMode": 384,
  "CacheSize": 10240,
  "FreeSpace": 0.1,
  "AutoCreate": true,
  "Compress": "none",
  "MaxUpload": 20,
  "MaxDeletes": 2,
  "MaxRetries": 10,
  "UploadLimit": 0,
  "DownloadLimit": 0,
  "Writeback": false,
  "UploadDelay": 0,
  "HashPrefix": false,
  "BlockSize": 4194304,
  "GetTimeout": 60000000000,
  "PutTimeout": 60000000000,
  "CacheFullBlock": true,
  "BufferSize": 314572800,
  "Readahead": 0,
  "Prefetch": 1,
  "UseMountUploadLimitConf": false,
  "UseMountDownloadLimitConf": false
 },
 "Version": "1.2.0",
 "AttrTimeout": 1000000000,
 "DirEntryTimeout": 1000000000,
 "EntryTimeout": 1000000000,
 "BackupMeta": 0,
 "HideInternal": false
}

2.3.3 .stats

cat 能输出一些 prometheus metrics:

$ cat .stats
...
juicefs_uptime 374.021754516
juicefs_used_buffer_size_bytes 0
juicefs_used_inodes 7
juicefs_used_space 28672

用 prometheus 采集器把这个数据收上去,就能在 grafana 上展示 volume 的各种内部状态。

2.3.4 .trash

类似于 Windows 的垃圾箱。如果启用了,删掉的文件会在里面保存一段时间再真正从对象存储删掉。

3 创建、更新、删除文件

接下来做一些文件操作,看看 TiKV 中对应元数据的变化。

3.1 创建文件

3.1.1 创建文件

$ cd /tmp/foo-dev
$ echo test3 > file3.txt

3.1.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585251,1725585251,4096]) <0.001561>
[uid:0,gid:0,pid:169604] lookup (1,file3.txt): no such file or directory <0.000989>
[uid:0,gid:0,pid:169604] create (1,file3.txt,-rw-r-----:0100640): OK (103,[-rw-r-----:0100640,1,0,0,1725585318,1725585318,1725585318,0]) [fh:27] <0.003850>
[uid:0,gid:0,pid:169604] flush (103,27): OK <0.000005>
[uid:0,gid:0,pid:169604] write (103,6,0,27): OK <0.000048>
[uid:0,gid:0,pid:169604] flush (103,27): OK <0.026205>
[uid:0,gid:0,pid:0     ] release (103): OK <0.000006>
[uid:0,gid:0,pid:169749] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585318,1725585318,4096]) <0.000995>
[uid:0,gid:0,pid:169750] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585318,1725585318,4096]) <0.001219>

3.1.3 TiKV 元数据

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
...
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
...

可以看到 meta 中多了几条元数据,依稀可以分辨出对应的就是我们创建的文件,

  1. 这个 key 经过了 juicefs 和 tikv 两次编码,
  2. 简单来说,它是 volume + 0xFD(8 进制的 \375)+ 文件名 + tikv 编码,最终得到的就是上面看到的这个 key。

对应的 value 一般长这样:

$ ./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372' --show-cf default,lock,write
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
         write cf value: start_ts: 452330816414416901 commit_ts: 452330816414416903 short_value: 010000000000000002

先粗略感受一下,后面再具体介绍 key/value 的编解码规则。

3.2 删除文件操作

3.2.1 删除文件

rm file4.txt

3.2.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585532,1725585532,4096]) <0.001294>
[uid:0,gid:0,pid:169902] lookup (1,file4.txt): OK (104,[-rw-r-----:0100640,1,0,0,1725585532,1725585532,1725585532,6]) <0.001631>
[uid:0,gid:0,pid:169902] unlink (1,file4.txt): OK <0.004206>
[uid:0,gid:0,pid:169904] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.000718>
[uid:0,gid:0,pid:169905] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.000843>

3.2.3 TiKV 元数据

对应的元数据就从 TiKV 删掉了。

3.3 更新(追加)文件

3.3.1 更新文件

$ echo test3 >> file3.txt

3.3.2 JuiceFS .accesslog

$ cat .accesslog
[uid:0,gid:0,pid:169604] getattr (1): OK (1,[drwxrwxrwx:0040777,3,0,0,1725503250,1725585623,1725585623,4096]) <0.001767>
[uid:0,gid:0,pid:169604] lookup (1,file3.txt): OK (103,[-rw-r-----:0100640,1,0,0,1725585318,1725585318,1725585318,6]) <0.001893>
[uid:0,gid:0,pid:169604] open (103): OK [fh:51] <0.000884>
[uid:0,gid:0,pid:169604] flush (103,51): OK <0.000011>
[uid:0,gid:0,pid:169604] write (103,6,6,51): OK <0.000068>
[uid:0,gid:0,pid:169604] flush (103,51): OK <0.036778>
[uid:0,gid:0,pid:0     ] release (103): OK <0.000024>

3.3.3 TiKV 元数据

  • 如果追加的内容不多,TiKV 中还是那条元数据,但 value 会被更新;
  • 如果追加的内容太多(例如几百兆),文件就会被切分,这时候元数据就会有多条了。

4 元数据操作和 TiKV key/value 编码规则

上一节简单看了下创建、更新、删除 volume 中的文件,TiKV 中对应的元数据都有什么变化。 我们有意跳过了 key/value 是如何编码的,这一节就来看看这块的内容。

4.1 JuiceFS key 编码规则

4.1.1 每个 key 的公共前缀:<vol_name> + 0xFD

TiKV 客户端初始化:每个 key 的 base 部分<vol_name> + 0xFD

// pkg/meta/tkv_tikv.go

func init() {
    Register("tikv", newKVMeta)
    drivers["tikv"] = newTikvClient
}

func newTikvClient(addr string) (tkvClient, error) {
    client := txnkv.NewClient(strings.Split(tUrl.Host, ","))
    prefix := strings.TrimLeft(tUrl.Path, "/")
    return withPrefix(&tikvClient{client.KVStore, interval}, append([]byte(prefix), 0xFD)), nil
}

4.1.2 每个 key 后面的部分

根据对应的是文件、目录、文件属性、系统元数据等等,会有不同的编码规则:

// pkg/meta/tkv.go

/**
  Ino     iiiiiiii
  Length  llllllll
  Indx    nnnn
  name    ...
  sliceId cccccccc
  session ssssssss
  aclId   aaaa

All keys:
  setting            format
  C...               counter
  AiiiiiiiiI         inode attribute
  AiiiiiiiiD...      dentry
  AiiiiiiiiPiiiiiiii parents // for hard links
  AiiiiiiiiCnnnn     file chunks
  AiiiiiiiiS         symlink target
  AiiiiiiiiX...      extented attribute
  Diiiiiiiillllllll  delete inodes
  Fiiiiiiii          Flocks
  Piiiiiiii          POSIX locks
  Kccccccccnnnn      slice refs
  Lttttttttcccccccc  delayed slices
  SEssssssss         session expire time
  SHssssssss         session heartbeat // for legacy client
  SIssssssss         session info
  SSssssssssiiiiiiii sustained inode
  Uiiiiiiii          data length, space and inodes usage in directory
  Niiiiiiii          detached inde
  QDiiiiiiii         directory quota
  Raaaa                 POSIX acl
*/

具体可以再看看这个文件中的代码。

4.1.3 最终格式:字节序列

// pkg/meta/tkv.go

func (m *kvMeta) fmtKey(args ...interface{}) []byte {
    b := utils.NewBuffer(uint32(m.keyLen(args...)))
    for _, a := range args {
        switch a := a.(type) {
        case byte:
            b.Put8(a)
        case uint32:
            b.Put32(a)
        case uint64:
            b.Put64(a)
        case Ino:
            m.encodeInode(a, b.Get(8))
        case string:
            b.Put([]byte(a))
        default:
            panic(fmt.Sprintf("invalid type %T, value %v", a, a))
        }
    }
    return b.Bytes()
}

4.2 TiKV 对 JuiceFS key 的进一步编码

JuiceFS client 按照以上规则拼好一个 key 之后,接下来 TiKV 会再进行一次编码:

  1. 加一些 TiKV 的前缀,例如给文件 key 加个 z 前缀;

  2. 转义,例如 8 个字节插入一个 \377(对应 0xFF),不够 8 字节的补全等等;

最终得到的就是我们用 tikv-ctl scan 看到的那些 key。

4.3 例子:查看特殊元数据:volume 的 setting/format 信息

JuiceFS 的 Format 配置保存在 tikv 中,原始 key 是 setting,经过以上两层编码就变成了下面的样子:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

其中的 value 是可以解码出来的,

# hex -> escaped string
$ ./tikv-ctl.sh --to-escaped '7B0A22...'
{\n\"Name\": \"foo-dev\",\n\"UUID\": \"8cd1ac73\",\n\"Storage\": \"S3\",\n\"Bucket\": \"http://xxx\",\n\"AccessKey\": \"...\",\n\"BlockSize\": 4096,\n\"Compression\": \"none\",\n\"KeyEncrypted\": true,\n\"MetaVersion\": 1,\n\"UploadLimit\": 0,\n\"DownloadLimit\": 0,\n\"\": \"\"\n}

对应的就是 pkg/meta/config.go 中的 Format 结构体。

5 总结

本文结合一些具体 JuiceFS 操作,分析了 TiKV 内的元数据格式与内容。

参考资料

  1. What’s inside Cilium Etcd (kvstore)

Written by Human, Not by AI Written by Human, Not by AI

JuiceFS 元数据引擎初探:高层架构、引擎选型、读写工作流(2024)

2024年9月12日 08:00

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 JuiceFS 高层架构与组件

Fig. JuiceFS components and architecutre.

如图,最粗的粒度上可以分为三个组件。

1.1 JuiceFS client

  • juicefs format ... 可以创建一个 volume;
  • juicefs config ... 可以修改一个 volume 的配置;
  • juicefs mount ... 可以把一个 volume 挂载到机器上,然后用户就可以在里面读写文件了;

1.2 Metatdata engine(元数据引擎)

  • 用于存储 JuiceFS 的元数据,例如每个文件的文件名、最后修改时间等等;
  • 可选择 etcd、TiKV 等等;

1.3. Object store

实际的对象存储,例如 S3、Ceph、阿里云 OSS 等等,存放 JuiceFS volume 内的数据。

2 JuiceFS 元数据存储引擎对比:tikv vs. etcd

2.1 设计与优缺点对比

  TiKV as metadata engine etcd as metadata engine
管理节点(e.g. leader election) PD (TiKV cluster manager) etcd server
数据节点(存储 juicefs metadata) TiKV server etcd server
数据节点对等 无要求 完全对等
数据一致性粒度 region-level (TiKV 的概念,region < node) node-level
Raft 粒度 region-level (multi-raft,TiKV 的概念) node-level
缓存多少磁盘数据在内存中 一部分 所有
集群支持的最大数据量 PB 级别 几十 GB 级别
性能(JuiceFS 场景) 高(猜测是因为 raft 粒度更细,并发读写高)
维护和二次开发门槛 高(相比 etcd)
流行度 & 社区活跃度 低(相比 etcd)
适用场景 大和超大 JuiceFS 集群 中小 JuiceFS 集群

2.2 几点解释

etcd 集群,

  • 每个节点完全对等,既负责管理又负责存储数据;
  • 所有数据全部缓存在内存中,每个节点的数据完全一致。 这一点限制了 etcd 集群支持的最大数据量和扩展性, 例如现在官网还是建议不要超过 8GB(实际上较新的版本在技术上已经没有这个限制了, 但仍受限于机器的内存)。

TiKV 方案可以可以理解成把管理和数据存储分开了,

  • PD 可以理解为 TiKV cluster manager,负责 leader 选举、multi-raft、元数据到 region 的映射等等;
  • 节点之间也不要求对等,PD 按照 region(比如 96MB)为单位,将 N(默认 3)个副本放到 N 个 TiKV node 上,而实际上 TiKV 的 node 数量是 M,M >= N
  • 数据放在 TiKV 节点的磁盘,内存中只缓存一部分(默认是用机器 45% 的内存,可控制)。

2.3 例子:TiKV 集群 engine size 和内存使用监控

TiKV 作为存储引擎,总结成一句话就是:根据硬件配置干活,能者多劳 —— 内存大、磁盘大就多干活,反之就少干活。

下面的监控展示是 7 台 TiKV node 组成的一个集群,各 node 内存不完全一致: 3 台 256GB 的,2 台 128GB 的,2 台 64GB 的, 可以看到每个 TiKV server 确实只用了各自所在 node 一半左右的内存:

Fig. TiKV engine size and memory usage of a 7-node (with various RAMs) cluster.

3 JuiceFS + TiKV:集群启动和宏观读写流程

3.1 架构

用 TiKV 作为元数据引擎,架构如下(先忽略其中的细节信息,稍后会介绍):

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

3.2 TiKV 集群启动

3.2.1 TiKV & PD 配置差异

两个组件的几个核心配置项,

$ cat /etc/tikv/pd-config.toml
name = "pd-node1"
data-dir = "/var/data/pd"

client-urls = "https://192.168.1.1:2379" # 客户端(例如 JuiceFS)访问 PD 时,连接这个地址
peer-urls   = "https://192.168.1.1:2380" # 其他 PD 节点访问这个 PD 时,连接这个地址,也就是集群内互相通信的地址

# 创建集群时的首批 PD
initial-cluster-token = "<anything you like>"
initial-cluster = "pd-node1=https://192.168.1.3:2380,pd-node2=https://192.168.1.2:2380,pd-node3=https://192.168.1.1:2380"

可以看到,PD 的配置和 etcd 就比较类似,需要指定其他 PD 节点地址,它们之间互相通信。

TiKV 节点(tikv-server)的配置就不一样了,

$ cat /etc/tikv/tikv-config.toml
[pd]
endpoints = ["https://192.168.1.1:2379", "https://192.168.1.2:2379", "https://192.168.1.3:2379"]

[server]
addr = "192.168.1.1:20160"        # 服务地址,JuiceFS client 会直接访问这个地址读写数据
status-addr = "192.168.1.1:20180" # prometheus 

可以看到,

  1. TiKV 会配置所有 PD 节点的地址,以便自己注册到 PD 作为一个数据节点(存储JuiceFS 元数据);
  2. TiKV 还会配置一个地址的 server 地址,这个读写本节点所管理的 region 内的数据用的; 正常流程是 JuiceFS client 先访问 PD,拿到 region 和 tikv-server 信息, 然后再到 tikv-server 来读写数据(对应 JuiceFS 的元数据);
  3. TiKV 不会配置其他 TiKV 节点的地址,也就是说 TiKV 节点之间不会 peer-to-peer 互连。 属于同一个 raft group 的多个 region 通信,也是先通过 PD 协调的,最后 region leader 才发送数据给 region follower。 详见 [1]。

3.2.2 服务启动

Fig. JuiceFS cluster initialization, and how POSIX file operations are handled by JuiceFS.

对应图中 step 1 & 2:

  • step 1. PD 集群启动,选主;
  • step 2. TiKV 节点启动,向 PD 注册;每个 TiKV 节点称为一个 store,也就是元数据仓库。

3.3 宏观读写流程

对应图中 step 3~5:

  • step 3. JuiceFS 客户端连接到 PD;发出读写文件请求;

    • JuiceFS 客户端中会初始化一个 TiKV 的 transaction kv client,这里面又会初始化一个 PD client,
    • 简单来说,此时 JuiceFS 客户端就有了 PD 集群的信息,例如哪个文件对应到哪个 region,这个 region 分布在哪个 TiKV 节点上,TiKV 服务端连接地址是多少等等;
  • step 4. JuiceFS (内部的 TiKV 客户端)直接向 TiKV 节点(准确说是 region leader)发起读写请求;
  • step 5. 元数据处理完成,JuiceFS 客户端开始往对象存储里读写文件。

4 TiKV 内部数据初探

TiKV 内部存储的都是 JuiceFS 的元数据。具体来说又分为两种:

  1. 用户文件的元数据:例如用户创建了一个 foo.txt,在 TiKV 里面就会对应一条或多条元数据来描述这个文件的信息;
  2. JuiceFS 系统元数据:例如每个 volume 的配置信息,这些对用户是不可见的。

TiKV 是扁平的 KV 存储,所以以上两类文件都放在同一个扁平空间,通过 key 访问。 本文先简单通过命令看看里面的元数据长什么样,下一篇再结合具体 JuiceFS 操作来深入解读这些元数据。

4.1 简单脚本 tikv-ctl.sh/pd-ctl.sh

简单封装一下对应的命令行工具,使用更方便,

$ cat pd-ctl.sh
tikv-ctl \
        --ca-path /etc/tikv/pki/root.crt --cert-path /etc/tikv/pki/tikv.crt --key-path /etc/tikv/pki/tikv.key \
        --host 192.168.1.1:20160 \
        "$@"

$ cat pd-ctl.sh
pd-ctl \
        --cacert /etc/tikv/pki/root.crt --cert /etc/tikv/pki/pd.crt --key /etc/tikv/pki/pd.key \
        --pd https://192.168.1.1:2379  \
        "$@"

4.2 tikv-ctl scan 扫描 key/value

tikv-ctl 不支持只列出所有 keys,所以只能 key 和 value 一起打印(扫描)。

扫描前缀是 foo 开头的所有 key:

$ ./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
...
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile3.\377txt\000\000\000\000\000\372
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile4.\377txt\000\000\000\000\000\372
...
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

扫描的时候一定要在 key 前面加一个 z 前缀,这是 TiKV 的一个设计

The raw-scan command scans directly from the RocksDB. Note that to scan data keys you need to add a ‘z’ prefix to keys.

代码出处 components/keys/src/lib.rs。 但对用户来说不是太友好,暴露了太多内部细节,没有 etcdctl 方便直接。

4.3 tikv-ctl mvcc 查看给定 key 对应的 value

$ ./tikv-ctl.sh mvcc -k 'zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1.\377txt\000\000\000\000\000\372' --show-cf default,lock,write
key: zfoo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile1.\377txt\000\000\000\000\000\372
         write cf value: start_ts: 452330816414416901 commit_ts: 452330816414416903 short_value: 010000000000000002

4.4 tikv-ctl --decode <key> 解除字符转义

# tikv escaped format -> raw format
./tikv-ctl.sh --decode 'foo-dev\375\377A\001\000\000\000\000\000\000\377\000Dfile4.\377txt\000\000\000\000\000\372'
foo-dev\375A\001\000\000\000\000\000\000\000Dfile4.txt

4.5 tikv-ctl --to-hex:转义表示 -> 十六进制表示

$ ./tikv-ctl.sh --to-hex '\375'
FD

4.6 tikv-ctl --to-escaped <value>:十六进制 value -> 带转义的字符串

./tikv-ctl.sh scan --from 'zfoo' --to 'zfop' --limit 100
key: zfoo-dev\375\377setting\000\376
        default cf value: start_ts: 452330324173520898 value: 7B0A22...

其中的 value 是可以解码出来的,

# hex -> escaped string
$ ./tikv-ctl.sh --to-escaped '7B0A22...'
{\n\"Name\": \"...\",\n\"UUID\": \"8cd1ac73\",\n\"Storage\": \"S3\",\n\"Bucket\": \"http://xxx\",\n\"AccessKey\": \"...\",\n\"BlockSize\": 4096,\n\"Compression\": \"none\",\n\"KeyEncrypted\": true,\n\"MetaVersion\": 1,\n\"UploadLimit\": 0,\n\"DownloadLimit\": 0,\n\"\": \"\"\n}

5 总结

本文介绍了一些 JuiceFS 元数据引擎相关的内容。

参考资料

  1. A Deep Dive into TiKV, 2016, pincap.com

Written by Human, Not by AI Written by Human, Not by AI

图解 JuiceFS CSI 工作流:K8s 创建带 PV 的 Pod 时,背后发生了什么(2024)

2024年7月13日 08:00

JuiceFS 是一个架设在对象存储(S3、Ceph、OSS 等)之上的分布式文件系统, 简单来说,

  • 对象存储:只能通过 key/value 方式使用;
  • 文件系统:日常看到的文件目录,能执行 ls/cat/find/truncate 等等之类的文件读写操作。

本文从 high-level 梳理了 JuiceFS CSI 方案中,当创建一个带 PV 的 pod 以及随后 pod 读写 PV 时, k8s/juicefs 组件在背后都做了什么,方便快速了解 K8s CSI 机制及 JuiceFS 的基本工作原理。

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 背景知识

简单列几个基础知识,有背景的可直接跳过。

1.1 K8s CSI (Container Storage Interface )

The Container Storage Interface (CSI) is a standard for exposing arbitrary block and file storage systems to containerized workloads on Container Orchestration Systems (COs) like Kubernetes.

https://kubernetes-csi.github.io/docs/

CSI 是 K8s 支持的一种容器存储机制,扩展性非常好, 各存储方案只要根据规范实现一些接口,就能集成到 k8s 中提供存储服务。

一般来说,存储方案需要在每个 node 上部署一个称为 “CSI plugin” 的服务, kubelet 在创建带 PV 容器的过程中会调用这个 plugin。但要注意,

  • K8s 的网络插件 CNI plugin 是一个可执行文件, 放在 /opt/cni/bin/ 下面就行了,kubelet 在创建 pod 网络时直接运行 这个可执行文件;
  • K8s 的存储插件 CSI plugin 是一个服务(某种程度上, 称为 agent 更好理解),kubelet 在初始化 PV 时通过 gRPC 调用这个 plugin;

1.2 FUSE (Filesystem in Userspace)

FUSE 是一种用户态文件系统,使得用户开发自己的文件系统非常方便。

懒得再重新画图, 这里借 lxcfs(跟 juicefs 没关系,但也是一种 FUSE 文件系统)展示一下 FUSE 的基本工作原理

Linux 容器底层工作机制:从 500 行 C 代码到生产级容器运行时(2023)

Fig. lxcfs/fuse workflow: how a read operation is handled [2]

JuiceFS 基于 FUSE 实现了一个用户态文件系统。

来自社区文档的一段内容,简单整理:

传统上,实现一个 FUSE 文件系统,需要基于 Linux libfuse 库,它提供两种 API:

  • high-level API:基于文件名和路径

    libfuse 内部做了 VFS 树的模拟,对外暴露基于路径的 API。

    适合元数据本身是基于路径提供的 API 的系统,比如 HDFS 或者 S3 之类。 如果元数据本身是基于 inode 的目录树,这种 inode → path →inode 的转换就会 影响性能。

  • low-level API:基于 inode。内核的 VFS 跟 FUSE 库交互就使用 low-level API。

JuiceFS 的元数据基于 inode 组织,所以用 low-level API 实现( 依赖 go-fuse 而非 libfuse),简单自然,性能好。

1.3 JuiceFS 三种工作模式

JuiceFS 有几种工作或部署方式:

  1. 进程挂载模式

    JuiceFS client 运行在 CSI Node plugin 容器中,所有需要挂载的 JuiceFS PV 都会在这个容器内以进程模式挂载。

  2. CSI 方式,又可分为两种:

    1. mountpod 方式:在每个 node 上,CSI plugin 动态为每个被 local pod 使用的 PV 创建一个保姆 pod,

      • 这个 mount pod 是 per-PV 而非 per-business-pod 的, 也就是说如果 node 上有多个业务 pod 在使用同一 PV,那只会有一个 mount pod, 下图可以看出来,

        Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

      • mount pod 里面装了 juicefs client,替业务 pod 完成 juicefs 相关的读写操作; 为了从字面上更容易理解,本文接下来把 mount pod 称为 dynamic client pod 或 client pod。
      • 这是 JuiceFS CSI 的默认工作方式
      • FUSE 需要 mount pod 具有 privilege 权限;
      • client pod 重启会导致业务 pod 一段时间读写不可用,但 client pod 好了之后业务 pod 就能继续读写了。
    2. . CSI sidecar 方式:给每个使用 juicefs PV 的业务 pod 创建一个 sidecar 容器。

      • per-pod 级别的 sidecar;
      • 注意 sidecar 就不是 JuiceFS plugin 创建的了,CSI Controller 会注册一个 Webhook 来监听容器变动,在创建 pod 时, webhook 给 pod yaml 自动注入一个 sidecar,跟 Istio 自动给 pod 注入 Envoy 容器类似;
      • Sidecar 重启需要重建业务 Pod 才能恢复。
      • 也依赖 FUSE,所以 sidecar 需要 privilege 权限。这会导致每个 sidecar 都能看到 node 上所有设备,有风险,所以不建议;

1.4 小结

有了以上基础,接下来看 k8s 中创建一个业务 pod 并且它要求挂载一个 PV 时,k8s 和 juicefs 组件都做了什么事情。

2 创建一个使用 PV 的 pod 时,k8s 和 juicefs 组件都做了什么

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 1:kubelet 启动,监听集群的 pod 资源变化

kubelet 作为 k8s 在每个 node 上的 agent,在启动后会监听整个 k8s 集群中的 pod 资源变化。 具体来说就是,kube-apiserver 中有 pod create/update/delete events 发生时,kubelet 都会立即收到。

Step 2:kubelet 收到业务 pod 创建事件,开始创建 pod

kubelet 收到一条 pod create 事件后,首先判断这个 pod 是否在自己的管辖范围内(spec 中的 nodeName 是否是这台 node), 是的话就开始创建这个 pod

Step 2.1 创建业务 pod:初始化部分

kubelet.INFO 中有比较详细的日志:

10:05:57.410  Receiving a new pod "pod1(<pod1-id>)"
10:05:57.411  SyncLoop (ADD, "api"): "pod1(<pod1-id>)"
10:05:57.411  Needs to allocate 2 "nvidia.com/gpu" for pod "<pod1-id>" container "container1"
10:05:57.411  Needs to allocate 1 "our-corp.com/ip" for pod "<pod1-id>" container "container1"
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id>]
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id>]
10:05:57.413  Cgroup has some missing paths: [/sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/pids/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpuset/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/systemd/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/memory/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/cpu,cpuacct/kubepods/burstable/pod<pod1-id> /sys/fs/cgroup/hugetlb/kubepods/burstable/pod<pod1-id>]
10:05:57.415  Using factory "raw" for container "/kubepods/burstable/pod<pod1-id>"
10:05:57.415  Added container: "/kubepods/burstable/pod<pod1-id>" (aliases: [], namespace: "")
10:05:57.419  Waiting for volumes to attach and mount for pod "pod1(<pod1-id>)"

10:05:57.432  SyncLoop (RECONCILE, "api"): "pod1(<pod1-id>)"

10:05:57.471  Added volume "meminfo" (volSpec="meminfo") for pod "<pod1-id>" to desired state.
10:05:57.471  Added volume "cpuinfo" (volSpec="cpuinfo") for pod "<pod1-id>" to desired state.
10:05:57.471  Added volume "stat" (volSpec="stat") for pod "<pod1-id>" to desired state.
10:05:57.480  Added volume "share-dir" (volSpec="pvc-6ee43741-29b1-4aa0-98d3-5413764d36b1") for pod "<pod1-id>" to desired state.
10:05:57.484  Added volume "data-dir" (volSpec="juicefs-volume1-pv") for pod "<pod1-id>" to desired state.
...

可以看出里面会依次处理 pod 所需的各种资源:

  1. 设备:例如 GPU
  2. IP 地址;
  3. cgroup 资源隔离配置;
  4. volumes

本文主要关注 volume 资源。

Step 2.2 处理 pod 依赖的 volumes

上面日志可以看到,业务 pod 里面声明了一些需要挂载的 volumes。几种类型

  1. hostpath 类型:直接把 node 路径挂载到容器内;
  2. lxcfs 类型:为了解决资源视图问题 [2];
  3. 动态/静态 PV 类型

本文的 JuiceFS volume 就属于 PV 类型,继续看 kubelet 日志:

# kubelet.INFO
10:05:57.509  operationExecutor.VerifyControllerAttachedVolume started for volume "xxx"
10:05:57.611  Starting operationExecutor.MountVolume for volume "xxx" (UniqueName: "kubernetes.io/host-path/<pod1-id>-xxx") pod "pod1" (UID: "<pod1-id>") 
10:05:57.611  operationExecutor.MountVolume started for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
10:05:57.611  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.611  kubernetes.io/csi: created path successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv]
10:05:57.611  kubernetes.io/csi: saving volume data file [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/vol_data.json]
10:05:57.611  kubernetes.io/csi: volume data file saved successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/vol_data.json]
10:05:57.613  MountVolume.MountDevice succeeded for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") device mount path "/var/lib/k8s/kubelet/plugins/kubernetes.io/csi/pv/juicefs-volume1-pv/globalmount"
10:05:57.616  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.616  kubernetes.io/csi: Mounter.SetUpAt(/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount)
10:05:57.616  kubernetes.io/csi: created target path successfully [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.618  kubernetes.io/csi: calling NodePublishVolume rpc [volid=juicefs-volume1-pv,target_path=/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:57.713  Starting operationExecutor.MountVolume for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
...
10:05:59.506  kubernetes.io/csi: mounter.SetUp successfully requested NodePublish [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]
10:05:59.506  MountVolume.SetUp succeeded for volume "juicefs-volume1-pv" (UniqueName: "kubernetes.io/csi/csi.juicefs.com^juicefs-volume1-pv") pod "pod1" (UID: "<pod1-id>") 
10:05:59.506  kubernetes.io/csi: mounter.GetPath generated [/var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount]

对于每个 volume,依次执行,

  1. operationExecutor.VerifyControllerAttachedVolume() 方法,做一些检查;
  2. operationExecutor.MountVolume() 方法,将指定的 volume 挂载到容器目录;
  3. 对于 CSI 存储,还会调用到 CSI plugin 的 NodePublishVolume() 方法,初始化对应的 PV,JuiceFS 就是这种模式。

接下来 kubelet 会不断检测所有 volumes 是否都挂载好,没好的话不会进入下一步(创建 sandbox 容器)。

Step 3:kubelet --> CSI plugin(juicefs):setup PV

下面进一步看一下 node CSI plugin 初始化 PV 挂载的逻辑。调用栈

         gRPC NodePublishVolume()
kubelet ---------------------------> juicefs node plugin (also called "driver", etc)

Step 4:JuiceFS CSI plugin 具体工作

看一下 JuiceFS CSI node plugin 的日志,这里直接在机器上看:

(node) $ docker logs --timestamps k8s_juicefs-plugin_juicefs-csi-node-xxx | grep juicefs-volume1
10:05:57.619 NodePublishVolume: volume_id is juicefs-volume1-pv

10:05:57.619 NodePublishVolume: creating dir /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount

10:05:57.620 ceFormat cmd: [/usr/local/bin/juicefs format --storage=OSS --bucket=xx --access-key=xx --secret-key=${secretkey} --token=${token} ${metaurl} juicefs-volume1]
10:05:57.874 Format output is juicefs <INFO>: Meta address: tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1
10:05:57.874 cefs[1983] <INFO>: Data use oss://<bucket>/juicefs-volume1/

10:05:57.875 Mount: mounting "tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1" at "/jfs/juicefs-volume1-pv" with options [token=xx]

10:05:57.884 createOrAddRef: Need to create pod juicefs-node1-juicefs-volume1-pv.
10:05:57.891 createOrAddRed: GetMountPodPVC juicefs-volume1-pv, err: %!s(<nil>)
10:05:57.891 ceMount: mount tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1 at /jfs/juicefs-volume1-pv
10:05:57.978 createOrUpdateSecret: juicefs-node1-juicefs-volume1-pv-secret, juicefs-system
10:05:59.500 waitUtilPodReady: Pod juicefs-node1-juicefs-volume1-pv is successful

10:05:59.500 NodePublishVolume: binding /jfs/juicefs-volume1-pv at /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount with options []
10:05:59.505 NodePublishVolume: mounted juicefs-volume1-pv at /var/lib/k8s/kubelet/pods/<pod1-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount with options []

可以看到确实执行了 NodePublishVolume() 方法, 这个方法是每个 CSI plugin 方案各自实现的,所以里面做什么事情就跟存储方案有很大关系。 接下来具体看看 JuiceFS plugin 做的什么。

Step 4.1 给 pod PV 创建挂载路径,初始化 volume

默认配置下,每个 pod 会在 node 上对应一个存储路径,

(node) $ ll /var/lib/k8s/kubelet/pods/<pod-id>
containers/
etc-hosts
plugins/
volumes/

juicefs plugin 会在以上 volumes/ 目录内给 PV 创建一个对应的子目录和挂载点,

/var/lib/k8s/kubelet/pods/{pod1-id}/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount

然后用 juicefs 命令行工具格式化

$ /usr/local/bin/juicefs format --storage=OSS --bucket=xx --access-key=xx --secret-key=${secretkey} --token=${token} ${metaurl} juicefs-volume1

例如,如果 JuiceFS 对接的是阿里云 OSS,上面就对应阿里云的 bucket 地址及访问秘钥。

Step 4.2 volume 挂载信息写入 MetaServer

此外,还会把这个挂载信息同步到 JuiceFS 的 MetaServer,这里用的是 TiKV,暂不展开:

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 4.3 JuiceFS plugin:如果 client pod 不存在,就创建一个

JuiceFS CSI plugin 判断这个 PV 在 node 上是否已经存在 client pod,如果不存在,就创建一个;存在就不用再创建了。

当 node 上最后一个使用某 PV 的业务 pod 销毁后,对应的 client pod 也会被 juicefs CSI plugin 自动删掉。

我们这个环境用的是 dynamic client pod 方式,因此会看到如下日志:

(node) $ docker logs --timestamps <csi plugin container> | grep 
...
10:05:57.884 createOrAddRef: Need to create pod juicefs-node1-juicefs-volume1-pv.
10:05:57.891 createOrAddRed: GetMountPodPVC juicefs-volume1-pv, err: %!s(<nil>)
10:05:57.891 ceMount: mount tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1 at /jfs/juicefs-volume1-pv
10:05:57.978 createOrUpdateSecret: juicefs-node1-juicefs-volume1-pv-secret, juicefs-system
10:05:59.500 waitUtilPodReady:

JuiceFS node plugin 会去 k8s 里面创建一个名为 juicefs-{node}-{volume}-pv 的 dynamic client pod。

Fig. JuiceFS as K8s CSI solution: workflow when a business pod is created (JuiceFS mountpod mode).

Step 5:kubelet 监听到 client pod 创建事件

这时候 kubelet 的业务 pod 还没创建好,“伺候”它的 juicefs client pod 又来“请求创建”了:

(node) $ grep juicefs-<node>-<volume>-pv /var/log/kubernetes/kubelet.INFO | grep "received "
10:05:58.288 SyncPod received new pod "juicefs-node1-volume1-pv_juicefs-system", will create a sandbox for it

所以接下来进入创建 juicefs dynamic client pod 的流程。

兵马未动,粮草先行。juicefs client pod 没有好,业务 pod 即使起来了也不能读写 juicefs volume

Step 6:kubelet 创建 client pod

创建 client pod 的流程跟业务 pod 是类似的,但这个 pod 比较简单,我们省略细节,认为它直接就拉起来了。

查看这个 client pod 内运行的进程

(node) $ dk top k8s_jfs-mount_juicefs-node1-juicefs-volume1-pv-xx
/bin/mount.juicefs ${metaurl} /jfs/juicefs-volume1-pv -o enable-xattr,no-bgjob,allow_other,token=xxx,metrics=0.0.0.0:9567

/bin/mount.juicefs 其实只是个 alias,指向的就是 juicefs 可执行文件

(pod) $ ls -ahl /bin/mount.juicefs
/bin/mount.juicefs -> /usr/local/bin/juicefs

Step 7:client pod 初始化、FUSE 挂载

查看这个 client pod 干了什么:

root@node:~  # dk top k8s_jfs-mount_juicefs-node1-juicefs-volume1-pv-xx
<INFO>: Meta address: tikv://node1:2379,node2:2379,node3:2379/juicefs-volume1
<INFO>: Data use oss://<oss-bucket>/juicefs-volume1/
<INFO>: Disk cache (/var/jfsCache/<id>/): capacity (10240 MB), free ratio (10%), max pending pages (15)
<INFO>: Create session 667 OK with version: admin-1.2.1+2022-12-22.34c7e973
<INFO>: listen on 0.0.0.0:9567
<INFO>: Mounting volume juicefs-volume1 at /jfs/juicefs-volume1-pv ...
<INFO>: OK, juicefs-volume1 is ready at /jfs/juicefs-volume1-pv
  1. 初始化本地 volume 配置
  2. 与 MetaServer 交互
  3. 暴露 prometheus metrics
  4. 以 juicefs 自己的 mount 实现(前面看到的 /bin/mount.juicefs),将 volume 挂载到 /jfs/juicefs-volume1-pv,默认对应的是 /var/lib/juicefs/volume/juicefs-volume1-pv

此时在 node 上就可以看到如下的挂载信息

(node) $ cat /proc/mounts | grep JuiceFS:juicefs-volume1
JuiceFS:juicefs-volume1 /var/lib/juicefs/volume/juicefs-volume1-pv fuse.juicefs rw,relatime,user_id=0,group_id=0,default_permissions,allow_other 0 0
JuiceFS:juicefs-volume1 /var/lib/k8s/kubelet/pods/<pod-id>/volumes/kubernetes.io~csi/juicefs-volume1-pv/mount fuse.juicefs rw,relatime,user_id=0,group_id=0,default_permissions,allow_other 0 0

可以看到是 fuse.juicefs 方式的挂载。 忘了 FUSE 基本工作原理的,再来借 lxcfs 快速回忆一下:

Fig. lxcfs/fuse workflow: how a read operation is handled [2]

这个 dynamic client pod 创建好之后, 业务 pod(此时还不存在)的读写操作都会进入 FUSE 模块, 然后转发给用户态的 juicefs client 处理。juicefs client 针对不同的 object store 实现了对应的读写方法。

Step 8:kubelet 创建业务 pod:完成后续部分

至此,Pod 所依赖的 volumes 都处理好了,kubelet 就会打印一条日志:

# kubelet.INFO
10:06:06.119  All volumes are attached and mounted for pod "pod1(<pod1-id>)"

接下来就可以继续创建业务 pod 了:

# kubelet.INFO
10:06:06.119  No sandbox for pod "pod1(<pod1-id>)" can be found. Need to start a new one
10:06:06.119  Creating PodSandbox for pod "pod1(<pod1-id>)"
10:06:06.849  Created PodSandbox "885c3a" for pod "pod1(<pod1-id>)"
...

小结

更详细的 pod 创建过程,可以参考 [1]。

3 业务 pod 读写 juicefs volume 流程

juicefs dynamic client pod 先于业务 pod 创建,所以业务 pod 创建好之后,就可以直接读写 juicefs PV (volume) 了,

Fig. JuiceFS as K8s CSI solution: workflow when a business pod reads/writes (JuiceFS mountpod mode).

这个过程可以大致分为四步。

Step 1:pod 读写文件(R/W operations)

例如在 pod 内进入 volume 路径(e.g. cd /data/juicefs-pv-dir/),执行 ls、find 等等之类的操作。

Step 2:R/W 请求被 FUSE 模块 hook,转给 juicefs client 处理

直接贴两张官方的图略作说明 [3],这两张图也透露了随后的 step 3 & 4 的一些信息:

读操作:

Fig. JuiceFS Internals: read operations.

写操作:

Fig. JuiceFS Internals: write operations.

Step 3:juicefs client pod 从 meta server 读取(文件或目录的)元数据

上面的图中已经透露了一些 JuiceFS 的元数据设计,例如 chunk、slice、block 等等。 读写操作时,client 会与 MetaServer 有相关的元信息交互。

Step 4:juicefs client pod 从 object store 读写文件

这一步就是去 S3 之类的 object store 去读写文件了。

4 总结

以上就是使用 JuiceFS 作为 k8s CSI plugin 时,创建一个带 PV 的 pod 以及这个 pod 读写 PV 的流程。 限于篇幅,省略了很多细节,感兴趣的可移步参考资料。

参考资料

  1. 源码解析:K8s 创建 pod 时,背后发生了什么(系列)(2021)
  2. Linux 容器底层工作机制:从 500 行 C 代码到生产级容器运行时(2023)
  3. 官方文档:读写请求处理流程, juicefs.com
  4. kubernetes-csi.github.io/docs/, K8s CSI documentation

Written by Human, Not by AI Written by Human, Not by AI

❌
❌