🌊

KubernetesでAirPlayサーバーを動かす

2020/12/31に公開

Kubernetes上でAirPlayサーバーを動かしたのでその顛末を紹介します。

前提

  • 自宅にKubernetesクラスタがある
  • Kubernetesクラスタのノードがあるセグメントとクライアントのセグメントは分離されている

目的

AirPlayで音声を飛ばしてスピーカーから再生することで再生デバイスの接続が切り替わっても音が途切れないようにします。

主に音楽を再生しているMacBookProにはUSB接続されたオーディオインターフェスがあります。AirPlay導入前はこのオーディオインターフェースを利用してスピーカーから音を鳴らしていました。

しかしオーディオインターフェースは有線接続なので、部屋の中で少しPCを移動しようとケーブルを抜くと当然スピーカーが使えないので内蔵スピーカーに切り替わります。
切り替わり自体は素早いですしMacBookPro 16インチの内蔵スピーカーはそんなに悪くないので大きな不満があるわけではないのですが部屋の中にはスピーカーがあるのでやはりそこから鳴って欲しい。

そこでAirPlayを使えばオーディオ送出がワイヤレスでもできるので、切り替わりをなくすことができます。

そのためにAirPlayサーバーのプロセスをクライアントと同一セグメントに立てればいいわけですが、自宅にはKubernetesクラスタがあるのでせっかくなのでk8sクラスタの下で動かしてみます。

k8sはWebアプリなどのデプロイに特化しているわけではなく、条件を組み合わせてプロセスを配置することも得意なので様々な使い方ができます。
k8sクラスタはプロセスのスーパーバイザです。もしAirPlayサーバーのプロセスが止まってしまった場合は勝手に再起動をします。

今回はAirPlayサーバーのプロセスを起動するために最適なノードが1台しかないのでノード故障に対する耐性はありませんが、適切なノードを複数台配置すればそれも可能になります。
(そこまで必要かどうかは考えないこととします。)

自宅ネットワーク

まずは前提となるk8sクラスタが構築されているネットワークですが、次のようになっています。

このようにk8sクラスタとクライアントのセグメントは分離されていて、最低限のポートのみが開いているという状況です。

なのでk8sクラスタのセグメントにAirPlayサーバーがあるとクライアントのセグメントからディスカバリができません。

ゴール

クライアントのセグメントでAirPlayサーバーを起動することでディスカバリを容易にし(むしろセグメントを超えたAirPlayサーバーは面倒)複数の端末からAirPlayが使えるようにします。
そのためAirPlay用のノードを追加し、そのノードはクライアントのセグメントに設置します。

このAirPlayサーバーのプロセスはk8sが管理をし、何らかの原因で止まってしまった場合でも自動で復旧されるようにします。

つまりネットワーク的には次のような構成を目指します。

ハードウェア

今回はAirPlay専用ハードウェアをいくつか購入しました

  • Raspberry Pi 4 2GB
  • ファン付きのケース
  • まぁまぁ速いSDカード
  • 安価なUSB DAC

この選択には次のような理由があります。

Raspberry Pi 4 2GBは、待機電力が低く多くのソフトウェアが動き、USBがついてるノードということで選んでいます。4GBではなく2GBなのは、主にAirPlayしか動かさないので2GBあれば十分だと考えたからです(実際十分でした)

こういった用途でRaspberry Piを使う記事はインターネット上にいくつかあるのですが多くがDAC Hatを利用しています。
これはRaspberry Pi 4の場合はやめたほうがいいです。

Raspberry Pi 4はCPUの発熱が増えておりファンでの冷却がほぼ必須になっています。
ファンレスでもOSを動かすことはできますが、少しプロセスが動き出すと温度が上がってしまいクロックダウンされます。
更に負荷をかけると強制的にシャットダウンします。
これを防ぐにはファンを付けるしかありません。が、Hatを使うとCPUの上部にファンが付けられなくなります。
したがって安定稼働が難しくなります。
サイドから送風するという方法もありますが、そういったケースはほとんど見かけません。

ですので今回はHatは使わずにUSB DACを利用し、Raspberry Piケースにファンがついているものを選びました。
ファンをつけると安定稼働させることができるのは事前に検証 & 確認済みなのでこのようなハードウェアを買いました。

配線

上記のオーディオの接続にRaspberry Piを加えます。

今は上のような接続になっています。

ノードのセットアップ

Raspberry PiのOSとしてUbuntuを利用します。
他のk8sノードもホストOSはUbuntuにしているのでここは合わせます。

OSのセットアップはSDカードに直接イメージを書き込みます。
これには公式のツールがあるのでそれを使うだけです。

k8sのノードとしてのセットアップもk8s標準の方法で行います。
今回はノードの中でも専用用途の特殊構成なのでラベルを付与しておきます。

