← nacyot / artifacts

NAS는 800MB/s를 낼 수 있는데 rsync로는 230밖에 안 나온다

2026-08-10 · 2026-08-11 개정

10GbE로 묶인 NAS에 큰 파일을 넣는데 rsync가 230MB/s를 냈다. 같은 장비의 디스크는 800MB/s 넘게 읽고 쓴다. 어디서 70%가 사라지는지 계층별로 잘라보니 범인은 넷이었다. ssh 암호화, 직렬 처리, 10G를 상정하지 않은 커널 소켓 버퍼, 그리고 순차 읽기 힌트의 부재. 각각의 몫을 단독 A/B로 재고 나니 같은 하드웨어에서 868MB/s가 나왔다.

먼저, 이 장비가 낼 수 있는 속도

격차를 말하려면 기준선부터 있어야 한다. 전송과 무관하게 각 계층을 따로 쟀다.

계층별 상한 (MB/s)

계층실측측정 방법
10G 링크1054대역폭 측정 도구
NAS 디스크 읽기838 이상캐시를 비운 상태에서 실제로 낸 값
NAS 디스크 쓰기783램 용량을 넘는 395GB 전송에서도 707 유지

이 조합의 현실적인 천장은 700~850MB/s이고 병목은 디스크다. 네트워크는 여유가 있다. 그런데 실제 파일 전송은 그 근처에도 못 갔다.

표준 도구의 실제 성적 (MB/s)

디스크가 낼 수 있는 값
~838
rsync, 여러 파일
451~696
rsync, 단일 대용량
228~238

큰 파일 하나를 옮길 때가 가장 나쁘다. 하드웨어가 낼 수 있는 것의 30%도 못 쓴다. 모델 파일처럼 수 GB짜리 덩어리를 다루면 매번 이 구간이다.

10G 망은 이렇게 생겼다

테스트 경로는 스위치 세 대를 지난다. CRS312가 호스트를 수용하는 코어이고, 10G 링크 하나가 CRS304로 넘어가며, 거기서 QNAP 스위치를 거쳐 NAS에 닿는다. 게이트웨이로 가는 1G 링크는 인터넷용이고 이 전송에는 관여하지 않는다.

맥 워크스테이션 대형 모델 추론 연산 노드 ×2 영상 생성 · 추론 하이퍼바이저 가상머신 호스트 10G CRS312 10G 코어 10G CRS304 분배 10G QNAP NAS HDD 8구 RAID10 1G 게이트웨이 · 1G 액세스 망 모델 전송 경로: 워크스테이션 ⇄ CRS312 ⇄ CRS304 ⇄ QNAP ⇄ NAS 전 구간 10G · 암호화 없는 병렬 TCP · 게이트웨이를 지나지 않는다

모델 전송은 10G 망 안에서만 오간다. 게이트웨이를 지나지 않으므로 1G 접점은 병목이 아니다.

NAS는 저전력 4코어 x86 CPU에 램 31GB, HDD 8구 RAID10과 NVMe 캐시를 얹은 8베이 모델이다. 이 CPU가 이야기의 중심이다. 10G를 감당하기엔 여유가 없어서, 소프트웨어가 조금만 낭비해도 바로 속도로 드러난다.

사라진 70%를 찾아서

원인을 하나씩 벗겨냈다. 아래는 같은 조건에서 잰 하나의 사슬이다. 큰 파일 하나를 NAS에 올리는 상황, 단일 스트림, 매번 디스크까지 쓴 값이다.

큰 파일 하나 업로드, 단계별 (MB/s)

rsync over ssh
230
+ rsync 걷어냄
263
+ 암호화 걷어냄
426
+ 커널 버퍼·구현 수정
719

각 단계는 앞 단계 위에 쌓인다. 가장 큰 계단은 암호화 제거(+62%)이고, 그다음이 커널 버퍼와 구현 수정(+69%)이다.

