k3s上で自分専用のBluesky PDSを動かす――Docker Composeを使わずKubernetes+GitOpsで運用する設計

分散型SNSプロトコル「AT Protocol」を採用するBlueskyでは、ユーザーの投稿データやアカウントを管理するサーバー(PDS: Personal Data Server)を自分でホストできます。
公式ドキュメントで紹介されている標準の構築方法は、シェルスクリプトを実行し、Docker Composeで立ち上げる構成です。しかし、すでに個人でKubernetes(k3s)クラスタを運用し、サービスをGitOpsで管理している環境に、別のDockerホストを持ち込みたくはありませんでした。
そこで、k3s上に公式PDSコンテナ(ghcr.io/bluesky-social/pds)をデプロイし、リバースプロキシ・永続ストレージ・シークレット管理を既存のKubernetes基盤に統合する構成にしました。
この記事では、k3s上でBluesky PDSを運用するためのアーキテクチャ、必要なKubernetesリソース、DNSとTLSの設計、構築時に注意すべき点をまとめます。証明書発行や自動更新については、既存クラスタに組み込む際の構成例も紹介します。
記事確認日: 2026年11月1日
先に結論
- 公式のDocker Composeインストーラーを使わなくても、PDSはNode.jsアプリケーションと永続ストレージ(SQLite・Blob)を組み合わせたステートフルなサービスとしてKubernetesにデプロイできる。
- 最大の関門はコンテナ起動ではなく、DNS・TLS証明書・リバースプロキシ・外部ネットワークとの連合。PDS管理下のサブドメインをHTTPSで検証する構成では、ワイルドカードDNSとTLS証明書が有効。ただし、DNS TXT方式によるハンドル検証も可能であり、ワイルドカードTLSはすべての構成で必須ではない。
- PLCローテーション鍵、リポジトリ署名鍵、JWTシークレットなどは、OpenBao+External Secrets Operatorを利用することで、Gitリポジトリに秘密情報の平文を保存せずに管理できる。
- Blueskyのリポジトリ同期にはWebSocketが使用される。Traefikは標準でWebSocketを転送できるが、外部からHTTPS/WSSが正常に利用できることを確認する必要がある。
- Argo CDによる宣言的構成管理と、イメージ更新の検出・検証・反映を組み合わせれば、既存のGitOps運用へ統合できる。
全体アーキテクチャ
クラスタ内でのPDS配置と周辺基盤との連携は、次のように構成できます。
[クライアント(公式App / Web)]
[外部Relay]
│
▼ HTTPS / WSS
[公開エンドポイント]
│
▼
[Ingress: Traefik]
│
├── TLS証明書
│ (既存の証明書管理基盤)
│
▼
[Service: bluesky-pds-svc]
│ ClusterIP / Port 2583
▼
[Pod: bluesky-pds]
├── PDS Node.js Application
├── PVC
│ ├── SQLite DB
│ └── Blobストレージ
└── Kubernetes Secret
▲
│ External Secrets Operator
│
[OpenBao]
[Git Repository]
│
▼
[Argo CD]
│
▼
[Kubernetesリソースの同期]
PDS本体は単一レプリカで運用し、SQLiteとBlobを永続ボリュームに保存します。
外部公開にはTraefikを利用し、TLS証明書は既存クラスタの証明書管理方式に合わせます。DNS-01によるワイルドカード証明書を使用する場合は、cert-managerなどを利用できます。
なぜKubernetesで動かすのか?
Docker Compose版と比較した際の主なメリットは次の3つです。
- Argo CDによるGitOps管理:Deployment、Service、Ingress、PVCなどの構成を宣言的に管理できる。
- 既存のセキュリティ・運用基盤の再利用:External Secrets、RBAC、NetworkPolicy、監視、バックアップなどを既存クラスタへ統合できる。
- 更新管理の統一:RenovateやArgo CD Image Updaterなどでイメージ更新を検出し、検証・承認を経て反映する運用を構築できる。
ただし、Kubernetesにすれば自動的に安全になるわけではありません。ストレージ、公開経路、権限、更新手順を適切に設計することが前提です。
1. 事前準備:DNSと証明書の設計
PDSを外部公開する際に、まず設計する必要があるのがDNSとTLSです。
ドメイン設計
自分のドメインを example.com とした場合、次の構成を考えます。
- PDSエンドポイント:
bsky.example.com - PDS管理下のハンドル:
alice.bsky.example.comなど - ワイルドカードDNS:
*.bsky.example.com
公式PDSの標準的なセルフホスト構成では、PDS配下で新しいアカウントを発行できるよう、ワイルドカードDNSを利用します。
# DNSレコードの例
bsky.example.com. IN A <PUBLIC_IP>
*.bsky.example.com. IN A <PUBLIC_IP>
これは公開IPへ直接接続する場合の例です。Cloudflare Tunnelなどを利用する場合は、公開経路に合わせてDNS設定を変更します。
ワイルドカードTLS証明書を使う理由
AT Protocolでは、ハンドルとDID(分散ID)の対応関係を検証するため、主に次の2つの方法が使用されます。
- DNS TXT方式:
_atproto.<handle>にDIDを登録する。 - HTTPS方式:
https://<handle>/.well-known/atproto-didからDIDを取得する。
例えば alice.bsky.example.com をHTTPS方式で検証する場合、次のエンドポイントが必要です。
https://alice.bsky.example.com/.well-known/atproto-did
PDS管理下の複数ハンドルにHTTPS方式を提供する構成では、ワイルドカードDNSとTLS証明書を使用すると管理しやすくなります。
一方、DNS TXT方式で個別にハンドルを検証する場合、ハンドルごとのHTTPSエンドポイントやワイルドカードTLS証明書は必須ではありません。
重要なのは、PDS本体のHTTPS公開と、ハンドルの検証方式を分けて考えることです。
cert-managerによる証明書発行例
ワイルドカードTLSを採用する場合は、cert-managerとDNS-01チャレンジを組み合わせて証明書を発行できます。
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: bsky-wildcard-cert
namespace: bluesky
spec:
secretName: bsky-wildcard-tls-secret
issuerRef:
name: letsencrypt-dns-issuer
kind: ClusterIssuer
dnsNames:
- "bsky.example.com"
- "*.bsky.example.com"
この例では、DNS-01に対応した ClusterIssuer が事前に構成されている必要があります。
DNSプロバイダーの認証情報には最小権限を設定し、DNSレコードを必要以上に変更できないよう制限します。
なお、HTTP-01チャレンジではワイルドカード証明書を発行できません。ワイルドカードTLSを使用する場合はDNS-01など対応する検証方式が必要です。
2. 秘密鍵とシークレットの管理(OpenBao / Vault)
Bluesky PDSでは、複数の秘密鍵とシークレットを使用します。
代表的なものは次のとおりです。
| 環境変数 | 用途 |
|---|---|
PDS_JWT_SECRET |
セッショントークンの署名 |
PDS_DPOP_SECRET |
DPoP関連の秘密情報 |
PDS_ADMIN_PASSWORD |
管理APIの認証 |
PDS_REPO_SIGNING_KEY_K256_PRIVATE_KEY_HEX |
リポジトリ署名用秘密鍵 |
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX |
DID PLCのローテーション用秘密鍵 |
特に署名鍵やPLCローテーション鍵は慎重に保管する必要があります。これらを紛失すると、アカウントの復旧や移管に重大な影響が生じる可能性があります。
秘密情報の生成
JWTシークレットなどのランダムな秘密情報は、OpenSSLで生成できます。
# ランダムな256bitの秘密情報をHEXで生成
openssl rand -hex 32
secp256k1の秘密鍵は、公式インストーラーでも使用されている次の方法で生成できます。
openssl ecparam \
--name secp256k1 \
--genkey \
--noout \
--outform DER \
| tail --bytes=+8 \
| head --bytes=32 \
| xxd --plain --cols 32
このコマンドにはOpenSSLと xxd が必要です。
リポジトリ署名鍵とPLCローテーション鍵には、それぞれ独立して生成した鍵を使用します。生成結果が64桁のHEX文字列になっていることも確認します。
OpenBaoとExternal Secretsによる管理
生成した秘密情報はOpenBaoに保管し、External Secrets Operatorを経由してKubernetes Secretへ同期します。
この方式であれば、Gitリポジトリに実際の秘密鍵やパスワードを平文で保存する必要がありません。
ただし、External Secretsを使用してもKubernetes Secret自体は秘密情報を保持する点に注意が必要です。
そのため、次の対策も重要になります。
- Kubernetes Secretを参照できるRBAC権限を最小限にする。
- Secretへのアクセスやバックアップを保護する。
- Pod内の環境変数をログや診断出力に漏らさない。
- OpenBaoとKubernetes側の双方で秘密情報の復元方法を確認する。
また、External Secretsが同期先Secretを更新しても、既存Podの環境変数が自動的に更新されるわけではありません。シークレットをローテーションする際は、Podの再起動や適切なロールアウトも必要です。
3. Kubernetesマニフェスト設計
PDS本体はDeployment、Service、PVC、Ingressを組み合わせて構成します。
以下は構成を説明するための例です。実際の運用では、イメージバージョン、StorageClass、リソース制限、セキュリティ設定などを環境に合わせて調整してください。
Deployment
PDSには公式コンテナイメージ ghcr.io/bluesky-social/pds を使用します。
apiVersion: apps/v1
kind: Deployment
metadata:
name: bluesky-pds
namespace: bluesky
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: bluesky-pds
template:
metadata:
labels:
app: bluesky-pds
spec:
containers:
- name: pds
# 公式Composeで使用されるタグの例。
# 本番では検証済みバージョンやdigestを固定する。
image: ghcr.io/bluesky-social/pds:0.4
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 2583
env:
- name: PDS_HOSTNAME
value: "bsky.example.com"
- name: PDS_SERVICE_HANDLE_DOMAINS
value: ".bsky.example.com"
- name: PDS_PORT
value: "2583"
- name: PDS_DATA_DIRECTORY
value: "/pds"
- name: PDS_BLOBSTORE_DISK_LOCATION
value: "/pds/blocks"
- name: PDS_DID_PLC_URL
value: "https://plc.directory"
- name: PDS_BSKY_APP_VIEW_URL
value: "https://api.bsky.app"
- name: PDS_BSKY_APP_VIEW_DID
value: "did:web:api.bsky.app"
- name: PDS_CRAWLERS
value: "https://bsky.network"
envFrom:
- secretRef:
name: bluesky-secrets
volumeMounts:
- name: pds-data
mountPath: /pds
volumes:
- name: pds-data
persistentVolumeClaim:
claimName: bluesky-pds-pvc
秘密鍵などは bluesky-secrets にあらかじめ同期されている前提です。
ここでは PDS_PORT=2583 を明示しています。公式コンテナのデフォルトポートは3000ですが、環境変数で変更できるため、Service側も2583に統一します。
重要: PDSはSQLiteとファイルベースのBlobストレージを使用するため、Deploymentは単一レプリカとし、更新戦略には Recreate を採用します。
これにより、通常のローリング更新時に旧Podと新Podが同じ永続ストレージへ同時にアクセスする事態を避けられます。
Service
PDSへのクラスタ内アクセスにはClusterIPを使用します。
apiVersion: v1
kind: Service
metadata:
name: bluesky-pds-svc
namespace: bluesky
spec:
type: ClusterIP
selector:
app: bluesky-pds
ports:
- name: http
port: 2583
targetPort: http
Serviceを直接インターネットへ公開する必要はありません。外部通信はIngressを経由させます。
永続ストレージ
SQLite DBとBlobストレージはPVC上に保存します。
PDSのデータをPodの一時ファイルシステムに保存してしまうと、Podの再作成時にアカウント情報や投稿データを失う可能性があります。
k3sのローカルストレージを使用する場合、Podの再スケジュール先と実データの配置先に注意が必要です。
特に、ノード障害が発生しても別ノードへ自動的にデータが移動するわけではありません。
単一レプリカ・ローカルストレージの構成は比較的シンプルですが、ノード障害時の復旧方法を含めて設計する必要があります。
Ingress(Traefik)
Traefik IngressRouteまたは標準Ingressを使用します。
ここでは、PDS本体とハンドル用サブドメインの両方をTraefikへルーティングする例を示します。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ingress-bluesky-pds
namespace: bluesky
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
ingressClassName: traefik
tls:
- hosts:
- "bsky.example.com"
- "*.bsky.example.com"
secretName: bsky-wildcard-tls-secret
rules:
- host: "bsky.example.com"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bluesky-pds-svc
port:
number: 2583
- host: "*.bsky.example.com"
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bluesky-pds-svc
port:
number: 2583
この例では、TLS Secretが同じNamespaceに作成されていることを前提としています。
Bluesky PDSでは通常のHTTP API(XRPC)に加え、リポジトリ同期でWebSocketを使用します。
TraefikはWebSocketのUpgrade処理に標準対応しているため、通常は専用Middlewareを追加する必要はありません。
ただし、通信経路にCloudflareなど別のプロキシが存在する場合は、それらを含めてWSS通信が正常に成立することを確認します。
4. 疎通確認とアカウント作成
Podが起動したら、外部からの通信とアカウント作成を確認します。
1. ヘルスチェック
curl -fsS https://bsky.example.com/xrpc/_health
正常な場合は、例えば次のようなJSONが返されます。
{"version":"0.4.x"}
0.4.x は説明用のバージョン表記です。実際の応答には稼働中のPDSバージョンが表示されます。
これにより、DNS、TLS、Ingress、Service、PDS本体までの基本的な疎通を確認できます。
ただし、この結果だけで外部Relayとの連合まで正常に動作しているとは判断できません。
2. WebSocket購読の確認
WebSocketクライアント wsdump などを利用して確認します。
wsdump \
"wss://bsky.example.com/xrpc/com.atproto.sync.subscribeRepos"
WebSocketの接続が確立し、切断されずに購読状態を維持できるかを確認します。
作成直後のPDSではイベントが発生していない場合があるため、メッセージがすぐに表示されなくても異常とは限りません。
実際に投稿を作成し、そのイベントが購読側へ届くことまで確認できると、より確実です。
3. アカウント作成
公式PDSコンテナには管理CLIの goat が含まれています。
次の例では、手元のBash環境でパスワードを非表示入力し、標準入力を介してPod内のCLIへ渡します。
# Bashでパスワードを非表示入力
read -rsp "Account password: " ACCOUNT_PASSWORD
printf '\n'
# パスワードを標準入力経由で渡す
printf '%s\n' "$ACCOUNT_PASSWORD" |
kubectl exec -i -n bluesky deploy/bluesky-pds -- sh -ec '
IFS= read -r NEW_ACCOUNT_PASSWORD
export NEW_ACCOUNT_PASSWORD
exec goat pds admin \
--pds-host http://127.0.0.1:2583 \
account create \
--handle alice.bsky.example.com \
--email [email protected]
'
# ローカルの変数を消去
unset ACCOUNT_PASSWORD
あらかじめ強力なパスワードを生成してパスワードマネージャーに保存し、その値を入力します。
この例では、PDS_ADMIN_PASSWORD はすでにPodの環境変数へ注入されている前提です。また、goat が NEW_ACCOUNT_PASSWORD 環境変数から新しいアカウントのパスワードを取得します。
管理APIの接続先には、Deploymentで設定した PDS_PORT=2583 を指定しています。
この方法ではパスワードを直接コマンドライン引数に指定することを避けられますが、Pod内の環境変数には一時的にパスワードが存在します。管理権限とPodアクセス権限の保護は引き続き必要です。
アカウント作成に成功すると、DID(例:did:plc:...)が発行されます。
5. 公式クライアントからログインする
アカウントが作成できたら、Blueskyの公式アプリやブラウザからログインします。
- Blueskyのログイン画面を開く。
- ホスティングプロバイダーをデフォルトのBlueskyから「カスタム(Custom)」に変更する。
- サービスURLに
https://bsky.example.com/を入力する。 - 作成したハンドル
alice.bsky.example.comとパスワードを入力する。
ログイン後は、プロフィールの表示や投稿の作成を確認します。
ただし、ログインできることと、Blueskyネットワーク全体への連合が正常に成立していることは別です。
公開した投稿が外部のサービスで認識されるか、Relayとの同期に問題がないかも確認します。
自分でPDSをホストすることで、アカウントの元データや運用基盤を自己管理できます。一方、公開投稿やプロフィール情報は、RelayやApp Viewなどネットワーク上の別サービスにも複製されます。
したがって、セルフホストはすべてのデータが自分のサーバー内だけに保存されることを意味するわけではありません。
運用して分かった注意点・ハマりどころ
1. バックアップの重要性
PDSのデータはSQLite DBとBlob(画像など)に分かれています。
ストレージ障害でデータを失うと、アカウントや投稿を復元できなくなる可能性があります。
単にPVCのデータを定期コピーするだけでは不十分な場合もあります。
特にSQLiteでは、稼働中のDBファイルだけをコピーすると、WALなどとの整合性が失われる可能性があります。
そのため、次の点を考慮します。
- SQLiteの整合性を維持できるバックアップ方式を使用する。
- DBとBlobのバックアップを取得する。
- 署名鍵やPLCローテーション鍵を安全に保管する。
- 定期的にバックアップからの復元を検証する。
バックアップ設計では、取得できていることだけでなく、実際に復元できることが重要です。
2. PDSバージョンの追従
AT ProtocolとPDSの実装は継続的に更新されています。
本番運用では、latest のような可変タグを無条件に追従するより、検証済みバージョンやdigestを管理する方が再現性を確保できます。
RenovateやArgo CD Image Updaterなどを使用すると、更新候補の検出やGitへの変更書き戻しを自動化できます。
ただし、更新候補の検出とKubernetesへの自動デプロイは別の処理です。
Argo CDのSync Policyによっては、Git側の変更後も手動同期が必要です。
本番での安全性を重視する場合は、変更の検査、レビュー、承認を経て反映する構成も有効です。
3. 独自ドメインハンドル
@alice.example.com のように、PDSのホスト名とは異なる独自ドメインをハンドルとして利用することもできます。
DNS TXT方式では、次のように登録します。
_atproto.alice.example.com. IN TXT "did=did:plc:..."
DIDとハンドルの対応関係は双方向に検証されるため、TXTレコードの設定だけでなく、DIDドキュメント側との一致も必要です。
4. 外部公開とアクセス制限
PDSでは、クライアントからのログインやAPIアクセスだけでなく、外部Relayからのアクセスも考慮する必要があります。
そのため、認証プロキシですべてのアクセスを遮断したり、特定の送信元IPのみを許可したりすると、連合に必要な通信まで妨げる場合があります。
Cloudflare WAFやアクセス制御を利用する場合も、必要な公開APIが外部から利用できることを確認します。
一方で、Kubernetes API、OpenBao、管理用ダッシュボードなどをPDSと同じように公開する必要はありません。
公開するサービスと管理系サービスは明確に分離します。
5. SQLiteとローカルストレージの制約
単一レプリカのPDSでは、SQLiteとローカルストレージの組み合わせはシンプルで管理しやすい構成です。
ただし、ローカルストレージはノード障害や移行時の制約があります。
Kubernetesの Recreate 戦略は、通常のDeployment更新時にPodの重複起動を避けるためのものであり、ストレージ障害やあらゆる二重書き込みを防ぐ仕組みではありません。
可用性が必要な場合は、バックアップだけでなく、ストレージの配置、復旧手順、ノード障害時の対応方針も事前に決めておきます。
まとめと次に読む記事
公式ではDocker Composeを中心に紹介されているBluesky PDSですが、Kubernetesの基本リソース(Deployment、Service、Ingress、PVC、Secret)に分解することで、既存のGitOps運用へ組み込むことができます。
特に重要なのは、コンテナを起動することではなく、DNS・TLS・ハンドル検証・外部Relayとの連合・秘密鍵保護・データ復元までを一体として設計することです。
Kubernetesには既存の認証、監視、シークレット管理、バックアップなどを集約できるメリットがあります。一方で、構成が複雑になるため、単一ホストのDocker Composeより常に優れているわけではありません。
すでにk3sクラスタを運用している場合には、既存の運用基盤を再利用できることが大きなメリットになります。
参考資料
次に読む記事
更新を受け取る
個人 k3s クラスタの運用記録を、実際に踏んだ障害と一緒に書いています。新しい記事は RSS か SNS でどうぞ。



