목차

 

들어가며

회사에서 OpenZFS 기반 스토리지를 개발하던 중, zfs create로 볼륨을 생성하는 중 dataset 생성 직후의 자동 마운트에서 간헐적으로 mountpoint or dataset is busy 오류로 실패하는 버그를 발견했습니다. 동시에 실행 중이던 스토리지 모니터링 데몬의 zfs userspace 조회가 같은 dataset을 먼저 잡아버린 것이 원인이었습니다. 이 포스트는 원인 분석부터 OpenZFS 업스트림에 패치를 병합시키기까지의 과정을 기록한 글입니다.

 

버그 증상

zfs create는 dataset을 만든 뒤 곧바로 마운트까지 수행합니다. 이 마운트 단계에서, pool과 dataset 상태를 10초마다 수집하는 모니터링 데몬의 zfs_job이 같은 dataset에 zfs userspace / groupspace / projectspace를 동시에 실행 중이면, zfs create의 마운트 단계가 EBUSY로 실패하며 exit(1)을 반환합니다. zfsmanager는 exit(1)을 볼륨 생성 실패로 처리하므로, dataset은 ZFS에 orphan으로 남고 볼륨 생성 API가 실패로 응답하는 사례가 있었습니다.

# zfs create -o acltype=posix -o refquota=154G -o refreservation=154G ci_zfsPool_1B/ci_zfsVol_beta
STDERR: cannot mount 'ci_zfsPool_1B/ci_zfsVol_beta': mountpoint or dataset is busy
         filesystem successfully created, but not mounted

 

코드 경로 분석

zfs userspace는 커널 ioctl ZFS_IOC_USERSPACE_MANY를 호출합니다. 이 핸들러는 zfsvfs_hold()를 통해 대상 데이터셋의 zfsvfs 구조체(ZFS 파일시스템 인스턴스를 표현하는 커널 구조체)를 참조합니다. 데이터셋이 이미 마운트되어 있으면 getzfsvfs()로 기존 구조체를 가져오고, 마운트되지 않은 상태면 zfsvfs_create()로 임시 구조체를 생성합니다.

이 코드 경로에는 세 가지 동기화 메커니즘이 관여합니다. 각각 보호하는 데이터와 동작 방식이 다르며, 버그의 원인과 해법을 이해하려면 세 가지 잠금을 확인해볼 필요가 있습니다.

세 가지 잠금, 한눈에 보기

이 경로에 등장하는 잠금은 모두 세 가지입니다. 이름이 비슷해 보이지만, 지키는 대상과 동작 방식이 다릅니다.

  • z_teardown_lock (R/W rwlock, zfsvfs_t 소속)
    • 보호: 파일시스템 인스턴스(zfsvfs_t)의 생명주기 · inode 캐시 · VFS 마운트 정보입니다.
    • 목적: 한 스레드가 쿼터를 조회하는 도중 다른 스레드가 umount·롤백으로 파일시스템을 부수면, 조회 쪽이 막 해제된 inode/구조체를 건드리는(use-after-free) 충돌이 생깁니다. 이러한 상황을 방지하기 위해 조회는 R, 해체는 W로 직렬화합니다.
    • R(읽기): 쿼터 조회·일반 입출력·zfsvfs_hold() 내부에서 여러 스레드가 동시에 진입합니다.
    • W(쓰기): umount·rollback·receive(파일시스템 해체) 시 기존 R이 모두 빠질 때까지 대기한 뒤 점유합니다.
  • dp_config_rwlock (R/W rwlock, dsl_pool_t 소속)
    • 보호: 풀 전체의 데이터셋 트리 · 속성 · 스냅샷 목록입니다.
    • 목적: 트리를 순회해 데이터셋을 찾는 도중 누군가 노드를 추가/삭제하면, 읽는 쪽이 중간에 끊긴 트리나 잘못된 속성값을 보는 불일치가 생깁니다. 읽기는 공유(R), 구성 변경은 단독(W)으로 막습니다.
    • R(읽기): own()/hold()가 객체셋을 찾을 때 내부적으로 사용하며 동시 읽기를 허용합니다.
    • W(쓰기): 풀 구성 변경(스냅샷 생성·삭제, 데이터셋 추가) 시 단독으로 점유합니다.
  • ds_owner (ds_lock으로 보호, mutex = 포인터 1개)
    • 보호: 잠금을 획득한 데이터셋의 “배타적 소유자” 표시입니다.
    • 목적: 한 데이터셋에 구조 변경(예: 마운트가 zfsvfs_t.z_os에 objset을 연결)이 들어갈 때, 두 명령이 동시에 독점 제어를 가정하면 이중 마운트·objset 연결 꼬임 등으로 상태가 꼬입니다. 그래서 한 번에 하나의 소유자만 허용합니다.
    • 동작: R/W 구분 없이 항상 배타입니다. own()ds_owner = tag로 설정하고 disown()NULL로 해제하며, 이미 값이 있으면 EBUSY를 반환합니다.

