CSI 메커니즘과 JuiceFS CSI Driver 아키텍처 심층 분석

컨테이너 스토리지 인터페이스(CSI, Container Storage Interface)는 컨테이너 오케스트레이션 시스템(CO)이 다양한 스토리지 백엔드를 표준화된 방식으로 워크로드에 노출할 수 있게 해주는 표준 규격입니다. JuiceFS CSI Driver는 이 CSI 표준을 구현하여 Kubernetes 환경에서 실행되는 애플리케이션이 PVC(PersistentVolumeClaim)를 통해 JuiceFS 파일 시스템을 손쉽게 사용할 수 있도록 지원합니다. 본 문서에서는 CSI의 핵심 작동 원리와 이를 기반으로 구축된 JuiceFS CSI Driver의 내부 아키텍처를 기술적으로 분석합니다.

CSI 구성 요소 및 아키텍처

Kubernetes 스토리지 플러그인은 크게 In-tree와 Out-of-tree 두 가지 방식으로 분류됩니다. In-tree 방식은 Kubernetes 코어 코드베이스에 직접 통합되는 방식이며, Out-of-tree 방식은 독립적인 프로세스로 실행되며 gRPC를 통해 Kubernetes와 통신합니다. 현대적인 CSI 드라이버는 주로 Out-of-tree 방식을 채택하며, 이 방식은 유연성과 유지보수성을 높여줍니다. Out-of-tree 아키텍처는 Kubernetes에서 제공하는 다양한 사이드카(Sidecar) 컨포넌트와 스토리지 공급자가 구현해야 할 CSI 플러그인으로 구성됩니다.

사이드카 컴포넌트 (Sidecar Components)

Kubernetes는 CSI 드라이버와 협력하여 스토리지 기능을 수행하는 몇 가지 표준 사이드카 컨테이너를 제공합니다.

  • external-provisioner: PVC(PersistentVolumeClaim) 객체를 감시하며, StorageClass에 정의된 프로비저너 이름과 일치할 때 CSI Controller의 CreateVolume을 호출하여 스토리지를 생성합니다. PVC가 삭제되고 회수 정책(Reclaim Policy)이 Delete인 경우 DeleteVolume을 호출하여 정리합니다. 또한 볼륨 스냅샷을 소스로 사용하여 볼륨을 생성하는 기능도 지원합니다.
  • external-attacher: VolumeAttachment 객체를 감시하며, CSI Controller의 ControllerPublishVolumeControllerUnpublishVolume 인터페이스를 호출하여 볼륨을 노드에 연결(Attach)하거나 분리(Detach)합니다. 이는 주로 블록 스토리지와 같이Attach/Detach 단계가 필요한 시스템에서 사용됩니다.
  • external-resizer: PVC의 스토리지 용량 변경 요청을 감지하고, CSI Controller의 ControllerExpandVolume 또는 NodeExpandVolume을 호출하여 볼륨 크기를 동적으로 확장합니다.
  • external-snapshotter: VolumeSnapshotContent 객체를 감시하며, CSI Controller의 CreateSnapshot, DeleteSnapshot, ListSnapshots 인터페이스를 호출하여 볼륨 스냅샷을 관리합니다.
  • node-driver-registrar: CSI 드라이버의 Node 서비스 NodeGetInfo를 호출하여 드라이버 정보를 수집하고, 이를 kubelet의 플러그인 등록 메커니즘을 통해 해당 노드에 등록합니다.
  • livenessprobe: CSI 드라이버의 상태를 주기적으로 모니터링하고, Kubernetes의 Liveness Probe 메커니즘을 통해 드라이버에 장애가 발생했을 때 파드를 재시작하도록 합니다.

CSI 플러그인 인터페이스

스토리지 공급자는 CSI规范에 정의된 세 가지 주요 gRPC 서비스인 Identity, Controller, Node를 구현해야 합니다.

1. CSI Identity