세 계단에는 공통점이 하나 있다. 전부 NAS의 CPU를 덜 쓰게 만드는 일이라는 것이다. 이 장비의 CPU는 저전력 4코어이고 10G를 감당할 여유가 없다. 그래서 바이트를 옮기는 것 말고 CPU가 하는 일(복호화, 시스템 콜, 메모리 복사, 대기)을 줄일 때마다 속도가 올랐다. 디스크도 네트워크도 여유가 있었으니, 이 구간은 처음부터 끝까지 CPU 예산 싸움이었다.

1. rsync 자체: 작은 몫

macOS가 기본 탑재한 rsync는 실은 openrsync라는 별개 구현이고, 오래된 프로토콜에 단일 스레드다. 더 중요한 건 rsync가 하는 일 대부분이 이 상황에서 무의미하다는 점이다. 목적지에 없는 새 파일을 보내는 중이니 델타를 계산할 대상이 없는데도 블록 체크섬과 파일 목록 협상을 거친다. 그 비용이 고스란히 CPU에 얹힌다.

같은 파일을 cat 파일 | ssh nas 'cat > 대상'으로 보내면 230에서 263이 된다. 14% 회수다. 생각보다 작은데, ssh가 아직 남아 있어서 그렇다. rsync는 범인이라기보다 거들었다.

2. ssh 암호화: 가장 큰 몫

암호화 없는 TCP로 같은 파일을 보내니 263에서 426MB/s가 됐다. 디스크에 쓰지 않고 버리면 477까지 나온다. 62% 회수, 이 글에서 가장 큰 계단이다.

먼저 의심한 건 암호 알고리즘이었다. 이 CPU에도 AES 가속 명령이 있으니 그쪽으로 바꾸면 나아질 줄 알았는데 효과가 정확히 0이었다. 그래서 범인은 암호 연산 자체가 아니다.

ssh는 데이터를 채널 단위로 잘라 각 조각마다 암호화하고 인증 태그를 붙인 뒤, 받는 쪽에서 그 태그를 검증하고 다시 조립한다. 이 과정이 연결 하나당 한 스레드에서 돌아간다. 즉 코어 하나가 복호화·검증·조립·창 관리를 전부 떠맡고, 263MB/s 지점에서 그 코어가 포화된다. 암호화를 걷어낸다는 건 연산 하나를 빼는 게 아니라 이 파이프라인 전체를 들어내는 것이다.

대신 잃는 것도 분명하다. 기밀성과 무결성이 사라진다. 이 전송은 집 안 스토리지 전용 VLAN 안에서만 오가고 밖으로 나가지 않으므로 감수할 만하다고 판단했지만, 경로가 신뢰 구간을 벗어난다면 성립하지 않는 선택이다.

집 안 전용 스토리지 VLAN이니 암호화를 버리는 선택은 어렵지 않았다. 다만 이 결정이 뒤의 모든 것을 좌우한다.

3. 커널 소켓 버퍼가 208KB

NAS의 net.core.rmem_max는 리눅스 기본값 208KB였다. 10G를 상정한 값이 아니다. 여기엔 함정이 하나 더 있었다. 애플리케이션이 SO_RCVBUF로 큰 버퍼를 요청하면 그 순간 커널의 자동 조절(최대 6MB)이 꺼지고, 요청값은 208KB 상한에 잘린다. 좋아지라고 손댔다가 아무것도 안 한 것보다 나빠지는 구조다.

버퍼가 작으면 왜 느려지는지는 이렇게 생각하면 된다. 디스크는 항상 일정한 속도로 받아주지 않는다. 쓰기가 순간 밀리면 그동안 도착한 데이터를 어딘가 담아둬야 하는데, 그 대기실이 208KB면 금방 차고 보내는 쪽이 멈춘다. 멈췄다 다시 가기를 반복하면 평균이 떨어진다. 대기실을 키우면 디스크가 잠깐 딴짓을 해도 네트워크는 계속 흐른다.

이 항목만 따로 떼어, 나머지 설정은 그대로 둔 채 기본값과 64MB 사이를 오가며 교대로 쟀다.

소켓 버퍼만 바꿔가며 잰 업로드 (여러 파일 9.7GB, MB/s)

