📦

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

2022/06/12に公開約11,400字

概要

Docker や VSCode + Remote-Container を利用していてパーミッションの問題に出会ったことはありませんか?Docker にはパーミッションの扱いに面倒くささがあり、VSCode + Remote-Container はその問題を黒魔術(=力技)で解決しているのでよく分からずに使っていると想定しない結果になることがあります。

そこで Docker や VSCode + Remote-Container におけるパーミッションの扱いと対応方法をまとめてみます。

Docker とパーミッション

まずは Docker がパーミッションをどう扱うかを考えてみます。

Docker がホスト側のリソースをコンテナ内にマウントする際のスタンスは 「何もしない」 です。
それだけでは説明になっていないので一般ユーザーが docker コマンドを利用してホスト側の /etc/shadow を表示する例で解説します。(※ docker コマンドの実行が許可されていることを前提とします)

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 コマンドはバインドマウントしたホスト側のディレクトリに cp -a で所有者を保持したままコピーした上で chmod u+s でスティッキービットを立てています。
普通は一般ユーザーが root 所有の実行ファイルにスティッキービットを立てることはできませんが、コンテナ内は root で実行されるためスティッキービットが立った cat コマンドをファイルシステム上に記録することができます。
続けてホスト側に戻ってからスティッキービットの立った cat を利用して、ホスト側の root 権限で /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

イメージをビルドした後に実行します。次の 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 のユーザーが所有していて、ls -al の実行結果の通り所有者のみが書き込み可能なので 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

試しにホスト側でファイルに追記しようとすると失敗します。
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 にはいくつか引数が指定されていますが、それらには以下のような値がセットされてビルドされます。

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 はレイヤーで構成されていて読み込みは上位レイヤーから順に探索をします。一方書き込みは最上位のレイヤーにのみ行われるので、下位レイヤーのファイルを更新する場合は同じファイルを最上位レイヤーにコピーアップした上で更新後のファイルを保存します。

依存ライブラリがインストールされて巨大になったホームディレクトリ以下の所有者を一括更新すると、所有者だけが異なる同一のファイルが最上位レイヤーにもコピーされてしまいます。
コピーアップ処理は遅いのでファイル数が多いと非常に時間がかかりますし、イメージサイズも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 をチームで共有しつつ、build.args を指定したい場合は 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 などに追加することで環境ごとの違いを吸収できます。ちなみに .env がなければデフォルト値である 20000 が利用されます。

まとめ

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

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

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

Discussion

ログインするとコメントできます