Dockerコンテナ内のX11 GUIアプリを使用する

# 概要

Dockerイメージに構築したX11のGUIアプリをコンテナとして起動し、GUIをX11で表示するための手順をまとめます。同様の内容はたくさんありますが、前提条件がまとまっているものを見つけられなかったのでいくつか検証したのでまとめておきます。

## 検証環境

- CentOS 7.9(2022-09-03時点でアップデート済み) on KVM(CentOS 8.2)
    - 7.8 LiveGNOMEでインストールし、`yum update`を行った環境
- Docker version 20.10.17, build 100c701
    - Dockerのオフィシャルリポジトリからのインストール
    - https://docs.docker.com/engine/install/centos/
- ローカルコンソールはvirt-managerのコンソール(SPICE)
- SSHクライアント側X11はWindows 10 + VcXsrv

# 前提
## GUI表示方法

あまり何も考えずにできるのはローカルコンソール(VNCやNoMachineのPhisical Display)を使った場合ですが、現実的にはSSHでDockerホストに接続し、X11 Forwardingを使うことがあると思います。この記事ではローカルコンソールおよびSSHによるX11 Forwardingについてまとめます。

## コンテナの起動方法

コンテナを起動する際には`--net`オプションでネットワークを選択できますが、オプションを付けない場合はブリッジネットワークが使用されます。今回は、ブリッジネットワークと`--net=host`の時に使用されるホストネットワークを使用しました。どちらを選択するかで設定が変わりますので、ネットワークの選択は重要です。

コンテナ内のGUIアプリを使用する際はホストネットワークの方が圧倒的に簡単ですが、ブリッジネットワークについても検証しました。

## 使用イメージ

`xeyes`を起動することでGUI動作を確認しました。以下の`Dockerfile`でイメージを作成しました。

```Dockerfile
FROM centos:7.9.2009
RUN yum install -y xeyes \
 && useradd -m testuser \
 && useradd -m testuser2
USER testuser
ENTRYPOINT ["bash"]
```

    $ docker build -t xeyes-cl7 .

で作成できます。
エントリーポイントはbashにしていますので、X11の設定がうまくできていれば`-c xeyes`でxeyesが表示されるはずです。

# 設定方法
## ローカルコンソール

ローカルコンソールはホストネットワークを使う方法と、ブリッジネットワークで`/tmp/.X11-unix`をマウントする方法があります。
ただし、これはローカルコンソールのみであり、VNCなどで接続して使う場合などは利用できますが、X11 Forwardingでは使えません。
Xのアクセスコントロールをローカルに対して許可しておきます。

```bash
$ xhost +local:
```

アクセスコントロールリストの確認

```bash
$ xhost
access control enabled, only authorized clients can connect
LOCAL:
```

### ホストネットワーク

ホストネットワークを指定するだけで動作します。
`-e=DISPLAY`で環境変数`DISPLAY`をコンテナに渡します。

```bash
$ docker run --rm -e=DISPLAY --net=host xeyes-cl7 -c xeyes
```

### ブリッジネットワーク

ディフォルトのブリッジネットワークの場合は`/tmp/.X11-unix`をマウントします。

```bash
$ docker run --rm -e=DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix xeyes-cl7 -c xeyes
```

## X11 Forwarding

SSHでDockerホストに接続し、X11 ForwardingによりGUIを手元のPCに表示させます。
SSH接続時は`-X`または`-Y`オプションでX11 Forwardingを有効にして接続します(WindowsのTeraTermなどではX転送を有効にしておく)。ホストネットワーク、ブリッジネットワークどちらでも実現可能ですが、ホストネットワークを使う方が無難だと思われます。

気をつける点はコンテナ内で.Xauthorityへのアクセスができているかどうかです。確認するにはコンテナ内でシェルを起動し、`xauth list`でマジッククッキーのリストが出てくるかで確認できます。xeyes-cl7イメージにはxauthが含まれていないので試す場合は`xorg-x11-xauth`パッケージをインストールして下さい。

