Migrate a vBulletin 4 forum to Discourse

저는 최근에 Discourse로 전환한 사용자라, 시행착오를 많이 거친 끝에 위 내용을 모두 명령어 순서대로 정리한 리스트를 만들었습니다 (@titusca와 @enigmaty님께 감사드립니다).

이 글이 같은 입장의 신규 사용자분들이 시작부터 끝까지 과정을 진행하는 데 도움이 되거나(최소 속도 향상에라도) 도움이 되기를 바랍니다. mysql에서 mariadb로 변경된 업데이트로 인해 과정에 많은 혼란이 생겼다고 생각하므로, 이 내용을 첫 번째 게시글에 반영하고 싶습니다.

배경:

  • 160만 개의 게시물 이전.
  • Digital Ocean Droplet (CPU Optimized 4 vCPU/8GB) 사용

#1 - Digital Ocean Discourse 1클릭 Droplet 설치

#2 - SSH를 통해 Discourse 설치 완료 (프롬프트에 따라 진행)

SSH 콘솔 열기
root
(root 비밀번호)
(엔터)
(도메인).com
(etc…)

#3 - SFTP 로그인하여 데이터베이스 덤프 업로드

sftp root@XXX.XXX.XX.XX
y
yes
(root 비밀번호)
put db.sql /var/discourse/shared/standalone/db.sql

#4 - 새로운 Discourse 웹사이트에 로그인하여 관리자 계정 설정

#5 - SSH 로그인 - 프로세스 시작

ssh root@XXX.XXX.XX.XX
cd /var/discourse
./launcher start app
docker exec -it app bash
sudo apt-get update
sudo apt-get upgrade
y

#6 - MariaDB 설치 (mysql 대체)

apt-get update && apt-get install mariadb-server-10.3 libmariadbd-dev
y

#7 - Mysql 데이터베이스 설정

service mysql start
mysql -u root -p
password
create database vbulletin;
exit;

#8 - Vbulletin → Mysql 데이터베이스 이전

mysql -u root -p vbulletin < /shared/db.sql
password

#9 - GEM 파일

echo “gem ‘mysql2’” >>Gemfile
echo “gem ‘mysql2’, require: false” >> /var/www/discourse/Gemfile
echo “gem ‘php_serialize’, require: false” >> /var/www/discourse/Gemfile
cd /var/www/discourse
su discourse -c ‘bundle install --no-deployment --without test --without development --path vendor/bundle’
(빨간 글씨 결과는 무시)

#10 - 설치 스크립트 설정

vi /var/www/discourse/script/import_scripts/vbulletin.rb

#10.a - 텍스트 파일에 필요한 편집 수행

DB_HOST ||= ENV[‘DB_HOST’] || “localhost”
DB_NAME ||= ENV[‘DB_NAME’] || “vbulletin”
DB_PW ||= ENV[‘DB_PW’] || “password”
DB_USER ||= ENV[‘DB_USER’] || “root”
TIMEZONE ||= ENV[‘TIMEZONE’] || “America/Los_Angeles”
TABLE_PREFIX ||= ENV[‘TABLE_PREFIX’] || “”
ATTACHMENT_DIR ||= ENV[‘ATTACHMENT_DIR’] || ‘/shared/attachments/’

#10.c - 편집 종료

:wq

#11 - Bundle 설정

bundle config set path ‘vendor/bundle’
bundle config set without ‘development:test’
bundle config unset deployment
su discourse -c ‘bundle install’

#12 - Mysql 설정 (이전 단계에서 수행할 수도 있음)

mysql --version
sudo mysql -u root -p
password
ALTER USER 'root'@'localhost' IDENTIFIED BY 'password';
FLUSH PRIVILEGES;
exit

#13 - 설치 스크립트

su discourse -c ‘bundle exec ruby script/import_scripts/vbulletin.rb’

성공을 기원합니다!

8개의 좋아요