1회2회3회평균
기본 (208KB)676678662672
64MB878957770868

+29%이고 두 구간이 겹치지 않는다. 같은 시점에 조정한 나머지(수신 백로그, NIC 링 버퍼)는 개별 기여를 분리하지 않았으니, 근거를 갖고 말할 수 있는 건 소켓 버퍼뿐이다.

3-1. 그리고 32KB짜리 시스템 콜

네 번째 계단에는 커널 설정 말고 하나가 더 섞여 있다. 내가 만든 전송 도구가 소켓에서 파일로 복사할 때 32KB씩 끊어서 하고 있었다. Go의 io.Copy가 최적 경로를 찾지 못하면 그 크기로 떨어지는데, 그걸 모르고 썼다.

426MB/s로 흐르는 데이터를 32KB씩 처리하면 초당 만 삼천 번 넘게 커널을 드나든다. 여유 있는 CPU라면 묻히지만 이 장비에서는 아니었다. 버퍼를 8MB로 명시하니 같은 구간에서 314에서 636MB/s가 됐다. 보내는 쪽도 파일을 사용자 공간으로 읽어 올리지 않고 커널이 바로 소켓에 밀어 넣는 방식으로 바꿨다.

4. 읽기 쪽의 마지막 한 겹: 순차 읽기 힌트

여기까지는 쓰기 이야기였다. 읽어 오는 쪽은 사정이 다르다. 위의 수정을 다 하고도 캐시에 없는 데이터를 NAS에서 읽어 오는 속도가 690MB/s에서 멈췄다. 디스크를 직접 재보니 636MB/s였으니 상한에 닿았다고 생각했는데, 틀렸다.

커널은 파일을 읽을 때 다음에 필요할 것 같은 부분을 미리 읽어 둔다. 다만 접근 패턴을 모르니 조심스럽게, 적게 읽는다. 파일을 열자마자 posix_fadvise(SEQUENTIAL)로 "이 파일은 처음부터 끝까지 순서대로 읽는다"고 알려주면 미리 읽는 양이 두 배로 늘어난다. 스피닝 디스크에서는 이 차이가 크다. 미리 읽어둔 게 부족하면 헤드가 다음 위치로 이동할 때마다 기다려야 하기 때문이다.

앞서 디스크 자체를 잴 때도 이 힌트가 없었다. 그래서 636MB/s를 상한으로 적었던 것이고, 상한 자체를 과소 평가한 셈이었다. 효과를 확인하려고 힌트를 켠 서버와 끈 서버를 동시에 띄우고 매 회 캐시를 비우며 교대로 쟀다.

콜드 읽기 10.3GB, 동일 파일, 매회 캐시 드롭 (MB/s)

600650700 750800 MB/s (600부터, 900까지 축약) 힌트 끔 평균 690 힌트 켬 평균 838 두 집단이 겹치는 구간 없음

켠 쪽의 최저치(788)가 끈 쪽의 최고치(702)보다 높아 두 분포가 겹치지 않는다. +21.5%.

병렬 스트림은 생각보다 작았다

rsync가 읽기·프로토콜·암호화·쓰기를 한 파이프라인에서 순차로 처리하니 병렬화가 크게 먹힐 줄 알았는데, 아니었다. 콜드 읽기에서 스트림을 1개에서 8개로 늘려도 766 → 840MB/s, 10% 남짓이다. 두 개 이후로는 수확체감이라 기본값은 4로 뒀다. 위의 세 항목이 훨씬 크다.

그런데 이 튜닝은 rsync를 도와주지 않는다

커널 설정을 고쳤으니 rsync도 빨라졌을까. 같은 스위치를 켜고 끄며 재보면 549/453(기본) 대 453/474(튜닝)로 차이가 없다.

이유는 단순하다. rsync와 ssh는 소켓 버퍼를 명시 설정하지 않아 커널 자동 조절을 그대로 쓰고, 실제 한계는 ssh의 채널 윈도우와 CPU다. 즉 3번 항목은 그 이득을 가져갈 수 있는 도구가 있어야만 의미가 있다.