このラベルを使って

  • AirPlayサーバー以外が簡単にスケジュールされることを防ぐ
  • AirPlayサーバーがそのノードにのみスケジュールされるようにする

というのを実現しています。

自宅にk8sクラスタをセットアップできる人には特に説明する必要はないと思うので、ここでは省略します。

セグメントをまたぐノード

kubelet は kubelet から kube-apiserver に向けて接続をします。
したがって kube-apiserver に対して接続できれば最低限は動きます。

ただし、 kube-apiserver 側からの接続もあるのでそのポートについては通信できるようにします。

詳細については オフィシャルのドキュメント を参照してください。

AirPlayサーバーを動かす

shairport-sync を利用しました。これをいい感じに狙ったホストにスケジュールすれば良いはずです。

実際に使っているマニフェストは次のようなものです。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: shairport-sync
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: shairport-sync
  template:
    metadata:
      labels:
        app.kubernetes.io/name: shairport-sync
    spec:
      containers:
        - name: shairport-sync
          image: mikebrady/shairport-sync:latest
          securityContext:
            privileged: true
          volumeMounts:
            - name: conf
              mountPath: /etc/shairport-sync.conf
              subPath: shairport-sync.conf
            - name: snd
              mountPath: /dev/snd
            - name: alsa
              mountPath: /usr/share/alsa
      hostNetwork: true
      tolerations:
        - key: network
          operator: Equal
          value: client
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: dac
                    operator: Exists
      volumes:
        - name: conf
          configMap:
            name: shairport-sync-conf
        - name: snd
          hostPath:
            path: /dev/snd
        - name: alsa
          hostPath:
            path: /usr/share/alsa

ポイントは

  • ホストネットワークで動かす
    • mDNSでAirPlayをディスカバリさせるために
  • tolerationとaffinityを使って専用ノードにスケジュールさせる
  • privilegedでコンテナを動かす
  • alsaをコンテナにマウントする

です。

自分の環境に合わせた適当な shairport-sync.conf を用意してそれをコンテナにマウントすれば動くでしょう。

shairport-sync.conf はコンテナの中からファイルを取り出し、必要な部分のみを修正しました。

修正した箇所は

  • generalセクションの name を自分の好きな名前に
  • alsaセクションのoutput_deviceをUSB DACにする

くらいです。

PulseAudioではなくALSAを利用しているのは、このコンテナに含まれるshairport-syncが --with-pa でビルドされていないためです。

デバイス名の決定方法

設定ファイルに指定するALSAのデバイス名は aplay -L で見つけることができます。
何を指定したらどこから音が出るのか、というのは設定ファイルに書く前にテストすることをおすすめします。

テストする場合は speaker-test コマンドを使うことができます。

例えば次のようなコマンドで実際にスピーカーを鳴らしテストすることができます。

$ sudo speaker-test -D hw:CARD=DAC -c 2

コンテナスケジュールの制御

専用ノードにはラベルが付与されています。

意図しないPodのスケジューリングを防ぐため、ほぼ全てのPodでは NoSchedule になるようなTaintを設定しておきます。
AirPlayのPodはこれをTolerationに設定し、スケジュールできるようにします。

AirPlayのPodを専用ノードにのみスケジュールしてもらうようにラベルをAffinityに設定します。

これで専用ノードの入れ替えが可能な状態でありつつ、専用ノードにのみスケジュールされるようになります。

いまのところ予定しているわけではありませんが、将来この専用ノードを入れ替えたくなった場合でもPodの移動作業が簡単にできます。

結果

このAirPlayサーバーは安定して稼働しています。

普段仕事をする時はAirPlayを使って音楽を流していますがこれといった不具合は起きていません。

音声と映像をsyncするためにAirPlayはかなり大きくバッファを取ります。そのため動画などを見ても音ズレはあまり感じませんが、リアルタイムで音を出す必要があるものには向きません。
例えば音ゲーは無理ですし、DAWなどでの作曲も難しいでしょうし音声の編集も同様でしょう。

syncの精度は高そうですが、たまに音ズレしているなと感じることはあります。

使用時間の大部分が音楽の再生なのでそれも特に不満というわけではないのですが。

まとめ

Kubernetes上でAirPlayサーバーを動かす方法を大雑把に紹介しました。

このように自宅クラスタはただGrafanaやPrometheusなどのPodを動かすだけにとどまらず、k8sの様々な機能を利用することで多くのソフトウェアの管理が楽になります。

shairport-sync を動かすためだけにk8sクラスタを構築するのはどう考えてもオーバーエンジニアリングでしょう。

ですが、うちのクラスタでは現時点で166のPodが起動しています。(k8sのコントロールプレーンも含みます)オブジェクトストレージやbot、コンテナレジストリやCIツールなどが動いています。
それらによって自宅内のサービスが構成されています。
この数のコンテナでもk8sというスーパーバイザがなければメンテしていくのは大変でしょう。

参考

Discussion