vB4에서 마이그레이션한 후 피드백을 남기고자 합니다:

  • 수정됨 [s]소프트 삭제된 게시글이 제대로 숨겨지지 않던 문제: FIX: vBulletin importers should hide soft-deleted posts - Pull Request #12057%5B - discourse/discourse - GitHub
  • [ul] + [li] 및 중첩된 [LIST]가 제대로 마이그레이션되지 않았고, BBCode 플러그인도 이를 처리하지 않는 것 같습니다 → 이는 예상된 사항인 것으로 보입니다: CommonMark testing started here! (인용: 코어는 실패의 원인이 되므로 BBCode에 대한 [ul], [ol], [li] 지원을 구현하지 않습니다.) → 이 문제를 해결하기 위해 정규식(RegEx)을 활용한 사후 수정 스크립트를 작성해야 할 것 같습니다.
  • 일반 임포터(normale importer)를 사용하여 초기 마이그레이션을 수행했으며(3일 이상 소요), 데이터를 ‘신선한’ 상태로 유지하고 다운타임을 실질적으로 30분으로 줄이기 위해 더 새로운 DB 스냅샷으로 마이그레이션을 여러 번 재시작했습니다. 이 절차는 잘 작동했지만, 초기에 스레드와 게시글을 임포트한 후에 편집된 모든 내용에 대해서는 예외였습니다. 이제 이 정보를 수동으로 다시 작업해야 합니다.
  • 문서화가 부족하고 폴더 구조가 어떻게 작동하는지에 대한 전체적인 그림이 명확하지 않아 Discourse용 플러그인 개발은 매우 어렵습니다. 다만, 어떻게 작동하는지 이해하게 되면 점차 더 좋아지고 개선되는 느낌입니다.

남은 질문들:

  • 임포터가 이미 임포트된 게시글을 어떻게 매핑하는지, 그리고 구 vB4의 post_id를 새 Discourse의 post_id와 일치시켜 해당 “소프트 삭제된” 게시글을 숨기는 방법을 확신하지 못합니다. 힌트를 주실 수 있다면 매우 감사하겠습니다! 찾았습니다: post_custom_fields 테이블 안의 import_id입니다. 좋습니다. 이제 이를 수정하기 위한 유용한 스크립트를 작성해야 합니다 :slight_smile: → 수정: 모든 임포트된 id를 쉽게 사용할 수 있도록 매핑하는 임포터 스크립트를 사용하는 것이 더 나은 방법입니다.
2개의 좋아요

불행히도 이전 게시물을 수정할 수 없습니다 :slight_smile:

또 다른 문제를 발견했습니다: 게시물에 링크되지 않은 첨부파일은 Discourse에서 사용할 수 없습니다.

이 문제를 수정하기 위한 제 초안 PR: FIX: vBulletin importer should import unreferenced attachments - Pull Request #12187 - discourse/discourse - GitHub

감사합니다!

3개의 좋아요

이슈 목록에 대해 간단히 후속 보고드립니다. 가시성 문제를 수정했습니다.

기존 vBulletin 데이터베이스에서 영향을 받은 모든 게시물을 덤프하세요:

SELECT postid
FROM `vb4_post`
WHERE `visible` > '1'
ORDER BY postid

모든 postid를 한 줄씩 나열한 imported_post_ids.txt 파일을 생성하세요.

수정 스크립트를 위한 새 파일을 생성하세요:

nano script/import_scripts/fix_visibility.rb 

내용:

require_relative '../../config/environment'
require_relative 'base/lookup_container'

@lookup = ImportScripts::LookupContainer.new

broken_postids = []
broken_real_postids = []

File.foreach("imported_post_ids.txt") do |line|
  broken_postids.append(line.to_i)
end

broken_postids.each do |id|
  broken_real_postids.append(@lookup.post_id_from_imported_post_id(id))
end

broken_real_postids.each do |id|
  puts id
  Post.find(id).trash!
end

스크립트를 실행하세요:

su discourse -c 'bundle exec ruby script/import_scripts/fix_visibility.rb'

이 스크립트는 가져오기(importer)의 로직을 사용하여 가져온 post_id를 숨기고자 하는 실제 discourse post_id로 매핑합니다.

