대량 업로드 시 재구축 시간이 오래 걸리는 문제 (chown 성능)

parallel-fs-ops.template.yml (2.8 KB)

Discourse 사이트가 대용량 업로드 라이브러리를 축적하기 시작하면 painfully obvious(매우 명확해지)는 확장성 문제에 대해 이야기해 보겠습니다.

chown 명령어가 거대한 uploads 디렉터리 전체에서 실행되던 것이 분 단위가 걸리던 것이 이제는 초 단위로 줄었습니다!

배경

Discourse 리빌드는 다음과 같은 재귀적 작업을 실행할 수 있습니다:

chown -R ...
chmod -R ...

보다 구체적으로, templates/web.template.yml의 이 줄입니다:

- chown -R discourse:www-data /shared/log/rails /shared/uploads /shared/backups /shared/tmp

이러한 명령어들은 파일시스템을 직렬로, 하나씩 순회합니다.

작은 설치 환경에서는 합리적인 접근입니다. 하지만 shared/uploads에 수십만 개 또는 수백만 개의 파일이 포함된 경우, 재귀적 소유권 및 권한 작업이 배포를 지배하게 될 수 있습니다. CPU, 스토리지, 네트워크 용량이 사용 가능할 수 있지만, 하나의 프로세스가 인오드(inode) 하나씩 전체 트리를 순회합니다.

업로드가 많은 커뮤니티의 경우, 그 결과는 다음과 같을 수 있습니다:

  • 매우 긴 리빌드 시간
  • 더 긴 유지보수 창(Maintenance Window)
  • 배포 및 보안 업데이트 지연
  • 고속 또는 분산 스토리지의 낮은 활용률
  • 거대한 파일 트리를 처리하는 동안 배포가 멈춘 것처럼 보이는 현상
  • NFS, JuiceFS, CephFS 및 기타 원격 파일시스템에서 특히 심각한 성능 저하

좌절스러운 점은 이러한 파일의 상당수가 독립적이라는 것입니다.它们的 권한은 병렬로 처리될 수 있습니다.

해결책: 병렬 파일시스템 작업 템플릿

재귀적 chmodchown 작업을 병렬 findxargs 파이프라인으로 투명하게 대체하는 pups 템플릿을 만들었습니다.

래퍼(wrappers)는 재귀 작업을 가로채는 때마다 자신을 알립니다:

echo "[parallel-fs-ops] chmod -R override active: $*" >&2

그리고:

echo "[parallel-fs-ops] chown -R override active: $*" >&2

이 괄호로 감싸진 접두어(prefix) 덕분에 배포 로그에서 최적화를 쉽게 식별할 수 있습니다.

배포가 어떻게 보이는가

배포의 초기 단계에서 템플릿은 나중에 파일시스템 작업을 처리할 바이너리를 확인합니다:

[parallel-fs-ops] chmod -> /usr/local/bin/chmod
[parallel-fs-ops] chown -> /usr/local/bin/chown

상위(upstream) 템플릿이 나중에 재귀적 권한 변경을 실행하면, 배포 출력에는 다음과 유사한 줄이 포함됩니다:

[parallel-fs-ops] chmod -R override active: -R 0755 /var/www/discourse/public

재귀적 소유권 변경은 다음을 생성합니다:

[parallel-fs-ops] chown -R override active: -R discourse:www-data /shared/log/rails /shared/uploads /shared/backups /shared/tmp

업로드가 많은 설치 환경에서는 다음과 유사한 것을 볼 수 있을 것입니다:

[parallel-fs-ops] chown -R override active: -R discourse:www-data /shared/log/rails /shared/uploads /shared/backups /shared/tmp

정확한 경로와 인자는 사용되는 템플릿에 따라 다르지만, 중요한 부분은 가시적인 마커입니다:

[parallel-fs-ops]

