DO Spaces 데이터센터 변경 후 업로드 오류

안녕하세요,

최선을 다해 포럼을 검색해 보았지만 해결책을 찾지 못해, 최근 Digital Ocean 데이터센터 변경 이후 발생한 특이한 상황에 대해 지원을 요청드립니다.

우리는 모든 업로드 파일을 ams3 데이터센터의 Digital Ocean Spaces 버킷에 저장하고 있었습니다.
약 한 달여 동안 두 번의 큰 하드웨어 문제와 이에 따른 서비스 장애가 발생했고, 지난 주말에 모든 파일을 fra1 데이터센터로 이전하기로 결정했습니다.

제가 수행한 단계는 다음과 같습니다:

  1. 이전을 준비하기 위해 ams3에 있던 모든 파일(3개의 표준 디렉터리: originals, optimized, tombstone)을 s3cmd를 사용하여 fra1의 새 버킷에 업로드했습니다.
  2. 포럼 설정으로 이동하여 첨부파일, CDN, 백업 버킷의 새 엔드포인트를 설정했습니다.
  3. 모든 것이 한 번에 해결되기를 기대하며 전체 포스트 리베이킹(rebake)을 실행했습니다.

불행히도 그것은 사실이 아니었습니다. 대부분의 첨부파일이 올바르게 "이전"되었지만, 수백 개는 그렇지 않았습니다. 무슨 일이 있었는지는 명확하지 않지만, 이 누락된 첨부파일들은 tombstone 디렉터리로 이동되었습니다.

rake uploads:recover_from_tombstone rake 태스크를 실행하면 이 문제가 해결될 것이라고 생각했지만, 그렇지 않았습니다. 파일은 인식되지만, 태스크가 끝난 후에도 첨부파일이 복구되지 않았으며, 이미지들은 여전히 포스트에서 표시되지 않았습니다.

조금 더 깊이 파고들어 보니, rails 콘솔에서 UploadRecovery.new(dry_run: true).recover를 실행하면(meta에서 찾아낸 방법) 포스트 URL과 문제 있는 이미지의 짧은 또는 긴 URL과 같은 유용한 정보를 제공한다는 것을 발견했습니다.

짧은 형식으로 반환된 URL에 대해, 짧은 업로드 파일명을 긴 형식으로 "변환"하여 버킷에 해당 파일이 존재하는지 확인해 볼 수 있도록 약간의 Python 코드를 작성했습니다.
확인 결과, 모든 누락된 파일이 존재하는 것을 확인할 수 있었습니다. 새 버킷에도, 구 버킷에도요. 누락된 업로드 중 일부는 예상대로 tombstone 디렉터리에 있는 것을 발견했지만, 일부는 이상하게도 여전히 original 디렉터리에 있었습니다. 파일은 손상되지 않았습니다. URL로 접근하면 두 데이터센터 모두에서 올바르게 열리며, 내 Linux 머신에 로컬로 덤프해도 오류 없이 열립니다.

어떤 이유로 업로드 복구 프로세스가 이들을 가져와 DB에서 엉망이 된 것을 수정하지 못하는 것 같습니다. :man_shrugging:

따라서 제 질문은 다음과 같습니다:

  • 업로드 파일이 tombstone(또는 original)에 있는 경우에도 rake 태스크가 이를 복구하지 못하는 이유를 이해하는 방법이 있을까요?
  • 버킷 변경 또는 DO에서 다른 AWS 호환 환경으로의 전환 시 모든 첨부파일이 올바르게 이동되고 스왑을 위해 준비되도록 보장하는 올바른 단계는 무엇일까요? 더 일반적으로, 이런 경우 단계별로 무엇을 해야 합니까? 분명히 단순한 리베이킹만으로는 충분하지 않습니다. :confused:
  • posts:invalidate_broken_images 태스크는 무엇을 하는 것인가요? 즉, invalidate(무효화)는 무엇을 의미합니까?