4개의 좋아요

여러분, 안녕하세요.

VB3 마이그레이션 스크립트를 실행 중입니다. 한 단계씩 진행하고 있으며, 현재 122,000명의 사용자를 분당 330명 속도로 처리하고 있습니다. 이후에는 250만 개의 게시글을 처리해야 합니다.

이 작업은 프로덕션 서버에서 수행하고 있습니다. 현재 디스코urs 사이트를 사용하는 사람은 없으며, 사이트는 방금 설정을 마친 상태로 익명 URL에 위치해 있습니다. 로그인하면 신규 사용자 알림이 증가하는 것을 확인할 수 있습니다. 아마도 바보 같은 질문일 수도 있지만, 라이브 사이트를 일시 중단하거나 비활성화하면 마이그레이션 처리 속도가 더 빨라질지 궁금합니다.

1개의 좋아요

이것은 프로덕션 서버의 부하와 CPU 수에 따라 달라집니다. 웹 서버를 5분간 중지하여 가져오기 속도가 빨라지는지 항상 시도해 볼 수 있습니다.

3개의 좋아요

임포트는 정말 시간이 오래 걸립니다. 제가 아는 한, 벌크 임포터가 더 빠를 것입니다. 우리는 강력한 개발 머신에서 백업으로 첫 임포트를 수행한 후, 다른 백업에서 증분 임포트를 수행하여 다운타임 30분만으로 Discourse로의 전환을 완료했습니다. 증분 업데이트 시 문제가 발생할 수 있는 점에 주의하세요 :slight_smile: (여기 참조: Migrate a vBulletin 4 forum to Discourse - #132 by paresy)

paresy

3개의 좋아요

서버가 업데이트된 데이터를 가져오는 것으로 보이는 코어가 하나 100% 점유된 상태이고, import 스크립트를 실행할 때 또 다른 코어가 100% 점유된 것을 확인했습니다. 이 두 프로세스 간의 db 리소스 경쟁이 import 프로세스를 늦추고 있는지, 그리고 컨테이너를 켜둔 채로 데이터 가져오기(ingestion)를 중지하는 것이 실제로 가능한지에 대한 도메인 지식이 부족합니다. 어쨌든 데이터 가져오기는 반드시 수행되어야 하므로, 가장 안전한 방법은 그대로 계속 진행되도록 두는 것 같습니다.

향후 독자를 위한 팁으로, 사용자의 27k(22%!)가 금지된 스팸봇임을 확인했습니다. 최종 import를 수행하기 전에 소스 측에서 이들을 제거할 것입니다.

[추가] 위에서 언급되지 않은 필수 수정 사항 하나:

--- a/script/import_scripts/vbulletin.rb
+++ b/script/import_scripts/vbulletin.rb
@@ -134,6 +133,7 @@ EOM
        , usertitle
        , usergroupid
        , joindate
+       , lastvisit
        , email
        , password
        , salt

vb3에 특정한 수정 사항이 될 수 있는 또 다른 수정 사항:

--- a/script/import_scripts/vbulletin.rb
+++ b/script/import_scripts/vbulletin.rb
@@ -987,7 +989,7 @@ EOM
   end

   def parse_timestamp(timestamp)
-    Time.zone.at(@tz.utc_to_local(timestamp))
+    Time.zone.at(@tz.utc_to_local(Time.at(timestamp)))
   end

[추가] import는 oracle cloud 4-core ampere 인스턴스에서 실행되고 있습니다. 비교를 위해 M1 macbook air에 discourse dev 서버를 로컬/네이티브로 설치해 보았는데, import 프로세스가 훨씬 느리게 실행되는 것에 놀랐습니다.

6개의 좋아요

기존 스크립트로 인해 오류가 발생했나요? 그 문제로 인해 저희의 기존 vBulletin 4 게시물에서 모든 날짜 및 시간 정보가 손실되었습니다. 이것이 수정 사항이라면, 모든 게시물이 복사된 상태에서 다시 가져오기(reimport)하는 것이 좋은 방법인지 알고 싶습니다.

2개의 좋아요

예, 해당 스크립트는 정수(int)를 시간 함수에 전달했기 때문에 오류가 발생했습니다.

3개의 좋아요

아니요. 스크립트는 이미 가져온 게시글을 건너뜁니다.

3개의 좋아요

안녕하세요,

이 문제를 어떻게 해결하는지 알아내셨나요?

저희의 두 개의 주요/하위 포럼의 parentid가 -1로 설정되어 있습니다(예전에 v3에서 전환하면서 생긴 문제인 것 같습니다).

어떻게 진행해야 할지 모르겠습니다. -1인 경우 변환 스크립트에서 0으로 설정하면 되는 건가요? 0이 discourse의 주요 카테고리라고 가정하고요.

사실 지금 discourse 사이트를 보니, 그 두 개가 유일하게 가져와진 것 같습니다?

 importing top level categories...
         2 / 2 (100.0%)  [211 items/min]  in]
 importing children categories...
 Traceback (most recent call last):
         5: from script/import_scripts/vbulletin.rb:1003:in `<main>'
         4: from /var/www/discourse/script/import_scripts/base.rb:47:in `perform'
         3: from script/import_scripts/vbulletin.rb:84:in `execute'
         2: from script/import_scripts/vbulletin.rb:287:in `import_categories'
         1: from script/import_scripts/vbulletin.rb:287:in `each'
script/import_scripts/vbulletin.rb:289:in `block in import_categories': undefined method `[]' for nil:NilClass (NoMethodError)
1개의 좋아요