잠금이 세 개나 필요한 이유: 각각 지키는 범위(계층)가 다르기 때문입니다. z_teardown_lock은 “이 파일시스템을 당장 부수거나 고치고 있느냐”, dp_config_rwlock은 “이 풀의 구성(데이터셋 목록)이 바뀌고 있느냐”, ds_owner는 “이 데이터셋을 누가 독점적으로 쓰고 있느냐”를 각각 검사합니다. 계층이 다르므로 하나로 합칠 수 없습니다.

핵심은 앞의 두 잠금은 R/W 락이라 두 스레드가 모두 읽기(R)로 들어가면 서로를 막지 않는다는 점입니다. 반면 세 번째 ds_owner는 뮤텍스(한 번에 하나만)라, 두 명령이 동시에 own()을 부르면 반드시 한 쪽이 밀립니다. 이번 버그는 바로 이 세 번째 층에서 터집니다.

EBUSY가 발생하는 순간

그림을 보기 전에, 실제로 실패가 일어나는 순간을 시나리오로 따라가 보겠습니다. 상황은 zfs create가 dataset을 막 만들고 자동 마운트를 시도하려는 찰나에, 모니터링 데몬의 zfs userspace가 같은 dataset에 거의 동시에 도달하는 것입니다. dataset은 아직 마운트되지 않은 상태입니다.

  1. zfs userspace: dataset이 마운트되지 않았으므로 getzfsvfs()가 실패합니다. 그래서 임시 인스턴스를 만들기 위해 zfsvfs_create()로 들어가고, 그 안에서 dmu_objset_own(FTAG)을 호출해 ds_owner = FTAG로 배타적 소유를 선점합니다.
  2. zfs create: dataset 생성 직후, 자동 마운트를 위해 zfs_domount()dmu_objset_own(tag)을 호출합니다.
  3. 충돌: 두 own() 중 먼저 도착한 쪽이 ds_owner를 차지합니다. 1번이 먼저라면 2번은 ds_owner != NULL을 보고 곧바로 EBUSY를 받습니다. teardown/config 잠금은 둘 다 읽기(R)라 서로를 막지 않으므로, 충돌은 오직 ds_owner에서만 발생합니다.
  4. 결과: zfs create의 마운트가 실패해 exit(1)을 반환합니다. dataset 자체는 이미 생성돼 ZFS에 남지만 마운트는 안 된 ‘고아(orphan)’ 상태가 되고, zfsmanager는 이 exit(1)을 볼륨 생성 실패로 해석합니다.

아래 그림 1은 이 시나리오를 시간 흐름대로 펼쳐 놓은 것입니다. 두 명령이 각자의 경로를 따라 내려가다 ds_owner에서 마주치는 구조를 보여줍니다.

EBUSY 레이스 시나리오 타임라인: ds_owner 충돌
그림 1. EBUSY 레이스: 두 명령이 ds_owner(배타)에서 충돌한다

