관리 메뉴

근묵자흑

Kubernetes PV-PVC 바인딩 워크플로우 본문

k8s

Kubernetes PV-PVC 바인딩 워크플로우

Luuuuu 2025. 4. 30. 17:28

Kubernetes를 사용하다 보면 영구 스토리지 관리는 핵심적인 부분입니다. 특히 PersistentVolume(PV)과 PersistentVolumeClaim(PVC)이 어떻게 바인딩되는지 그 내부 동작 방식을 이해하는 것은 효율적인 스토리지 관리에 매우 중요합니다. 이 블로그에서는 Pod가 생성될 때 PV와 PVC가 바인딩되는 전체 워크플로우를 심층적으로 분석하고, Kubernetes 소스 코드를 통해 내부 동작 원리를 알아보겠습니다.

pod 생성 시 PV-PVC 바인딩 워크플로우

Kubernetes 스토리지 기본 개념

Kubernetes에서 영구 스토리지를 관리하는 주요 객체들을 먼저 이해해 봅시다:

  • PersistentVolume(PV): 관리자가 프로비저닝하거나 스토리지 클래스를 통해 동적으로 프로비저닝된 클러스터의 스토리지 조각입니다. PV는 Pod와 독립적인 수명 주기를 가집니다.
  • PersistentVolumeClaim(PVC): 사용자의 스토리지 요청입니다. Pod는 PVC를 사용하여 PV에 접근합니다. PVC는 특정 크기와 접근 모드를 요청할 수 있습니다.
  • StorageClass: 관리자가 제공하는 스토리지의 "클래스"를 설명합니다. 동적 프로비저닝 시 어떤 종류의 스토리지를 생성할지 정의합니다.
  • VolumeAttachment: 노드에 볼륨을 연결하는 프로세스를 추적하는 API 객체입니다. CSI(Container Storage Interface) 드라이버가 사용합니다.

PV-PVC 바인딩 워크플로우 개요

Kubernetes에서 PV와 PVC의 바인딩부터 Pod에서 사용까지의 전체 워크플로우는 다음과 같은 순서로 진행됩니다:

  1. PV 생성: 관리자가 PV를 생성하면 Available 상태가 됩니다.
  2. PVC 생성: 사용자가 PVC를 생성하면 Pending 상태가 됩니다.
  3. PV-PVC 바인딩: PV 컨트롤러가 적합한 PV를 찾아 PVC와 바인딩합니다.
  4. Pod 생성 및 스케줄링: PVC를 참조하는 Pod가 생성되고 노드에 스케줄링됩니다.
  5. 볼륨 마운트: Kubelet이 볼륨을 노드에 마운트하고 컨테이너를 시작합니다.
  6. 라이프사이클 종료: Pod 삭제 시 볼륨이 언마운트되고, PVC 삭제 시 PV의 리클레임 정책에 따라 처리됩니다.

이제 각 단계를 코드 수준에서 자세히 살펴보겠습니다.

 

1단계: PV 생성

PV 생성은 일반적으로 클러스터 관리자에 의해 수행되며, 정적으로 생성되거나 StorageClass를 통해 동적으로 생성될 수 있습니다.

PV 정의 예시

apiVersion: v1
kind: PersistentVolume
metadata:
  name: example-pv
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: standard
  hostPath:
    path: /mnt/data

API 서버의 PV 생성 처리

사용자가 kubectl apply -f pv.yaml을 실행하면, API 서버는 다음과 같은 코드 흐름으로 처리합니다:

// kubernetes/pkg/registry/core/persistentvolume/storage/storage.go
type REST struct {
    *genericregistry.Store
}

// NewREST returns a RESTStorage object that will work against persistent volumes.
func NewREST(optsGetter generic.RESTOptionsGetter) *REST {
    store := &genericregistry.Store{
        NewFunc:                  func() runtime.Object { return &api.PersistentVolume{} },
        NewListFunc:              func() runtime.Object { return &api.PersistentVolumeList{} },
        DefaultQualifiedResource: api.Resource("persistentvolumes"),

        CreateStrategy: persistentvolume.Strategy,
        UpdateStrategy: persistentvolume.Strategy,
        DeleteStrategy: persistentvolume.Strategy,
        ExportStrategy: persistentvolume.Strategy,
    }
    options := &generic.StoreOptions{RESTOptions: optsGetter}
    if err := store.CompleteWithOptions(options); err != nil {
        panic(err) // TODO: Propagate error up
    }
    return &REST{store}
}

