Docker rootlessで研鯖運用
複数人で共有して使う研究室のサーバでは、rootfulなDockerを用いると権限周りでさまざまな問題が発生します。
Docker rootlessで権限関係の諸問題を解決し、最強の研究室サーバ環境を作りましょう。
筆者の研究室の環境
筆者は東京大学 相澤・山肩・松井研、山﨑研で、院生鯖缶をしています。コンピュータビジョン・マルチメディアを主な研究分野としている研究室で、ほとんどのメンバーはGPUを使用し深層学習のコードを実行しています。
研究室には複数のGPUが刺さったオンプレ計算サーバが10台程度存在し、これらを共有して使用しています。しかし、導入時期によってOSやCUDA、ライブラリなどのバージョンが変わってしまい、新人の環境構築の難易度・コストが上昇していました。そこで、Dockerを用いて簡単に環境構築を行えないか?ということを考えます。*1
しかし、通常のDocker (rootful) を共有環境で使用すると、次のような問題があります。
docker run --user 42
などとしないと、コンテナがrootで実行される*2- 1つのデーモンをrootで実行し、そこに全ユーザがアクセスするという形になっているため、他人のコンテナに自由に触れる
- デーモンはrootで実行されるため、セキュリティ上様々な懸念が発生する
そこでDocker rootlessを用いてデーモン・コンテナを全てユーザのプロセスとして実行することで、諸問題を解決しながら 安心して便利に Dockerを活用できる環境を作ります。
Docker rootlessとは
Docker 20.10より、Dockerのrootlessモードが正式対応されました。これはデーモンとコンテナを非rootユーザで実行する技術で、以下のようなメリットがあります。
- デーモン・コンテナを各ユーザで実行するため、ユーザ間で完全に独立したDockerを使用できる
- 各コンテナの中ではrootユーザとして振る舞えるので、ライブラリのインストールなども可能
- これはホストのroot権限とは異なる、見せかけのroot権限である
- デーモンもユーザ権限で実行されるので、Dockerの脆弱性や設定ミスによるセキュリティ上の脅威が軽減できる
NTTの須田さんによる記事が非常にわかりやすいため、あわせて参照ください。 medium.com
Set Up
前提
計算用のサーバはUbuntu Server 16.04, 18.04, 20.04のいずれかがインストールされています。 また、一般ユーザはファイルサーバを各自のホームにマウントして使用しているものとします。 一般ユーザはsudoを行う権限はなく、管理者のみがsudoを使用できるものとします。
管理者が一括で行うこと
必要なパッケージのインストール
こちらに従って、uidmapをインストールします。
また、推奨パッケージである slirp4netns もインストールしておきます*3 。Ubuntu 20.04の場合はaptでインストール可能です。
それ以前のディストリビューションの場合は、バイナリをDLし、/usr/local/bin
などPATHが通っているところに配置します。chmodを忘れずに!
Dockerのインストール
各ユーザがHOME以下にインストールすることも可能ですが、ここでは管理者がシステム全体にDocker 20.10をインストールし、dockerdなどのバイナリは全ユーザで共通のものを使用することにします。
まず、19.03以前のDockerがインストールされている場合は削除します。sudo dpkg -l | grep container
などするとわかりますが、docker-ce
docker-ce-cli
containerd.io
docker.io
containerd
docker-engine
runc
などがあれば削除します。(Dockerのバージョンによってインストールされているパッケージは異なります。) *4
こちら にしたがって、Dockerをインストールします。
rootfulなデーモンは停止しておきます。
$ sudo systemctl disable --now docker.service $ sudo systemctl disable --now docker.socket
nvidia-docker2のインストール
DockerでGPUを使用するため、記事やドキュメントを参考にnvidia-docker2をインストールします。
また、rootlessモードでGPUを使用するためにはnvidia-container-runtimeの設定変更が必要です。/etc/nvidia-container-runtime/config.toml
を編集し、no-cgroups = true
を記述します。
参考:
nvidia-container-runtime doesn't work with rootless mode · Issue #38729 · moby/moby · GitHub
DockerのRootlessモードでNVIDIAのGPUを使用する - Qiita
uidmapの設定
/etc/subuid
、/etc/subgid
それぞれで、1ユーザにつき最低65536個のsubuid、subgidを割り当てる必要があります。最初のユーザは100000から割り当てるように記述します。記法は<user name>:<start>:<range>
です。サーバごとに割当が異なっていても問題ありません。
以下の例では、各ユーザに65536個のsubuid、subgidを順に割り当てています。
$ cat /etc/subuid user1:100000:65536 user2:165536:65536 user3:231072:65536 $ cat /etc/subgid user1:100000:65536 user2:165536:65536 user3:231072:65536
全ユーザを記述するのは非常に大変なので、スクリプトを組むのがオススメです(後述)。
各ユーザで行うこと
Dockerをインストールすると、/usr/bin/dockerd-rootless-setuptool.sh
が入っているはずです。 *5
$ dockerd-rootless-setuptool.sh install
を実行することで、ユーザごとのデーモンが利用可能になります。serviceが作成・起動されていることは、
$ systemctl --user status docker
でわかります。
デーモンに接続するためのパスを指定するDOCKER_HOSTは環境変数で指定するのが楽ですので、
$ export DOCKER_HOST=unix://${XDG_RUNTIME_DIR}/docker.sock
を.bashrc
などに記述すると良いでしょう。各自で記述するのが面倒な場合は、後述のように管理者が/etc/profile.d/
以下に設定ファイルを置くこともできます。
$ docker run hello-world $ docker run --rm --gpus all nvidia/cuda:11.0-base nvidia-smi
を実行して、それぞれ正常に動けばOKです。
コンテナやイメージが置かれるdata-rootは、デフォルトでは~/.local/share/docker
となりますが、DockerではNFSマウントされたディレクトリをdata-rootとして使用することはできません。
ホームディレクトリがNFSマウントされているケースでは、他のローカルストレージをdata-rootに指定する必要があります。
これに関しても後述します。
運用上のtips
data-rootの場所
筆者の環境では、各ユーザのホームディレクトリはファイルサーバをNFSマウントして用いています。そのためdata-rootを別に指定する必要があります。
デーモンのconfigは~/.config/docker/daemon.json
です。
ここでdata-rootを指定します。
{ "data-root" : "/docker_rootless/$(id -un)/docker/" }
お気づきかもしれませんが、このconfigファイルはHOMEに作成されているため、全サーバで共通のものを用いることになります。
しかしサーバによってdata-rootの場所を変えたいということはよくあります。
そこで/docker_rootless
*6をシンボリックリンクにして、これが実際にdata-rootの場所を指すようにします。
例えば/work/docker_rootless/$(id -un)/docker/
をdata-rootにしたいケースでは、
sudo mkdir -r /work/docker_rootless/ sudo chmod 777 /work/docker_rootless/ sudo ln -s /work/docker_rootless/ /docker_rootless
とします。
なお、シンボリックリンクをdata-rootに指定することも本来はできないようです。 実際、この設定を行った後の初回はアクセスに失敗し、エラーとなります。
systemctl --user restart docker
で一度デーモンを再起動すると、シンボリックリンクをたどってdata-rootが指定されるようになり、動作するようになりました。(正規の挙動ではない可能性がありますので、自己責任でお願いします。また、良い解決策をご存知の方はご教示ください。)
DOCKER_HOST環境変数の一括設定
DOCKER_HOST環境変数は各ユーザで設定しなくとも、管理者がまとめて設定することが可能です。以下のように/etc/profile.d/
以下にスクリプトを作成すると、ログイン時にDOCKER_HOSTがセットされます。
$ cat /etc/profile.d/set-docker-rootless-host.sh export DOCKER_HOST="unix://${XDG_RUNTIME_DIR}/docker.sock"
subuid/subgidの一括設定
存在しているユーザをサーチし、それらのsubuid/subgidがセットされていない場合は追記するという内容のスクリプトを作成しました。これを管理者が実行するだけで設定が完了します。
#!/usr/bin/python3 | |
import subprocess | |
subuid_path = '/etc/subuid' | |
subgid_path = '/etc/subgid' | |
# Get the list of LDAP users via $getent passwd | |
proc = subprocess.run(['getent', 'passwd'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) | |
user_list = proc.stdout.decode('utf8').split('\n') | |
user_list = [user.split(':') for user in user_list] | |
# The filter 10000 <= uid <= 30000 is used to eliminate system users. This should be changed according to your own environment | |
user_list = [user[0] for user in user_list if len(user[0]) > 0 and 10000 <= int(user[2]) <= 30000] | |
# Get the list of users already registered in /etc/subuid and /etc/subgid | |
def get_subugids(file_path): | |
with open(file_path) as f: | |
ids = [line.split(':') for line in f] | |
users = {subid[0] for subid in ids} | |
next_id_begin = 100000 | |
if len(ids) > 0: | |
_, last_id_begin, last_id_count = max(ids, key=lambda item: int(item[1])) | |
next_id_begin = max(int(last_id_begin) + int(last_id_count), next_id_begin) | |
assert next_id_begin >= 100000 | |
return users, ids, next_id_begin | |
existing_users, subuids, next_uid_begin = get_subugids(subuid_path) | |
existing_users2, subgids, next_gid_begin = get_subugids(subgid_path) | |
print('Available subuids/subgids begin at %d/%d'% (next_uid_begin, next_gid_begin)) | |
# Check consistency between /etc/subuid and /etc/subgid, which is required to excute the following lines. | |
if existing_users != existing_users2 or next_uid_begin != next_gid_begin: | |
print('Inconsistent content:', subuid_path, subgid_path) | |
exit(1) | |
# Add users to /etc/subuid and /etc/subgid | |
# Perhaps you should use $usermod command instead of editting directly. | |
id_incr = 65536 | |
count = 0 | |
with open(subuid_path, mode='a') as subuid_file: | |
with open(subgid_path, mode='a') as subgid_file: | |
for user in user_list: | |
if user not in existing_users: | |
new_line = '%s:%d:%d' % (user, next_uid_begin, id_incr) | |
print('Adding ' + new_line) | |
subuid_file.write(new_line + '\n') | |
subgid_file.write(new_line + '\n') | |
next_uid_begin += id_incr | |
count += 1 | |
print('Added %d users' % count) |
スクリプトでは直にファイルに書き込んでいますが、 usermod
コマンドを使ったほうが良いかもしれません。
セットアップの自動化
新しいGPUサーバを導入するたびにこれらのセットアップを手動で行うのは大変なので、ansibleで自動化しました。 ansibleを使うと、パッケージのインストールからconfigファイルの編集まで、あらゆる設定を複数サーバに対してワンコマンドで一括適用することが可能です。
参考:Ansible ドキュメント — Ansible Documentation
以下は実際のansibleタスクです。data-rootのシンボリックリンクの作成以外の各種インストール、設定ファイルの編集を自動で行うようになっています。例えばsubuid/subgidの設定では、前掲のスクリプトを各サーバに配置・実行するようにしています。
- name: copy files to /usr/local/bin/ | |
copy: src=./files/{{item}} dest=/usr/local/bin/{{item}} owner=root group=root mode=0755 | |
with_items: | |
- "docker_add_subugids" | |
- name: Add users to /etc/subuid, /etc/subgid | |
command: docker_add_subugids | |
- name: be sure installed for common dependencies | |
apt: | |
name: "{{ item }}" | |
state: present | |
with_items: | |
- uidmap | |
- name: be sure installed for the latest nvidia-docker2 | |
apt: | |
name: "{{ item }}" | |
state: latest | |
with_items: | |
- nvidia-docker2 | |
- name: install slirp4netns for Ubuntu <= 19 | |
copy: src=./files/{{item}} dest=/usr/local/bin/{{item}} owner=root group=root mode=0755 | |
with_items: | |
- "slirp4netns" | |
when: | |
- ansible_facts['distribution'] == "Ubuntu" | |
- ansible_facts['distribution_major_version'] <= "19" | |
- name: install slirp4netns for Ubuntu >= 20 | |
apt: | |
name: "{{ item }}" | |
state: present | |
with_items: | |
- slirp4netns | |
when: | |
- ansible_facts['distribution'] == "Ubuntu" | |
- ansible_facts['distribution_major_version'] >= "20" | |
- name: rewrite no-cgroups | |
replace: | |
path: /etc/nvidia-container-runtime/config.toml | |
regexp: '^#? *no-cgroups *= *false$' | |
replace: 'no-cgroups = true' | |
- name: set docker rootless host | |
copy: src=./files/{{item}} dest=/etc/profile.d/{{item}} owner=root group=root mode=0755 | |
with_items: | |
- "set-docker-rootless-host.sh" |
ファイルの所有権
実際にDocker rootlessを使用してみます。
ホスト側で以下のようなディレクトリを用意します。通常のユーザのファイルと、rootのファイルが存在します。
$ ll ~/tutorial 合計 4.0K drwxr-xr-x 2 user01 users 50 5月 6 23:53 . drwxr-xr-x 31 user01 users 4.0K 5月 6 23:53 .. -rw-r--r-- 1 user01 users 0 5月 6 23:53 my_file -rw-r--r-- 1 root root 0 5月 6 23:53 root_file
試しにこのディレクトリをマウントして、コンテナを実行してみます。
$ docker run -it -v ~/tutorial:/example ubuntu bash root@7994a231e0c0:/# ll /example total 0 drwxr-xr-x 2 root root 50 May 6 14:53 ./ drwxr-xr-x 1 root root 21 May 6 14:55 ../ -rw-r--r-- 1 root root 0 May 6 14:53 my_file -rw-r--r-- 1 nobody nogroup 0 May 6 14:53 root_file root@7994a231e0c0:/# touch /example/root_file touch: cannot touch '/example/root_file': Permission denied
このように、ホストの自ユーザがコンテナ内部ではroot、ホストのrootはnobodyにマップされており、ホストのroot権限が必要なファイルにはアクセスできません。
さいごに
これまでDockerはrootfulな運用が前提になっていたため、複数人でサーバを共有して使う研究室のような環境では活用が難しいという課題がありました。本記事では、rootlessモード導入の手順を紹介しました。rootlessモードにより各ユーザが分離したDocker環境を使用することができ、安心して仮想環境の恩恵を受けることができます。