배타적 소유의 핵심은 ds_owner 포인터입니다. dmu_objset_own()을 호출하면 ds_lock 뮤텍스를 획득한 뒤 ds_owner가 비어 있는지 확인합니다. 이미 값이 설정되어 있으면 EBUSY를 반환하고, 비어 있으면 ds_owner = tag로 소유를 등록합니다.

 

원인 분석

zfs userspace는 마운트되지 않은 dataset에서 zfsvfs_create() 내부의 dmu_objset_own()으로 ds_owner를 설정합니다. zfs create의 자동 마운트 경로인 zfs_domount() 역시 동일한 dataset에 dmu_objset_own()을 호출합니다. 두 명령이 같은 시점에 실행되면, 먼저 도착한 쪽이 ds_owner를 선점하고 나중에 도착한 zfs_domount()가 EBUSY를 받아 exit(1)로 종료됩니다.

그렇다면 두 명령에서 꼭 잠금 처리가 필요할까요? 아래 그림은 두 명령이 own() 이후 실제로 하는 일을 보여줍니다. zfs createzfsvfs_t.z_os에 objset을 연결하는 구조 변경이 필요하므로 배타적 소유권이 필수입니다. 반면 zfs userspace는 ZAP 오브젝트에서 quota 숫자를 읽기만 하므로, 애초에 ds_owner를 선점할 이유가 없었습니다.

own() 이후 각 명령이 하는 것
그림 2. own() 이후 각 명령이 하는 것

 

해결 방법

zfs userspace 에서는 사용량 정보를 확인하기 위해서 zfsvfs_hold() 를 호출합니다. zfsvfs_hold()는 내부적으로 마운트 여부를 분기합니다. 이미 마운트된 경우 getzfsvfs()로 기존 구조체를 가져오므로 own()이 개입하지 않아 레이스가 발생하지 않습니다. 문제는 마운트되지 않은 경우 zfsvfs_create() 경로에서는 own() 이 개입하므로 레이스가 발생할 수 있습니다. 이 경로에서 dmu_objset_own() 대신 ds_owner를 건드리지 않는 dmu_objset_hold()를 사용하면 레이스 자체가 사라집니다. 이를 위해 zfsvfs_create_hold()라는 새 함수를 도입했습니다.

기존 zfsvfs_create()를 직접 수정하지 않고 새 함수를 만든 이유는, 기존 함수가 마운트 경로(zfs_domount())에서도 쓰이며 거기서는 own()이 여전히 필요하기 때문입니다.

수정 후 시나리오(그림1과 같은 흐름): hold+own 공존
그림 3. 수정 후(그림 1과 같은 흐름): hold()로 전환해 충돌 없이 공존한다

새 함수: zfsvfs_create_hold()

// module/os/linux/zfs/zfs_vfsops.c
static int
zfsvfs_create_hold(const char *osname, zfsvfs_t **zfvp)
{
    objset_t  *os;
    int        error;

    error = dmu_objset_hold(osname, FTAG, &os);
    if (error != 0)
        return (error);

    /* dmu_objset_hold()는 타입 체크를 하지 않으므로 명시적으로 확인 */
    if (dmu_objset_type(os) != DMU_OST_ZFS) {
        dmu_objset_rele(os, FTAG);
        return (SET_ERROR(EINVAL));
    }

    error = zfsvfs_create_impl(zfvp, NULL, os);
    if (error != 0)
        dmu_objset_rele(os, FTAG);
    return (error);
}

⚠️dmu_objset_hold()dmu_objset_own()과 달리 내부에서 객체셋 타입을 확인하지 않습니다. 타입 체크 없이 zvol(타입 2)에 이 경로가 호출되면 VERIFY3U(os_type == DMU_OST_ZFS) 커널 PANIC이 발생합니다. 이 케이스를 리뷰 과정에서 발견해 DMU_OST_ZFS 가드를 추가했습니다.

zfsvfs_hold() 수정

