📦

Docker や VSCode + Remote-Container のパーミッション問題に立ち向かう

2022/06/13に公開

概要

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 がずれていることにあります。
そこで先ほどの DockerfileARG 命令で 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.jsonremoteUser でも指定できます。
それぞれを「指定無し」「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.jsonremoteUser がまず優先され、存在しない時は DockerfileUSER 指示が利用され、両方とも存在しない場合は 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 で行われているのは以下のような処理です。(※細かい部分は省略しています)

  1. コンテナ内の /etc/passwd から $REMOTE_USER のエントリを探して コンテナ側の uid, gid, ホームディレクトリを取得。
  2. 引数で渡されたホスト側のuid, gid と上記処理で取得したコンテナ側の情報を比べ、一致していれば処理終了
  3. コンテナ内の /etc/passwd を更新、$REMOTE_USERuid, gid をホスト側の uid, gid で上書きする
  4. コンテナ内の /etc/group を更新、$REMOTE_USERgid をホスト側の gid で上書きする
  5. $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 などの更新は行われないので問題は解決します。

そこで DockerfileARG 命令で 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, gid1000, 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.jsonupdateRemoteUserUID: false を設定すると、Remote-Container が uid, gid の更新のために生成する Dockerfile を停止することができます。
自力で uid, gid を揃えることができたなら、以下のように設定を追加しましょう。

devcontainer.json

{
  ...
  "updateRemoteUserUID": false,
  ...
}

まとめ

Docker を利用すると手軽に開発者間の環境を揃えることができて便利ですよね。
一方で意外とはまりどころも多いので誰が何をどのように処理してくれているのか、それを意識しながら利用しないと思わぬ罠にはまってしまいます。

かく言う私もホームディレクトリにインストールされた数万ファイルにもおよぶ依存ライブラリ達を chown -R が処理する10分ほどの時間を構築のたびに待っていましたが、今回紹介した方法でビルド時間が3分の1以下になりました。
パッケージマネージャがインターネットから tar.gz ファイルをダウンロードして解凍する処理よりも OverlayFS の探索とコピーアップ処理の方がオーバーヘッドが大きくて重いのです。

チーム全体で共有する開発環境のビルド時間は早いに超したことはないので効率的な書き方を心がけましょう。

Discussion