Create 메서드는 실제로는 임베드된 genericregistry.Store에서 상속받으며, 이벤트 처리도 다르게 이루어집니다:

// kubernetes/pkg/registry/core/persistentvolume/strategy.go
type Strategy struct {
    runtime.ObjectTyper
    names.NameGenerator
}

var Strategy = persistentVolumeStrategy{Strategy(scheme.Scheme, names.SimpleNameGenerator)}

func (persistentVolumeStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
    pv := obj.(*api.PersistentVolume)
    pv.Status = api.PersistentVolumeStatus{}
}

// kubernetes/staging/src/k8s.io/apiserver/pkg/registry/generic/registry/store.go
func (e *Store) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
    if err := rest.BeforeCreate(e.CreateStrategy, ctx, obj); err != nil {
        return nil, err
    }
    // ... 실제 생성 로직
    out, err := e.Storage.Create(ctx, key, obj, nil, ttl, dryrun.IsDryRun(options.DryRun))
    if err != nil {
        // ... 에러 처리
    }
    return out, nil
}

이벤트 생성은 별도의 이벤트 레코더를 통해 이루어집니다:

// kubernetes/pkg/controller/volume/persistentvolume/events.go
const (
    volumeFailedToProvision = "Provisioning failed for PersistentVolume %q"
    volumeProvisioned = "Provisioned PersistentVolume %q"
)

// kubernetes/pkg/controller/volume/persistentvolume/pv_controller.go
type PersistentVolumeController struct {
    eventRecorder  record.EventRecorder
    // ...
}

func (ctrl *PersistentVolumeController) syncVolume(volume *v1.PersistentVolume) error {
    // ... 로직
    ctrl.eventRecorder.Eventf(volume, v1.EventTypeNormal, "Provisioned", 
        volumeProvisioned, volume.Name)
}

PV 상태 관리

// kubernetes/pkg/controller/volume/persistentvolume/pv_controller.go

// syncVolume은 PV의 상태를 관리합니다
func (ctrl *PersistentVolumeController) syncVolume(volume *v1.PersistentVolume) error {
    // 볼륨 상태 확인
    if volume.Status.Phase == v1.VolumeAvailable {
        // Available 상태의 볼륨은 바인딩 가능한 클레임을 찾습니다
        return ctrl.syncVolumePhaseWithClaims(volume, false)
    }
    
    // ... 다른 상태 처리 로직
    
    return nil
}

이 메서드는 PV 컨트롤러가 Available 상태의 PV를 감지하고 처리하는 방법을 보여줍니다.

2단계: PVC 생성

사용자가 PVC를 생성하면 API 서버와 PV 컨트롤러가 이를 처리합니다.

PVC 정의 예시

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: example-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
  storageClassName: standard

API 서버의 PVC 생성 처리

// kubernetes/pkg/registry/core/persistentvolumeclaim/storage/storage.go

// Create PersistentVolumeClaim 객체 생성 처리
func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
    // 입력 객체를 PersistentVolumeClaim 타입으로 변환
    pvc := obj.(*api.PersistentVolumeClaim)
    
    // 객체 유효성 검증
    if err := rest.BeforeCreate(persistentvolumeclaim.Strategy, ctx, obj); err != nil {
        return nil, err
    }
    
    // PersistentVolumeClaim 생성 및 etcd에 저장
    out, err := r.Store.Create(ctx, obj, createValidation, options)
    if err != nil {
        return nil, err
    }
    
    // Created 이벤트 발생
    r.emitEventForPersistentVolumeClaim(pvc, corev1.EventTypeNormal, "ProvisioningRequested", "successfully requested provisioning")
    
    return out, nil
}

PVC는 생성 시 기본적으로 Pending 상태를 가집니다.

PVC 상태 관리

// kubernetes/pkg/controller/volume/persistentvolume/pv_controller.go

// syncClaim은 PVC의 상태를 관리합니다
func (ctrl *PersistentVolumeController) syncClaim(claim *v1.PersistentVolumeClaim) error {
    // 클레임 상태 확인
    if claim.Status.Phase == v1.ClaimPending {
        // Pending 상태의 클레임은 바인딩 가능한 볼륨을 찾습니다
        return ctrl.syncClaimPhaseWithVolume(claim)
    }
    
    // ... 다른 상태 처리 로직
    
    return nil
}