여기서 선택지가 갈렸다. 이미 NAS에는 rsync 데몬과 NFS가 떠 있었지만 각각 계정 설정과 공유 폴더 권한에 막혀 있었다. 결국 암호화 없이 병렬로 보내는 작은 도구를 직접 만들었다. Go 정적 바이너리 하나에 서버와 클라이언트를 넣고, 저장 구조는 HuggingFace와 같은 <네임스페이스>/<모델>로 맞췄다.

mstore push ~/models/GLM-5.2-mxfp4  mlx-community/GLM-5.2-mxfp4
mstore pull mlx-community/GLM-5.2-mxfp4  ~/models/GLM-5.2-mxfp4
mstore ls    mstore info <모델>    mstore rm <모델>

파일을 크기 내림차순으로 가장 한가한 스트림에 배정해 완료 시각을 맞추고, 보내기 전에 서버에 크기를 물어 같은 파일은 건너뛴다. 중단 후 다시 실행하면 남은 것만 간다. 수신은 임시 이름으로 받아 완료 후 바꾸므로 끊긴 파일이 정상 파일로 오인되지 않는다.

결과

업로드 (MB/s)

도구단일 대용량 파일여러 파일 (9.7GB)
rsync over ssh228~238451~696
전용 도구719868

워크로드에 따라 1.25배에서 3배다. 큰 파일 하나일 때 차이가 가장 크고, 여러 파일이면 rsync도 꽤 따라온다. 다운로드는 NAS 램에 있는 데이터면 1116MB/s로 링크 한계에 붙고, 디스크에서 읽으면 838MB/s다. 이제 디스크가 진짜 병목이다. 400GB 모델 하나를 올리는 데 8분이면 된다.

틀린 결론 하나

이 글의 초판에는 "동시 읽기 스트림 수는 성능과 무관하다"는 대목이 있었다. 스트림 수를 바꿔가며 쟀는데 값이 오차 범위였기 때문이다. 그런데 그 측정은 스트림 수 옵션이 파싱되지 않던 상태에서 이뤄졌다. 네 번 모두 실제로는 같은 설정이었고, 나는 같은 조건을 네 번 잰 노이즈를 근거로 결론을 냈다. 고치고 다시 재니 766에서 840으로 완만히 오른다.

측정 도구를 자기가 만들었다면, 결론보다 도구를 먼저 의심해야 한다.

검토했다가 기각한 것들도 적어 둔다. 네트워크 인터럽트를 여러 코어로 분산하는 건 불필요했다. 전송 중에 세어 보니 초당 130회 수준이라 포화와 거리가 멀었다. 점보 프레임은 보류했다. 이득이 캐시 적중 경로에만 생기는데 스위치와 양쪽 호스트까지 다섯 지점의 설정을 동시에 맞춰야 하고, 한 곳만 빠지면 통신이 조용히 끊긴다. RAID 스트라이프 캐시와 파일시스템 옵션은 해당 RAID 레벨에 무관하거나 무결성을 내주는 대가에 비해 이득이 없었다.

정리

10G 장비를 꽂는다고 10G가 나오지 않는다. 이 경우엔 링크가 1054MB/s, 디스크가 838MB/s를 낼 수 있는데 표준 도구가 230MB/s를 쓰고 있었다. 사라진 몫은 암호화, 직렬 처리, 10G를 상정하지 않은 커널 기본값, 그리고 디스크에게 알려주지 않은 접근 패턴에 나뉘어 있었다.

확인해 볼 만한 순서는 이렇다. 먼저 각 계층의 상한을 따로 재서 격차가 있는지 본다. 격차가 있으면 암호화를 뺀 경로가 가능한지 본다. 그다음 net.core.rmem_max 같은 커널 기본값이 10G 시대의 값인지 확인한다. 마지막으로 콜드 읽기가 느리다면 순차 읽기 힌트를 준다.

다만 셋째 항목은 그 이득을 가져갈 수 있는 도구가 있을 때만 의미가 있다. 표준 rsync를 쓸 생각이라면 커널을 아무리 만져도 달라지지 않는다.