이 템플릿이 없으면, 재귀적 파일시스템 작업 중에 배포가 오랫동안 일시 정지된 것처럼 보일 수 있습니다. 템플릿이 있으면, 로그가 다음을 알려줍니다:

  1. 래퍼가 올바르게 설치되었습니다.
  2. 재귀 작업이 감지되었습니다.
  3. 병렬 구현이 활성화되었습니다.
  4. 처리 중인 원래 인자가 표시됩니다.

이는 트러블슈팅 중에 특히 가치 있으며, 느린 병렬 파일시스템 순회와 멈춘 빌드를 구별할 수 있게 해줍니다.

작업이 완료된 후, 배포는 정상적인 pups 출력으로 계속됩니다. 래퍼 자체는 파일당 한 줄을 출력하지 않으므로, 수백만 개의 업로드를 포함하는 트리도 배포 로그를 범람시키지 않습니다.

템플릿

run:
  - file:
      path: /usr/local/bin/chmod
      chmod: "+x"
      contents: |
        #!/bin/bash
        if [[ "$*" =~ (^|[[:space:]])-R([[:space:]]|$) ]]; then
          echo "[parallel-fs-ops] chmod -R override active: $*" >&2
          args=()
          for arg in "$@"; do
            [[ "$arg" != "-R" ]] && args+=("$arg")
          done
          mode="${args[0]}"
          targets=("${args[@]:1}")
          [[ ${#targets[@]} -eq 0 ]] && targets=(".")
          find "${targets[@]}" -print0 |
            xargs -0 -n 32 -P 128 /bin/chmod "$mode"
        else
          exec /bin/chmod "$@"
        fi

  - file:
      path: /usr/local/bin/chown
      chmod: "+x"
      contents: |
        #!/bin/bash
        if [[ "$*" =~ (^|[[:space:]])-R([[:space:]]|$) ]]; then
          echo "[parallel-fs-ops] chown -R override active: $*" >&2
          args=()
          for arg in "$@"; do
            [[ "$arg" != "-R" ]] && args+=("$arg")
          done
          owner="${args[0]}"
          targets=("${args[@]:1}")
          [[ ${#targets[@]} -eq 0 ]] && targets=(".")
          find "${targets[@]}" -print0 |
            xargs -0 -n 32 -P 128 /bin/chown "$owner"
        else
          exec /bin/chown "$@"
        fi

  - exec:
      cmd: |
        echo "[parallel-fs-ops] chmod -> $(command -v chmod)"
        echo "[parallel-fs-ops] chown -> $(command -v chown)"

이 템플릿은 래퍼를 /usr/local/bin에 설치하며, 이는 일반적으로 PATH에서 /bin보다 앞에 위치합니다.

일반적인 비재귀 작업이 요청되면, 래퍼는 표준 유틸리티로 직접 위임합니다:

exec /bin/chmod "$@"

-R가 존재하면, 재귀 플래그를 제거하고 널(null) 구분자를 사용하여 대상물을 안전하게 열거한 후, 배치(batch)를 병렬로 처리합니다:

find "${targets[@]}" -print0 |
  xargs -0 -n 32 -P 128 /bin/chmod "$mode"

이것은 pups가 /bin/sh를 통해 명령을 실행하는 경우에도 작동합니다. 호출하는 셸이 Dash일지라도, 실행 파일이 시작될 때 래퍼의 Bash shebang이 존중됩니다.

왜 많은 업로드가 있을 때 이것이 가장 중요한가

업로드가 많은 커뮤니티는 배포 동작이 우아하게(scale gracefully) 확장되어야 하는 바로 그 곳입니다.

장기간 운영되는 포럼에는 다음이 포함될 수 있습니다:

  • 수년에 걸쳐 게시물에 내장된 이미지
  • 아바타 및 프로필 배경
  • 원본 및 최적화된 이미지 변형(variants)
  • 비디오 및 오디오 첨부 파일
  • 문서 및 아카이브
  • 보안 업로드(Secure uploads)
  • 플러그인 관리 미디어
  • 멀티사이트 업로드 트리

애플리케이션 코드의 양은 상대적으로 안정적일 수 있지만, 업로드된 파일시스템 객체의 수는 계속 증가합니다. 파일시스템 순회—컴파일이나 컨테이너 생성이 아니라—가 궁극적으로 지배적인 배포 비용이 될 수 있습니다.

이것은 일반적인 확장성 문제가 아닙니다: 커뮤니티가 더 성공적이고 콘텐츠가 풍부해질수록, 일상적인 운영 작업이 더 비싸질 수 있습니다.

왜 템플릿이 필요한가

.bashrc를 변경하거나 BASH_ENV를 설정하는 것은 이 문제를 신뢰할 수 있게 해결하지 않습니다. pups는 run 명령을 /bin/sh를 통해 실행하며, Dash는 Bash 구성을 로드하지도 Bash 전용 함수를 이해하지도 않습니다.

템플릿은 래퍼를 설치하는 반복 가능한 방법을 제공하여, 상위 템플릿에서 오는 재귀 작업을 포함하여 이후의 재귀 작업이 병렬 구현을 통해 해석되도록 충분히 일찍 설치할 수 있게 합니다:

templates:
  - "templates/postgres.template.yml"
  - "templates/redis.template.yml"
  - "templates/web.template.yml"
  - "containers/parallel-fs-ops.template.yml"

구성 가능한 옵션

병렬 처리 옵션

템플릿은 다음을 사용합니다:

find "${targets[@]}" -print0 |
  xargs -0 -n 32 -P 128 /bin/chmod "$mode"

관련 xargs 매개변수는 다음과 같습니다:

옵션 목적
-0 find -print0이 생성한 널(null) 구분된 경로를 읽습니다. 이는 공백, 따옴표, 탭 또는 줄바꿈을 포함하는 파일 이름을 안전하게 처리합니다.
-n 32 chmod 또는 chown 호출에 최대 32개의 경로를 전달합니다. 이것이 배치 크기입니다.
-P 128 최대 128개의 chmod 또는 chown 프로세스가 동시에 실행되도록 허용합니다. 이것이 병렬성 수준입니다.

함께, -n 32 -P 128은 최대 128개의 프로세스가 동시에 실행될 수 있으며, 각 프로세스가 최대 32개의 경로를 포함하는 배치를 처리한다는 의미입니다. 따라서 약 4,096개의 경로가 동시에 명령 배치를 통해 능동적으로 분배될 수 있습니다.

-n 선택

-n은 각 명령에 할당되는 작업량을 제어합니다:

  • 더 낮은 값은 더 세밀한 작업 분배를 제공하지만 더 많은 프로세스를 시작합니다.
  • 더 높은 값은 프로세스 시작 오버헤드를 줄이지만 더 크고 균등하게 분배되지 않은 배치를 생성합니다.
  • -n 1은 경로당 하나의 chmod 또는 chown 명령을 실행합니다.
  • -n 32는 배치와 병렬성을 균형 있게 조정하기 위한 합리적인 시작점입니다.
  • 매우 큰 값은 전체 배치 수가 적게 생성되므로 -P의 효과를 감소시킬 수 있습니다.

-P 선택

-P는 동시에 실행될 수 있는 명령의 수를 제어합니다:

  • 더 낮은 값은 CPU 및 파일시스템에 대한 부하를 줄입니다.
  • 더 높은 값은 고속 또는 분산 스토리지에서 성능을 향상시킬 수 있습니다.
  • 과도한 병렬성은 디스크를 압도하거나 메타데이터 서버를 포화 상태로 만들거나 성능을 악화시킬 수 있습니다.
  • -P 1은 사실상 직렬 실행입니다.
  • -P 8 또는 -P 16은 보수적인 시작점입니다.
  • -P 32는 고속 SSD 기반 스토리지에 적합할 수 있습니다.
  • -P 128은 파일시스템과 호스트가 그 동시성을 유지할 수 있을 때만 사용해야 합니다.

최선의 값은 파일시스템 지연 시간, 메타데이터 성능, CPU 용량 및 파일 수에 따라 다릅니다. 이상적으로는 둘 다 구성 가능해야 하며 특정 설치를 위해 벤치마크되어야 합니다.

과도한 병렬성은 파일시스템을 압도하거나 메타데이터 서버를 포화 상태로 만들거나 배포 성능을 저하시킬 수 있습니다. 따라서 배치 크기와 동시성은 구성 가능해야 합니다.

이 템플릿은 실용적인 우회책(workaround)이지만, 더 큰 제안은 더 광범위합니다:

Discourse가 배포 중에 대규모 재귀 파일시스템 작업에 대해 구성 가능한 병렬성을 공식적으로 지원할 수 있을까요?

상위 구현은 다음을 수행할 수 있습니다:

  • 알려진 대규모 디렉터리 트리만 병렬화
  • 변경되지 않은 업로드 트리를 불필요하게 순회하지 않도록 방지
  • 동시성을 구성 가능하게 만듦
  • 로컬 파일시스템과 네트워크 기반 파일시스템을 감지
  • chmodchown 인자 의미를 완전히 보존
  • 매우 큰 트리에 대해 주기적인 진행 상황을 출력
  • 관리자가 배포 병목 현상을 식별할 수 있도록 타이밍을 기록

중요한 주의사항

위의 래퍼는 우리의 빌드 프로세스에서 사용되는 재귀 명령 형식에 초점을 맞추고 있습니다. 이것은 가능한 모든 chmod 또는 chown 옵션 조합의 완전한 재구현이 아닙니다.

프로덕션 사용 전에 사이트의 템플릿이 생성하는 정확한 명령어와 테스트되어야 합니다. 운영자는 보수적인 병렬성으로 시작하여 스토리지에 대한 영향을 측정해야 합니다.

하지만 근본적인 문제는 실재합니다: 커뮤니티가 거대한 업로드 트리를 축적한 경우, 직렬 재귀 메타데이터 작업은 잘 확장되지 않습니다.

행운을 빕니다. 그리고 어떤 의견이나 제안도 감사히 받겠습니다(다른 사람의 노력을 중복했을 수도 있지만, 그렇다면 그쪽으로의 가리켜 주시는 것도 감사하겠습니다)!

감사합니다!

좋네요. 그래서 S3CDN이 존재하는 것 같네요.

맞아요. 하지만 모든 사람이 그 복잡성을 추가하기를 원하지는 않죠.

저는 JuiceFS를 통해 S3/R2/B2를 사용 중입니다. 이렇게 하면 로컬에 실제 파일 시스템처럼 마운트되니까 걱정할 필요가 없어요. 그리고 Cloudflare가 자동으로 에셋을 캐시해 줍니다.

이것은 chown을 수행하는 템플릿을 변경하는 PR이어야 하는 것일까요?

네, 그렇게 해주세요!

이것은 #wiki에서 #support:self-hosting으로 다시 옮겼습니다. 왜냐하면 우리가 모두에게 수동으로 처리하도록 권장하고 싶은 패턴은 아니기 때문입니다. 업스트림 템플릿에서 안전하게 성능을 향상시킬 수 있는 부분이 있다면, 그렇게 합시다!

시스템 수준의 chown/chmod 실행 파일을 오버라이드하고 싶지는 않습니다. 하지만 적절한 find 명령을 직접 사용하는 것은 어떨까요?

먼저 더 간단한 버전을 시도해 보겠습니다. 이 버전은 병렬로 아무것도 실행하지 않지만, 이미 올바른 권한을 가진 파일에 대해서는 chown을 건너뜁니다. 이는 discourse_docker의 다른 부분에서 이미 사용 중인 패턴입니다:

방금 이것을 보고 PR도 확인했어요. 확실히 좋은 타협점이지만, 성능 향상은 확실합니다. 늘 작은 것들이 중요한 법이죠… :wink: