Docker や VSCode + Remote-Container のパーミッション問題に立ち向かう
概要
Docker や VSCode の Remote-Container でファイルシステムをマウントする際にパーミッションの問題に出会ったことはありませんか?Docker はパーミッションの扱いに面倒くささがあり、VSCode + Remote-Container はそれを黒魔術(=力技)で解決しているのでよく分からずに使っていると想定しない結果になることがあります。
そこで Docker や VSCode + Remote-Container におけるパーミッションの扱いと対応方法をまとめてみます。
Docker とパーミッション
まずは Docker がファイルシステムのマウント時にパーミッションをどう扱うかを解説します。
結論として Docker がホスト側のディレクトリをコンテナ内にマウントする際のスタンスは 「何もしない」 です。
しかしそれだけでは説明になっていないので、とある一般ユーザーが docker
コマンドを悪用して本来は閲覧できないホスト側の /etc/shadow
を表示する例で解説します。(※ その一般ユーザーは docker
コマンドの実行が許可されている前提です)
以下の二つのコマンドを順に実行すると一般ユーザーなのに /etc/shadow
の中身が表示されます。
docker run -it -v $(pwd):/tmp/v ubuntu:20.04 \
bash -c "cp -a /bin/cat /tmp/v/cat; chmod u+s /tmp/v/cat"
./cat /etc/shadow
最初の docker run ...
コマンドでスティッキービットの立った root 所有の cat
コマンドをホスト側のディレクトリに生成してます。
通常は一般ユーザーが root 所有の実行ファイルにスティッキービットを立てることはできませんが、コンテナ内は root で実行されるためスティッキービットが立った cat
コマンドをファイルシステム上に記録することができます。
続けてホスト側ではその cat
コマンドを利用して、ホスト側の /etc/shadow
を表示しています。
Docker はファイルシステムに対して何ら制御をしないので、ホスト側だろうとコンテナ側だろうとマウントされたファイルシステムに uid
, gid
, スティッキービットなどがそのまま記録されるだけのシンプルな仕組みです。
Docker で開発する場合のパーミッション問題
では Docker コンテナ内で開発する場合に発生するパーミッションの問題を考えてみます。
以下の Dockerfile
は dev ユーザー(uid=20000)と dev グループ(gid=20000)を作成して ubuntu 環境を構築しています。
FROM ubuntu:20.04
RUN groupadd -g 20000 dev \
&& useradd -m -u 20000 -g 20000 dev
USER dev
この Dockerfile
を以下のコマンドでビルドしてから、docker run
コマンドでバインドマウントしたホスト側のディレクトリにファイルを書き込んでみます。
docker build -t ubuntu:dev1 .
docker run --rm -v $(pwd):/tmp/v ubuntu:dev1 \
bash -c "echo echo_dev1_contents > /tmp/v/echo_dev1"
しかし残念ながらエラーで書き込めませんでした。
bash: /tmp/v/echo_dev1: Permission denied
Docker コンテナ内のユーザーは uid=20000, gui=20000 の dev ユーザーです。
バインドマウントしたホスト側のディレクトリは以下の通り uid=1000, gid=1000 のユーザーが所有しているので uid=20000 では書き込みできません。
$ id
uid=1000(jun) gid=1000(jun) groups=1000(jun) ...
$ ls -al
total 60
drwxr-xr-x 3 jun jun 4096 Jun 6 23:52 .
drwxr-xr-x 5 jun jun 4096 Jun 4 16:15 ..
...
次はホスト側でカレントディレクトリの書き込み権限を 777 にしてから再実行してみます。
chmod 777 .
docker run --rm -v $(pwd):/tmp/v ubuntu:dev1 \
bash -c "echo echo_dev1_contents > /tmp/v/echo_dev1"
書き込みには成功しますが、ホスト側で echo_dev1
ファイルを確認してみると以下の通り所有者が uid=20000, gid=20000 となってしまいました。
$ ls -al
total 64
drwxrwxrwx 3 jun jun 4096 Jun 6 23:58 .
drwxr-xr-x 5 jun jun 4096 Jun 4 16:15 ..
...
-rw-r--r-- 1 jun jun 94 Jun 6 23:35 Dockerfile
...
-rw-r--r-- 1 20000 20000 19 Jun 6 23:58 echo_dev1
$ cat echo_dev1
echo_dev1_contents
この状態でホスト側から echo_dev1
に追記すると失敗します。
uid=20000 のユーザーしか書き込めないパーミッションになっているので当然の結果です。
$ echo echo_host_contents >> echo_dev1
bash: echo_dev1: Permission denied
Docker コンテナで開発しているとホスト側からもコンテナ側からもファイルを生成・更新するシーンが出てきがちなので、所有者や権限が一致しないと非常に面倒くさいです。
Docker コンテナ内のユーザーの uid, gid をホスト側と揃える
所有者が変わってしまう原因はホストとコンテナでユーザーの uid
, gid
がずれていることにあります。
そこで先ほどの Dockerfile
に ARG
命令で uid
, gid
を受け取るようにして、ホストとコンテナを一致させるようにしてみます。
FROM ubuntu:20.04
ARG UID=20000
ARG GID=20000
RUN groupadd -g $GID dev \
&& useradd -m -u $UID -g $GID dev
USER dev
次のように docuker build
に --build-arg UID=$(id -u)
, --build-arg GID=$(id -g)
を指定すると ARG
命令経由で useradd
, groupadd
の引数に渡されます。
docker build -t ubuntu:dev2 \
--build-arg UID=$(id -u) --build-arg GID=$(id -g) .
先ほどカレントディレクトリを誰でも書き込みができるようにしていたので、chmod 755
で所有者のみが書き込める状態に戻した上でコンテナを実行すると今度は書き込みに成功しました。
chmod 755 .
docker run --rm -v $(pwd):/tmp/v ubuntu:dev2 \
bash -c "echo echo_dev2_contents > /tmp/v/echo_dev2"
続けてファイルの所有者を確認すると、echo_dev2
ファイルはホスト側のユーザー(uid=1000, gid=1000) の所有となっています。
$ ls -al
total 68
drwxr-xr-x 3 jun jun 4096 Jun 7 00:16 .
drwxr-xr-x 5 jun jun 4096 Jun 4 16:15 ..
...
-rw-r--r-- 1 jun jun 120 Jun 7 00:16 Dockerfile
...
-rw-r--r-- 1 20000 20000 19 Jun 6 23:59 echo_dev1
-rw-r--r-- 1 jun jun 19 Jun 7 00:21 echo_dev2
$ cat echo_dev2
echo_dev2_contents
これでコンテナ内でファイルを生成しても、ホスト側からファイルを生成しても双方共に同じ uid
, gid
で生成されます。
VSCode + Remote-Container におけるパーミッション問題は?
Docker で開発するには前述のようにパーミッションに関する考慮が必要ですが、VSCode + Remote-Container を利用するとたいていの場合はなぜか問題が発生しません。
何もしなくてもホスト側とコンテナ側で双方違和感なく書き込みができる状態になるのですが、その背景には想像以上に力技な処理があります。
その謎を解き明かす前にまずは Remote-Container がコンテナ側の実行ユーザーを決定する方法をまとめます。
コンテナ側の実行ユーザー決定方法
Dockerfile
では USER
命令で実行ユーザーを指定しますが、Remote-Container はそれに加えて devcontainer.json
の remoteUser
でも指定できます。
それぞれを「指定無し」「rootと指定」「devと指定」の3パターンの組合せで、コンテナがどのユーザーで起動するか検証してみます。
devcontainer.json - remoteUser | Dockerfile - USER | 結果 |
---|---|---|
指定無し | 指定無し | root で起動 |
指定無し | root と指定 | root で起動 |
指定無し | dev と指定 | dev で起動 |
root と指定 | 指定無し | root で起動 |
root と指定 | root と指定 | root で起動 |
root と指定 | dev と指定 | root で起動 |
dev と指定 | 指定無し | dev で起動 |
dev と指定 | root と指定 | dev で起動 |
dev と指定 | dev と指定 | dev で起動 |
結果は devcontainer.json
の remoteUser
がまず優先され、存在しない時は Dockerfile
の USER
指示が利用され、両方とも存在しない場合は root になります。
Remote-Container がホストとコンテナの uid, gid を揃える方法
Remote-Container は devcontainer.json
の設定を元にビルドした Docker イメージを起動するのですが、実は Dockerfile
を元に構築したイメージをそのまま使うわけではありません。
そのイメージを FROM
に指定した以下の Dockerfile
でさらに構築されたイメージが最終的に起動します。
ARG BASE_IMAGE
FROM $BASE_IMAGE
USER root
ARG REMOTE_USER
ARG NEW_UID
ARG NEW_GID
SHELL ["/bin/sh", "-c"]
RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/OLD_UID=\1;OLD_GID=\2;HOME_FOLDER=\3/p" /etc/passwd); \
eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_UID}:.*/EXISTING_USER=\1/p" /etc/passwd); \
eval $(sed -n "s/\([^:]*\):[^:]*:${NEW_GID}:.*/EXISTING_GROUP=\1/p" /etc/group); \
if [ -z "$OLD_UID" ]; then \
echo "Remote user not found in /etc/passwd ($REMOTE_USER)."; \
elif [ "$OLD_UID" = "$NEW_UID" -a "$OLD_GID" = "$NEW_GID" ]; then \
echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \
elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \
echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \
elif [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \
echo "Group with GID exists ($EXISTING_GROUP=$NEW_GID)."; \
else \
echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \
sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \
if [ "$OLD_GID" != "$NEW_GID" ]; then \
sed -i -e "s/\([^:]*:[^:]*:\)${OLD_GID}:/\1${NEW_GID}:/" /etc/group; \
fi; \
chown -R $NEW_UID:$NEW_GID $HOME_FOLDER; \
fi;
ARG IMAGE_USER
USER $IMAGE_USER
上記の Dockerfile
に用意されている ARG
命令の引数には以下のような値がセットされます。
docker build \
-f /tmp/vsch/updateUID.Dockerfile-0.234.0 \
-t vsc-container-3f168de38e9afa11206f6cb0f46dc19d-uid \
--build-arg BASE_IMAGE=vsc-container-3f168de38e9afa11206f6cb0f46dc19d \
--build-arg REMOTE_USER=dev \
--build-arg NEW_UID=1000 \
--build-arg NEW_GID=1000 \
--build-arg IMAGE_USER=dev \
/tmp/vsch
FROM
命令の $BASE_IMAGE
に指定されるのが devcontainer.json
を元に構築されたイメージ名です。
$REMOTE_USER
と $IMAGE_USER
にはコンテナ内のユーザー名(dev
)が入ります。
$NEW_UID
と $NEW_GID
にはホスト側の uid
, gid
(1000
, 1000
) が入ります。
この Dockerfile
で行われているのは以下のような処理です。(※細かい部分は省略しています)
- コンテナ内の
/etc/passwd
から$REMOTE_USER
のエントリを探して コンテナ側のuid
,gid
, ホームディレクトリを取得。 - 引数で渡されたホスト側の
uid
,gid
と上記処理で取得したコンテナ側の情報を比べ、一致していれば処理終了 - コンテナ内の
/etc/passwd
を更新、$REMOTE_USER
のuid
,gid
をホスト側のuid
,gid
で上書きする - コンテナ内の
/etc/group
を更新、$REMOTE_USER
のgid
をホスト側のgid
で上書きする -
$REMOTE_USER
のホームディレクトリ以下の所有者をすべてホスト側のuid
,gid
に変更する
つまりコンテナ側のユーザーは強制的にホスト側の uid
, gid
に置き換えられ、かつホームディレクトリは chown -R
で全て更新されます。
この処理によって大抵のケースでは uid
, gid
の差を吸収できます。
Remote-Container の問題点
前項の処理をふまえて問題点と解決策を考えます。
chown に時間がかかる問題とイメージサイズ肥大化問題
昨今の開発環境ではパッケージマネージャが利用され、依存ライブラリのインストール先としてユーザーのホームディレクトリやプロジェクトのルートディレクトリが利用されがちです。
そして Docker で開発環境を構築する場合は、Dockerfile
内でパッケージマネージャのインストールから依存ライブラリのインストールまでを行うケースが多いと思われます。
そこで問題となるのが子・孫・ひ孫と再帰的にインストールされる膨大な依存ライブラリのファイル達と Docker が利用する OverlayFS ストレージの相性の悪さです。
OverlayFS はレイヤーで構成されていて書き込みは最上位レイヤーにのみ行われるので、下位レイヤーに存在するファイルを更新する場合は同じファイルを最上位レイヤーにコピーアップした上で更新したファイルを最上位レイヤーにも重複して保持します。
Remote-Container が膨大な依存ライブラリのインストールされたホームディレクトリ以下を chown -R
で一括更新してしまうと、所有者だけが異なる同一のファイルが最上位レイヤーにもコピーされてしまいます。
コピーアップ処理は遅いのでファイル数が多いと非常に時間がかかりますし、イメージサイズも2倍になります。
ホームディレクトリ以外はケアされない問題
/etc/passwd
を更新しつつホームディレクトリの所有者も更新してくれるので大抵の環境では問題なく動作しますが、ホームディレクトリ以外にもファイルを配置していた場合はその所有者は古い uid
, gid
のままになります。
Remote-Container がうまいことケアしすぎているので、いざ問題に直面するとその動作を知らないと解決が難しくなるので要注意です。
どうすれば良いか?
これら問題が起きるのはホスト・コンテナ間での uid
, gid
のズレが原因です。その両方がそろっている場合は /etc/passwd
などの更新は行われないので問題は解決します。
そこで Dockerfile
に ARG
命令で uid
, gid
を渡す方法を紹介します。
.devcontainer/
フォルダ内に以下のような devcontainer.json
, Dockerfile
ファイルを配置します。
devcontainer.json
{
"name": "Python 3",
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {
"UID": "1000",
"GID": "1000"
}
},
...(省略)
}
Dockerfile
FROM ubuntu:20.04
ARG UID=20000
ARG GID=20000
RUN groupadd -g $GID dev \
&& useradd -m -u $UID -g $GID dev
USER dev
devcontainer.json
では build.args
にビルド時の引数を指定できます。ホスト側の uid
, gid
(1000
, 1000
)を UID
, GID
引数に設定することで Dockerfile
に対して値を渡しています。
ホストとコンテナで uid
, gid
がそろっている場合は Remote-Container の黒魔術的な処理がスキップされるため構築が速くなりイメージサイズの肥大化も防げます。
ただし build.args
に設定すべき値は環境によって異なるので、devcontainer.json
をソース管理ツールに追加することができなくなります。
そこで devcontainer.json
をチームで共有する場合は docker-compose
の利用をオススメします。Remote-Container は Dockerfile
を直接利用するだけでなく、docker-compose
経由でコンテナを起動することも可能です。
まずは devcontainer.json
を以下のように docker-compose.yml
を利用する形に更新します。
devcontainer.json
{
"name": "Python 3",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/container",
...
}
.devcontainer/
フォルダに docker-compose.yml
を作成します。
docker-compose.yml
version: "3.9"
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
args:
UID: ${UID:-20000}
GID: ${GID:-20000}
command: sleep infinity
volumes:
- ..:/workspaces/container
Dockerfile
は先ほどのまま利用します。
最後に .devcontainer/
フォルダ内に .env
ファイルを作成します。
.env
UID=1000
GID=1000
docker-compose
は .env
ファイルを自動的に読み込んで docker-compose.yml
内で利用できる環境変数にセットしてくれます。
この方法ならば .env
を .gitignore
に追加して環境ごとの違いを吸収できます。
updateRemoteUserUID
devcontainer.json
に updateRemoteUserUID: false
を設定すると、Remote-Container が uid
, gid
の更新のために生成する Dockerfile
を停止することができます。
自力で uid
, gid
を揃えることができたなら、以下のように設定を追加しましょう。
devcontainer.json
{
...
"updateRemoteUserUID": false,
...
}
まとめ
Docker を利用すると手軽に開発者間の環境を揃えることができて便利ですよね。
一方で意外とはまりどころも多いので誰が何をどのように処理してくれているのか、それを意識しながら利用しないと思わぬ罠にはまってしまいます。
かく言う私もホームディレクトリにインストールされた数万ファイルにもおよぶ依存ライブラリ達を chown -R
が処理する10分ほどの時間を構築のたびに待っていましたが、今回紹介した方法でビルド時間が3分の1以下になりました。
パッケージマネージャがインターネットから tar.gz ファイルをダウンロードして解凍する処理よりも OverlayFS の探索とコピーアップ処理の方がオーバーヘッドが大きくて重いのです。
チーム全体で共有する開発環境のビルド時間は早いに超したことはないので効率的な書き方を心がけましょう。
Discussion