청크 업로드의 서버 쪽

업로드의 청크가 서버에서 어떻게 처리되고, 워커가 여럿일 때 무엇을 공유해야 하는지를 다룬다(#83).

컴포넌트에서 업로드를 쓰는 법은 File Uploads 심화 튜토리얼이 정본이다. 저장소를 아예 거치지 않는 방식은 external-uploads.md.

문제#

청크는 WebSocket이 아니라 HTTP로 온다.

/__wireview_upload__/<connection_id>/<component_id>/<upload_name>/

WebSocket은 워커 하나에 고정되지만 HTTP 요청은 로드밸런서가 아무 워커에나 보낸다. #83 이전에는 업로드 레지스트리가 프로세스 안의 dict였고, 엔드포인트가 토큰을 검사하기 전에 그 dict를 조회했다. 그래서 다른 워커에 닿은 청크는 유효한 토큰이어도 404 Component not found였다.

지금 (#83 이후)#

HTTP 핸들러는 업로드 상태를 하나도 들고 있지 않다. 판단 근거는 서명된 토큰뿐이고, 바이트는 그 토큰에서 계산한 경로로 간다. 어느 워커든 같은 답에 도달한다.

1. 토큰이 유일한 권한 증거#

토큰은 allow_upload()한 컴포넌트가 엔트리를 등록할 때 발급된다(v2, sign_object).

담긴 것 왜
connection_id, component_id, config_name, ref 어느 업로드인지. URL 세그먼트와 전부 대조한다. 하나라도 다르면 403
max_bytes 클라이언트가 신고한 크기. 이 값을 넘겨 쓰려 하면 413이고, 도달하면 완료다
ext 클라이언트 파일명의 확장자. 마지막 청크에서 magic bytes 검사에 쓴다

문자열을 구분자로 잇지 않고 객체로 서명한다. 컴포넌트 id와 업로드 이름은 앱 템플릿에서 오므로 콜론이 들어갈 수 있고, 그러면 누가 무엇을 쓸 수 있는지 정하는 자리에서 파싱이 어긋난다.

#83 이전의 4토막 문자열 토큰(conn:comp:config:ref)은 받지 않는다(#99부터). 크기도 확장자도 없어 한도와 magic bytes 검사를 할 수 없기 때문이다. 그 토큰을 발급하던 0.2 이하에서 롤링 배포로 바로 올라오면(0.3·0.4는 둘 다 받았다, 0.5에서 끊었다), 옛 워커가 그린 페이지에서 진행 중이던 업로드는 403으로 실패한다. 새 워커가 그린 페이지에서 다시 올리면 된다. 배포 중에 업로드가 끊기면 안 되는 서비스라면 한 번에 바꾼다.

2. 경로는 계산한다#

<UPLOAD_TEMP_DIR 또는 시스템 temp>/wireview-uploads/<connection_id>/<digest>.part
digest = sha256(f"{component_id}\0{config_name}\0{ref}")[:32]

컴포넌트 id·업로드 이름·ref는 템플릿과 클라이언트에서 오므로 경로에 그대로 넣지 않고 해시한다 (경로 탈출 차단). connection_id는 서버가 secrets.token_urlsafe(16)으로 만든 값이고 디렉터리 이름이 되므로 URLconf와 upload_store가 둘 다 문자 집합을 강제한다.

계산은 전부 wireview/features/upload_store.py에 있다. 나중에 오브젝트 스토리지 백엔드를 끼운다면 그 자리다.

3. 가변 상태는 소유 워커가 브로커로 받는다#

HTTP 워커는 컴포넌트 객체를 갖고 있지 않으므로 this.uploads를 갱신할 수 없다. 그래서 자기가 한 일을 연결의 진행률 그룹(wireview_upload_<connection_id>)에 publish하고, WebSocket을 쥔 워커가 그것을 엔트리에 적용한다.

메시지 HTTP 워커가 보낼 때 소유 워커가 하는 일
upload.progress 청크마다 bytes_received·progress 갱신, 브라우저에 전달
upload.completed 마지막 청크가 검증을 통과 파일을 stat해 크기가 맞으면 COMPLETED로 승격
upload.error 크기 초과, 형식 불일치 엔트리를 ERROR로, 브라우저에 전달

모든 메시지에 component id가 들어간다. 그룹은 연결당 하나라 페이지의 업로드 전부가 같은 그룹으로 오기 때문이다.

완료는 두 경로로 알려진다. 브라우저의 upload_complete WS 명령과 브로커의 upload.completed는 순서가 보장되지 않는다. 소유 워커는 둘 중 어느 것도 그대로 믿지 않고 파일을 stat해서 크기가 client_size에 도달했을 때만 승격한다. on_upload_complete 콜백은 WS 명령 쪽에서만 호출된다 — 양쪽에서 부르면 두 번 실행된다.

4. 취소는 마커로 프로세스를 넘는다#

파일을 지우는 것으로는 취소가 안 된다. 무상태 엔드포인트는 다음 청크에서 파일을 다시 만든다.

일 남기는 것
cancel_upload() / 컴포넌트 leave <digest>.cancelled
disconnect 연결 디렉터리의 .gone, 그리고 그 안의 .part 전부 삭제

쓰기 워커는 청크를 쓰기 전과 후에 둘 다 확인하고, 있으면 자기가 쓴 것을 지우고 410을 준다. 쓰기가 요청 안의 유일한 await이므로 그 사이에 취소가 끼어들 수 있기 때문이다.

5. 남는 것은 나이로 정리한다#

워커가 죽으면 그 워커의 취소 경로도 같이 사라진다. 무상태 엔드포인트는 이미 떠난 컴포넌트의 청크를 토큰이 만료될 때까지 받아 준다. 그래서 남을 수 있는 것은 UPLOAD_TOKEN_MAX_AGE로 묶이고, 정리 기준은 나이 하나다 — 그보다 오래된 파일은 아직 끝날 수 있는 업로드의 것일 수 없다.

python manage.py wireview_upload_gc            # 지금 청소
python manage.py wireview_upload_gc --dry-run  # 뭐가 지워질지만
python manage.py wireview_upload_gc --max-age 600  # 기준 나이(초). 기본은 UPLOAD_TOKEN_MAX_AGE

쓰기 경로도 프로세스당 10분에 한 번 기회적으로 청소하므로, cron을 걸지 않아도 남는 양은 유계다.

배포에 필요한 것#

배포 되나
한 호스트에 워커 N개 된다. 같은 temp 디렉터리를 보므로 추가 인프라 0
여러 호스트 공유 볼륨(NFS/EFS)을 UPLOAD_TEMP_DIR로 지정하거나, external 업로드
공유 볼륨 없는 여러 호스트 오브젝트 스토리지 백엔드가 필요하다. 아직 없다

워커들이 같아야 하는 값은 두 개다. 청크 저장소(UPLOAD_TEMP_DIR)와 서명 키다. 서명 키가 어긋나면 청크가 403으로 실패한다. docs/DEPLOYMENT.md가 이것을 다룬다.

설정#

키 기본값 뜻
UPLOAD_TEMP_DIR None 청크 저장소의 부모 디렉터리. None이면 시스템 temp. 빈 문자열은 미설정으로 취급한다(Path("")가 cwd이므로). 없으면 만들고, 쓸 수 없으면 ImproperlyConfigured
UPLOAD_MAX_FILE_SIZE 10MB allow_upload에 max_file_size가 없을 때의 한도
UPLOAD_CHUNK_SIZE 64KB 클라이언트가 자르는 크기
UPLOAD_TOKEN_MAX_AGE 1시간 토큰 유효 기간이자 sweep 기준 나이
SIGNING_KEY None None이면 Django의 SECRET_KEY. 아래 참고
SIGNING_KEY_FALLBACKS None None이면 SECRET_KEY_FALLBACKS

왜 전용 서명 키인가#

토큰이 유일한 권한 증거이므로 그 키의 수명이 곧 업로드의 수명이다. SECRET_KEY를 그대로 쓰면 세션 때문에 키를 돌리는 순간 진행 중인 업로드와 열려 있는 페이지의 data-state(기본 14일)가 같이 죽는다. 전용 키는 그 둘을 분리한다 — 양방향으로.

fallback을 같이 두는 것이 조건이다. 지금은 SECRET_KEY_FALLBACKS를 공짜로 받아 로테이션이 되므로, 자체 키만 만들고 fallback을 빠뜨리면 오히려 나빠진다. SIGNING_KEY가 빈 문자열이면 key or SECRET_KEY 때문에 조용히 되돌아가므로 manage.py check의 wireview.W009가 잡는다.

salt(wireview.upload / wireview.state.v2)는 이미 분리되어 있어 토큰 교차 사용은 전부터 막혀 있었다. 그러므로 이것은 취약점 수정이 아니라 키 수명 관리다.

브라우저 쪽 수명#

업로드의 config는 페이지를 live로 만드는 렌더보다 늦게 온다(태스크가 채널 레이어로 보낸다). 브라우저는 그 사이에 고른 파일을 들고 있다가 config가 오면 등록한다(#137). 입력·드롭 존·업로드 버튼이 모두 같은 경로다.

브라우저가 들고 있는 것(설정, 엔트리와 진행 중인 요청, 미리보기 blob URL, config를 기다리는 파일)은 컴포넌트의 서버 인스턴스 하나의 것이다(wireview/static/wireview/uploads.mjs). 인스턴스가 끝나면 버린다: 요청은 중단하고 blob URL은 해제한다. 해제되는 blob URL에는 미리보기 태그가 만든 것뿐 아니라 window.wireview.getPreviewUrl()이 돌려준 것도 들어간다 — 그 URL은 인스턴스가 끝날 때까지만 쓸 수 있다. 서버에 따로 알리지 않는다 — 서버는 위 「5. 남는 것은 나이로 정리한다」대로 스스로 정리한다.

인스턴스가 끝나는 때 브라우저 쪽
요소가 페이지를 떠남(leave). 부모 렌더에서 빠진 LiveComponent 포함 버리고 그 인스턴스를 잊는다. 옛 인스턴스의 config는 언제 와도 무시한다
같은 id의 새 join(boost 이동으로 새 DOM, 예외 뒤 재join) join을 보내면서 버린다(안의 LiveComponent 것도)
join 실패 버린다(안의 LiveComponent 것도)
같은 id의 다른 인스턴스를 렌더가 알림 옛 인스턴스의 config로 설정된 것을 버린다. config를 아직 받지 않은(파일만 기다리는) 것은 새 인스턴스가 이어받는다
연결 끊김 그 연결에서 config를 받은 것만 한 번 버린다. 재연결이 실패해 끊김이 다시 와도 더 버리지 않는다

연결이 끊기면 진행 중이던 업로드는 중단된다. 서버가 끊긴 연결의 청크를 지우므로 이어 보낼 곳이 없다. 외부 업로드(external)도 스토리지로 보내던 요청을 중단한다 — 스토리지에 무엇이 남는지는 중단 시점에 달렸다. 재연결 뒤 사용자는 파일을 다시 골라야 한다. 반면 아직 어느 인스턴스의 config도 받지 않은 파일 — 첫 연결 전이나 끊긴 동안 고른 것 — 은 버리지 않고, 재연결 뒤 새 인스턴스의 config가 오면 등록된다.

어느 config가 누구의 것인가. 서버는 컴포넌트 인스턴스마다 번호를 매기고, 두 곳에서 그 번호로 가른다.

  • 서버: 컴포넌트가 보내는 업로드 op에는 보낸 인스턴스의 번호가 실린다. 세션은 그 id의 지금 인스턴스가 아니거나 이미 떠난 인스턴스의 것을 브라우저에 넘기지 않고 버린다. config는 태스크가 채널 레이어로 보내므로 세션이 leave나 새 join을 처리한 뒤에 도착할 수 있는데, 그런 config는 여기서 끝난다.
  • 브라우저: 세션이 leave나 새 join을 처리하기 전에 이미 소켓으로 내보낸 config는 브라우저가 leave나 join을 보낸 뒤에 도착할 수 있다. 그래서 인스턴스의 첫 렌더(join의 응답, 또는 LiveComponent가 join된 부모의 렌더)가 instances로 그 번호를 알리고, 세션이 넘기는 config도 번호를 싣는다. 브라우저는 id마다 마지막으로 알림받은 인스턴스를 들고 있다가 번호가 같은 config만 받는다. 떠났거나 새 join으로 대체한 id는 번호를 잊으므로 옛 인스턴스의 config는 언제 와도 맞는 번호가 없다. 이미 페이지를 떠난 요소의 첫 렌더(떠나기 전에 보낸 join의 응답)는 번호를 알리지 못한다. 같은 id로 보낸 새 join의 응답을 기다리는 동안 도착한 이전 join의 render도 알리지 못한다 — join의 ref로 가른다(#139). 그래서 그 사이에 고른 파일은 어느 인스턴스의 것도 아닌 채 기다리다가 새 인스턴스의 config로 등록된다.

브라우저는 메시지 개수를 세지 않는다. 그래서 join 하나에 응답이 둘 오는 경우(첫 렌더 뒤 params_changed가 던져 error가 뒤따름)나 응답이 없는 경우에도 다음 인스턴스의 판정이 어긋나지 않고, join을 보내지 않는 LiveComponent도 같은 규칙으로 다뤄진다.

번호를 싣지 않는 서버(#137 이전)의 config는 예전처럼 그대로 받는다. 그런 서버는 렌더에도 번호를 싣지 않으므로 브라우저는 가릴 수 없고, 롤링 배포 중에만 생기는 조합이다.

그래서 옛 인스턴스를 위해 고른 파일이 늦게 온 config로 등록되거나 같은 id의 새 인스턴스로 넘어가지 않고, 옛 인스턴스의 설정(auto_upload 등)이 새 인스턴스의 파일에 쓰이지 않는다.

업로드 버튼({% upload_button %})은 config 전에도 선택 창을 연다. 브라우저는 클릭의 사용자 활성화 안에서만 선택 창을 열어 주므로 config를 기다렸다가 열 수 없다. config가 없을 때 연 선택 창에는 accept 필터가 없고 한 번에 파일 하나만 고를 수 있다. 고른 파일은 다른 입구와 같이 config를 기다렸다가 그 설정으로 검사된다 — 확장자가 맞지 않으면 그때 오류 엔트리가 된다.

함정#

  • ATOMIC_REQUESTS = True. Django의 핸들러는 async 뷰를 트랜잭션으로 감쌀 수 없어 뷰를 부르기도 전에 실패한다. wireview.urls가 엔드포인트를 모든 alias에 대해 non_atomic_requests로 등록하므로 앱에서 할 일은 없지만, 업로드 URL을 직접 등록한다면 같은 처리가 필요하다.
  • 워커마다 UPLOAD_TEMP_DIR이 다르면 청크는 200을 받지만 소유 워커가 파일을 못 찾아 완료가 되지 않는다. 한 호스트라면 기본값(시스템 temp)이 이미 공유다.
  • external 업로드는 이 경로를 쓰지 않는다. 바이트가 워커를 아예 지나지 않으므로 저장소 조건도, 청소도 해당하지 않는다.

관련#