업로드 가이드: 새 업로드 추가하기
GitLab v19.1Uploader를 생성할 때는 AttachmentUploader의 서브클래스로 만드세요. 이 문서의 테이블에 Uploader를 추가하세요. 새 오브젝트 스토리지 버킷을 추가하지 마세요. Direct Upload를 구현하세요.
권장 사항#
-
Uploader를 생성할 때는
AttachmentUploader의 서브클래스로 만드세요. -
이 문서의 테이블에 Uploader를 추가하세요.
-
새 오브젝트 스토리지 버킷을 추가하지 마세요.
-
Direct Upload를 구현하세요.
-
업로드를 처리해야 한다면 처리 위치를 결정하세요.
배경 정보#
파일을 어디에 저장해야 하나요?#
CarrierWave Uploader는 파일이 저장되는 위치를 결정합니다. 새 Uploader 클래스를 생성할 때 새 기능의 파일을 어디에 저장할지 결정하게 됩니다.
우선, 새 Uploader 클래스가 필요한지 스스로 물어보세요. 서로 다른 마운트 포인트나 서로 다른 모델에 동일한 Uploader 클래스를 사용하는 것은 괜찮습니다.
자체 Uploader 클래스가 필요하거나 원한다면 AttachmentUploader의 서브클래스로 만들어야 합니다.
그러면 해당 클래스에서 저장 위치와 디렉터리 체계를 상속받게 됩니다.
디렉터리 체계는 다음과 같습니다:
File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
GitLab 코드베이스를 살펴보면 자체 저장 위치를 가진 Uploader가 꽤 많이 있습니다. 오브젝트 스토리지의 경우, Uploader마다 별도의 버킷을 가지고 있습니다. 현재 GitLab은 다음과 같은 이유로 새 버킷 추가를 권장하지 않습니다:
-
새 버킷을 사용하면 GDK, Omnibus GitLab, CNG에서 다운스트림 변경이 필요하기 때문에 개발 시간이 늘어납니다.
-
새 버킷을 사용하면 GitLab.com 인프라 변경이 필요하므로 새 기능 출시가 느려집니다.
-
새 버킷을 사용하면 GitLab Self-Managed에서 새 기능 채택이 느려집니다: 로컬 GitLab 관리자가 새 버킷을 구성하기 전까지는 사용자들이 새 기능을 사용할 수 없습니다.
기존 버킷을 사용하면 이러한 추가 작업과 마찰을 모두 피할 수 있습니다.
AttachmentUploader가 사용하는 Gitlab.config.uploads 저장 위치는 이미 구성되어 있음이 보장됩니다.
Direct Upload 지원 구현#
아래에서는 Direct Upload 지원을 구현하는 방법을 설명합니다.
Direct Upload를 사용하는 것이 항상 필요한 것은 아니지만, 일반적으로 좋은 아이디어입니다. 기능에서 처리하는 업로드가 빈번하지 않고 용량이 작지 않다면 Direct Upload를 구현하는 것이 좋습니다. 빈번하지 않고 용량이 작은 업로드를 사용하는 기능의 예로는 프로젝트 아바타가 있습니다: 아바타는 거의 변경되지 않으며 애플리케이션에서 엄격한 크기 제한을 적용합니다.
기능이 빈번하지 않고 용량도 작지 않은 업로드를 처리한다면, Direct Upload 지원을 구현하지 않으면 기술 부채를 안게 됩니다. 최소한 나중에 Direct Upload 지원을 추가할 수 있도록 해야 합니다.
Direct Upload를 지원하려면 두 가지가 필요합니다:
-
Rails의 사전 인가(pre-authorization) 엔드포인트
-
Workhorse 라우팅 규칙
Workhorse는 업로드를 어디에 저장할지 알 수 없습니다. 이를 알기 위해 사전 인가 요청을 만듭니다. 또한 사전 인가 요청을 어디에 해야 하는지도 알 수 없습니다. 이를 위해 라우팅 규칙이 필요합니다.
기억하시는 분들께 말씀드리자면, Workhorse는 별도의 프로젝트였습니다: 이 두 단계를 별도의 머지 리퀘스트로 분리할 필요가 없습니다. 사실 하나의 머지 리퀘스트에서 모두 처리하는 것이 더 쉬울 것입니다.
Workhorse 라우팅 규칙 추가#
라우팅 규칙은 workhorse/internal/upstream/routes.go에 정의되어 있습니다. 다음으로 구성됩니다:
-
HTTP 동사(보통 "POST" 또는 "PUT")
-
경로 정규식
-
업로드 타입: MIME multipart 또는 "전체 요청 본문"
-
선택적으로
Content-Type과 같은 HTTP 헤더로 매칭할 수도 있습니다.
예시:
u.route("PUT", apiProjectPattern+`packages/nuget/`, mimeMultipartUploader),
workhorse/upload_test.go의
TestAcceleratedUpload에 라우팅 규칙에 대한 테스트를 추가해야 합니다.
또한 새 기능에 대한 업로드 요청을 수행할 때 Workhorse가 사전 인가 요청을 하는지 수동으로 확인해야 합니다. Rails 액세스 로그를 통해 확인할 수 있습니다. 라우팅 규칙에 실수가 있어도 하드 실패가 발생하지 않고 비효율적인 기본 경로를 사용하게 될 뿐이므로, 이 확인이 필요합니다.
사전 인가 엔드포인트 추가#
Rails 컨트롤러, Grape API 엔드포인트, GraphQL 리소스 세 가지 경우를 구분합니다.
먼저 나쁜 소식을 드리자면: 현재 GraphQL에 대한 Direct Upload는 지원되지 않습니다. 그 이유는 Workhorse가 GraphQL 쿼리를 파싱하지 않기 때문입니다. 이슈 #280819도 참고하세요. 파일 업로드를 Grape를 통해 수락하는 것을 고려해보세요.
Grape 사전 인가 엔드포인트의 경우, /authorize 경로를 구현하는 기존 예시를 찾아보세요.
한 가지 예시로
POST :id/uploads/authorize 엔드포인트가 있습니다.
이 특정 예시는 FileUploader를 사용하며, 해당 Uploader 클래스의 저장 위치(버킷)에 업로드가 저장됩니다.
Rails 엔드포인트의 경우 WorkhorseAuthorization concern을 사용할 수 있습니다.
업로드 처리#
일부 기능은 업로드된 파일에서 메타데이터를 추출하는 등 업로드를 처리해야 합니다. 이를 구현하는 몇 가지 방법이 있습니다. 주요 선택사항은 처리를 어디에 구현할지, 즉 "누가 처리자인가"입니다.
| 처리자 | Direct Upload 가능 여부 | HTTP 요청 거부 가능 여부 | 구현 |
|---|---|---|---|
| Sidekiq | 가능 | 불가능 | 간단함 |
| Workhorse | 가능 | 가능 | 복잡함 |
| Rails | 불가능 | 가능 | 쉬움 |
Rails에서 처리하는 것이 매력적으로 보이지만, Direct Upload를 사용할 수 없기 때문에 장기적으로 확장성 문제로 이어지는 경향이 있습니다. 그러면 Workhorse에서 처리하도록 기능을 재구축해야 합니다. 따라서 기능 요구 사항이 허용한다면, Sidekiq에서 처리하는 것이 복잡성과 확장 능력 사이의 적절한 균형을 이룹니다.
CarrierWave Uploaders#
GitLab은 업로드를 관리하기 위해 수정된 버전의 CarrierWave를 사용합니다. 아래에서 CarrierWave를 어떻게 사용하고 어떻게 수정했는지 설명합니다.
CarrierWave의 핵심 개념은 Uploader 클래스입니다.
Uploader는 파일이 저장되는 위치를 정의하고, 선택적으로 유효성 검사와 처리 로직을 포함합니다.
Uploader를 사용하려면 ActiveRecord 모델의 텍스트 칼럼과 연결해야 합니다.
이를 "마운팅(mounting)"이라고 하며, 해당 칼럼을 mountpoint라고 합니다.
예를 들어:
class Project < ApplicationRecord
mount_uploader :avatar, AttachmentUploader
end
이제 tanuki.png라는 아바타를 업로드하면, 프로젝트의 projects.avatar 칼럼에 CarrierWave가 tanuki.png 문자열을 저장하고, AttachmentUploader 클래스에 구성 데이터와 디렉터리 스키마가 포함됩니다.
예를 들어 프로젝트 ID가 123이라면 실제 파일은
/var/opt/gitlab/gitlab-rails/uploads/-/system/project/avatar/123/tanuki.png에 있을 수 있습니다.
디렉터리
/var/opt/gitlab/gitlab-rails/uploads/-/system/project/avatar/123/은
구성(/var/opt/gitlab/gitlab-rails/uploads), 모델 이름(project), 모델 ID(123), 마운트 포인트(avatar) 등을 사용하여 Uploader가 선택한 것입니다.
Uploader는 업로드의 개별 저장 디렉터리를 결정합니다.
모델의 mountpoint 칼럼에는 파일 이름이 포함됩니다.
CarrierWave가 파일 핸들 객체로 동작하는 getter와 setter를 모델에 정의하기 때문에 mountpoint 칼럼에 직접 액세스하지 않습니다.
선택적 Uploader 동작#
업로드의 저장 디렉터리를 결정하는 것 외에도,
CarrierWave Uploader는 콜백을 통해 여러 다른 동작을 구현할 수 있습니다.
이러한 동작 모두가 GitLab에서 사용 가능한 것은 아닙니다.
특히 현재 CarrierWave의 version 메커니즘은 사용할 수 없습니다.
사용할 수 있는 것들은 다음과 같습니다:
-
파일명 유효성 검사
-
Direct Upload와 호환되지 않음: 이미지 크기 조정 등 파일 콘텐츠의 일회성 전처리
-
Direct Upload와 호환되지 않음: 저장 시 암호화
이미지 크기 조정이나 암호화와 같은 CarrierWave 전처리 동작은 업로드된 파일에 대한 로컬 액세스가 필요합니다. 이는 Ruby에서 처리된 파일을 업로드하도록 강제합니다. 이는 Ruby에서 업로드를 하지 않는 것이 핵심인 Direct Upload에 반합니다. 전처리 동작이 있는 Uploader와 함께 Direct Upload를 사용하면 전처리 동작이 자동으로 무시됩니다.
CarrierWave 스토리지 엔진#
CarrierWave에는 2가지 스토리지 엔진이 있습니다:
| CarrierWave 클래스 | GitLab 이름 | 설명 |
|---|---|---|
| CarrierWave::Storage::File | ObjectStorage::Store::LOCAL | Ruby stdlib을 통해 액세스하는 로컬 파일 |
| CarrierWave::Storage::Fog | ObjectStorage::Store::REMOTE | Fog gem을 통해 액세스하는 클라우드 파일 |
GitLab은 구성에 따라 이 두 엔진 모두 사용합니다.
CarrierWave에서 스토리지 엔진을 선택하는 일반적인 방법은 Uploader.storage 클래스 메서드를 사용하는 것입니다.
GitLab에서는 이 방법을 사용하지 않고, 대신 Uploader#storage를 재정의했습니다.
이를 통해 파일마다 스토리지 엔진을 변경할 수 있습니다.
CarrierWave 파일 생명주기#
Uploader는 일반 스토리지와 캐시 스토리지 두 가지 스토리지 영역과 연결됩니다.
각각 별도의 스토리지 엔진을 가집니다.
마운트 포인트 setter에 파일을 할당(project.avatar = File.open('/tmp/tanuki.png'))하면 cache! 메서드를 통해 파일을 캐시 스토리지에 복사/이동해야 합니다.
파일을 영속적으로 저장하려면 store! 메서드를 어떤 방식으로든 호출해야 합니다.
이는 ActiveRecord 콜백을 통하거나 Uploader 인스턴스에서 store!를 직접 호출하여 이루어집니다.
일반적으로 cache!와 store!와 직접 상호작용할 필요는 없지만, GitLab CarrierWave 수정 사항을 디버깅해야 할 경우 이들이 존재하며 항상 호출된다는 것을 알면 도움이 됩니다.
특히, CarrierWave 전처리 동작(process 등)은 before :cache 훅으로 구현되며, Direct Upload의 경우 이러한 훅이 무시되어 실행되지 않는다는 것을 알아두면 좋습니다.
Direct Upload는 모든 CarrierWave before :cache 훅을 건너뜁니다.
CarrierWave에 대한 GitLab 수정 사항#
GitLab은 여러 가지를 가능하게 하기 위해 수정된 버전의 CarrierWave를 사용합니다.
스토리지 엔진 간 데이터 마이그레이션#
app/uploaders/object_storage.rb에는 로컬 스토리지와 오브젝트 스토리지 간에 사용자 데이터를 마이그레이션하는 코드가 있습니다. 이 코드가 존재하는 이유는 오랫동안 GitLab.com이 NFS를 통한 로컬 스토리지에 업로드를 저장했기 때문입니다. 인프라 마이그레이션의 일환으로 업로드를 오브젝트 스토리지로 이전해야 했을 때 이 코드가 필요했습니다.
이것이 GitLab에서 CarrierWave storage가 업로드마다 다른 이유이며, uploads.store나 ci_job_artifacts.file_store와 같은 데이터베이스 칼럼이 있는 이유입니다.
Workhorse를 통한 Direct Upload#
Workhorse Direct Upload는 많은 Ruby CPU 시간을 소비하지 않고 대용량 업로드를 허용하는 메커니즘입니다. Workhorse는 Go로 작성되어 있으며 고루틴(goroutine)은 Ruby 스레드보다 훨씬 낮은 리소스 사용량을 가집니다.
Direct Upload는 다음과 같이 동작합니다.
-
Workhorse가 사용자 업로드 요청을 수락합니다.
-
Workhorse가 Rails를 통해 요청을 사전 인가하고 임시 업로드 위치를 받습니다.
-
Workhorse가 임시 업로드 위치에 파일 업로드를 저장합니다.
-
Workhorse가 요청을 Rails로 전달합니다.
-
Rails가 업로드된 파일을 임시 위치에서 최종 위치로 복사하는 원격 복사 작업을 실행합니다.
-
Rails가 임시 업로드를 삭제합니다.
-
Workhorse가 Rails 타임아웃에 대비하여 임시 업로드를 두 번째로 삭제합니다.
일반적으로 cache!는 CarrierWave::SanitizedFile 인스턴스를 반환하고, 이후 store!가
Fog를 사용하여 해당 파일을 업로드합니다.
오브젝트 스토리지의 경우, GitLab 전용 수정 사항이 적용된 상태에서,
임시 위치에서 최종 위치로의 복사는 Rails가 CarrierWave를 속이는 방식으로 구현됩니다.
CarrierWave가 업로드를 cache!하려고 할 때,
임시 파일을 가리키는 CarrierWave::Storage::Fog::File 파일 핸들을
반환합니다.
그런 다음 store! 단계에서 CarrierWave가 이 파일을
복사합니다.
테이블#
Scalability::Frameworks 팀은 오브젝트 스토리지와 업로드를 더 사용하기 쉽고 더 견고하게 만들기 위해 노력하고 있습니다. Uploader를 추가하거나 변경할 경우 이 테이블도 함께 업데이트해주시면 도움이 됩니다. 이를 통해 Uploader가 어디서 어떻게 사용되는지 개요를 파악하는 데 도움이 됩니다.
기능 버킷 세부 정보#
| 기능 | 업로드 기술 | Uploader | 버킷 구조 |
|---|---|---|---|
| Job 아티팩트 | direct upload | workhorse | /artifacts/<proj_id_hash>/ |
| 파이프라인 아티팩트 | carrierwave | sidekiq | /artifacts/<proj_id_hash>/pipelines/<pipeline_id>/artifacts/<artifact_id> |
| 라이브 job 트레이스 | fog | sidekiq | /artifacts/tmp/builds/<job_id>/chunks/<chunk_index>.log |
| Job 트레이스 아카이브 | carrierwave | sidekiq | /artifacts/<proj_id_hash>/ |
| 오토스케일 러너 캐싱 | 해당 없음 | gitlab-runner | /gitlab-com-[platform-]runners-cache/??? |
| 백업 | 해당 없음 | s3cmd, awscli, 또는 gcs | /gitlab-backups/??? |
| Git LFS | direct upload | workhorse | /lfs-objects/<lfs_obj_oid[0:2]>/<lfs_obj_oid[2:2]> |
| 디자인 관리 썸네일 | carrierwave | sidekiq | /uploads/design_management/action/image_v432x230/<model_id>/<original_lfs_obj_oid[2:2] |
| 일반 파일 업로드 | direct upload | workhorse | /uploads/@hashed/[0:2]/[2:4]/ |
| 일반 파일 업로드 - 개인 스니펫 | direct upload | workhorse | /uploads/personal_snippet/<snippet_id>/ |
| 전역 외관 설정 | disk buffering | rails controller | /uploads/appearance/... |
| 토픽 | disk buffering | rails controller | /uploads/projects/topic/... |
| 아바타 이미지 | direct upload | workhorse | /uploads/[user,group,project]/avatar/<model_id> |
| 가져오기 | direct upload | workhorse | /uploads/import_export_upload/import_file/<model_id>/<file_name> |
| 내보내기 | carrierwave | sidekiq | /uploads/import_export_upload/export_file/<model_id>/ |
| 플레이스홀더 재할당 CSV | direct_upload | workhorse | /uploads/-/system/group/<model_id>/placeholder_reassignment_csv/<file_name> |
| GitLab 마이그레이션 | carrierwave | sidekiq | /uploads/bulk_imports/??? |
| MR diff | carrierwave | sidekiq | /external-diffs/merge_request_diffs/mr-<mr_id>/diff-<diff_id> |
| 패키지 매니저 에셋(NPM 제외) | direct upload | workhorse | /packages/<proj_id_hash>/packages/<package_id>/files/<package_file_id> |
| NPM 패키지 매니저 에셋 | carrierwave | grape API | /packages/<proj_id_hash>/packages/<package_id>/files/<package_file_id> |
| Debian 패키지 매니저 에셋 | direct upload | workhorse | /packages/<group_id or project_id_hash>/debian_*/<group_id or project_id or distribution_file_id> |
| Dependency Proxy 캐시 | send_dependency | workhorse | /dependency-proxy/<group_id_hash>/dependency_proxy/<group_id>/files/<blob_id or manifest_id> |
| Terraform 상태 파일 | carrierwave | rails controller | /terraform/<proj_id_hash>/<terraform_state_id> |
| Pages 콘텐츠 아카이브 | carrierwave | sidekiq | /gitlab-gprd-pages/<proj_id_hash>/pages_deployments/<deployment_id>/ |
| 보안 파일 | carrierwave | sidekiq | /ci-secure-files/<proj_id_hash>/secure_files/<secure_file_id>/ |
| Sbom 스캔 - sbom 파일 | direct upload | workhorse | /uploads/sbom_scans/<proj_id_hash>/ |
| Sbom 스캔 - 결과 파일 | carrierwave | sidekiq | /uploads/sbom_scans/<proj_id_hash>/ |
CarrierWave 통합#
| 파일 | CarrierWave 사용 | 분류됨 |
|---|---|---|
| app/models/project.rb | include Avatarable | check-circle Yes |
| app/models/projects/topic.rb | include Avatarable | check-circle Yes |
| app/models/group.rb | include Avatarable | check-circle Yes |
| app/models/user.rb | include Avatarable | check-circle Yes |
| app/models/terraform/state_version.rb | include FileStoreMounter | check-circle Yes |
| app/models/ci/job_artifact.rb | include FileStoreMounter | check-circle Yes |
| app/models/ci/pipeline_artifact.rb | include FileStoreMounter | check-circle Yes |
| app/models/pages_deployment.rb | include FileStoreMounter | check-circle Yes |
| app/models/lfs_object.rb | include FileStoreMounter | check-circle Yes |
| app/models/dependency_proxy/blob.rb | include FileStoreMounter | check-circle Yes |
| app/models/dependency_proxy/manifest.rb | include FileStoreMounter | check-circle Yes |
| app/models/packages/composer/cache_file.rb | include FileStoreMounter | check-circle Yes |
| app/models/packages/package_file.rb | include FileStoreMounter | check-circle Yes |
| app/models/concerns/packages/debian/component_file.rb | include FileStoreMounter | check-circle Yes |
| ee/app/models/issuable_metric_image.rb | include FileStoreMounter | |
| ee/app/models/vulnerabilities/remediation.rb | include FileStoreMounter | |
| ee/app/models/vulnerabilities/export.rb | include FileStoreMounter | |
| app/models/packages/debian/project_distribution.rb | include Packages::Debian::Distribution | check-circle Yes |
| app/models/packages/debian/group_distribution.rb | include Packages::Debian::Distribution | check-circle Yes |
| app/models/packages/debian/project_component_file.rb | include Packages::Debian::ComponentFile | check-circle Yes |
| app/models/packages/debian/group_component_file.rb | include Packages::Debian::ComponentFile | check-circle Yes |
| app/models/merge_request_diff.rb | mount_uploader :external_diff, ExternalDiffUploader | check-circle Yes |
| app/models/note.rb | mount_uploader :attachment, AttachmentUploader | check-circle Yes |
| app/models/appearance.rb | mount_uploader :logo, AttachmentUploader | check-circle Yes |
| app/models/appearance.rb | mount_uploader :header_logo, AttachmentUploader | check-circle Yes |
| app/models/appearance.rb | mount_uploader :favicon, FaviconUploader | check-circle Yes |
| app/models/project.rb | mount_uploader :bfg_object_map, AttachmentUploader | |
| app/models/import_export_upload.rb | mount_uploader :import_file, ImportExportUploader | check-circle Yes |
| app/models/import_export_upload.rb | mount_uploader :export_file, ImportExportUploader | check-circle Yes |
| app/models/ci/deleted_object.rb | mount_uploader :file, DeletedObjectUploader | |
| app/models/design_management/action.rb | mount_uploader :image_v432x230, DesignManagement::DesignV432x230Uploader | check-circle Yes |
| app/models/concerns/packages/debian/distribution.rb | mount_uploader :signed_file, Packages::Debian::DistributionReleaseFileUploader | check-circle Yes |
| app/models/bulk_imports/export_upload.rb | mount_uploader :export_file, ExportUploader | check-circle Yes |
| ee/app/models/user_permission_export_upload.rb | mount_uploader :file, AttachmentUploader | |
| app/models/ci/secure_file.rb | include FileStoreMounter | |
| ee/app/models/security/vulnerability_scanning/sbom_scan.rb | mount_uploader :sbom_file, Security::VulnerabilityScanning::SbomScanUploader | check-circle Yes |
| ee/app/models/security/vulnerability_scanning/sbom_scan.rb | mount_uploader :result_file, Security::VulnerabilityScanning::SbomScanUploader | check-circle Yes |