13
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Misskeyはどのようにして分散型SNSを成立させているのか

13
Last updated at Posted at 2025-12-15

Misskeyはどのようにして分散型SNSを成立させているのか

はじめに

こんにちは、レアゾン・ホールディングスでエンジニアをしている新田です。

分散型SNSって、実際どうやって動いているんでしょうか?

MisskeyやMastodonなどの分散型SNSは、中央集権的なサーバーに依存せず、各インスタンスが独立して動作しながらも、ユーザーのフォロー関係を通じて「なんとなく」繋がっている……という概念は知っています。

でも、「具体的にコードレベルでどうやって実現しているのか?」 という部分が気になったので調べてみました。

この記事では、Misskeyの実際のソースコードを読み解きながら、分散型SNSを実現する裏側の仕組みを解説してみます。
(※読み解きが間違っていたらすみません)

Misskeyの分散型アーキテクチャ

システム全体像

まずはざっくりとした全体像から。
Misskeyは、各インスタンスが独立したデータベースを持つ分散型アーキテクチャを採用しています。

各インスタンスは以下の特徴を持っています:

  • 独立したRDBを持つ
  • ActivityPub プロトコルで通信する
  • ユーザーのフォロー関係を通じて自動的に接続される

ここでのポイントは、「誰かが投稿したデータが、どうやって他のサーバーに飛んでいくか」 です。

ActivityPubプロトコルによる連合

Misskeyは、W3C標準の ActivityPub プロトコルを使用してインスタンス間で通信します。
ActivityPubでは、SNS上のアクションを以下のような「アクティビティ」として定義しています。

  • Create: note作成
  • Announce: Renote
  • Like: リアクション
  • Follow: フォロー
  • Delete: 削除

Misskeyでは、投稿のことを「note(ノート)」と呼びます。X(Twitter)でいうPostやTweetと同じような概念です。
また、RenoteはXのRepostと同じ機能です。

ノート配信の仕組み

ここからコードを見ていきます。
ローカルユーザーが public なノートを投稿すると、どのようなフローでリモートインスタンスに配信されるのでしょうか。

1. ノート作成時の配信処理

ノートが作成されると、NoteCreateService がそれをActivityPub形式に変換し、配信先を決定します。

src/core/NoteCreateService.ts

//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
    (async () => {
        const noteActivity = await this.renderNoteOrRenoteActivity(data, note);
        const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);

        // メンションされたリモートユーザーに配送
        for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) {
            dm.addDirectRecipe(u as MiRemoteUser);
        }

        // リプライ先がリモートユーザーなら配送
        if (data.reply && data.reply.userHost !== null) {
            const u = await this.usersRepository.findOneBy({ id: data.reply.userId });
            if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
        }

        // Renote元がリモートユーザーなら配送
        if (data.renote && data.renote.userHost !== null) {
            const u = await this.usersRepository.findOneBy({ id: data.renote.userId });
            if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
        }

        // フォロワーに配送
        if (['public', 'home', 'followers'].includes(note.visibility)) {
            dm.addFollowersRecipe();
        }

        if (['public'].includes(note.visibility)) {
            this.relayService.deliverToRelays(user, noteActivity);
        }

        trackPromise(dm.execute());
    })();
}
//#endregion

この実装から、以下の挙動が読み取れます。

  1. ローカルユーザーのノートのみ配信: isLocalUser(user) でチェックしています。
  2. 配信先候補一覧:
    • メンション先のリモートユーザー
    • リプライ/Renote先のリモートユーザー
    • フォロワー(ここが重要)
    • Relayサーバー(publicノートの場合)

つまり、「誰もフォローしていない(フォロワーがいない)状態」かつ「誰にもメンションしていない」場合、フォロワーへの配信は行われません。ただし、Relayサーバーに接続していれば、そこ経由で配信されます(後述)。

2. 配信マネージャーによる最適化

配信先が決まったら、ApDeliverManagerService が実際に送る準備をします。

src/core/activitypub/ApDeliverManagerService.ts

@bindThis
public async execute(): Promise<void> {
    // key: inbox URL, value: whether it is sharedInbox
    const inboxes = new Map<string, boolean>();

    // フォロワーへの配信レシピを処理
    if (this.recipes.some(r => isFollowers(r))) {
        const followers = await this.followingsRepository.find({
            where: {
                followeeId: this.actor.id,
                followerHost: Not(IsNull()),
            },
            select: {
                followerSharedInbox: true,
                followerInbox: true,
            },
        });

        for (const following of followers) {
            // SharedInboxがあればそれを優先して使う
            const inbox = following.followerSharedInbox ?? following.followerInbox;
            if (inbox === null) throw new Error('inbox is null');
            inboxes.set(inbox, following.followerSharedInbox != null);
        }
    }
    
    // (中略: 直接指定の配信処理)

    // deliver
    await this.queueService.deliverMany(this.actor, this.activity, inboxes);
}

特筆すべきは SharedInbox の活用です。
同じインスタンスに100人のフォロワーがいても、そのインスタンス共通の SharedInbox に1回送れば済むようになっています。これがないと、フォロワーが増えるにつれて通信量が爆発してしまいますね。

ノート受信の仕組み

逆に、リモートからノートが飛んできたときは ApInboxService が処理します。

src/core/activitypub/ApInboxService.ts

@bindThis
private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise<string> {
    // (中略: 検証処理)

    const unlock = await acquireApObjectLock(this.redisClient, uri);

    try {
        const exist = await this.apNoteService.fetchNote(note);
        if (exist) return 'skip: note exists';

        await this.apNoteService.createNote(note, actor, resolver, silent);
        return 'ok';
    } finally {
        unlock();
    }
}

