콘텐츠로 건너뛰기
Codex

Rust에서 라이브러리 없이 flock과 xattr 쓰기

TECH NOTE · AUTOMATION OPS

파일 잠금과 확장 속성은 크레이트 없이 시스템 콜을 직접 선언해 쓸 수 있습니다. 상수를 직접 적어야 하고 getxattr는 호출이 두 번이라는 점만 알면 됩니다.

파일 잠금 하나 때문에 크레이트를 받아야 하나

예제 코드를 만들면서 파일 잠금이 필요했다. 두 프로세스가 같은 파일을 동시에 쓰면 내용이 섞이는 상황을 보여줘야 했다.

fs2nix 같은 크레이트를 쓰면 한 줄이다. 그런데 예제에 의존성이 붙으면 받는 사람이 cargo build를 할 때 크레이트를 내려받아야 하고, 버전이 안 맞으면 거기서 막힌다.

잠금 함수 하나 쓰려고 그걸 감수할 이유가 없다. 직접 선언해서 쓰면 된다.

`flock` 선언하기

C 함수를 Rust에서 부르려면 시그니처를 적어 주면 된다.

use std::os::fd::AsRawFd;

const LOCK_EX: std::os::raw::c_int = 2;
const LOCK_NB: std::os::raw::c_int = 4;
const LOCK_UN: std::os::raw::c_int = 8;

unsafe extern "C" {
    fn flock(fd: std::os::raw::c_int, operation: std::os::raw::c_int) -> std::os::raw::c_int;
}

libc를 링크하는 건 표준 라이브러리가 이미 하고 있어서 따로 설정할 게 없다. 선언만 하면 부를 수 있다.

쓰는 쪽은 이렇다.

pub fn lock_exclusive(file: &File) -> Result<(), StoreError> {
    if unsafe { flock(file.as_raw_fd(), LOCK_EX) } == 0 {
        Ok(())
    } else {
        Err(StoreError::StorageUnavailable)
    }
}

pub fn try_lock_exclusive(file: &File) -> bool {
    unsafe { flock(file.as_raw_fd(), LOCK_EX | LOCK_NB) == 0 }
}

두 함수의 차이는 LOCK_NB 하나다. 붙이면 기다리지 않고 바로 돌아온다.

상수는 직접 확인해야 한다

여기가 이 방식의 실제 비용이다. LOCK_EX = 2 같은 값은 헤더 파일에 있고, 크레이트를 쓰면 남이 확인해 준 값을 쓰는 것이다.

직접 적으면 내가 확인해야 한다. 헤더 위치가 플랫폼마다 다른 것부터 걸린다.

Linux는 /usr/include에 있지만 macOS에는 그 경로가 없다. Xcode SDK 안으로 들어갔다.

grep -n "define.*LOCK_" "$(xcrun --show-sdk-path)/usr/include/sys/fcntl.h"
486:#define LOCK_SH         0x01            /* shared file lock */
487:#define LOCK_EX         0x02            /* exclusive file lock */
488:#define LOCK_NB         0x04            /* don't block when locking */
489:#define LOCK_UN         0x08            /* unlock file */

16진수로 적혀 있어서 그대로 옮기면 안 된다. 0x022다.

이 값들은 macOS와 Linux가 같지만 전부 그런 건 아니다. O_NONBLOCK처럼 플랫폼마다 갈리는 것도 있다.

틀리면 컴파일은 되고 동작만 이상해진다. 타입이 c_int라 어떤 숫자든 통과하기 때문이다. 그래서 테스트로 확인하는 게 필수다.

#[test]
fn a_second_holder_cannot_take_the_lock() {
    let first = StoreLock::acquire(&path).expect("first");
    let second = StoreLock::try_acquire(&path).expect("no error");
    assert!(second.is_none(), "두 번째가 잠금을 가져갔다");

    drop(first);
    assert!(StoreLock::try_acquire(&path).expect("no error").is_some());
}