이 코드는 PV 컨트롤러가 Pending 상태의 PVC를 감지하고 처리하는 방법을 보여줍니다.

3단계: PV-PVC 바인딩

PV와 PVC의 바인딩은 PersistentVolume 컨트롤러에 의해 관리됩니다. 컨트롤러는 지속적으로 클러스터 상태를 모니터링하고 바인딩 작업을 수행합니다.

PersistentVolume 컨트롤러 초기화

// kubernetes/pkg/controller/volume/persistentvolume/pv_controller.go

// NewController는 PersistentVolume 컨트롤러를 생성합니다
func NewController(
    kubeClient clientset.Interface,
    syncPeriod time.Duration,
    volumePluginMgr *volume.VolumePluginMgr,
    cloud cloudprovider.Interface,
    clusterName string,
    // ... 다른 파라미터들
) (*PersistentVolumeController, error) {
    
    controller := &PersistentVolumeController{
        kubeClient:   kubeClient,
        volumePluginMgr: volumePluginMgr,
        // ... 기타 필드 초기화
    }
    
    // PV 및 PVC에 대한 Informer 설정
    controller.volumeLister = volumeLister
    controller.volumeListerSynced = volumeInformer.Informer().HasSynced
    controller.claimLister = claimLister
    controller.claimListerSynced = claimInformer.Informer().HasSynced
    
    // 이벤트 핸들러 등록
    volumeInformer.Informer().AddEventHandler(
        cache.ResourceEventHandlerFuncs{
            AddFunc:    func(obj interface{}) { controller.enqueueWork(controller.volumeQueue, obj) },
            UpdateFunc: func(oldObj, newObj interface{}) { controller.enqueueWork(controller.volumeQueue, newObj) },
            DeleteFunc: func(obj interface{}) { controller.enqueueWork(controller.volumeQueue, obj) },
        },
    )
    
    claimInformer.Informer().AddEventHandler(
        cache.ResourceEventHandlerFuncs{
            AddFunc:    func(obj interface{}) { controller.enqueueWork(controller.claimQueue, obj) },
            UpdateFunc: func(oldObj, newObj interface{}) { controller.enqueueWork(controller.claimQueue, newObj) },
            DeleteFunc: func(obj interface{}) { controller.enqueueWork(controller.claimQueue, obj) },
        },
    )
    
    // 워커 고루틴 시작
    go controller.run()
    
    return controller, nil
}

이 코드는 PV 컨트롤러가 PV와 PVC의 변경 사항을 감시하기 위해 어떻게 설정되는지 보여줍니다.

바인딩 적합성 검사

PV 컨트롤러는 PVC와 일치하는 PV를 찾기 위해 다음과 같은 매칭 알고리즘을 사용합니다:

// kubernetes/pkg/controller/volume/persistentvolume/pv_controller.go

// findBestMatchForClaim은 주어진 클레임에 가장 적합한 볼륨을 찾습니다
func (ctrl *PersistentVolumeController) findBestMatchForClaim(claim *v1.PersistentVolumeClaim) (*v1.PersistentVolume, error) {
    // 사용 가능한 볼륨 목록 조회
    volumes, err := ctrl.volumeLister.PersistentVolumes().List(labels.Everything())
    if err != nil {
        return nil, err
    }
    
    // 클레임에 맞는 볼륨 필터링
    filteredVolumes := ctrl.filterVolumes(volumes, claim)
    
    // 볼륨을 크기 순으로 정렬 (작은 것부터)
    sort.Sort(byCapacity{volumes: filteredVolumes})
    
    // 첫 번째 (최소 크기) 볼륨 반환
    if len(filteredVolumes) > 0 {
        return filteredVolumes[0], nil
    }
    
    return nil, nil
}

// filterVolumes는 클레임과 일치하는 볼륨만 필터링합니다
func (ctrl *PersistentVolumeController) filterVolumes(volumes []*v1.PersistentVolume, claim *v1.PersistentVolumeClaim) []*v1.PersistentVolume {
    var result []*v1.PersistentVolume
    
    for _, volume := range volumes {
        // Available 상태인지 확인
        if volume.Status.Phase != v1.VolumeAvailable {
            continue
        }
        
        // 스토리지 클래스 일치 확인
        if !ctrl.storageClassesMatch(claim, volume) {
            continue
        }
        
        // 용량 일치 확인
        if !ctrl.checkVolumeCapacity(volume, claim) {
            continue
        }
        
        // 접근 모드 일치 확인
        if !ctrl.checkVolumeAccessModes(volume, claim) {
            continue
        }
        
        // 볼륨 모드 일치 확인
        if !ctrl.checkVolumeMode(volume, claim) {
            continue
        }
        
        // 노드 어피니티 확인 (해당되는 경우)
        if !ctrl.checkNodeAffinity(volume, claim) {
            continue
        }
        
        // 모든 조건 만족 시 결과에 추가
        result = append(result, volume)
    }
    
    return result
}