受信時は、署名検証 → リモートオブジェクトの解決(取得) → DB保存 という流れです。Redisを使ってLockを取っているのも、並列処理での競合を防ぐために重要そうです。

グローバルタイムラインの実装

Misskeyの特徴でもあるグローバルタイムライン。これは「世界中の全ツイート」ではなく、「観測範囲内の全パブリックノート」 です。

src/server/api/endpoints/notes/global-timeline.ts

const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
    ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
    .andWhere('note.visibility = \'public\'')
    .andWhere('note.channelId IS NULL')
    // ...

クエリ自体は非常にシンプルで、DBにある visibility = 'public' なノートを全部引っ張ってきているだけです。
つまり、「DBに入っていない(=連合していない)インスタンスの投稿は、GTLには流れてこない」 ということになります。

実際にサーバーを立てて検証してみた

コードを読んだだけでは実感が湧かないので、ローカル開発環境で実際にMisskeyインスタンスを立ち上げてみました。

1. セットアップ

設定ファイルを作成してから docker compose でサクッと立ち上げます。

# RedisとPostgreSQLを起動
docker compose up -d

# 開発サーバー起動
pnpm dev

Screenshot 2025-12-02 at 19.23.53.png

Screenshot 2025-12-02 at 19.24.09.png

セットアップ画面を経て、管理者アカウントを作成しました。
さあ、自分だけのSNSの誕生です。

2. ひとりぼっちのグローバルタイムライン

サーバーを立てた直後、期待に胸を膨らませて「グローバルタイムライン」を開いてみました。

Screenshot 2025-12-02 at 19.34.01.png

……静かすぎる。

GTLには、自分がテスト投稿したノートしか表示されていません。
「グローバル」と言いつつ、誰もフォローしていないため、他のサーバーからの投稿が一切流れてこないのです。

3. なぜ静かなのか?

先ほどの NoteCreateService のコードを思い出してみます。

// フォロワーに配送
if (['public', 'home', 'followers'].includes(note.visibility)) {
    dm.addFollowersRecipe();
}
  1. こちらから発信しない: フォロワーがいないので、私の投稿は外部に飛ばない。
  2. あちらから来ない: 誰もフォローしていないので、外部インスタンスは私のサーバーに投稿を投げてくれない。

Misskeyのネットワークには「中央レジストリ」が存在しません。
そのため、ユーザーが能動的に他サーバーのユーザーをフォローしない限り、インスタンスは孤立したままなのです。

誰かを一人でもフォローすれば、そのユーザーのインスタンスと接続が確立され、そこから徐々にネットワークが広がっていくはずです。

余談: Relayサーバーという選択肢

実は、フォロー関係を作らなくても、他のインスタンスのpublicノートを受信する方法があります。それが Relayサーバー です。

Relayサーバーは、ActivityPubネットワーク上でpublicなノートを中継する専用のサーバーです。インスタンスがRelayサーバーに接続(Follow)することで、そのRelay経由で他のインスタンスのpublicノートを受信できるようになります。

Relayサーバーの仕組み

  1. インスタンス → Relay: publicノートを投稿すると、登録済みのRelayサーバーに配信される
  2. Relay → 他のインスタンス: Relayサーバーは、自分に接続している他のインスタンスに、受信したpublicノートを中継配信する
  3. 結果: フォロー関係がなくても、Relay経由で他のインスタンスのpublicノートがグローバルタイムラインに流れてくる

つまり、Relayサーバーに接続していれば、フォローしていないインスタンスのpublicノートも受信できる ということです。これにより、ネットワークの接続性が向上し、より多くのノートがグローバルタイムラインに流れてきます。

ただし、Relayサーバーはオプション機能であり、インスタンス管理者が手動でRelayサーバーを登録する必要があります。また、Relayサーバー側も接続を承認する必要があります。

今回立てたインスタンスでは、Relayサーバーに接続していなかったため、フォローしていない状態では完全に孤立していました。もしRelayサーバーに接続していれば、フォロー関係がなくても他のインスタンスのpublicノートが流れてきていたはずです。

実際に運用されているRelayサーバーとしては、おたそーリレーサービスMisskey.GG relayなどがあります。これらに接続することで、フォロー関係がなくても様々なインスタンスのpublicノートがグローバルタイムラインに流れてくるようになります。

まとめ

Misskeyのコードを追いかけ、実際に動かしてみることで、分散型SNSの「分散」の意味が腹落ちしました。

  1. ActivityPub という共通言語で喋る。
  2. SharedInbox などで通信を最適化している。
  3. 自動接続: 繋がりは「フォロー」というユーザーのアクションによって作られる。

誰もフォローしていないGTLの静けさは、「分散型SNSとは、参加者が作り上げるネットワークである」 という事実を如実に物語っていました。

技術的に興味深いポイント(HTTP署名やジョブキュー周りなど)はまだまだありますが、今回はこのあたりで。

さようなら

▼新卒エンジニア研修のご紹介

レアゾン・ホールディングスでは、2025年新卒エンジニア研修にて「個のスキル」と「チーム開発力」の両立を重視した育成に取り組んでいます。 実際の研修の様子や、若手エンジニアの成長ストーリーは以下の記事で詳しくご紹介していますので、ぜひご覧ください!

▼採用情報

レアゾン・ホールディングスは、「世界一の企業へ」というビジョンを掲げ、「新しい"当たり前"を作り続ける」というミッションを推進しています。 現在、エンジニア採用を積極的に行っておりますので、ご興味をお持ちいただけましたら、ぜひ下記リンクからご応募ください。

13
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?