드라이버의 정보와 capability를 노출하는 역할을 합니다. Controller와 Node 서비스 모두에서 구현되어야 합니다.

service Identity {
  rpc GetPluginInfo(InfoRequest) returns (InfoResponse) {}
  rpc GetPluginCapabilities(CapabilitiesRequest) returns (CapabilitiesResponse) {}
  rpc Probe(ProbeRequest) returns (ProbeResponse) {}
}

GetPluginInfo는 드라이버의 이름과 버전을 반환하며, node-driver-registrar가 이를 호출하여 kubelet에 등록합니다.

2. CSI Controller

볼륨의 수명 주기 관리를 담당합니다. 이 서비스는 일반적으로 StatefulSet으로 배포되며 볼륨 생성, 삭제, 스냅샷, Attach/Detach 등의 작업을 처리합니다.

service Controller {
  rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse) {}
  rpc DeleteVolume(DeleteVolumeRequest) returns (DeleteVolumeResponse) {}
  rpc ControllerPublishVolume(ControllerPublishVolumeRequest) returns (ControllerPublishVolumeResponse) {}
  rpc ControllerUnpublishVolume(ControllerUnpublishVolumeRequest) returns (ControllerUnpublishVolumeResponse) {}
  rpc ValidateVolumeCapabilities(ValidateVolumeCapabilitiesRequest) returns (ValidateVolumeCapabilitiesResponse) {}
  rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse) {}
  rpc GetCapacity(GetCapacityRequest) returns (GetCapacityResponse) {}
  rpc CreateSnapshot(CreateSnapshotRequest) returns (CreateSnapshotResponse) {}
  rpc DeleteSnapshot(DeleteSnapshotRequest) returns (DeleteSnapshotResponse) {}
  rpc ControllerExpandVolume(ControllerExpandVolumeRequest) returns (ControllerExpandVolumeResponse) {}
}

3. CSI Node

노드 레벨에서 볼륨을 마운트하고 마운트 지점을 관리합니다. 이 서비스는 각 노드에서 DaemonSet으로 실행됩니다.

service Node {
  rpc NodeStageVolume(NodeStageVolumeRequest) returns (NodeStageVolumeResponse) {}
  rpc NodeUnstageVolume(NodeUnstageVolumeRequest) returns (NodeUnstageVolumeResponse) {}
  rpc NodePublishVolume(NodePublishVolumeRequest) returns (NodePublishVolumeResponse) {}
  rpc NodeUnpublishVolume(NodeUnpublishVolumeRequest) returns (NodeUnpublishVolumeResponse) {}
  rpc NodeGetVolumeStats(NodeGetVolumeStatsRequest) returns (NodeGetVolumeStatsResponse) {}
  rpc NodeExpandVolume(NodeExpandVolumeRequest) returns (NodeExpandVolumeResponse) {}
  rpc NodeGetInfo(NodeGetInfoRequest) returns (NodeGetInfoResponse) {}
}

볼륨 마운트 워크플로우 상세 분석

파드(Pod)에 볼륨을 마운트하는 과정은 크게 Provision(생성), Attach(연결), Mount(마운트)의 세 단계로 나뉩니다. NFS나 JuiceFS와 같은 네트워크 파일 시스템의 경우 Attach 단계를 건너뛸 수 있습니다.

1. Provision 단계

사용자가 PVC를 생성하면 다음과 같은 절차가 수행됩니다.

  1. PVController는 새로운 PVC를 감시하고, 해당 스토리지 클래스가 Out-of-tree CSI 드라이버를 사용함을 확인하면 PVC에 어노테이션을 추가합니다.
  2. external-provisioner는 해당 어노테이션을 감지하고, CSI Controller의 CreateVolume RPC를 호출하여 스토리지 백엔드에 볼륨을 생성합니다.
  3. 볼륨 생성이 성공하면 external-provisioner는 해당 볼륨 정보를 담은 PV(PersistentVolume) 객체를 생성합니다.
  4. PVController는 PV와 PVC를 바인딩(Bind)합니다.