상수가 틀리면 이 테스트가 깨진다. 잠금이 안 걸리거나, 걸린 채로 안 풀린다.

`getxattr`는 호출이 두 번이다

확장 속성을 읽는 것도 같은 방식인데, 여기엔 패턴이 하나 더 있다.

unsafe extern "C" {
    fn getxattr(
        path: *const c_char,
        name: *const c_char,
        value: *mut c_void,
        size: usize,
        position: u32,
        options: c_int,
    ) -> isize;
}

값이 얼마나 큰지 모르니 버퍼를 미리 잡을 수 없다. 처음에는 크기만 물어보고, 그만큼 잡아서 다시 부른다.

// 1) 크기 조회 — value에 null, size에 0을 넘긴다
let size = unsafe {
    getxattr(path_c.as_ptr(), name_c.as_ptr(), std::ptr::null_mut(), 0, 0, XATTR_NOFOLLOW)
};
if size < 0 {
    return Ok(None);   // 속성이 없다. 오류가 아니다
}

// 2) 버퍼를 잡고 다시 부른다
let mut buffer = vec![0u8; size as usize];
let read = unsafe {
    getxattr(path_c.as_ptr(), name_c.as_ptr(),
             buffer.as_mut_ptr().cast(), buffer.len(), 0, XATTR_NOFOLLOW)
};
buffer.truncate(read as usize);

이 2단계 호출은 POSIX 계열 API에서 흔한 패턴이다. readlink, sysctl, getgroups가 모두 비슷하다. 한 번 알면 다음부터 보인다.

두 번째 호출에서 크기가 달라질 수 있다는 것도 알아 둘 만하다. 그 사이에 값이 바뀌면 잘린다. 그래서 반환값으로 truncate한다.

문자열을 넘길 때

C 함수는 널로 끝나는 문자열을 받는다. Rust String은 널로 안 끝나므로 변환이 필요하다.

fn to_cstring(path: &Path) -> io::Result<CString> {
    CString::new(path.to_string_lossy().as_bytes())
        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "경로에 널 바이트가 있다"))
}

CString::new는 문자열 중간에 널 바이트가 있으면 실패한다. 파일 경로에는 없는 게 정상이지만, 사용자 입력에서 온 값이면 확인해야 한다.

그리고 CString을 살려 둬야 한다. 포인터만 넘기고 원본을 떨어뜨리면 해제된 메모리를 가리킨다.

let path_c = to_cstring(path)?;          // 살아 있다
unsafe { getxattr(path_c.as_ptr(), ...) }

이걸 한 줄로 붙여 쓰면 컴파일은 되는데 동작이 이상해진다.

// 위험 — 임시값이 바로 떨어진다
unsafe { getxattr(to_cstring(path)?.as_ptr(), ...) }

언제 크레이트를 쓰나

직접 선언하는 게 늘 나은 건 아니다. 기준은 이렇게 잡았다.

상황 선택
함수 한두 개, 상수 몇 개 직접 선언
플랫폼 분기가 많다 크레이트
구조체를 주고받는다 크레이트
예제라 의존성을 0으로 두고 싶다 직접 선언 + 테스트

구조체가 끼면 얘기가 달라진다. 필드 순서와 패딩까지 맞춰야 하고, 플랫폼마다 다르다. 그건 직접 할 일이 아니다.

정리

  • unsafe extern "C"로 선언만 하면 libc 함수를 부를 수 있다. 링크 설정은 필요 없다
  • 상수는 헤더에서 직접 확인한다. 틀려도 컴파일은 되므로 테스트가 필수다
  • getxattr 같은 API는 크기 조회 → 버퍼 잡기 → 재호출의 2단계다
  • CString은 포인터를 넘기는 동안 살아 있어야 한다
  • 함수 몇 개면 직접, 구조체가 끼면 크레이트

예제에서 의존성을 0으로 두면 받는 사람이 cargo test 한 줄로 확인할 수 있다. 그 값어치가 상수 몇 개 확인하는 품보다 크다고 봤다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다