zfs userspace와 zfs create 사이의 EBUSY 레이스 수정
by 이 헌제 (lhj4125@gluesys.com)
목차
들어가며
회사에서 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은 아직 마운트되지 않은 상태입니다.
zfs userspace측: dataset이 마운트되지 않았으므로getzfsvfs()가 실패합니다. 그래서 임시 인스턴스를 만들기 위해zfsvfs_create()로 들어가고, 그 안에서dmu_objset_own(FTAG)을 호출해ds_owner = FTAG로 배타적 소유를 선점합니다.zfs create측: dataset 생성 직후, 자동 마운트를 위해zfs_domount()→dmu_objset_own(tag)을 호출합니다.- 충돌: 두
own()중 먼저 도착한 쪽이ds_owner를 차지합니다. 1번이 먼저라면 2번은ds_owner != NULL을 보고 곧바로EBUSY를 받습니다. teardown/config 잠금은 둘 다 읽기(R)라 서로를 막지 않으므로, 충돌은 오직ds_owner에서만 발생합니다. - 결과:
zfs create의 마운트가 실패해exit(1)을 반환합니다. dataset 자체는 이미 생성돼 ZFS에 남지만 마운트는 안 된 ‘고아(orphan)’ 상태가 되고, zfsmanager는 이 exit(1)을 볼륨 생성 실패로 해석합니다.
아래 그림 1은 이 시나리오를 시간 흐름대로 펼쳐 놓은 것입니다. 두 명령이 각자의 경로를 따라 내려가다 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 create는 zfsvfs_t.z_os에 objset을 연결하는 구조 변경이 필요하므로 배타적 소유권이 필수입니다. 반면 zfs userspace는 ZAP 오브젝트에서 quota 숫자를 읽기만 하므로, 애초에 ds_owner를 선점할 이유가 없었습니다.
그림 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()이 여전히 필요하기 때문입니다.
그림 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_owner는 ds_lock 뮤텍스로 보호되는 필드여서 다른 스레드의 dmu_objset_own()이 동시에 설정할 수 있습니다. 그러므로 검사 자체가 레이스 대상이 되어 레이스가 발생할 수 있었습니다.
대신 Dataset 인스턴스의 vfs 계층 정보와 ZFS 내부 정보를 연결하고 관리하는 zfsvfs_t에 z_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 잠금 구조를 더 상세하게 이해하는 계기가 됐습니다.
참고
- OpenZFS PR #18611 — github.com/openzfs/zfs/pull/18611
- OpenZFS 기여 가이드 — CONTRIBUTING.md
- ZFS 파일 시스템 소개 — tech.gluesys.com