미리 감사드립니다. 일주일째 이 문제로 고생하고 있으며, 정말로 이 문제를 해결해야 합니다. 그렇지 않으면 미쳐 버릴 것 같습니다 :smiley: :stuck_out_tongue:
참고로 800개 이상의 첨부파일을 수동으로 다시 로드하라는 제안은 유효한 답변으로 간주되지 않습니다. 알고리즘적인 이유가 있을 것입니다… :laughing:

2단계와 3단계 사이에 DbHelper.remap('oldbucketurl', 'newbucketurl')을 놓친 것 같습니다.

안녕하세요 @falco, 답변해 주셔서 감사합니다.
네, 처음에는 제가 그걸 잊고 있었습니다.
meta에서 이것저것 찾아보다가 발견한 후 실행했는데, 일부 파일을 복구하는 데 도움이 되었습니다. :confused: 참고로 실행한 후 전체 재베이킹을 했습니다.

그 밖에 시도해 볼 수 있는 것이 있을까요?

자, 여기서 무슨 일이 일어나고 있는지 단서를 찾았을 수도 있겠네요.
rake uploads: recover_from_tombstone rake 태스크 출력과 관련된 사실을 언급하지 않은 것이 생각나는데, 이것이 흥미로운 힌트를 제공할 수 있을 것 같습니다.

태스크가 실제로는 tombstone에서 업로드 파일을 찾고 있지만, 무언가(업로드 전체 파일명)가 올바르지 않다는 경고가 나옵니다. 다음과 같은 형태입니다:

Warning /t/i-miei-modellini-volanti/28272/212 had an incorrect 487b613752a0c338646fecc942512e5de9afeb3f should be c87c4f08d1a9aac3f43d19722cfd5a94f2544272 storing in custom field 'rake uploads:fix_relative_upload_links' can fix this

로컬 업로드 디렉터리 사본에서 find 명령을 실행해 보니, 487b613752a0c338646fecc942512e5de9afeb3f.jpeg라는 이름의 파일이 실제로 존재하는 것으로 나타났습니다.

이 특정 업로드에 해당하는 숏링크는 upload://alcIv6jVlmjiEOEBh8fNDJyRms7.jpeg이며, 이를 기반으로 전체 파일명을 계산하는 base62 알고리즘을 적용해 보면 값이 487b613752a0c338646fecc942512e5de9afeb3f로 나오는데, 이는 정확히 recover_from_tombstone rake 태스크가 오류라고 경고한 파일명입니다. :thinking:

왜 이 도구가 이 파일명이 잘못되었다고 주장하고, 대신 c87c4f08d1a9aac3f43d19722cfd5a94f2544272여야 한다고 하는 걸까요?

어쨌든 rake uploads:fix_relative_upload_links 태스크를 여러 번 실행한 후 rake uploads: recover_from_tombstone을 다시 실행해 보았지만 아무것도 변하지 않는 것 같습니다.

수정:
버킷을 변경하기 전에 만든 데이터베이스 백업에서 487b613752a0c338646fecc942512e5de9afeb3f를 검색해 보니, 이 이미지에 해당하는 uploads 테이블의 레코드에 정확히 이 16진수 파일명이 표시되어 있었습니다. 따라서 rake 태스크가 왜 이에 대해 문제를 제기하는지 더 이해할 수 없게 되었습니다.

image

이것은 Meta에서 가장 오래된 오해 중 하나입니다.
잘 타겟팅된 리맵 후에는 리베이크가 필요하지 않습니다.

말씀이 맞을 수도 있지만, 문제는 개발진의 튜토리얼이나 가이드가 없으면 이런 상황에서 정확히 무엇을 해야 하고 무엇을 하지 말아야 하는지 알기 어렵다는 점이에요.
항상 뭔가 더 해야 했거나 다른 순서로 진행했어야 했다는 느낌이 들어요. 지난 3~4년 동안 작성된 수십 개의 게시글을 훑어보며 작동하는 레시피를 찾아내는 것과 비슷하죠. :stuck_out_tongue:
리베이킹(Rebaking)은 많은 문제에 대한 만병통치약처럼 보이며 기존 게시글에는 해가 되지 않는 것 같아요.
조금 복잡하게 말했지만, 업로드 관리 등에서 사람들이 여러 번 어려움을 겪었던 점을 고려하면, 스태프가 작성한 공식 가이드가 중요한 참고 자료가 될 거예요. :wink:

죄송합니다, 이 항목을 다시 재머지(reemerge)해야 합니다.
지난주에는 recover_from_tombstone과 recover 작업의 내부 동작 원리를 이해하기 위해 업로드 rake 태스크 코드를 읽는 데 시간을 보냈습니다.
클래스의 캡슐화(encapsulation) 때문에 이해하기가 까다로웠고, 결국 대부분 실패했다고 말할 수 있습니다.

다만 제가 이해한 부분(잘못된 부분이 있다면 @Falco 님, 지적해 주세요)은 다음과 같습니다. 업로드 파일의 디스크상 파일명은 해당 파일의 sha1과 원래 확장자를 조합하여 생성됩니다. 그런 다음 디스크 또는 AWS에 저장되며, 저장 경로는 파일명의 첫 글자(때로는 두 번째 글자)에 따라 결정되는 1X 또는 2X 또는 3X… 같은 디렉터리 구조를 따릅니다(이 값들이 어떻게 결정되는지는 이해하지 못했습니다).
마지막으로 sha1과 파일명 등은 PostgreSQL의 uploads 테이블 레코드에 다른 정보들과 함께 저장됩니다.

Digital Ocean 데이터센터 변경 과정에서 무슨 일이 일어났는지 제 이해 범위 내에서 다시 살펴보면 다음과 같습니다:

  1. ams3에서 fra1로 모든 파일을 복사했습니다.
  2. @falco 님이 제안한 DbHelper.remap('oldbucketurl', 'newbucketurl')을 실행하지 못했는데, 이 경우 이를 실행해야 한다는 점이 우리에게 명확하지 않았기 때문입니다.
  3. 전역 리베이크(global rebake)를 실행했습니다. 이 단계에서 수천 개의 이미지가 “깨져”(broken) 많은 수가 톰스톤(tombstone)으로 이동했습니다. 그 이유가 완전히 명확하지는 않습니다.
  4. 뭔가 문제가 있다는 것을 깨닫고 진행 중이던 리베이크를 중단하고, 메타(meta)에서 검색하여 remap 명령어를 발견했습니다. 이후 DbHelper.remap('oldbucketurl', 'newbucketurl') 태스크를 실행했습니다.
  5. 3단계에서 톰스톤으로 이동된 이미지들을 복구하기 위해 rake uploads:recover_from_tombstone을 실행했습니다. 일부는 복구되었지만, 수백 개는 복구되지 않았으며, 파일의 sha1과 관련된 오류가 표시되었습니다. 예를 들어 Warning /t/eclisse-parziale-di-sole-04-01-2011/14456/50 had an incorrect 3f5a1c136b97aebac4a188432c8e3ab7487f3bca should be ec88ee9eea18f3b8424bfef796345c68582911b5 storing in custom field 'rake uploads:fix_relative_upload_links' can fix this와 같은 오류로, 마치 파일이 어떻게 변경되어 sha1이 달라진 것처럼 보입니다. 이러한 파일의 복구는 실패합니다.

두 데이터센터 간 파일을 이동하는 동안 파일 자체는 절대 변경하지 않았습니다. s3cmd를 사용하여 기존 버킷에서 로컬로 직접 덤프(dump)하고 즉시 새 버킷에 다시 업로드했습니다.

그렇다면 Discourse가 계산한 sha1은 왜 달라질 수 있는 것일까요?

recover 태스크가 sha1 불일치를 무시하도록 강제하고, DB의 import를 실제 존재하는 내용에 맞게 조정하거나, 복구 시 기존 파일명을 새로운 sha1로 변경하는 방식으로 처리할 수 있을까요?

무엇을 놓치고 있는 건가요? 도움을 주셔서 감사합니다.

이 스레드에 다른 분들에게도 유용할 수 있는 결론을 내려드리기 위해, 우리가 이 상황을 어떻게 해결했는지 공유합니다.