마운트되지 않은 경우 zfsvfs_create_hold()로 분기하도록 변레이스니다. 단, dmu_objset_hold()는 pool config read lock을 유지한 채 반환합니다. 그 상태에서 teardown lock을 획득하면 zfs_resume_fs()의 잠금 순서(teardown(W) → config)와 역전되어 ABBA 교착이 발생합니다. 따라서 teardown lock 획득 전에 config lock을 먼저 해제합니다.

// module/zfs/zfs_ioctl.c
int
zfsvfs_hold(const char *name, const void *tag, zfsvfs_t **zfvp, boolean_t writer)
{
    int error = 0;

    if (getzfsvfs(name, zfvp) != 0)
        error = zfsvfs_create_hold(name, zfvp);
    if (error == 0) {
        /*
         * dmu_objset_hold()는 config read lock을 유지한 채 반환합니다.
         * teardown lock 획득 전에 먼저 해제합니다 — zfs_resume_fs()가
         * teardown(W) 보유 후 config lock을 획득하므로 ABBA 교착이 발생합니다.
         */
        if ((*zfvp)->z_use_hold)
            dsl_pool_config_exit(dmu_objset_pool((*zfvp)->z_os), *zfvp);
        if (writer)
            ZFS_TEARDOWN_ENTER_WRITE(*zfvp, tag);
        else
            ZFS_TEARDOWN_ENTER_READ(*zfvp, tag);
    }
    return (error);
}

zfsvfs_rele() 수정

해제 경로에서는 z_use_hold 플래그로 분기합니다. hold() 경로라면 zfsvfs_hold()에서 해제했던 config lock을 재획득한 뒤 dmu_objset_rele()를 호출하고, own() 경로라면 dmu_objset_disown()으로 배타적 소유를 해제합니다.

// module/zfs/zfs_ioctl.c
void
zfsvfs_rele(zfsvfs_t *zfsvfs, const void *tag)
{
    ZFS_TEARDOWN_EXIT(zfsvfs, tag);

    if (zfs_vfs_held(zfsvfs)) {
        zfs_vfs_rele(zfsvfs);
    } else {
        objset_t *os = zfsvfs->z_os;
        if (zfsvfs->z_use_hold) {
            /* hold() 경로 — zfsvfs_hold()에서 해제한 config lock을 재획득 후 rele() */
            dsl_pool_config_enter(dmu_objset_pool(os), zfsvfs);
            dmu_objset_rele(os, zfsvfs);
        } else {
            /* own() 경로 — 배타적 소유 해제 */
            dmu_objset_disown(os, B_TRUE, zfsvfs);
        }
        zfsvfs_free(zfsvfs);
    }
}

 

구현 과정에서 발견한 문제들

dmu_objset_hold()로 단순 교체하면 EBUSY 레이스은 사라지지만, CI를 돌리자 전혀 다른 곳에서 새 문제가 드러났습니다.

ABBA 교착 (hold() 전환의 부작용)

dmu_objset_own()은 내부에서 pool config read lock을 획득했다가 반환 전에 해제합니다. 반면 dmu_objset_hold()는 그 lock을 유지한 채 반환합니다. 이 차이가 잠금 순서 역전을 만들었습니다.

zfsvfs_hold()zfsvfs_create_hold() 반환 직후 teardown lock을 획득합니다. 이 경로의 잠금 순서는 config(R) → teardown이 됩니다. 그런데 zfs_resume_fs()(롤백·receive 경로)는 teardown write lock을 먼저 잡고 나중에 config lock을 획득합니다. 그러면 순서가 teardown(W) → config로 역전됩니다. 두 경로가 교차하면 서로가 서로를 기다리는 ABBA 교착이 발생하고, CI에서는 10분 타임아웃 후 SIGKILL로 나타났습니다.

해결책은 teardown lock 획득 전에 config lock을 먼저 내려놓는 것입니다. 해제 경로인 zfsvfs_rele()에서는 반대로 config lock을 재획득한 뒤 dmu_objset_rele()를 호출해 lock을 돌려줍니다. 위의 zfsvfs_hold() / zfsvfs_rele() 코드 블록에 이 로직이 반영되어 있습니다.