이 코드는 PV 컨트롤러가 다음과 같은 기준으로 적합한 PV를 선택하는 방법을 보여줍니다:

  1. Available 상태의 PV만 고려
  2. 스토리지 클래스 일치 확인
  3. 요청된 용량 이상의 PV 선택
  4. 접근 모드 호환성 확인
  5. 볼륨 모드(파일시스템/블록) 일치 확인
  6. 노드 어피니티 제약 조건 확인

실제 바인딩 수행

적합한 PV와 PVC가 매칭되면, 컨트롤러는 실제 바인딩을 수행합니다:

// kubernetes/pkg/controller/volume/persistentvolume/pv_controller.go

// bindVolumeToClaim은 볼륨과 클레임을 바인딩합니다
func (ctrl *PersistentVolumeController) bindVolumeToClaim(volume *v1.PersistentVolume, claim *v1.PersistentVolumeClaim) error {
    // 볼륨을 클레임에 바인딩 (PV 업데이트)
    newVolume, err := ctrl.updateBindVolumeToClaim(volume, claim, true)
    if err != nil {
        return err
    }
    
    // 클레임을 볼륨에 바인딩 (PVC 업데이트)
    _, err = ctrl.updateBindClaimToVolume(claim, newVolume, true)
    if err != nil {
        // 롤백 시도
        ctrl.updateBindVolumeToClaim(newVolume, claim, false)
        return err
    }
    
    // 성공적으로 바인딩됨
    klog.V(4).Infof("Volume %s bound to claim %s", volume.Name, claimToClaimKey(claim))
    return nil
}

// updateBindVolumeToClaim은 볼륨을 클레임에 바인딩하도록 업데이트합니다
func (ctrl *PersistentVolumeController) updateBindVolumeToClaim(volume *v1.PersistentVolume, claim *v1.PersistentVolumeClaim, setVolumeBoundByController bool) (*v1.PersistentVolume, error) {
    volumeClone := volume.DeepCopy()
    
    // 바인딩 정보 설정
    volumeClone.Spec.ClaimRef = &v1.ObjectReference{
        Kind:       "PersistentVolumeClaim",
        Namespace:  claim.Namespace,
        Name:       claim.Name,
        UID:        claim.UID,
        APIVersion: "v1",
    }
    
    if setVolumeBoundByController {
        metav1.SetMetaDataAnnotation(&volumeClone.ObjectMeta, pvutil.AnnBoundByController, "yes")
    }
    
    // 볼륨 상태를 Bound로 설정
    volumeClone.Status.Phase = v1.VolumeBound
    
    // API 서버에 업데이트 요청
    newVol, err := ctrl.kubeClient.CoreV1().PersistentVolumes().Update(context.TODO(), volumeClone, metav1.UpdateOptions{})
    if err != nil {
        return nil, err
    }
    
    return newVol, nil
}

// updateBindClaimToVolume은 클레임을 볼륨에 바인딩하도록 업데이트합니다
func (ctrl *PersistentVolumeController) updateBindClaimToVolume(claim *v1.PersistentVolumeClaim, volume *v1.PersistentVolume, setClaimBoundByController bool) (*v1.PersistentVolumeClaim, error) {
    claimClone := claim.DeepCopy()
    
    // 바인딩 정보 설정
    claimClone.Spec.VolumeName = volume.Name
    
    if setClaimBoundByController {
        metav1.SetMetaDataAnnotation(&claimClone.ObjectMeta, pvutil.AnnBoundByController, "yes")
    }
    
    // 클레임 상태를 Bound로 설정
    claimClone.Status.Phase = v1.ClaimBound
    
    // API 서버에 업데이트 요청
    newClaim, err := ctrl.kubeClient.CoreV1().PersistentVolumeClaims(claim.Namespace).Update(context.TODO(), claimClone, metav1.UpdateOptions{})
    if err != nil {
        return nil, err
    }
    
    return newClaim, nil
}