2. Attach 단계 (스토리지 유형에 따라 선택적)

볼륨 연결이 필요한 경우 다음 과정이 실행됩니다.

  1. Kubernetes의 ADController(AttachDetachController)는 파드가 특정 노드에 스케줄링되었음을 감지하고, CSI 볼륨에 대한 VolumeAttachment 객체를 생성합니다.
  2. external-attacher는 이 객체를 감지하고 CSI Controller의 ControllerPublishVolume을 호출하여 볼륨을 해당 노드에 연결합니다.
  3. 작업이 완료되면 VolumeAttachment 객체의 상태를 Attached(true)로 변경합니다.

3. Mount 단계와 Kubelet의 역할

마지막 단계는 kubelet이 주도하여 볼륨을 파드 내부에 마운트하는 과정입니다. kubelet의 syncPod 루프 내에서 volumeManager가 핵심적인 역할을 수행합니다.

아래는 kubelet 내부에서 마운트 작업을 조정하는 로직의 예시입니다.

func (kl *Kubelet) syncPod(opt syncPodOptions) error {
    // ...
    // 볼륨 마운트가 완료될 때까지 대기
    if !kl.podIsTerminated(opt.pod) {
        if err := kl.volumeManager.WaitForAttachAndMount(opt.pod); err != nil {
            return fmt.Errorf("볼륨 마운트 실패: %v", err)
        }
    }
    // ...
}

volumeManagerdesiredStateOfWorldPopulator(DSWP)와 reconciler라는 두 가지 주요 컴포넌트를 포함합니다. DSWP는 파드의 상태를 모니터링하여 마운트되어야 할 볼륨 목록(DesiredStateOfWorld)을 관리하고, reconciler는 실제 현재 상태(ActualStateOfWorld)를 기반으로 마운트 및 언마운트 작업을 수행합니다.

다음은 reconciler가 마운트 작업을 처리하는 로직을 개념적으로 재구성한 코드입니다.

func (r *reconciler) reconcileMounts() {
    // 마운트가 필요한 볼륨 목록 가져오기
    targets := r.desiredState.GetVolumesToMount()

    for _, vol := range targets {
        // 현재 볼륨 상태 확인
        isMounted, err := r.actualState.CheckMounted(vol.PodName, vol.VolumeName)
        
        if err == nil && isMounted {
            continue // 이미 마운트됨
        }

        if needsRemount(err) {
            // 마운트 수행
            r.executor.Mount(vol)
        } else if requiresExpansion(err) {
            // 볼륨 확장 수행
            r.executor.Expand(vol)
        }
    }

    // 더 이상 필요하지 않은 볼륨 언마운트 로직...
}

실제 마운트 실행은 csiMountMgr에 의해 처리되며, 이는 최종적으로 CSI Node 서비스의 NodePublishVolume RPC를 호출합니다.

func (c *csiMountMgr) ExecuteMount(targetDir string, opts *MountOptions) error {
    csiClient, err := c.csiClient.Get()
    if err != nil {
        return err
    }

    // CSI Node 서비스 호출
    return csiClient.NodePublishVolume(
        ctx,
        opts.VolumeID,
        opts.ReadOnly,
        opts.StagingPath,
        targetDir,
        opts.AccessMode,
        opts.PublishContext,
        opts.VolumeAttributes,
        opts.NodeSecrets,
        opts.FSType,
        opts.MountOptions,
    )
}

JuiceFS CSI Driver의 설계 및 작동 원리

JuiceFS CSI Driver는 CSI 표준을 준수하면서도 JuiceFS의 특성(분산 파일 시스템, FUSE 클라이언트)을 최적화하기 위해 독특한 아키텍처를 사용합니다. 가장 큰 특징은 NodePublishVolume 단계에서 실제 파일 시스템을 마운트하는 JuiceFS 클라이언트를 별도의 파드(Mount Pod) 내에서 실행한다는 점입니다.

마운트 파드(Mount Pod) 관리 전략