```bash
$ xauth list
dockerhost/unix:10  MIT-MAGIC-COOKIE-1  7ee9988e96ce340727b4e1913064b915
```

### ホストネットワーク

ホストネットワークを指定し、`$HOME/.Xauthority`をバインドすれば動作します。ただし、.Xauthorityのパーミッションは`600`なので、dockerを実行するUIDとコンテナのUIDが異なる場合は工夫が必要です。

#### UIDが同じ場合

まずsshで接続します。WindowsからはTeraTermを使いましたが、Linuxなどからの場合は`-X`または`-Y`オプションを使います。

```bash
$ ssh -X 192.168.100.11 <-- Dockerホストのアドレス
```

接続できたら以下のオプションで、設定できます。
ここではsshでログインしたユーザーのUIDとコンテナ内の`testuser`のUIDが同じ(UID=1000)とします。

```bash
$ docker run --rm -e=DISPLAY --net=host -v $HOME/.Xauthority:/home/testuser/.Xauthority xeyes-cl7 -c xeyes
```

これで手元のスクリーンにxeyesが表示されるはずです。

#### UIDが異なる場合

UIDが異なる場合は、.Xauthorityにアクセスできないので、コピーした上でパーミッションを変更し、一度別ディレクトリにマウントして、それをホームディレクトリにコピーします。ここではUID=1001の`testuser2`を使います。

```bash
$ cp $HOME/.Xauthority $HOME/tmp.Xauthority
$ chmod 644 $HOME/tmp.Xauthority
$ docker run --rm -e=DISPLAY --net=host \
  -v $HOME/tmp.Xauthority:/tmp/.Xauthority \
  --user testuser2 \
  xeyes-cl7 \
  -c "cp /tmp/.Xauthority /home/testuser2 && xeyes"
$ rm $HOME/tmp.Xauthority
```

これだと面倒なので、私はコンテナ権限をrootで起動して、プログラム起動時にユーザー権限にしています。
実装例を示します。

実行するスクリプトをdocker-entrypoint.shとして作成します。ここではコンテナをrootで起動して、UIDが異なるtestuser2でプログラムを実行すると仮定しています。プログラムはxeyes固定で、プロセスの権限変更はsetprivを使っています(CentOS7のsetprivでは環境変数リセットオプションが使えないので、HOMEのみ再設定)。環境変数EXEC_USERでユーザー名を指定します。

```bash: docker-entrypoint.sh
#!/bin/bash
USR=$EXEC_USER
USRID=$(getent passwd $USR | cut -d: -f3)
GRPID=$(getent passwd $USR | cut -d: -f4)
GRP=$(getent group $GRPID | cut -d: -f1)
HOME=/home/$USR

cp /tmp/.Xauthority $HOME
chown $USR $HOME/.Xauthority
exec setpriv --reuid=$USRID --regid=$GRPID --clear-groups bash $@
```

本来はイメージにコピーするべきですが、例なのでスクリプトをマウントします。

```bash
$ chmod +x docker-entrypoint.sh
$ docker run --rm --net=host \
  -e=DISPLAY \
  -e EXEC_USER=testuser2 \
  -v $HOME/.Xauthority:/tmp/.Xauthority \
  -v $(pwd)/docker-entrypoint.sh:/usr/bin/docker-entrypoint.sh \
  --user root \
  --entrypoint docker-entrypoint.sh \
  xeyes-cl7 \
  -c xeyes
```

### ブリッジネットワーク

ブリッジネットワークでも可能ですが、iptablesによるDNATを使った力業なのであまりオススメできません。SSHの接続オプションで変更できるかもしれませんが、やり方を見つけられませんでした。

