작게 접히고, 그대로 늘어나요 — sukhi-fedi
안녕하세요, nyanrus예요. 우리 작은 연합 서버 sukhi.f3liz.casa를 움직이는 소프트웨어, sukhi-fedi를 소개합니다.
sukhi-fedi는 ActivityPub를 말하는 연합 SNS 서버예요. Mastodon 호환 API를 주 입구로 삼고(Misskey와는 연합이 통해요), JSON-LD와 HTTP 서명 번역을 Elixir로 직접 해요. 드넓은 페디버스와 지금 실제로 메시지가 오가고 있어요. AGPL-3.0, 현재 버전은 v0.4.14예요.
하고 싶은 말은 하나예요. 화려한 새 기능이 아니라, 수수한, 만든 순서에 대한 이야기 —— 이 코드 하나는 프리티어(서버 무료 등급)에 올릴 만큼 작은 상자에서도 돌아가고, 필요하면 노드를 분할해서 그대로 늘어나요. 그리고 연합의 핵심은 실제로 돌아가고 있어요. 차례로 볼게요.
뭐가 다른가
연합 서버를 세울 때, 보통 처음에 '크기'를 고르라고 해요. 큰 소프트웨어는 뭐든 연합할 수 있지만 제대로 된 서버를 원하고, 가벼운 소프트웨어는 작은 상자에는 들어가지만 그 상자에서 더 넓혀 가는 건 별로 설계되어 있지 않아요. sukhi-fedi가 말하는 것은, 여기가 갈림길이면 안 된다는 거예요 —— 같은 코드가 1GB 프리티어 상자에도 접혀 들어가고, 비좁아지면 새로운 서버에 노드를 나눠 넓어져요. 경계를 배포할 때 생각하기보다 OTP 모듈로 그어 뒀기 때문이에요.
또한, 작은 서버에서도, 큰 서버에서도. 비슷한 방식으로 관리할 수 있는 서버가 있었으면 하는 마음도 있었어요.
또 하나의 특징은 기능을 늘리는 방식이에요. 클라이언트 API가 플러그인 선반이라, 파일 하나가 곧 capability 하나이고, 부팅할 때 스스로 등록돼요(라우터는 안 건드려도 돼요). capability는 addon으로 묶이고, 그 묶음을 다른 노드에 올릴 수도 있어요. 서버를 확장하는 건 핵심에 패치를 대는 게 아니라 파일 한 장을 더하는 거예요 —— 그래서 새 엔드포인트도, 기능 뭉치도 서로를 방해하지 않아요.
마법은 아니에요. 경계를 한 곳에 두고, 그만큼의 값을 한 번만 치른다는 수수한 규율이에요. 그래도 그 덕분에, 취미용 상자로 sukhi-fedi를 시작해서 다시 쓰지 않고 키우고, 포크 없이 확장해 갈 수 있어요 —— 그리 흔한 거래는 아니에요.
OTP 경계를, 제대로 나눠요
두 가지가 같은 코드로 성립하는 이유는 거의 여기에 모여 있어요. sukhi-fedi는 역할마다 OTP 애플리케이션을 나눠 두었거든요.
- 안내인(gateway,
:sukhi_fedi) — 사람으로부터의 요청으로 HTTP를 받는 유일한 입구예요. 로그인, 글쓰기, inbox 수신, WebFinger를 처리해요. - 배달원(delivery,
:sukhi_delivery) — outbox를 읽어서 먼 서버의 inbox로 POST해요. 재전송은 Oban 큐가 담당해요. - API 플러그인(
:sukhi_api) — Mastodon 호환 REST 처리부에요.
중요한 건 이 경계가 네트워크가 아니라 모듈로 그어져 있다는 점이에요. 각각 독립된 OTP 앱이라, 서로 다른 BEAM(Erlang 가상 머신) 노드로 돌릴 수도 있고, 통째로 하나의 BEAM에 접을 수도 있어요. 코드로 나눈 경계는 접어서 작게 해도 사라지지 않아요. 나중에 빠르게 하려고 덧댄 층이 아니라, 처음부터 그렇게 나눠 둔 거예요.
프로세스끼리는 메시지로, 상태는 supervision(감독 구조)에 맡겨요. OTP를 있는 그대로 자연스럽게 쓰면, 서버 구성을 바꾸는 일이 '배선을 다시 까는 것'이 아니라 '부팅 방식을 고르는 것'이 돼요. 접는 것과 늘리는 건 그 양쪽 끝이에요.
작게 접혀요
sukhi-fedi는 메모리 512~768MB짜리 작은 상자에서 돌리는 걸 하나의 목표로 삼았어요.
docker-compose.combined.yml을 겹치면 안내인과 배달원이 하나의 BEAM(combined 릴리스)이 돼요. 1코어 / 2GB 상자를 상정한 구성이에요. 어느 1코어 내구 테스트에서, 이 combined는 읽기·쓰기·inbox 부하 아래서 ~130 MiB로 평평하게 완주했어요. 512MB 예산에서의 배분(combined 192M / postgres 112M / …)까지 compose 주석에 적어 뒀어요. 아마 768MB에서도 돌아갈 거예요. 1GB 환경에서, UI 없이 봇 전용 서버로 돌려 본 적도 있어요(https://watch-mjw.f3liz.casa, 지금은 sukhi.f3liz.casa와 같은 상자에 있어요).
"작은 서버에서도 돌아간다"는 건 단순한 홍보 문구가 아니라, 접히도록 나눠 뒀기 때문에 가능해진 거예요.
참고로, 이 작은 상자 이야기에서 제일 먼저 쓰러진 건 사실 Elixir가 아니라 다른 런타임인 Bun이었어요. 지금은 ActivityPub 번역을 Elixir가 직접 하고, Bun은 프로덕션에서 물러났어요. 그 전말은 다른 글에 —— fedify를 쓰다가 졸업한 이야기.
그대로 확장해요
작은 서버에서도 돌아가는 것과 반대 방향으로, 확장하는 것도 같은 코드예요. 확장의 방향은 둘이에요.
배달은 분산 Erlang이 아니라 PostgreSQL과 NATS가 받아요. Postgres가 사실을 적는 장부(system of record), NATS JetStream이 이벤트 담당. 안내인은 outbox 데이터를 쓰기만 하고, 배달원은 그걸 읽어서 배달만 해요. 역할이 이 한 겹을 사이에 두고 나뉘어서, 배달원을 늘리는 데 클러스터링 삽질이 필요 없어요. 배달의 핵심 Outbox.Relay는 OTP 안의 싱글턴 GenServer(상태를 관리하는 프로세스)이고, 코드에는 처음부터 "for future horizontal scale"이라고 적혀 있어요.
기능 면은 분산 Erlang이 받아요. Mastodon 호환 REST는 별도 노드에 올린 플러그인 선반이고, :rpc로 연결해요. 파일 하나가 곧 capability 하나여서, 부팅할 때 자동 등록되고(SukhiApi.Registry) addon으로 묶여요 —— 엔드포인트를 추가하는 건 파일 하나 올리는 게 전부, 라우터는 안 건드려요. plugin_nodes는 목록으로, 안내인은 먼저 닿는 노드를 써요. 그래서 플러그인 노드를 여러 대 둘 수 있고, 기능 계층만 다른 노드로 늘릴 수 있어요. 안내인과 배달원은 건드리지 않고요.
두 개의 면, 두 갈래의 확장 —— 배달은 Postgres와 NATS, 기능은 분산 Erlang. 접힌 상태로 시작해서, 필요한 부분만 노드로 떼어내요. 설계를 다시 하지 않고요.
돌아가는 게 보여요
소개 글에서 가장 솔직하게 말하고 싶은 게 여기예요. 이건 구상이 아니라, 지금 돌아가고 있는 거예요.
실제로 연합해서 round-trip(보낸 게 상대에게 닿고 되돌아오는 왕복)까지 확인한 상대는 Mastodon, Misskey, hackers.pub이에요. ActivityPub 번역은 Elixir 네이티브(SukhiFedi.Fedi)라, 이 경로를 지난 activity가 상대에게 닿아서 되돌아와요. 다른 서버 몫 —— 서명 방식(draft-cavage와 RFC 9421), 인용 표기, 이모지 반응 같은 와이어의 버릇 —— 도 상호운용을 위해 코드에는 미리 넣어 뒀지만, 그쪽은 아직 현장에서 이어 본 건 아니에요. Mastodon 호환 API에서는 Elk / Phanpy 같은 브라우저 클라이언트나 Ivory / Tusky가 로그인 후 오류로 멈추지 않을 정도로는 손봐 뒀어요.
그걸 숫자만이 아니라 풍경으로도 볼 수 있어요. sukhi의 /map은, 여기까지 이야기한 구조 —— 안내인에서 NATS 조차장(열차를 나누고 잇는 곳)을 지나 배달원으로, 그리고 연합의 우주로 —— 를 그대로 노선도로 만든 공개 페이지예요. 그 위를 달리는 열차 수는 장식이 아니라, 그날 실제로 오간 양이에요. 역과 선로는 비유지만, 이어진 방식은 실제 배선과 일대일이에요. 만든 전말은 서버 안에 열차를 달리게 한 이야기에 적었어요.
남은 부분도 있어요. 전문 검색, streaming WebSocket, 네이티브 Misskey 클라이언트 API —— 이것들은 TODO.md와 OPEN_QUESTIONS.md에 주변부로 놓여 있어요. 핵심이 아니라 주변부예요. 앞으로 기대해 달라는 뜻이에요.
4개월동안 쌓아왔어요
첫 커밋은 2026년 3월 16일, 오타가 남은 "inital commit"이었어요. 거기서 넉 달, 461개의 커밋을 쌓아 지금 모습이 됐어요.
4월에 배달원을 본체에서 떼어내면서 연합의 뼈대가 잡혔어요. 5월은 오로지 살을 붙이는 달이었어요. 인용도, 이모지 반응도, MFM도, DM도 —— 먼 서버와 주고받는 경로가 한꺼번에 늘었어요. 순탄하지만은 않아서, 월말에는 서명한 POST가 hackers.pub에서 계속 401로 튕기던 날도 있었어요. 그날은 로그 확인, 서명 재생성, 자체 검증이 끝없이 이어졌고, 마지막 한 줄은 "Mastodon처럼 필수 헤더만 서명한다"였어요. 연합이 안 될 때에는 대체로 원인이 우리에게 있다는 걸 실감했어요.
가장 큰 리팩터링은 6월 12일에 몰려 있어요. Bun이 하던 ActivityPub 번역을 Elixir로 옮기고, 작은 상자용 combined 릴리스를 분리하고, Ed25519 키를 끝에서 끝까지 만들고, 바로 그날 Bun 사이드카를 "은퇴"로 적어 뒀어요. 각각의 기능을 작게 만들 수 있게 분리해 뒀던 덕분에, 설계를 다시 하는 대신 부팅 방식을 갈아 끼우는 걸로 끝났어요.
그 뒤로도 되돌릴 수 없는 데이터 손실을 아슬아슬하게 두 번 막은 날이 있었고, /map을 만든 건 바로 지난주예요. 화려하진 않지만, 이렇게 하루하루 쌓아 왔어요.
만져보기
git clone https://github.com/sukhi-social/sukhi-fediREADME의 Quick start를 따라가면 스택 전체를 로컬에서 빌드해서 돌릴 수 있어요(localhost:4000에서 열려요). 제대로 된 셀프호스트는 SETUP.md, 속 구조가 궁금하면 docs/ARCHITECTURE.md. 둘 다 "이 한 장으로 전부 다시 짤 수 있게"를 목표로 쓰여 있어요. AGPL-3.0이니까 가져가서 써도 괜찮아요.
작게 접히고, 그대로 늘어나는 연합 서버가 하나 있어요. 눈에 띄었다면, 한번 들여다봐 주세요.
(sukhī는 팔리어로 '행복한'이라는 뜻이에요. 사람과 사람이 이어지는, 기분 좋은 자리를 엮어 갈 수 있기를 —— 그런 마음으로 붙인 이름이에요.)
댓글
이 글은 페디버스에 올라가 있어요. 여러분의 계정에서 답글을 달면 여기에 보여요.
아직 댓글이 없어요.