아마도요. 그 이후로 vBulletin 가져오기를 여러 번 해봤거든요. :person_shrugging:

직접 시도해보고 어떻게 되는지 확인해봐야 할 것 같습니다. 제가 설명했던 것과 같은 상황으로 보이네요.

그 값이 nil이면 … do something …하도록 스크립트를 수정하면 될 것 같습니다.

1개의 좋아요

물론이죠, 하지만 discourse가 어떻게 작동하는지에 대해 충분히 알지 못해서 무엇을 설정해야 할지 모르겠습니다.

0과 같은 임의의 숫자로 설정하면 discourse는 어떻게 동작할까요? 아니면 이미 데이터베이스에 존재하는 카테고리 번호를 찾아서 그 값으로 설정해야 하나요?

Ruby에 익숙하지 않아서, 이 코드가 제대로 동작할까요?

        if categories.detect { |c| c["forumid"] == cc["parentid"] }["parentid"].nil?
          cc["parentid"] = 52
        else
          cc["parentid"] = categories.detect { |c| c["forumid"] == cc["parentid"] }["parentid"]
        end

사실 더 이상 존재하지 않는 parentid를 가진 삭제된 포럼이 많은 것 같습니다.

수정
모두를 하나의 부모 주제(parent topic)로 설정해 두었습니다. 나중에 수정하면 되니까요.

1개의 좋아요

드디어 첨부 파일 가져오기 단계까지 왔는데, 약 1.9% 진행되었을 때 다음 오류가 발생했습니다

    67406 / 3550728 (  1.9%)  Traceback (most recent call last):
        23: from script/import_scripts/vbulletin.rb:1006:in `<main>'
        22: from /var/www/discourse/script/import_scripts/base.rb:47:in `perform'
        21: from script/import_scripts/vbulletin.rb:88:in `execute'
        20: from script/import_scripts/vbulletin.rb:610:in `import_attachments'
        19: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/querying.rb:22:in `find_each'
        18: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:70:in `find_each'
        17: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:137:in `find_in_batches'
        16: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:229:in `in_batches'
        15: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:229:in `loop'
        14: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:245:in `block in in_batches'
        13: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:138:in `block in find_in_batches'
        12: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:71:in `block in find_each'
        11: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:71:in `each'
        10: from /var/www/discourse/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.4.1/lib/active_record/relation/batches.rb:71:in `block (2 levels) in find_each'
         9: from script/import_scripts/vbulletin.rb:651:in `block in import_attachments'
         8: from script/import_scripts/vbulletin.rb:651:in `each'
         7: from script/import_scripts/vbulletin.rb:659:in `block (2 levels) in import_attachments'
         6: from /var/www/discourse/script/import_scripts/base.rb:873:in `html_for_upload'
         5: from /var/www/discourse/script/import_scripts/base/uploader.rb:40:in `html_for_upload'
         4: from /var/www/discourse/lib/upload_markdown.rb:10:in `to_markdown'
         3: from /var/www/discourse/lib/upload_markdown.rb:19:in `image_markdown'
         2: from /var/www/discourse/app/models/upload.rb:206:in `short_url'
         1: from /var/www/discourse/app/models/upload.rb:534:in `short_url_basename'
/var/www/discourse/app/models/upload.rb:270:in `base62_sha1': undefined method `hex' for nil:NilClass (NoMethodError)

