docker virtiofs volume mount symlink creation failure

## 증상
macOS Docker 에서 bind mount(-v) 된 경로에서 tar 압축 해제 시 symlink 포함 파일이 Permission denied 로 실패한다.
tar: re2-2025-11-05/python/LICENSE: Cannot open: Permission denied
tar: Exiting with failure status due to previous errors

ls 로 확인하면 symlink 대신 퍼미션 ---------- 인 0바이트 파일이 생성되어 있다.
---------- 1 root root 0 Aug 17 23:47 LICENSE

## 원인

Docker for Mac 은 호스트와 컨테이너 간 파일 공유에 VirtioFS 드라이버를 사용한다.

VirtioFS 는 호스트와 가상머신(VM) 간 파일 공유를 위한 파일시스템 프로토콜이다.
QEMU/KVM 의 virtio 가상화 프레임워크 위에서 동작하며, FUSE 프로토콜을 기반으로 호스트 파일시스템을 VM 내부에 마운트한다.
Docker for Mac 은 Linux VM 위에서 컨테이너를 실행하는데, -v bind mount 시 macOS 호스트 파일을 이 Linux VM 에 공유해야 한다.
이전에는 gRPC-FUSE 를 사용했으나, 성능 개선을 위해 VirtioFS 가 기본 파일 공유 드라이버로 채택되었다.
macOS (호스트) ←→ VirtioFS ←→ Linux VM ←→ 컨테이너

VirtioFS 는 gRPC-FUSE 대비 파일 I/O 성능이 크게 향상되지만, 일부 POSIX 파일시스템 동작(퍼미션 000 파일 생성, symlink 등)에서 호환성 문제가 있다.

tar 가 symlink 를 풀 때 내부적으로 다음 순서로 동작한다.
1. openat("dst", O_CREAT|O_EXCL, 000)  ← placeholder 파일 생성 (퍼미션 000)
2. 실제 타겟 파일 생성
3. unlinkat("dst")                       ← placeholder 삭제
4. symlinkat("../src", "dst")            ← symlink 생성

VirtioFS 는 퍼미션 000 으로 파일을 생성하는 openat() 호출을 Permission denied 로 거부한다.
1단계에서 실패하므로 symlink 가 만들어지지 않고 깨진 0바이트 파일만 남게 된다.
이 문제는 같은 디렉토리 내 symlink 에서는 발생하지 않고, 다른 디렉토리를 가리키는 symlink(예: python/LICENSE → ../LICENSE) 에서만 발생한다.

컨테이너 내부 경로(/tmp등)에서는 Linux 네이티브 파일시스템(overlay2)을 사용하므로 정상 동작한다.
./openat ./mounted/demo   → Permission denied (VirtioFS)
./openat /tmp/demo         → OK (overlay2)

## 해결 방법
1. named volume 사용 (권장)

bind mount 대신 Docker named volume 을 사용하면 Docker VM 내부 파일시스템이 사용되어 symlink 가 정상 동작한다.

# named volume 생성해서 마운트
docker volume create my_source_vol
docker run -dit \
    -v my_source_vol:/workspace/source \
    my_image

# 호스트 소스를 named volume 으로 복사
docker cp ./source/. "container_name:/workspace/source"

2. 컨테이너 내부 경로에서 빌드

소스를 컨테이너 내부 경로(`/tmp` 등)로 복사한 후 빌드한다.

docker exec -it my_container /bin/bash -c "
    cp -a /mounted/source /tmp/source &&
    cd /tmp/source &&
    ./build.sh /mounted/output"

3. VirtioFS 대신 다른 마운트 방식 사용
VirtioFS 를 사용하지 않으면 symlink 가 정상 생성된다. 다만 파일 I/O 성능이 저하될 수 있다.

# Docker Desktop 사용 시
Docker Desktop → Settings → General → "VirtioFS" 체크 해제

# Colima 사용 시
Colima 는 vmType 에 따라 사용 가능한 마운트 방식이 다르다.

vmType 종류
vz: macOS 자체 가상화 프레임워크(Virtualization.Framework) 사용. 네이티브라 빠르지만 macOS 13+ 필요
qemu: 오픈소스 하드웨어 에뮬레이터. CPU, 메모리, 디스크 등을 소프트웨어로 에뮬레이션한다. vz 보다 느리지만 다양한 마운트 타입 지원

mountType 종류:
virtiofs: macOS Virtualization.Framework 기반, 가장 빠름 (vmType vz 전용)
sshfs: SSH 기반 파일 전송, virtiofs 보다 느리지만 안정적 (vmType qemu 전용)
9p: Plan 9 프로토콜 기반, 가장 안정적 (vmType qemu 전용)

vmType vz 를 사용하는 경우 virtiofs 만 지원되므로 마운트 타입 변경으로는 해결할 수 없다.
이 경우 해결 방법 1(named volume) 또는 2(컨테이너 내부 경로에서 빌드)를 사용해야 한다.

현재 마운트 타입 확인:

# json 출력에서 mount_type, driver(vmType) 확인
colima status --json | jq '{mount_type, driver}'

# 또는 설정 파일 직접 확인
# colima.yaml 위치: ~/.config/colima/default/colima.yaml
grep -E 'mountType|vmType' ~/.config/colima/default/colima.yaml