이 코드는 바인딩 프로세스의 핵심을 보여줍니다:

  1. PV에 ClaimRef 필드 설정하여 PVC를 참조
  2. PV 상태를 Bound로 업데이트
  3. PVC에 VolumeName 필드 설정하여 PV를 참조
  4. PVC 상태를 Bound로 업데이트

바인딩은 두 단계로 이루어지며, 트랜잭션 방식으로 처리됩니다. 두 번째 업데이트가 실패하면 첫 번째 업데이트를 롤백합니다.

4단계: Pod 생성 및 스케줄링

Pod 정의 예시

apiVersion: v1
kind: Pod
metadata:
  name: example-pod
spec:
  containers:
  - name: app
    image: nginx
    volumeMounts:
    - mountPath: /data
      name: data-volume
  volumes:
  - name: data-volume
    persistentVolumeClaim:
      claimName: example-pvc

어드미션 컨트롤러 검증

Pod가 생성될 때, VolumeBindingAdmission 컨트롤러가 PVC가 바인딩되었는지 확인합니다:

// kubernetes/pkg/controller/volume/persistentvolume/admission_handler.go

// Admit는 PVC가 바인딩되었는지 확인합니다
func (c *PersistentVolumeClaimBinder) Admit(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
    // Pod 생성 요청인 경우만 처리
    if a.GetKind().GroupKind() != v1.SchemeGroupVersion.WithKind("Pod").GroupKind() || a.GetOperation() != admission.Create {
        return nil
    }
    
    pod, ok := a.GetObject().(*v1.Pod)
    if !ok {
        return admission.NewForbidden(a, fmt.Errorf("unexpected type %T", a.GetObject()))
    }
    
    // Pod의 각 볼륨에 대해
    for _, volume := range pod.Spec.Volumes {
        if volume.PersistentVolumeClaim == nil {
            continue
        }
        
        // PVC 이름 가져오기
        pvcName := volume.PersistentVolumeClaim.ClaimName
        
        // PVC 조회
        pvc, err := c.pvcLister.PersistentVolumeClaims(a.GetNamespace()).Get(pvcName)
        if err != nil {
            if errors.IsNotFound(err) {
                return admission.NewForbidden(a, fmt.Errorf("persistentvolumeclaim %s not found", pvcName))
            }
            return admission.NewForbidden(a, err)
        }
        
        // PVC가 바인딩되었는지 확인
        if pvc.Status.Phase != v1.ClaimBound {
            return admission.NewForbidden(a, fmt.Errorf("persistentvolumeclaim %s is not bound", pvcName))
        }
    }
    
    return nil
}

이 코드는 Pod가 사용하는 모든 PVC가 이미 바인딩되었는지 확인하는 중요한 보안 체크를 수행합니다. 바인딩되지 않은 PVC를 참조하는 Pod는 생성이 거부됩니다.

스케줄러 볼륨 바인딩 고려

스케줄러는 Pod의 볼륨 요구사항을 고려하여 적절한 노드를 선택합니다:

// kubernetes/pkg/scheduler/framework/plugins/volumebinding/volume_binding.go

// PreFilter는 Pod에 필요한 볼륨이 스케줄 가능한지 확인합니다
func (pl *VolumeBinding) PreFilter(ctx context.Context, state *framework.CycleState, pod *v1.Pod) (*framework.PreFilterResult, *framework.Status) {
    // Pod가 PVC를 사용하는지 확인
    podVolumes, err := pl.podVolumes(pod)
    if err != nil {
        return nil, framework.AsStatus(err)
    }
    
    if len(podVolumes.StaticBindings)+len(podVolumes.DynamicProvisions) == 0 {
        // PVC가 없으면 빠르게 넘어감
        return nil, framework.NewStatus(framework.Success)
    }
    
    // 상태 저장
    state.Write(stateKey, podVolumes)
    return nil, framework.NewStatus(framework.Success)
}