undefined method `hex’ for nil:NilClass (NoMethodError)

이 문제를 해결하는 방법에 대해 아이디어가 있는 분 계신가요?

short_url_basename을 읽으려고 하는데 nil을 반환해서 .hex가 실패하는 건가요?

1개의 좋아요

코드를 직접 확인하지는 않았지만, 제 추측으로는 파일이 없거나, 파일명 필드가 존재하지만 비어 있는 상태일 수도 있습니다. 아마도 import_attachments에 puts를 넣어보면서, 가져오려는 레코드에 어떤 값이 들어 있는지 확인해 보겠습니다.

1개의 좋아요

도와주셔서 감사합니다! 저는 Ruby를 처음 배우고 있는데, 이렇게 하는 것이 올바른 방법일까요?

      unless mapping[post.id].nil? || mapping[post.id].empty?
        mapping[post.id].each do |attachment_id|
          upload, filename = find_upload(post, attachment_id)
          unless upload
            fail_count += 1
            next
          end

          puts "{short_url_basename}"

          # internal upload deduplication will make sure that we do not import attachments again
          html = html_for_upload(upload, filename)
          if !new_raw[html]
            new_raw += "\n\n#{html}\n\n"
          end
        end
      end

아, short_url_basename은 함수이므로 그렇게 하면 작동하지 않겠네요.

그냥 puts "{post}"라고 하면 되나요? 그러면 post 객체의 모든 내용이 출력되나요?

upload.rb에서 크래시가 발생하는 줄은 이 것 같습니다.

upload_markdown 19
"![#{@upload.original_filename}|#{@upload.width}x#{@upload.height}](#{@upload.short_url})"

upload.rb 534
"#{Upload.base62_sha1(sha1)}#{extension.present? ? ".#{extension}" : ""}"

upload.rb 270
Base62.encode(sha1.hex)

그러면 upload.original_filename, upload.width, upload.height, upload.short_url 중 하나일 텐데

upload_markdown에서 nil 체크를 하면 에러를 막을 수 있지 않을까요?

작동하려면 shortURL이 꼭 필요한 건가요? 제 마음대로 랜덤 shortURL을 만들어도 될까요?

2개의 좋아요

문제가 바로 거기 있는 것 같습니다. 업로드를 찾지 못해서 nil이 반환되는 겁니다. 아마 파일이 없거나 잘못된 것 같습니다.

1개의 좋아요

그렇다면 이 코드가 그것을 잡지 않을까요?

unless upload
  fail_count += 1
  next
end

아니면 unless가 nil을 검사하지 않는 건가요?

아니면 upload 객체는 생성되었지만, upload 객체의 upload.short_url 속성이 없는 것일까요?

1개의 좋아요

미안. 그래. 그렇게 하면 잡을 수 있겠네. 이 수준의 디버깅은 사실 포럼에서 다루기엔 적절하지 않은 것 같아. :person_shrugging:

그래도 방향은 잘 잡고 있어. 계속 진행해봐. 알아낼 수 있을 만큼 충분히 아는 것 같아. 나는 Ruby를 배우기 전에 이미 몇 개의 가져오기(importer)를 작성해 봤거든.

1개의 좋아요