Docker コンテナをログインシェルとして利用してみた

Linux にログインする時のログインシェルとしては、`/bin/sh` や `/bin/bash` 等がよく利用されます。また、好みに応じて zsh 等を利用する方も多いでしょう。ログインシェルは切り替え可能なので、Docker のサブコマンド `docker run` をログインシェルとして利用し、監獄化した環境を構築してみましたので報告します。

## 改定履歴

 * 2015/05/16
  * Dockerfile を生成せずに起動できるようにした。
  * 上記を実現するにあたり、コメントで @tenmyo さんにヒントを頂きました。ありがとうございます。

## なんのためにこんな事を試すのか？

共有サーバー環境において、ユーザー間の隔離を実現するために検証しています。

レンタルサーバー等不特定多数が利用する共有サーバー環境では、モラルの低いユーザーや悪意あるユーザーが紛れ込む事を防げません。そのようなユーザーに対抗するためには各ファイル、ディレクトリに適切なパーミッションを設定する事が最低限必要ですが、chroot 等で監獄環境を構築できればより安心です。chroot を利用すればログインユーザーのルートディレクトリを変更できます。例えば `testuser` というユーザーのルートディレクトリを `/home/testuser` に変更すれば、testuser はそれより上の階層にアクセスできなくなります。このため、各種ログファイルや他ユーザーのホームディレクトリ等を覗き見られてしまう可能性を大きく減らせます。詳しくは以下のページ等をご覧下さい。

 * [Man page of chroot](https://linuxjm.osdn.jp/html/gnumaniak/man1/chroot.1.html)
 * [chroot Wikipedia](https://ja.wikipedia.org/wiki/Chroot)

しかし chroot は実際やってみると分かりますが、なかなか扱いにくいものです。監獄内で利用する全ファイルを監獄内にコピーしなければなりません。シェルはもちろん、ls, cp, find 等ツール類の実行ファイルやその設定ファイル、さらに `ldd` コマンドで依存関係を調べた上でライブラリ等もコピーする必要があるため、ディストリビューションそのものを作るようなものです。このような手間の掛かる chroot からおさらばし、同等程度の効果を得るために Docker は利用できないかと思い試してみました。

## 想定環境

共有サーバーではログインシェルを許可しないのが一番安全ですが、ユーザーの利便性を考えるとそうも行かない場合が多いです。今回の実験は以下のようなサーバーで利用される事を想定しています。

 1. SSH 接続でログインし、ログインシェルから自分の権限の許す範囲でコマンドを操作できる
 2. WEB サーバーが稼働している
 3. FTPサーバーが稼働している

レンタルサーバーでよくある構成ですが、このうち項番1が本稿で扱う範囲です。WEB サーバーや FTP サーバーをどのようにすべきかはまた別の問題ですが本稿では扱いません。ただし、WEB サーバーや FTP サーバーとデータを共有しなければならない事は年頭に置く必要があります。単純に Docker コンテナを立ち上げただけではディレクトリツリーも監獄化されるため、Docker の外部で動作する他のプロセスとは共有できません。またデータの永続化も行われませんので、その部分には工夫が必要という事になります。

## そもそも Docker をシェルとして利用できるのか？

有用かどうかは置いといて、標準入出力を処理できるプログラムは、シェルとして登録可能です。たとえば `/bin/cat` をシェルとして登録すれば、ユーザー入力をそのままエコーするだけのシェルになります。実際にやってみましょう。

```shell-session
[root@localhost ~]# useradd --shell /bin/cat testuser-1
```

これで `/bin/cat` をログインシェルとして利用するユーザー `testuser-1` が出来ました。ログインしてみます。

```shell-session
[root@localhost ~]# su testuser-1
a                                    // ユーザー入力
a                                    // シェルからの出力
b                                    // ユーザー入力
b                                    // シェルからの出力
c                                    // ユーザー入力
c                                    // シェルからの出力
^C割り込み                           // Ctrl+C を入力した
[root@localhost ~]#
```

こんな感じでひたすらオウム返しするシェルが出来上がりました。シェルが cat では実用性がまったくありませんが、例えば `/bin/mysql` や `/bin/python` 等、インタラクティブ性を備えたツールをシェルとして登録すれば、それなりに意味があるかも知れません。

なお、シェルはユーザー作成時の `useradd` コマンドの他に、`vipw` や `chsh` 等のコマンドでも変更可能です。`chsh` コマンドなら、ルートユーザー以外のユーザーでも利用可能です（ただし、その場合は /etc/shells に予めシェルとして利用可能なプログラムを登録しておく事が必要）。

シェルとして指定できるのは実行可能ファイルですが、実行時のコマンドライン引数を渡す事はできません。しかし、シェルスクリプトを登録するは可能なので、引数が必要なコマンドはシェルスクリプトでラップする事でシェルとして利用可能です。

ログインシェルのこのような性質を利用すれば、`docker run` コマンドをログインシェルとして登録し、ログインと同時にコンテナ内に封じ込める事はできそうな気がします。

## セキュリティに関する考察

`docker run` コマンドを記述したシェルスクリプトをログインシェルとして登録すれば、Docker コンテナをシェルとして利用可能である事を前の章で説明しました。しかし、当初の目的がセキュリティリスクの軽減なのですから、それがおろそかになってしまえば本末転倒です。そこで実際のスクリプトを紹介する前に、Docker コンテナをログインシェルとして利用する事の問題点とその対策について説明したいと思います。

Docker の動作環境において、以下3種類のユーザーをどうするべきか気を付ける必要があります。

 * Docker デーモンの実効ユーザー
 * Docker コマンドの実効ユーザー
 * コンテナ内のユーザー

### Docker デーモンの実効ユーザー

Docker デーモンの実効ユーザーはルートユーザーである事が多いと思います。しかし version 19.3 以降 rootless モードが利用可能になり、Docker デーモンを非ルートユーザーで実行できるようになりました。

 * [nttlabs: Docker 19.03新機能 (root権限不要化、GPU対応強化、CLIプラグイン…)](https://medium.com/nttlabs/docker-1903-5155754ff8ac)

rootless モードのセキュリティ的な価値は高く、仮に Docker デーモンに何か脆弱性が見つかり攻撃されたとしても、root 権限を奪われる事に直接的には繋がらないようになりました。しかし、rootlessモードの原則的な利用方法においては、Docker を利用するユーザーがそれぞれ Docker デーモンを立ち上げなければなりません。本稿の目的は不特定多数が利用する共有サーバーの監獄化なので、数十、数百のユーザーがそれぞれデーモンを立ち上げるとなると実用的ではありません。rootless モードについては導入したいとは思ってるのですが今回は扱いません。

### Docker コマンドの実効ユーザー

Docker デーモンにさまざまなコマンドを投げ、コンテナイメージを作ったりコンテナを起動したりするのが Docker コマンドです。通常、ルートユーザーだけが Docker コマンドを利用可能ですが、Docker グループに所属しているユーザーも Docker コマンドを利用可能になります。

Docker コマンドを利用可能なユーザーは、大変大きな権限を持ちます。そのサーバーにおけるルートユーザーと同等の権限を持つと言っても過言ではないので、どのユーザーを Docker グループに所属させるべきかは慎重に考えなくてはなりません。

ところで、Docker をログインシェルとして利用する場合、シェルスクリプトの中で Docker コマンドを実行しなければなりません。そうしないとコンテナが起動できないからです。しかし、全ユーザーを Docker グループに所属させる事はあまりにリスキーです。

そこでシェルスクリプトを2段構えにし、最初に実行されるシェルスクリプトが sudo を利用して2つ目のスクリプトを起動するようにしました。各ユーザーは sudo の設定で、2つ目のスクリプト**のみ**を sudo 可能に設定します。例えば `/etc/suders` に以下のように記述します。

```
%docker_shell ALL=(root)    NOPASSWD: /usr/local/bin/docker_shell.sh
```

これで docker_shell グループに属しているユーザーは、`sudo /usr/local/bin/docker_shell.sh` を実行できるようになります。`docker_shell.sh` の中はルート権限で動作するので、自由に Docker コマンドを利用可能です。ただし、docker_shell グループのユーザーは Docker そのものを操作する事はできません。また、docker_shell.sh 以外のコマンドを sudo する事もできません。

このような取り組みは本来は「セットユーザIDビット」が設定された実行ファイルを利用するべきなのですが、シェルスクリプトにはこの方法は利用できません。sudo を利用して似たような事が実現できるため、今回はそれを採用しました。

### コンテナ内のユーザー

`docker run` コマンドを特に意識せずに実行すると、コンテナ内で実行されるプロセスはルートユーザーで動作します。しかし、各ユーザーがルートユーザーとして動作するのは問題ありますし、ルート権限でファイルが作られてしまうとコンテナの外からアクセスする際にも問題があります。これらの問題を解決するために、Dockerfile の `USER` 命令で実行ユーザーを指定する事としました。

## マウントポイントについて

ユーザーがコンテナ内で更新したデータは永続化する必要があります。また、他のコンテナや非 Docker 環境で動作しているプロセスが、更新したデータを読み書きできる必要があります。これらを実現するために各ユーザーのホームディレクトリを Docker コンテナイメージにマウントする事としました。


## ~~Dockerfile の生成タイミング~~

~~ユーザー数は多数である事を意識しているので、それぞれのユーザーの分だけ Dockerfile が必要になります。これらを手動で用意するのは面倒なので、Dockerfile の生成自体をログイン時に行うようにしました。つまりログインの度に `docker build` コマンドを実行し、必要に応じてコンテナイメージをビルドします。docker では差分を利用したビルドを行いますので、この処理にかかる時間はわずかです。~~

 * 2020/05/16: Dockerfile を生成せずに実行できるようにしました。

## /etc/passwd, /etc/group について

コンテナを起動する時に、`-u` オプションを利用すれば、特定のユーザーの権限でコンテナを実行可能です。しかし、コンテナ内でユーザーを名前で認識できるようにするためには、/etc/passwd, /etc/group を適切に設定する事が必要です。これを実現するために起動するコンテナ内から /etc/passwd と /etc/group を取り出し、ログインさせるユーザーの行を追加してからマウントするようにしました。

## 完成品

以上の諸々を考慮し、ログインシェルとして利用可能なシェルスクリプトを作りました。以下がそれです。

```/usr/local/bin/docker_shell.sh
#!/bin/sh
set -eu

# 利用するシェルが含まれるイメージの名前
shell_image=centos:8

# 利用するシェル
shell_cmd=/bin/bash

# ルートユーザーでないなら root に昇格して再度このスクリプトを実行する
if [ "$(id -nu)" != "root" ]; then
    sudo "$0"
    exit
fi

# ここから先は root としてアクセスする

# 親プロセス（sudo のプロセス）の親プロセス（このスクリプトを呼び出した
# プロセス）のプロセスIDを求める
pppid=$(grep '^PPid:' /proc/$PPID/status | perl -pe 's/^.*:\s*//')

# 「親プロセスの親プロセス」の実ユーザー ID を求める。これは昇格前のユーザー
# ID なので、この後に Docker コンテナを起動するユーザーの ID になる。
user_id=$(cat /proc/$pppid/status|grep ^Uid:|perl -pe 's/^Uid:\s+(\d+)\s.*$/\1/')

# 求めたユーザー ID からユーザー名、グループID、グループ名を求める
user_name=$(id -nu $user_id)
group_name=$(id -ng $user_id)
group_id=$(id -g $user_id)

# テンポラリディレクトリを作成する
work_dir=$(mktemp -d /tmp/docker_sh.XXXXX)

# /etc/passwd, /etc/group を取得するためにコンテナを起動する
temp_container=$(docker run -d $shell_image sleep 36000)

# /etc/passwd を取得
docker cp $temp_container:/etc/passwd $work_dir

# /etc/group を取得
docker cp $temp_container:/etc/group $work_dir

# ファイル取得用に立ち上げたコンテナを終了
docker rm -f $temp_container > /dev/null

# /etc/password からホームディレクトリを求める
IFS_backup="$IFS"
IFS=: user_param=($(grep ^$user_name: /etc/passwd))
IFS="$IFS_backup"
home_dir=${user_param[5]}

# /etc/passwd にログインユーザーの情報を追加
echo $user_name:x:$user_id:$group_id::$home_dir:$shell_cmd >> $work_dir/passwd

# /etc/group にログインユーザーの情報を追加
echo $group_name:x:$group_id: >> $work_dir/group

# ユーザーのホームディレクトリをマウントして、コンテナを起動する
docker run \
    --rm -it \
    -v "$home_dir:$home_dir" \
    -u "$user_id:$group_id" \
    -v "$work_dir/passwd:/etc/passwd:ro" \
    -v "$work_dir/group:/etc/group:ro" \
    -w "$home_dir" \
    "$shell_image" \
    "$shell_cmd"

# 不要になったテンポラリディレクトリを削除
rm -fR $work_dir
```

## セットアップ方法

まず対象となるサーバーに Docker をインストールします。これについては割愛します。次に上記スクリプトを `/usr/loal/bin/docker_shell.sh` として保存し、実行属性を与えて下さい。

```shell_session
$ sudo cp (どこか) /usr/local/bin/docker_shell.sh
$ sudo chod 755 /usr/local/bin/docker_shell.sh
```

次に docker_shell を利用可能なユーザーを所属させる `docker_shell` グループを作成します。

```shell_session
$ groupadd docker_shell
```

次に /etc/sudoers に以下の行を加え、docker_shell グループに所属しているユーザーが、`sudo /usr/loal/bin/docker_shell.sh` を実行できるようにします。`visudo` コマンドを利用して書き換えて下さい。

```
%docker_shell ALL=(root)    NOPASSWD: /usr/local/bin/docker_shell.sh
```

以上でセットアップは完了です。

## 使い方

docker_shell.sh のセットアップが完了すると、既存ユーザーや新規ユーザーのログインシェルを Docker コンテナにする事ができます。ユーザーがログインするとコンテナが起動し、監獄化された環境で作業を行う状態になります。ログアウト時にコンテナは削除されますが、ホームディレクトリ以下に配置されたファイルは永続化され、他のプロセスからも利用可能になります。

新規ユーザーのログインシェルを Docker コンテナにしたい場合は、`useradd` コマンドに `--shell` オプションを指定し、ログインシェルを `/usr/local/bin/docker_shell.sh` にします。また、`--groups` オプションで `docker_shell` グループに所属させます。

```shell_session
$ sudo useradd --groups docker_shell --shell=/usr/local/bin/docker_shell.sh newuser-1
```

既存ユーザーの場合は `chsh` コマンドでログインシェルを、`usermode` コマンドで所属グループを変更します。

```shell_session
$ sudo usermod --append --groups docker_shell olduser-1
$ sudo chsh --shell /usr/local/bin/docker_shell.sh olduser-1
```

## 動作テスト

以下の一連のシェルセッションでは、Docker コンテナがシェルとして利用可能である事を確認しています。

ホストマシンは CentOS7 環境で動作している事を確認

```shell_session
sh-4.2# cat /etc/redhat-release
CentOS Linux release 7.6.1810 (Core)
```

`docker_shell.sh` をログインシェルとするユーザー `newuser-1` と `newuser-2`を作成

```shell_session
sh-4.2# useradd --groups docker_shell --shell=/usr/local/bin/docker_shell.sh newuser-1
sh-4.2# useradd --groups docker_shell --shell=/usr/local/bin/docker_shell.sh newuser-2
```

`/home/` 以下にそれぞれのユーザーのホームディレクトリが作成された事を確認

```shell_session
sh-4.2# ls -l /home/
合計 4
drwx------. 13 develop   develop   4096  5月 12 15:52 develop
drwx------   2 newuser-1 newuser-1   62  5月 13 13:27 newuser-1
drwx------   2 newuser-2 newuser-2   62  5月 13 13:27 newuser-2
```

※ develop ユーザーは元々あったユーザーで、本件に関係ありません。

各ユーザーの ID 情報を確認

```shell_session
sh-4.2# id newuser-1
uid=1002(newuser-1) gid=1005(newuser-1) groups=1005(newuser-1),1004(docker_shell)
sh-4.2# id newuser-2
uid=1003(newuser-2) gid=1003(newuser-2) groups=1003(newuser-2),1004(docker_shell)
```

各ユーザーのホームディレクトリにはまだファイルが無い事を確認

```shell_session
sh-4.2# ls -l /home/newuser-1/
合計 0
sh-4.2# ls -l /home/newuser-2/
合計 0
```

`newuser-1` でログインする。プロンプトが切り替わった事を確認

```shell_session
sh-4.2# su - newuser-1
[newuser-1@2ae9af8c4e2b ~]$
```

`newuser-1` は CentOS8 のコンテナで動作している事を確認

```shell_session
[newuser-1@2ae9af8c4e2b ~]$ cat /etc/redhat-release
CentOS Linux release 8.1.1911 (Core)
```

ユーザーID とグループID は、ホスト環境と同じである事を確認

```shell_session
[newuser-1@2ae9af8c4e2b ~]$ id
uid=1002(newuser-1) gid=1005(newuser-1) groups=1005(newuser-1)
```

`/home/` には自分のホームディレクトリしか存在しない事を確認

```shell_session
[newuser-1@2ae9af8c4e2b ~]$ ls -l /home/
total 0
drwx------ 2 newuser-1 newuser-1 62 May 13 04:27 newuser-1
```

ホームディレクトリにはファイルが何も無い事を確認

```shell_session
[newuser-1@2ae9af8c4e2b ~]$ ls -l /home/newuser-1/
total 0
```

テスト用ファイルを作成し、その所有者が `newuser-1` である事を確認

```shell_session
[newuser-1@2ae9af8c4e2b ~]$ echo test1 > ~/testfile
[newuser-1@2ae9af8c4e2b ~]$ ls -l /home/newuser-1/
total 4
-rw-rw-r-- 1 newuser-1 newuser-1 6 May 13 04:29 testfile
```

ログアウトする

```shell_session
[newuser-1@2ae9af8c4e2b ~]$ exit
sh-4.2#
```

`newuser-2` でログインし、`newuser-1` で確認した内容と同じ事を確認する

```shell_session
sh-4.2# su - newuser-2
[newuser-2@1ee118adc4bc ~]$ cat /etc/redhat-release
CentOS Linux release 8.1.1911 (Core)
[newuser-2@1ee118adc4bc ~]$ id
uid=1003(newuser-2) gid=1003(newuser-2) groups=1003(newuser-2)
[newuser-2@1ee118adc4bc ~]$ ls -l /home/
total 0
drwx------ 2 newuser-2 newuser-2 62 May 13 04:27 newuser-2
[newuser-2@1ee118adc4bc ~]$ ls -l /home/newuser-2/
total 0
[newuser-2@1ee118adc4bc ~]$ echo test2 > ~/testfile
[newuser-2@1ee118adc4bc ~]$ ls -l /home/newuser-2/
total 4
-rw-rw-r-- 1 newuser-2 newuser-2 6 May 13 04:30 testfile
[newuser-2@1ee118adc4bc ~]$ exit
sh-4.2#
```

ホストに戻り、それぞれのユーザーで作成したファイルの所有者が適切に設定されている事を確認

```shell_session
sh-4.2# ls -l /home/newuser-[12]/testfile
-rw-rw-r-- 1 newuser-1 newuser-1 6  5月 13 13:29 /home/newuser-1/testfile
-rw-rw-r-- 1 newuser-2 newuser-2 6  5月 13 13:30 /home/newuser-2/testfile
```

newuser-1 にパスワードを設定

```shell_session
sh-4.2# passwd newuser-1
ユーザー newuser-1 のパスワードを変更。
新しいパスワード:
よくないパスワード: このパスワードは回文です。
新しいパスワードを再入力してください:
passwd: すべての認証トークンが正しく更新できました。
sh-4.2#
```

他のマシンから SSH 経由でログインできる事を確認

```shell_session
[develop@localhost ~]$ ssh newuser-1@192.168.1.2
The authenticity of host '192.168.1.2 (192.168.1.2)' can't be established.
ECDSA key fingerprint is SHA256:AjBLDC6k2++17z6Hmz3rVJRT9w3S/MDv8adzN26FZAk.
ECDSA key fingerprint is MD5:61:4e:e2:7b:1d:a6:0f:fb:21:2e:b0:29:0f:30:25:4b.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '192.168.1.2' (ECDSA) to the list of known hosts.
newuser-1@192.168.1.2's password:
Last login: Wed May 13 15:13:13 2020 from 192.168.200.1
[newuser-1@6ea4a95985ad ~]$ cat /etc/redhat-release
CentOS Linux release 8.1.1911 (Core)
[newuser-1@6ea4a95985ad ~]$ exit
Connection to 192.168.1.2 closed.
[develop@localhost ~]$
```

## 最後に

Docker を利用した監獄化のテストは以上となります。今後も検証は続けていく予定なのですが、最後に現段階で気になっている部分等について記載しておきたいと思います。

 * ~~Docker イメージファイルがユーザーの数だけ必要になる。ユーザーを削除してもそのユーザー用の Docker イメージは自動的には削除されない~~
  * ~~実運用する場合は、不要なイメージを一括削除するようなユーティリティソフトウェアが必要かも知れない~~
  * ~~可能なら全ユーザーでイメージを共有できるようなシステムにしたい~~
  * この問題はコメントでヒントを頂き、解決しました。
 * 初期セットアップの sudo の設定は間違いが許されない
  * 間違わなければよい事なのだが、`%docker_shell ALL=(root) NOPASSWD: /usr/local/bin/docker_shell.sh` と書くべき所を `%docker_shell ALL=(ALL) NOPASSWD: /usr/local/bin/docker_shell.sh` と書いてしまうと重大な脆弱性を抱えてしまう
 * そもそも Docker に依存するのは仕方ないとして、sudo にまで依存したくない。そのためには docker_shell.sh に相当する物をコンパイル言語で記述する必要がある

これらの問題は今後解決していければと思っています。

年内に、とあるレンタルサーバーのリニューアルを計画しているのですが、そのための検証の一環として今回のテストを行ってみました。まー、まー使っていけそうな雰囲気は感じているのですが、特にセキュリティ面については本当にこれで大丈夫なのかどうかという所を今後も検証していきたいと思っています。