vmType qemu 로 변경하면 sshfs 또는 9p 를 사용할 수 있지만 비추천한다.
macOS 에서 QEMU 바이너리에 com.apple.security.hypervisor entitlement 서명이 필요한데, 서명 에러가 발생하는 경우가 많고 vz 대비 성능도 떨어진다.

# qemu 시작 시 서명 에러 예시
"QEMU binary does not seem properly signed with the com.apple.security.hypervisor entitlement"

또한 mountType 은 VM 생성 후 변경 불가하므로 삭제 후 재생성해야 한다.

# colima start --mount-type 만으로는 변경되지 않는다
WARN[0001] 'volume mount type' cannot be updated after initial setup, discarded

qemu 로 변경 시도 후 다시 vz 로 되돌리려면 VM 을 삭제하고 재생성해야 한다.

# 기존 VM 삭제
colima delete --force

# vz + virtiofs 로 재생성 (기존 옵션에 맞게 조정)
colima start --vm-type vz --mount-type virtiofs --cpu 10 --memory 4 --disk 100

결론: Colima + vz 환경에서는 마운트 타입 변경이 현실적이지 않으므로 해결 방법 1(named volume) 또는 2(컨테이너 내부 경로에서 빌드)를 사용하자.

integrate OpenCode and ClaudeCode into neovim

opencode, claudecode 를 별도의 터미널 창에서 사용하다 보면 특정 코드 block/range 를 지시할때 copy & paste 를 사용하는데 좀 불편하다.
neovim(nvim)에서 opencode, claudecode 띄우고 코드를 보면서 특정 코드영역을 바로 넘겨서 질의할 수 있는 환경을 만들 수 있다.

opencode 의 경우 다음 플러그인을 설치하자.
opencode 가 nvim window 으로 표시된다.
<leader>or 로 코드 블럭을 opencode 프롬프트로 전달

claudecode 의 경우 다음 플러그인을 설치하자.
claudecode 가 nvim snacks_terminal 로 실행된다.
<leader>as 로 코드 블럭을 opencode 프롬프트로 전달

local llm with claude code and opencode

어떤 로컬 llmfit(https://github.com/AlexsJones/llmfit) 을 사용하면 내 하드웨어에 맞는 최고의 모델이 무엇인지알 수 있다.

ollama 로컬 llm 구동하자.
lmstudio-community/Qwen3-Coder-30B-A3B-Instruct-MLX-4bit 은 ollama 에서는 qwen3-coder 모델로 다운받을 수 있다.

ollama pull qwen3-coder:latest
ollama ls
ollama run qwen3-coder
ollama ps

참고로 ollama는 OpenAI 호환 API (/v1/chat/completions 등)를 제공한다.    
opencode.json > provider > ollama 에 다음과 같이 설정한다.
{
  "provider": {
    "ollama": {
      "npm": "@ai-sdk/openai-compatible",
      "name": "Ollama (local)",
      "options": {
        "baseURL": "http://localhost:11434/v1"
      },
      "models": {
        "qwen3-coder:latest": {
          "name": "Qwen3 Coder 30.5B"
        },
        "llama3.2:latest": {
          "name": "Llama 3.2 3B"
        }
      }
    }
  }
}

opencode 에서 로컬 llm 사용 결과

claude code 에서는 openAI api 호환이 되지 않아 중간에 litellm(proxy) 서버를 둬야 한다.
참고로 litellm[proxy] 로 기본 + 프록시 실행에 필요한 추가 패키지들(backoff, uvicorn, fastapi 등)을 함께 설치해야 한다.
uv pip install 'litellm[proxy]' --system

litellm 프록시 서버 구동
litellm --model qwen-coder3 --api_base http://localhost:11434/v1 --port 8001

이제 다음 환경변수를 .local_llm_env_for_claude_code 등의 파일로 저장하자.

# 로컬 llm URL
export ANTHROPIC_BASE_URL="http://localhost:8001"

# 더미값으로 설정하면 된다.(sk:SecretKey)
export ANTHROPIC_API_KEY="sk-dummy-key"

# Claude Code의 외부 통신을 최소화하기 위해 다음 환경변수도 설정하자.
# 텔레메트리(사용 통계 수집)를 비활성화
export CLAUDE_CODE_ENABLE_TELEMETRY=0

# 핵심 API 호출 외의 불필요한 네트워크 트래픽 차단
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1

# API 요청 시 Claude Code가 보내는 attribution 헤더(어떤 클라이언트에서 요청했는지 식별하는 정보)를 비활성화
export CLAUDE_CODE_ATTRIBUTION_HEADER=0

이제 다음과 같이 실행한다.
source .local_llm_env_for_claude_code; claude --model qwen3-coder

실행 후 api key 사용을 선택

왼쪽: 기본 opus 모델을 사용한 경우
오른쪽: 로컬 llm(qwen3-coder) 사용한 경우

사용해보니 ollama 자체앱을 사용하면 빠르게 답변을 주는데 모델에 상관 없이 opencode/claudecode 에서 연결해 사용하면 단순 질문도 gpu 100% 이 되면서 느려지는 경우도 생겨서 좀 불편했다.