결론적으로, 다양한 업로드 복구 rake 작업을 통해 누락된 첨부 파일을 복구하는 것이 불가능했기 때문에, Ruby 스크립트를 작성했습니다. (미리 사과드립니다. 저는 Ruby나 Rails 개발자가 아니므로, 이 코드가 비효율적이고 추악할 것이라고 생각됩니다. 하지만 그것은 별개의 문제입니다 :P) 이 스크립트는 다음과 같은 일을 수행합니다:

  1. upload:// 문자열을 포함하는 모든 게시물을 찾습니다.
  2. 각 업로드의 쇼트링크를 추출하여 긴 형태의 sha1 해시로 변환합니다.
  3. Uploads 테이블을 조회합니다.
  4. Uploads에서 해당 sha1 해시를 가진 첨부 파일이 발견되면 그 업로드는 건너뛰고, 그렇지 않으면 해당 업로드의 URL이 이전 Digital Ocean 버킷/spaces에서 확인됩니다.
  5. 업로드가 이전 버킷/spaces에서 발견되면, 쇼트링크는 이전 버킷에 있는 동일한 업로드의 URL로 대체됩니다.
  6. 수정이 이루어진 경우, 원본 게시물의 rebake를 트리거하여 Discourse가 “잃어버린” 업로드를 로컬로 다시 다운로드하고 DB에 필요한 모든 것을 다시 생성하는 중대한 작업을 수행하도록 합니다.

블랙리스트 등재와 서버 부하를 줄이기 위해, rebake가 요청될 때마다 20초의 간격을 두었습니다.


def remoteFileExist(url, retries=3)
    puts "Requesting #{url} ..."
    uri = URI(url)
    response = nil
    res = Net::HTTP.get_response(uri)
    puts res['content-type']
    if res.code[0,1] == "2" and res['content-type'].include? 'image'
        return true
    else
        return false
    end
    rescue Net::ReadTimeout => e
        puts "TRY #{retries}/n ERROR: timed out while trying to connect #{e}"
        if retries <= 1
            raise
        end
        remoteFileExist(url, retries - 1)
    end
end


####################################################################


posts=Post.where("raw like '%upload://%' " ).order('topic_id ASC, post_number DESC');
idx = 0;
posts.each do |p|
    idx = idx + 1;
    puts ""

    matches = p.raw.scan(/(!\[(.)*\]\(upload:\/\/([a-zA-Z0-9]+)\.(jpeg|jpg|png|gif|pdf|mp3|mp4|mov)\))/)

    new_raw = p.raw

    matches.each do |m|  
        short_url = m[0];
        short_sha = m[2];
        ext = m[3];
        long_sha = Base62.decode(short_sha).to_s(16).rjust(40,"0")

        upload = Upload.where('sha1 = ?', long_sha)

        puts "#{short_url} -> #{long_sha}\n"

        if upload.all.count == 0
            puts "#{long_sha} not found in DB. Recovering from ams3...\n"

            subdir1 = long_sha[0]
            subdir2 = long_sha[1]

            new_url1 = "https://discourse-data.ams3.digitaloceanspaces.com/original/3X/#{subdir1}/#{subdir2}/#{long_sha}.#{ext}"
            test1 = remoteFileExist(new_url1)
            if test1
                new_raw = new_raw.gsub(short_url, "\n#{new_url1}")
            else
                new_url2 = "https://discourse-data.ams3.digitaloceanspaces.com/original/2X/#{subdir1}/#{long_sha}.#{ext}"
                if remoteFileExist(new_url2)
                    new_raw = new_raw.gsub(short_url, "\n#{new_url2}")
                end
            end
            puts ""
            sleep 5
        end
    end

    if p.raw != new_raw
        puts "OLD\n"
        puts p.raw
        puts "-----------"
        puts "NEW\n"
        puts new_raw
        puts "-----------"
        puts "UPDATING!"
        # goahead = gets
        p.raw = new_raw
        p.cooked = ''
        p.save
        p.rebake!(invalidate_broken_images: true);
        puts "*******************************************"
        sleep 30
    else
        puts "SKIP!"
        puts "*******************************************"
        sleep 1
    end
end