// Filter는 노드가 Pod의 볼륨 요구사항을 충족하는지 확인합니다
func (pl *VolumeBinding) Filter(ctx context.Context, state *framework.CycleState, pod *v1.Pod, nodeInfo *framework.NodeInfo) *framework.Status {
    // 이전 단계에서 저장된 정보 로드
    podVolumes, err := getPodVolumesFromState(state)
    if err != nil {
        return framework.AsStatus(err)
    }
    
    // 노드 이름 가져오기
    node := nodeInfo.Node()
    
    // 노드에서 볼륨 바인딩이 가능한지 확인
    unboundVolumesSatisfied, err := pl.Binder.Binder.FindPodVolumes(pod, node.Name, podVolumes)
    if err != nil {
        return framework.NewStatus(framework.Error, err.Error())
    }
    
    if !unboundVolumesSatisfied {
        return framework.NewStatus(framework.UnschedulableAndUnresolvable, "node(s) did not satisfy pod volumes")
    }
    
    return nil
}

이 코드는 스케줄러가 PVC 노드 어피니티, 토폴로지 제약 조건 등을 고려하여 Pod를 적절한 노드에 배치하는 방법을 보여줍니다.

5단계: 볼륨 마운트

Pod가 노드에 스케줄링되면, Kubelet이 볼륨을 마운트하는 과정을 처리합니다.

Kubelet의 볼륨 마운트 로직

// kubernetes/pkg/kubelet/volumemanager/volume_manager.go

// MountVolumes는 Pod의 볼륨을 마운트합니다
func (vm *volumeManager) MountVolumes(pod *v1.Pod, podVolumes kubecontainer.VolumeMap, container *v1.Container) ([]error, error) {
    var mountErrs []error
    
    // 각 볼륨에 대해
    for _, podVolume := range podVolumes {
        // 볼륨 마운트
        mountPath, err := vm.mounter.GetMountPath(podVolume)
        if err != nil {
            mountErrs = append(mountErrs, err)
            continue
        }
        
        // 컨테이너의 볼륨 마운트 정보 확인
        for _, volumeMount := range container.VolumeMounts {
            if volumeMount.Name == podVolume.Name {
                // 볼륨 마운트 수행
                err = vm.mounter.Mount(podVolume, mountPath, volumeMount.MountPath, volumeMount.ReadOnly)
                if err != nil {
                    mountErrs = append(mountErrs, err)
                }
            }
        }
    }
    
    return mountErrs, nil
}

CSI 드라이버와의 상호작용

Kubernetes는 Container Storage Interface(CSI)를 통해 스토리지 제공자와 통합됩니다:

// kubernetes/pkg/volume/csi/csi_attacher.go

// Attach는 CSI 드라이버를 사용하여 볼륨을 노드에 연결합니다
func (a *csiAttacher) Attach(spec *volume.Spec, nodeName types.NodeName) (string, error) {
    // 볼륨 핸들과 속성 가져오기
    pvSource, err := getPVSourceFromSpec(spec)
    if err != nil {
        return "", err
    }
    
    // VolumeAttachment 객체 생성
    attachment := &storage.VolumeAttachment{
        ObjectMeta: metav1.ObjectMeta{
            Name: getAttachmentName(pvSource.VolumeHandle, string(a.plugin.GetPluginName()), string(nodeName)),
        },
        Spec: storage.VolumeAttachmentSpec{
            Attacher: a.plugin.GetPluginName(),
            Source: storage.VolumeAttachmentSource{
                PersistentVolumeName: &spec.PersistentVolume.Name,
            },
            NodeName: string(nodeName),
        },
    }
    
    // API 서버에 VolumeAttachment 생성 요청
    attachment, err = a.k8s.StorageV1().VolumeAttachments().Create(context.TODO(), attachment, metav1.CreateOptions{})
    if err != nil {
        if !errors.IsAlreadyExists(err) {
            return "", err
        }
        // 이미 존재하는 경우 가져오기
        attachment, err = a.k8s.StorageV1().VolumeAttachments().Get(context.TODO(), attachment.Name, metav1.GetOptions{})
        if err != nil {
            return "", err
        }
    }
    
    // VolumeAttachment가 완료될 때까지 대기
    return a.waitForVolumeAttachment(attachment)
}

이 코드는 CSI 볼륨을 노드에 연결하는 프로세스를 보여줍니다:

  1. VolumeAttachment 객체 생성
  2. CSI 드라이버가 VolumeAttachment를 감지하고 실제 스토리지 연결 수행
  3. 연결이 완료될 때까지 대기
  4. 연결 완료 후 볼륨을 마운트 포인트에 마운트

6단계: 라이프사이클 종료

Pod 삭제 시 볼륨 정리

Pod가 삭제되면, Kubelet이 볼륨을 언마운트합니다:

// kubernetes/pkg/kubelet/volumemanager/volume_manager.go

