우선순위: 중간. 데이터 손실은 없으나, 잘못된 계정에서 사용자 대상 개인 메시지(PM)가 조용히 발송되며, 부팅 이후 변경된 모든 사이트 설정이 백그라운드 작업 내에서 잘못된 값으로 동작할 수 있습니다.
플랫폼: 셀프 호스팅, 표준 discourse_docker 2컨테이너 구성 (data + web_only). 코어 2026.7.0-latest (30d8364f0ab). 브라우저와 무관한 서버 사이드 문제입니다.
설명
실제 결과: Sidekiq 데몬이 재포크(re-fork)된 후(예: Demon::Sidekiq.rss_memory_check에 의한 RSS 메모리 재시작), 새로운 Sidekiq 프로세스는 데이터베이스가 아니라 유니콘 마스터의 부팅 시 스냅샷에서 사이트 설정을 제공합니다. 마스터가 부팅된 이후 변경된 모든 설정은 다음 두 가지 상황이 발생하기 전까지 백그라운드 작업 내에서 조용히 잘못된 값으로 유지됩니다: (a) 해당 Sidekiq 프로세스가 살아있는 동안 설정이 다시 변경되거나, (b) 컨테이너가 재시작될 때까지.
제 사이트에서 관찰된 증상: 자동화된 시스템 PM(post_hidden, flags_agreed_and_post_deleted, 자동 해제)이 site_contact_username의 이전 값에서 발송되었습니다. 데이터베이스에는 5일 전부터 수정된 값이 저장되어 있었고, 해당 기간 동안 스태프 액션 로그에는 기록이 없었습니다. 반면 웹 요청에서 발송된 동일한 유형의 메시지는 올바른 사용자로 표시되었는데, 이는 웹 워커가 MessageBus 리프레시를 처리한 반면 Sidekiq는 처리하지 않았기 때문입니다.
기대 결과: 새로 포크된 데몬은 현재 사이트 설정을 읽어야 합니다. Sidekiq 내의 SiteSetting 값은 마스터가 부팅된 이후 Sidekiq가 얼마나 많이 재시작되었든 데이터베이스와 일치해야 합니다.
재현 단계
- 셀프 호스팅 인스턴스를 부팅합니다. 현재
site_contact_username을 확인합니다(이를userA라고 가정). - 관리자 → 설정에서
site_contact_username을userB로 변경합니다. 모든 실행 중인 프로세스가 이를 올바르게 가져옵니다(MessageBus/site_settings→SiteSetting.refresh!). - 컨테이너 내의 Sidekiq 프로세스를 종료하여 유니콘 마스터가 이를 재포크하게 합니다 (
kill <sidekiq_pid>; 또는 1000 MB RSS 임계값을 초과했을 때Demon::Sidekiq.rss_memory_check가 재시작하도록 기다리면 됩니다 — 바쁜 사이트에서는 자동으로 발생합니다). Jobs::SendSystemMessage를 통해 전달되는 시스템 메시지를 트리거합니다. 예를 들어, 커뮤니티 플래그에 의해 게시물이 숨겨지도록 하면Post#hide!에서:send_system_message가 큐에 추가됩니다.- 생성된 PM을 엽니다.
관찰된 내용: PM은 userA가 작성자로 표시됩니다 — 이는 마스터가 부팅되었을 당시 유효했던 값이지, userB가 아닙니다.
기대되는 내용: userB가 작성자로 표시되어야 합니다.
분석
Discourse.after_fork는 SiteSetting.after_fork를 호출합니다 (lib/site_setting_extension.rb:736):
def after_fork
@process_id = nil
ensure_listen_for_changes
end
여기서 refresh!가 호출되지 않으므로, 자식 프로세스는 카피-온-라이팅(copy-on-write)을 통해 부모의 current 설정 해시를 상속받아, 후속 변경이 브로드캐스트되지 않는 한 그 해시를 수명 전체 동안 유지합니다.
ensure_listen_for_changes (lib/site_setting_extension.rb:713)도 자식 프로세스 내에서 사실상 아무것도 하지 않는(no-op) 상태입니다. @subscribed가 true로 상속되기 때문입니다:
def ensure_listen_for_changes
return if @listen_for_changes == false
unless @subscribed
MessageBus.subscribe(SITE_SETTINGS_CHANNEL) { |message| ... }
@subscribed = true
end
end
이것이 동작하는 이유는 MessageBus.after_fork가 먼저 실행(lib/discourse.rb:1064)되어 상속된 콜백 레지스트리에 대해 버스 스레드를 재가동하기 때문입니다.
데몬은 마스터로부터의 단순한 fork입니다 (lib/demon/base.rb:181), 그리고 프로덕션 환경에서 Sidekiq는 정기적으로 재포크됩니다: Demon::Sidekiq.rss_memory_check는 DEFAULT_MAX_ALLOWED_SIDEKIQ_RSS_MEGABYTES = 1000을 초과할 때 이를 재시작합니다. 각 포크는 2주 전에 마지막으로 true였던 값을 되살립니다.
이것은 회귀(regression)가 아니라 오래된 문제입니다: after_fork는 8fc2549 (2014)부터 이 형태를 유지해 왔으며, 현재 main 브랜치에서도 변경되지 않았습니다.
놓치기 쉬운 이유: 초기 부팅 시 마스터와 Sidekiq는 동일한(올바른) 스냅샷을 공유하며, Sidekiq가 살아있는 동안에는 변경 브로드캐스트를 수신합니다. 분기(divergence)는 마스터 부팅 이후 변경된 설정, 그리고 그 변경 이후 포크된 Sidekiq 프로세스에서만 나타납니다. 즉, 아무런 에러 없이 나중에 조용히 시작됩니다.
연락처 사용자 이상의 영향
site_contact_username은 사용자가 읽는 PM에 사용자명을 찍기 때문에 가시적인 사례일 뿐입니다. 동일한 스태일니스(staleness) 문제는 작업 내에서 참조되는 모든 사이트 설정 — 속도 제한, 이메일/알림 설정, 기능 토글, 플러그인 설정 — 에 적용되며, 에러나 로그 줄이 없습니다. 운영자는 "설정이 유지되지 않는다"고 합리적으로 결론 내리고 데이터베이스를 덮어쓰는 다른 요인을 찾아다니게 됩니다.
권장 수정 사항
SiteSetting.after_fork에서 상속된 상태를 제거하고 다시 읽습니다:
def after_fork
@process_id = nil
@subscribed = false
ensure_listen_for_changes
refresh!
end
@subscribed = false는 자식 프로세스의 구독을 명시적으로 만들어 MessageBus.after_fork가 상속된 구독을 되살리는 데 의존하지 않도록 합니다.
운영자를 위한 우회 방법
./launcher restart web_only (마스터가 부팅 시 설정을 다시 읽도록 하는 전체 컨테이너 재시작). 관리자에서 설정을 다시 저장하는 것은 현재 살아있는 Sidekiq 프로세스만 수정할 뿐, 다음 재포크 시 조용히 원복됩니다.