JuiceFS CSI Driver는 여러 애플리케이션 파드가 동일한 JuiceFS 볼륨을 공유할 때, 각 노드마다 하나의 JuiceFS 클라이언트 파드만 실행되도록 관리합니다. 이를 위해 파드의 어노테이션(Annotation)을 사용하여 레퍼런스 카운팅(Reference Counting)을 수행합니다.

아래는 마운트 파드를 생성하거나 참조를 추가하는 로직을 변형한 예시입니다.

func (m *MountManager) EnsureMountPod(cfg *Config) error {
    podName := generatePodName(cfg.VolumeID)

    // 기존 파드 존재 여부 확인 및 정리 대기 로직
    if err := m.waitIfNeeded(podName, cfg.Namespace); err != nil {
        return err
    }

    pod, exists, err := m.getPod(podName, cfg.Namespace)
    if err != nil {
        return err
    }

    if !exists {
        // 파드가 없으면 생성
        newPod := buildMountPod(podName, cfg)
        newPod.Annotations = map[string]string{
            "juicefs/mount-target": cfg.TargetPath,
        }
        return m.client.Create(newPod)
    }

    // 파드가 존재하면 참조 카운트 증가
    return m.addRefToPod(pod, cfg.TargetPath)
}

func (m *MountManager) waitIfNeeded(name, ns string) error {
    // 삭제 중인 파드가 있다면 정리될 때까지 대기
    for i := 0; i < 120; i++ {
        pod, err := m.client.Get(name, ns)
        if errors.IsNotFound(err) {
            return nil // 삭제 완료
        }
        if pod != nil && pod.DeletionTimestamp == nil {
            return nil // 정상 상태
        }
        time.Sleep(500 * time.Millisecond)
    }
    return fmt.Errorf("timeout waiting for pod deletion")
}

NodePublishVolume이 호출되면 위와 같은 로직을 통해 해당 볼륨을 위한 마운트 파드가 실행 중인지 확인하고, 없으면 생성한 뒤 해당 볼륨을 사용하려는 파드의 정보(여기서는 타겟 경로)를 어노테이션에 기록합니다. 여러 파드가 같은 볼륨을 요청하면 동일한 마운트 파드의 어노테이션에 경로가 추가됩니다.

반대로 NodeUnpublishVolume이 호출되면, 마운트 파드의 어노테이션에서 해당 경로를 제거합니다. 어노테이션에 남아있는 레퍼런스가 더 이상 없을 때만 마운트 파드를 삭제하여 리소스를 해제합니다.

func (m *MountManager) Unpublish(volumeID, targetPath string) error {
    podName := generatePodName(volumeID)
    
    // 어노테이션 업데이트 (참조 제거)
    if err := m.removeRefFromPod(podName, targetPath); err != nil {
        return err
    }

    // 남은 참조가 있는지 확인 후 파드 삭제 결정
    pod, err := m.client.Get(podName, namespace)
    if err != nil {
        return err
    }

    if !hasRemainingRefs(pod) {
        // 지연 삭제 정책 확인 후 삭제
        if !shouldDelayDeletion(pod) {
            return m.client.Delete(pod)
        }
    }
    return nil
}

이 아키텍처의 장점은 다음과 같습니다.

  • 결합 제거(Decoupling): JuiceFS 클라이언트 업그레이드 시 애플리케이션 파드에 영향을 주지 않고 마운트 파드만 교체하면 됩니다.
  • 관리 편의성: 클라이언트가 Kubernetes 파드로 실행되므로, 표준 Kubernetes 도구(kubectl, prometheus 등)를 통해 모니터링하고 로그를 수집할 수 있습니다.
  • 리소스 격리: 클라이언트 파드에 대한 CPU 및 메모리 리소스 제한을 별도로 설정할 수 있어, 노드의 안정성을 보장하기 쉽습니다.

태그: kubernetes CSI JuiceFS ContainerStorage gRPC

8월 14일 09:02에 게시됨