// UnmountVolumes는 Pod의 볼륨을 언마운트합니다
func (vm *volumeManager) UnmountVolumes(pod *v1.Pod) error {
    // 볼륨 목록 가져오기
    podVolumes, err := vm.getPodVolumes(pod)
    if err != nil {
        return err
    }
    
    // 각 볼륨에 대해
    for _, podVolume := range podVolumes {
        // 볼륨 언마운트
        if err := vm.mounter.Unmount(podVolume); err != nil {
            return err
        }
    }
    
    return nil
}

PVC 삭제와 PV 재활용

PVC가 삭제되면, PV의 reclaim 정책에 따라 처리됩니다:

// kubernetes/pkg/controller/volume/persistentvolume/pv_controller.go

// reclaimVolume는 PV의 reclaim 정책에 따라 처리합니다
func (ctrl *PersistentVolumeController) reclaimVolume(volume *v1.PersistentVolume) error {
    switch volume.Spec.PersistentVolumeReclaimPolicy {
    case v1.PersistentVolumeReclaimRetain:
        // Retain 정책: 볼륨을 Released 상태로 변경하고 관리자가 수동으로 처리하도록 함
        return ctrl.updateVolumePhase(volume, v1.VolumeReleased, "")
        
    case v1.PersistentVolumeReclaimDelete:
        // Delete 정책: 볼륨을 삭제
        return ctrl.deleteVolume(volume)
        
    case v1.PersistentVolumeReclaimRecycle:
        // Recycle 정책 (deprecated): 볼륨 내용 지우고 Available 상태로 변경
        return ctrl.recycleVolume(volume)
    }
    
    return fmt.Errorf("unknown PersistentVolumeReclaimPolicy: %v", volume.Spec.PersistentVolumeReclaimPolicy)
}

// deleteVolume은 볼륨을 삭제합니다
func (ctrl *PersistentVolumeController) deleteVolume(volume *v1.PersistentVolume) error {
    // 볼륨 삭제 전 상태 변경
    newVolume, err := ctrl.updateVolumePhase(volume, v1.VolumeReleased, "deleting volume")
    if err != nil {
        return err
    }
    
    // 플러그인을 통해 볼륨 삭제
    plugin, err := ctrl.findDeletablePlugin(newVolume)
    if err != nil {
        // 볼륨이 Failed 상태로 표시
        ctrl.updateVolumePhase(newVolume, v1.VolumeFailed, err.Error())
        return err
    }
    
    // 삭제 수행
    if err := plugin.Delete(newVolume); err != nil {
        // 삭제 실패 시 Failed 상태로 표시
        ctrl.updateVolumePhase(newVolume, v1.VolumeFailed, err.Error())
        return err
    }
    
    // 볼륨 자체를 API 서버에서 삭제
    if err := ctrl.kubeClient.CoreV1().PersistentVolumes().Delete(context.TODO(), newVolume.Name, metav1.DeleteOptions{}); err != nil {
        return err
    }
    
    return nil
}

이 코드는 PV의 세 가지 reclaim 정책을 처리하는 방법을 보여줍니다:

  1. Retain: PV는 Released 상태로 변경되고 데이터 보존
  2. Delete: PV와 연결된 스토리지 리소스가 삭제됨
  3. Recycle(사용 중단): 볼륨 내용을 지우고 다시 Available 상태로 변경

동적 프로비저닝의 경우

지금까지는 정적 프로비저닝 케이스를 살펴보았습니다. 동적 프로비저닝의 경우 프로세스가 약간 다릅니다:

// kubernetes/pkg/controller/volume/persistentvolume/provision_controller.go

// provisionClaimOperation은 PVC에 대한 PV를 동적으로 프로비저닝합니다
func (ctrl *ProvisionController) provisionClaimOperation(claim *v1.PersistentVolumeClaim) error {
    // StorageClass 이름 가져오기
    storageClassName := getPersistentVolumeClaimClass(claim)
    if storageClassName == "" {
        return nil // 동적 프로비저닝 대상 아님
    }
    
    // StorageClass 가져오기
    storageClass, err := ctrl.getStorageClass(storageClassName)
    if err != nil {
        return err
    }
    
    // 프로비저너 이름 확인
    if storageClass.Provisioner != ctrl.provisionerName {
        return nil // 이 프로비저너가 처리할 수 없음
    }
    
    // 볼륨 생성
    options := ctrl.buildProvisionerOptions(claim, storageClass)
    volume, err := ctrl.provisioner.Provision(options)
    if err != nil {
        return err
    }
    
    // PV 생성
    pv, err := ctrl.kubeClient.CoreV1().PersistentVolumes().Create(context.TODO(), volume, metav1.CreateOptions{})
    if err != nil {
        return err
    }
    
    // PV 컨트롤러가 PV와 PVC를 자동으로 바인딩함
    return nil
}

