📦

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 build -t ubuntu:dev1 .

続いて docker run -v $(pwd):/tmp/v でカレントディレクトリを /tmp/v にバインドマウントした上で、そのホスト側のディレクトリである /tmp/v/echo_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 となってしまいました。
ホスト側には存在しない uid, gid なので数値の数値のままで表示されています。

$ 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 は 644 の権限なので、ホスト側で echo_dev1 に対して追記すると失敗します。
uid=20000 のユーザーしか書き込めないパーミッションになっているので当然の結果です。

$ echo echo_host_contents >> echo_dev1
bash: echo_dev1: Permission denied

コンテナでの開発はホスト・コンテナ双方でファイルが生成・更新されるので、お互いに書き込みできないと非常に面倒くさいです。

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 はユーザーの指定した Dockerfile をビルドしたイメージをそのまま利用するわけではなく、実際にはさらに以下のような Dockerfile で拡張したイメージを利用します。

ユーザーの指定した Dockerfile でビルドしたイメージは、$BASE_IMAGE 変数に格納されて、以下の FROM 命令でベースイメージとなります。

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)."; \
        else \
                if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \
                        echo "Group with GID exists ($EXISTING_GROUP=$NEW_GID)."; \
                        NEW_GID="$OLD_GID"; \
                fi; \
                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

$REMOTE_USER$IMAGE_USER にはコンテナ内のユーザー名(この場合は dev)が入ります。
$NEW_UID$NEW_GID にはホスト側の uid, gid (1000, 1000) が入ります。

この Dockerfile ではホスト側のuid, gid とコンテナ側の uid, gid のどちらかでも異なる場合に以下の処理が実行実行されます

  1. コンテナ側の /etc/passwd を更新して、コンテナの実行ユーザー(この場合は dev)の uid, gid をホスト側の uid, gid で上書きする
  2. コンテナ側の /etc/group を更新して、コンテナの実行ユーザー(この場合は dev)の gid をホスト側の gid で上書きする
  3. コンテナの実行ユーザー(この場合は dev)のホームディレクトリ以下の所有者・グループをすべてホスト側の uid, gid に変更する

(※レアパターンとして、ホスト側 uid, gid がコンテナ側にすでに存在していたら処理がスキップされます)

コンテナの実行ユーザー(この場合は dev)の uid, gid はホスト側の uid, gid に置き換えられ、かつホームディレクトリの所有者・グループも新しい uid, gid へと chown -R されます。
この処理によって大抵のケースではホストとコンテナの uid, gid の差を吸収できます。

Remote-Container の問題点

前項の処理をふまえて問題点と解決策を考えます。

chown に時間がかかる問題とイメージサイズ肥大化問題

パッケージマネージャ及び依存ライブラリを Dockerfile でビルド時にインストールしていて、かつユーザーのホームディレクトリ以下にそれらのファイルを保存するケースで問題が起きます。

依存ライブラリは肥大化しがちのため、問題となるのが Docker の利用する OverlayFS ストレージに対して chown -R で一括処理をする相性の悪さです。
OverlayFS はレイヤーで構成されていて書き込みは最上位レイヤーにのみ行われます。下位レイヤーに存在するファイルを更新すると、同じファイルを最上位レイヤーにコピーアップした上で更新したファイルを最上位レイヤーにも重複して保持します。

膨大な依存ライブラリのインストールされたホームディレクトリ以下を 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.jsonbuild.args にホスト側の uid, gid1000, 1000)を設定して、Dockerfile では渡された uid, gid のユーザーを作成します。
ホストとコンテナで 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

前述のようにホストとコンテナの uid, gid を揃えることができれば問題は解決しますが、devcontainer.jsonupdateRemoteUserUID: false と設定すれば uid, gid を揃える処理自体を無効化することもできます。
ただし、単に無効化するとホストとコンテナで uid, gid がズレて使いづらい状況になるので、自分でなんとかできている場合にのみご利用ください。

この設定によって Remote-Container は uid, gid の更新するための Dockerfile を生成しなくなります。

devcontainer.json

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

まとめ

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

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

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

Discussion