z_use_hold 플래그 도입

zfsvfs_rele()에서 hold/own 경로를 구분할 때 처음에는 dmu_objset_ds(os)->ds_owner != NULL을 검사하려 했습니다. 그런데 ds_ownerds_lock 뮤텍스로 보호되는 필드여서 다른 스레드의 dmu_objset_own()이 동시에 설정할 수 있습니다. 그러므로 검사 자체가 레이스 대상이 되어 레이스가 발생할 수 있었습니다.

대신 Dataset 인스턴스의 vfs 계층 정보와 ZFS 내부 정보를 연결하고 관리하는 zfsvfs_tz_use_hold boolean을 추가해 zfsvfs_create_hold() 내부에서 한 번만 설정하고 이후 변경하지 않습니다. 이렇게 하면 잠금 없이 안전하게 읽을 수 있습니다.

 

테스트 케이스 추가

userspace / groupspace / projectspace 각각에 대해 레이스 테스트를 작성했습니다. zfs mount를 백그라운드로 실행하면서 조회 명령을 50회 동시에 실행합니다. 실제 버그는 zfs create에서 발생하지만, 테스트에서는 unmount → mount를 반복할 수 있는 zfs mount로 레이스 조건을 안정적으로 재현합니다.

# tests/zfs-tests/tests/functional/userquota/userspace_005_pos.ksh
typeset -i i=0 ebusy=0
while (( i < ITERS )); do
    log_must zfs unmount $RACE_FS
    zfs mount $RACE_FS &
    typeset mpid=$!
    zfs userspace $RACE_FS >"$ERRFILE" 2>&1
    typeset rc=$?
    wait $mpid
    if (( rc != 0 )) && grep -qi "busy" "$ERRFILE"; then
        (( ebusy++ ))
        log_note "Iteration $i: EBUSY: $(cat $ERRFILE)"
    fi
    (( i++ ))
done

(( ebusy > 0 )) && log_fail "EBUSY seen $ebusy/$ITERS times"
log_pass "zfs userspace does not EBUSY on mount race"

 

마치며

이 버그를 처음 봤을 때는 단순 타이밍 문제처럼 보였습니다. 하지만 EBUSY가 반환된 경위를 ds_owner 수준까지 추적하면서 드러난 것은 타이밍이 아니라 설계의 불일치였습니다. zfs userspace는 쿼터 숫자를 읽기만 하는 조회 연산인데도, 마운트 경로인 zfs_domount()와 동일하게 배타적 소유(own())를 요구하도록 구현되어 있었습니다. 두 연산이 서로 다른 목적으로 같은 자원을 독점하려 했기 때문에 충돌이 발생했습니다.

실제로 수정은 hold()로 교체하면 되는 한 줄 변경이었지만, 그 한 줄이 잠금 순서 역전과 ABBA 교착을 유발했습니다. 결국 config lock의 생명주기를 명시적으로 다루는 구조까지 설계해야 했고, 그 과정에서 ds_owner를 release 경로의 분기 조건으로 쓰면 안 된다는 것도 배웠습니다.

레이스 컨디션은 “그냥 재시도하면 된다”는 처방이 가장 흔합니다. 실제로 저희 제품에서도 zfs create 이후 마운트 실패 시 재시도하는 방식으로 먼저 대응했습니다. 그럼에도 왜 이런 일이 생기는지 궁금해서 파고들다 보니 ds_owner 수준까지 내려가게 됐고, 대응할 수 있도록 처리하다 보니 PR까지 쓰게 됐습니다. 결과적으로 OpenZFS에 반영됐고, 그 과정에서 ZFS 잠금 구조를 더 상세하게 이해하는 계기가 됐습니다.

 

참고

  1. OpenZFS PR #18611 — github.com/openzfs/zfs/pull/18611
  2. OpenZFS 기여 가이드 — CONTRIBUTING.md
  3. ZFS 파일 시스템 소개 — tech.gluesys.com