동적 프로비저닝 프로세스:

  1. PVC가 StorageClass를 지정하면 PV 컨트롤러가 적절한 프로비저너를 찾음
  2. 프로비저너가 스토리지 제공자에 실제 볼륨 생성 요청
  3. 프로비저너가 PV 객체 생성 및 PVC에 맞게 설정
  4. PV 컨트롤러가 PVC와 새로 생성된 PV를 바인딩

주요 코드 패턴 및 디자인

Kubernetes의 PV-PVC 시스템에서 볼 수 있는 주요 설계 패턴들을 살펴봅시다:

1. 선언적 API와 조정 루프

Kubernetes는 선언적 API 모델을 사용합니다. 사용자는 원하는 상태를 선언하고, 컨트롤러는 현재 상태를 원하는 상태로 조정합니다:

func (ctrl *PersistentVolumeController) run() {
    // 메인 루프
    for {
        // 큐에서 아이템 가져오기
        obj, shutdown := ctrl.volumeQueue.Get()
        if shutdown {
            break
        }
        
        // 처리
        volume := obj.(*v1.PersistentVolume)
        err := ctrl.syncVolume(volume)
        if err != nil {
            // 재시도 큐에 다시 넣기
            ctrl.volumeQueue.AddRateLimited(obj)
        } else {
            // 성공, 큐에서 제거
            ctrl.volumeQueue.Forget(obj)
        }
    }
}

2. Informer 패턴

API 서버에 대한 호출을 최소화하기 위해 로컬 캐시를 사용합니다:

// volumeLister와 claimLister는 SharedInformer를 통해 설정됨
volumeInformer.Informer().AddEventHandler(
    cache.ResourceEventHandlerFuncs{
        AddFunc:    func(obj interface{}) { controller.enqueueWork(controller.volumeQueue, obj) },
        UpdateFunc: func(oldObj, newObj interface{}) { controller.enqueueWork(controller.volumeQueue, newObj) },
        DeleteFunc: func(obj interface{}) { controller.enqueueWork(controller.volumeQueue, obj) },
    },
)

3. 상태 기계

PV와 PVC는 상태 기계 모델을 따릅니다:

  • PV: Available → Bound → Released → (Failed/Available)
  • PVC: Pending → Bound → (Lost)

4. 플러그인 아키텍처

스토리지 제공자는 다양한 플러그인 인터페이스를 통해 통합됩니다:

  • In-tree Volume Plugins: Kubernetes 코드베이스 내에 포함된 플러그인
  • CSI(Container Storage Interface): 외부 플러그인을 위한 표준 인터페이스

마무리

이 블로그에서는 Kubernetes에서 PV와 PVC가 바인딩되는 워크플로우를 코드 수준에서 심층 분석했습니다. 주요 단계를 요약하면:

  1. PV 생성: 관리자나 동적 프로비저너에 의해 PV가 생성되고 Available 상태가 됩니다.
  2. PVC 생성: 사용자가 PVC를 생성하고 Pending 상태가 됩니다.
  3. PV-PVC 바인딩: PV 컨트롤러가 적합한 PV를 찾아 PVC와 바인딩하고, 둘 다 Bound 상태가 됩니다.
  4. Pod 생성: PVC를 참조하는 Pod가 생성되고, 어드미션 컨트롤러가 PVC 바인딩 상태를 확인합니다.
  5. Pod 스케줄링: 스케줄러가 볼륨 요구사항을 고려하여 적절한 노드를 선택합니다.
  6. 볼륨 마운트: Kubelet이 CSI 드라이버를 통해 볼륨을 노드에 연결하고 컨테이너에 마운트합니다.
  7. 라이프사이클 종료: Pod 삭제 시 볼륨이 언마운트되고, PVC 삭제 시 PV는 reclaim 정책에 따라 처리됩니다.

이러한 이해를 통해 Kubernetes 스토리지 시스템을 더 효과적으로 활용하고, 문제 발생 시 적절히 디버깅할 수 있습니다.


참고 자료