DO Spaces データセンター変更後のアップロードに関する問題

皆様、こんにちは。

私の能力の限りを尽くしてフォームを検索しましたが、解決策が見つからなかったため、Digital Ocean のデータセンター変更後に発生した奇妙な状況についてサポートを仰ぎたいと思います。

以前、すべてのアップロードファイルを ams3 データセンターにある Digital Ocean Spaces バケットに保存していました。1 ヶ月強の間に 2 回の大規模なハードウェア障害とそれに伴うサービス停止が発生したため、先週末、すべてのファイルを fra1 データセンターへ移動させることを決めました。

私が行った手順は以下の通りです:

  1. 転送に備え、s3cmd を使用して、ams3 にあったすべてのファイル(元の 3 つのディレクトリ:originals、optimized、tombstone)を fra1 の新しいバケットへアップロードしました。
  2. フォーラムの設定画面で、添付ファイル、CDN、バックアップバケットの新しいエンドポイントを設定しました。
  3. 一度にすべてを修正できることを期待して、フルポストの再構築(rebake)を実行しました。

残念ながら、そうはなりませんでした。ほとんどの添付ファイルは正常に「移行」されましたが、数百件は移行されませんでした。何が起きたのかは明確ではありませんが、これらの欠落した添付ファイルは tombstone ディレクトリへ移動していました。

rake タスク rake uploads:recover_from_tombstone を実行すればそれが処理されるかと考えましたが、そうではありませんでした。ファイルは検出されるものの、タスク終了時には添付ファイルは復元されず、投稿内では画像が表示されないままでした。

さらに詳しく調査したところ、Rails コンソールで UploadRecovery.new(dry_run: true).recover(メタ情報を探して発見)を実行すると、投稿の URL や問題のある画像の短縮形・長縮形の URL など、貴重な情報が得られることがわかりました。

短縮形で返された URL については、Python のコードを少し書いて「翻訳」し、短縮形のアップロードファイル名を長縮形に変換しました。そうすることで、バケット内にファイルが存在するか確認できたのです。
確認した結果、欠落しているファイルはすべて存在することを確認しました。新しいバケットでも古いバケットでも同様です。欠落したアップロードの一部は予想通り tombstone ディレクトリにありましたが、他の一部は奇妙なことに original ディレクトリに残っていました。ファイルは破損していません。URL からアクセスすれば、どちらのデータセンターでも正常に開きますし、Linux ボックスにダンプしてローカルで開いてもエラーは発生しません。

なぜかアップロードの復元プロセスはそれらを取り込まず、DB 内で何かがおかしくなっている状態を修正できていません。:man_shrugging:

そこで、以下の質問があります:

  • tombstone(または original)にアップロードファイルが存在しているにもかかわらず、rake タスクがそれらを復元できない理由を理解する方法はありますか?
  • バケット変更、あるいは DO から他の AWS 互換環境への移行の場合、すべての添付ファイルが正しく移動され、切り替えの準備が整うようにするための正しい手順セットは何でしょうか?より一般的に、そのような場合、ステップバイステップで何をすべきでしょうか?明らかに、単純な再構築(rebake)だけでは不十分です。:confused:
  • タスク posts:invalidate_broken_images は何を行うのでしょうか?つまり、*無効化(invalidate)*とは何を意味するのでしょうか?

ご多忙の折、ご回答いただきありがとうございます。この件については 1 週間ほど格闘しており、これを解決しないと気が狂ってしまいそうです :smiley: :stuck_out_tongue:
参考までに、800 件以上の添付ファイルをすべて手動で再読み込みするという提案は、有効な回答とはみなさないでください。何かアルゴリズム的な理由があるはずです… :laughing:

「いいね!」 2

ステップ 2 と 3 の間に DbHelper.remap('oldbucketurl', 'newbucketurl') を実行するのを忘れたと思います。

「いいね!」 4

@falco さん、ご返信ありがとうございます。
はい、最初はうっかりしていました。
メタフォーラムで探しているうちにそれを見つけ、実行したのですが :confused:、それで一部のファイルを復元できました。
ちなみに、実行後には完全なリベイクも行いました。

他に試せることはありますか?

「いいね!」 1

さて、ここで何が起きているのかのヒントがあるかもしれません。
rake uploads:recover_from_tombstone という Rake タスクの出力に関連する事実について言及するのをすっかり忘れていましたが、これが何か興味深い手がかりを示している可能性があります。

どうやら、このタスクは実際にトームストーン(廃棄予定)内のアップロードファイルを検出しているようですが、何か(アップロードの完全なファイル名)が不正であるという警告を出力しています。例えば、次のようなものです:

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 を検索したところ、この画像に属するアップロードテーブルのレコードには、まさにこの 16 進数のファイル名が表示されていました。そのため、なぜ Rake タスクがこれを問題視するのか、ますます理解できません。

image

これはメタにおける最も古い誤解の一つです。
適切にターゲットを絞ったリマップを行った後、リベイクを行う必要はありません。

「いいね!」 2

もしかしたらおっしゃる通りかもしれません。ただ、開発者からチュートリアルやガイドがないと、この場合の正しい対処法が正確にわからないのが実情です。
何かをもっとやるべきだった、あるいは手順を間違えていたのではないかという感覚が常にあり、過去3〜4年間にわたって書かれた数十の投稿から、実際に機能する手順を絞り込んでいくようなものです :stuck_out_tongue:
リベイクは多くの問題に対する万能薬のように見え、既存の投稿にも無害なようです。

つまり、アップロード管理などで人々が何度もつまずいてきた現状を考えると、スタッフによる公式なガイドが重要な参考資料になるだろう、という複雑な言い方をしたかったのです :wink:

「いいね!」 1

申し訳ありませんが、このトピックを再浮上させる必要があります。

先週は、recover_from_tombstonerecover の内部で何が行われているかを理解するために、アップロード用の Rake タスクのコードを読み込みました。クラスのカプセル化により理解が難しく、ほとんど失敗してしまったと言えます。

しかし、私が理解した点(もし間違っていたら @Falco に訂正してください)をまとめます。ディスク上のアップロードファイル名は、SHA1 と元の拡張子を組み合わせることで作成されます。その後、ディスクまたは AWS 上のディレクトリに保存されますが、そのパスはファイル名の最初の文字、場合によっては 2 番目の文字に依存し、1X、2X、3X… という階層になります(これらがどのように決定されるかは理解できていません)。最後に、SHA1 とファイル名は、PostgreSQL の uploads テーブルのレコードに、他の情報とともに保存されます。

Digital Ocean のデータセンター移行中に発生した事象について、私の理解を振り返ると以下の通りです。

  1. ams3 から fra1 へすべてのファイルをコピーしました。
  2. @falco が提案した DbHelper.remap('oldbucketurl', 'newbucketurl') を実行しませんでした。このケースで実行する必要があるとは、私たちに明確に理解されていませんでした。
  3. グローバルなリベイク(re-bake)を開始しました。この段階で数千枚の画像が破損し、多くがトームストーン(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 が現在異なる値になっているかのようなエラーです。これらのファイルの復元は失敗します。

2 つのデータセンター間でファイルを移動している間、ファイル自体は変更していません。s3cmd を使用して、古いバケットからローカルにダンプし、すぐに新しいバケットに再アップロードするという作業をただ繰り返しました。

なぜ Discourse が計算する SHA1 が異なるのでしょうか?

recover タスクに SHA1 の不一致を無視し、DB 内の情報をそのまま適応させるか、あるいは復元時に既存のファイルを新しい 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


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


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
「いいね!」 1