原理について説明します。X11 ForwardingはSSHで接続した際にDockerホストに生成されるx11-ssh-offsetポートが使用されます。これは6000+スクリーン番号で、通常、SSHでのスクリーン番号は10(変更可能)から始まり、セッションごとに6010、6011…となります。これらのポートは127.0.0.1で待ち受けされているので、127.0.0.1:6010に対して通信ができればよいということになります。

ところが、ブリッジネットワークの場合は、コンテナ内で127.0.0.1に接続してもあくまでコンテナ内のループバックですので、ホストの127.0.0.1には到達できません。そこで、ホスト側でiptablesを設定し、Destination NAT(DNAT)により(無理矢理)127.0.0.1に転送する、ということを行います。

```
(コンテナ内)->[172.17.0.2(コンテナI/F)]->[172.17.0.1(ホスト側I/F)]->(DNAT)->[127.0.0.1]
```

あくまで、検証可能だった、というだけなので、実用に耐えるかどうかはわかりません…。検証したので備忘録として残します。

#### Dockerホストでの設定

まず、Dockerホスト側でDNATの設定を行います。ハマりどころとして、localhost(127.0.0.1)へのDNATは`net.ipv4.conf.all.route_localnet`フラグを1にする必要があるみたいです( https://superuser.com/questions/661772/iptables-redirect-to-localhost )。

```bash
$ su -
# sysctl -w net.ipv4.conf.all.route_localnet=1
```

続いてiptablesによるDNATを設定します。x11-ssh-offsetポートは可変のため、ポート範囲で転送します。上限があるのかわかりませんが、ここでは6010から6020の範囲とします。Docker自身がiptables設定を使用しているため、`-I`オプションでルールを先頭に挿入します。

```bash
# iptables -t nat -I PREROUTING 1 -i docker0 -p tcp -m multiport --dports 6010:6020 -j DNAT --to-destination 127.0.0.1
```

実用的ではないですが、単独ポートのみ転送したい場合は以下のようにします。通常はマルチポートでいいと思います。

```bash
# iptables -t nat -I PREROUTING 1 -i docker0 -p tcp --dport 6010 -j DNAT --to-destination 127.0.0.1:6010
```

#### コンテナでの設定

ホストネットワークの場合と同様に.Xauthorityによりアクセス制限を乗り越えます。これまではDISPLAYはlocalhostでよかったのですが、接続先が172.17.0.1(コンテナから見た場合のホスト; `gateway`でもいいはず)になるため、xauthにより追加する必要があります。

登録されているMagic Cookieを確認して、複数ある場合は、使用するスクリーン番号(ここでは10)と一致するクッキーを使用します。

```bash
$ xauth list
centos7test/unix:10  MIT-MAGIC-COOKIE-1  7ee9988e96ce340727b4e1913064b915
centos7test/unix:11  MIT-MAGIC-COOKIE-1  4ff9bbb0013ffebd23f32f657ad2383f
```

クッキーを現在の.Xauthorityに追加します。
コンテナから見たホストのアドレスは172.17.0.1なので、`172.17.0.1:10`に追加します。

```bash
$ xauth add 172.17.0.1:10 MIT-MAGIC-COOKIE-1  7ee9988e96ce340727b4e1913064b915
```

準備ができたらコンテナを起動します。DISPLAYに設定するホストのアドレスは172.17.0.1でもgatewayでもどちらでもよさそう。

```bash
$ docker run --rm \
  -e DISPLAY=gateway:10 \
  -v $HOME/.Xauthority:/home/testuser/.Xauthority \
  xeyes-cl7 \
  -c xeyes
```

一応起動するはずです。

以上のように、ブリッジネットワークでのX11 Forwardingはiptablesを使い、トリッキーなので可能であればホストネットワークを使う方がいいかもしれません。

# 参考

- Docker X11 Client Via SSH -- これで十分な気がする
    - https://dzone.com/articles/docker-x11-client-via-ssh
- Iptables redirect to localhost? -- localhostへのリダイレクトについて
    - https://superuser.com/questions/661772/iptables-redirect-to-localhost