公開日 2026-08-13

Dockerで「Permission denied」を解決する(WSL2のバインドマウントとUIDの食い違い)

WSL2のバインドマウントでコンテナが作ったファイルがroot所有になり Permission denied で止まるとき、失敗しているのが親ディレクトリである読み方と、所有者を回収する手順を実測で追う。

目次

  1. 1. 検証環境と、ホスト側・コンテナ側で変わるエラー文言
  2. 2. コンテナが作ったファイルを WSL 側から触って再現する
  3. 3. 失敗しているのはファイルではなく、置き場所
  4. 4. 直ったように見えて、所有者だけが入れ替わる操作
  5. 5. Alpine のコンテナでは、同じ失敗が別の文言で出る
  6. 6. 直し方: コンテナ側から所有者を回収する
  7. 7. 再発防止: compose.yml に user を書く / その代償
  8. 8. /mnt/c に置いている場合は症状が出ない
  9. 9. named volume では、この症状として表に出ない
  10. 10. 片付けと、対処

コンテナが吐き出したファイルを WSL 側のエディタで開くと保存できず、rmtouchPermission denied で止まる。この記事では、その所有者の食い違いを Docker Desktop + WSL2 環境で再現し、失敗しているのがファイルなのか置き場所なのかを読み分けて、所有者を回収するところまでを実際の出力で追います。非対象は SELinux / AppArmor が絡む権限、rootless Docker、Linux ネイティブ環境での運用です。

1. 検証環境と、ホスト側・コンテナ側で変わるエラー文言

  • Windows 11
  • WSL2(Ubuntu)
  • Docker Desktop 4.55.0(Docker Engine 29.1.3)
  • php:8.4-cli-alpine(PHP 8.4.24)

コマンドは断りがない限り WSL 側のシェルで実行します。パスはすべて作業ディレクトリからの相対表記です。

同じ原因でも、どこでコマンドを打ったかで文言が変わります。 ホスト側(GNU coreutils)ではこの形です。

touch: cannot touch 'src/cache/new.txt': Permission denied
rm: cannot remove 'src/cache/app.log': Permission denied

コンテナの中(Alpine の busybox)だと、同じ操作でこうなります。

touch: /workspace/cache/from-container.txt: Permission denied
sh: can't create /workspace/cache/out.log: Permission denied

cannot touch '...' で検索して見つからないときは、busybox 形の文字列でも引いてみてください。

2. コンテナが作ったファイルを WSL 側から触って再現する

Windows のターミナルで wsl を実行して Ubuntu に入り、作業ディレクトリを作成します。

# Windows側から始める場合のみ実行
# wsl
mkdir -p ~/projects/docker-volume-permission-denied-demo/src
cd ~/projects/docker-volume-permission-denied-demo
code .

compose.yml を作成します。user を書いていないため、コンテナのプロセスは root です。

services:
  app:
    image: php:8.4-cli-alpine
    working_dir: /workspace
    volumes:
      - ./src:/workspace
    command: ["sleep", "infinity"]

起動して、コンテナ側からファイルとディレクトリを作ります。ビルド生成物やキャッシュがコンテナ内で作られる状況を、最小の形にしたものです。

docker compose up -d
docker compose exec app sh -c 'mkdir -p /workspace/cache && echo generated > /workspace/cache/app.log && echo gen > /workspace/build.txt'

ここで両側の UID を見比べます。

id -u
docker compose exec app id -u
1000
0

WSL で最初に作ったユーザーは 1000、コンテナのプロセスは 0(root)でした。この2つの数字の差が、以降のすべての症状の出どころです。所有者を数字のまま読むため、確認には stat を使います。

stat -c '%u:%g %a %n' src src/build.txt src/cache src/cache/app.log
1000:1000 755 src
0:0 644 src/build.txt
0:0 755 src/cache
0:0 644 src/cache/app.log

src だけが 1000:1000 で、コンテナが作った3つは 0:0 です。src を作ったのは WSL 側の自分、中身を作ったのはコンテナの root、という履歴がそのまま出ています。

3. 失敗しているのはファイルではなく、置き場所

cache/ の中で4つの操作を試すと、すべて止まります。

touch src/cache/new.txt
rm src/cache/app.log
cp /etc/hostname src/cache/copied.txt
mkdir src/cache/sub
touch: cannot touch 'src/cache/new.txt': Permission denied
rm: cannot remove 'src/cache/app.log': Permission denied
cp: cannot create regular file 'src/cache/copied.txt': Permission denied
mkdir: cannot create directory ‘src/cache/sub’: Permission denied

4つに共通しているのは、src/cache/ の中で失敗している点です。見る先は操作対象のファイルではありません。

  • app.log のパーミッションは 644 で、所有者以外にも読み取りが許されています
  • 止めているのは src/cache 側の 0:0 755 で、ディレクトリの w が所有者にしか付いていない状態です
  • ディレクトリへの書き込み権限は「その中でファイルを作る・消す・名前を変える」権限にあたります。だから通常のディレクトリでは、rm の可否は消す対象のファイルではなく親ディレクトリで決まります(/tmp のように sticky bit が付いたディレクトリは例外で、他人のファイルは消せません)

同じファイルに対して、読み取りは成功し rm は失敗する形です。

cat src/cache/app.log
rm src/cache/app.log
generated
rm: cannot remove 'src/cache/app.log': Permission denied

rm が権限で止まったとき、そのファイル自身への chmod は効きません。見るのは1つ上の階層です。

4. 直ったように見えて、所有者だけが入れ替わる操作

0:0 のファイルでも通る操作があります。src の直下に置かれた build.txtsed で書き換えます。

stat -c '%u:%g %a %n' src/build.txt
sed -i 's/gen/edited/' src/build.txt
stat -c '%u:%g %a %n' src/build.txt
0:0 644 src/build.txt
1000:1000 644 src/build.txt

sed -i は成功し、しかも所有者が 0:0 から 1000:1000 へ変わりました-i は元のファイルを直接書き換えず、一時ファイルを作ってから置き換えます。作成と置き換えが起きるのは親ディレクトリの src で、そこは 1000:1000 なので許可されます。結果として中身は書き換わり、所有者は実行した側のものです。

同じ理由で、src 直下なら削除も通ります。

rm src/build.txt

この2つが通る一方で、cache/ の中は 0:0 のまま残ります。エディタで保存できたファイルがあっても回収は済んでいないので、stat0:0 が残っていないかを確かめてください。

5. Alpine のコンテナでは、同じ失敗が別の文言で出る

compose.yml を更新して user を足し、自分と同じ UID で動かします。今度はコンテナ側が cache/ に書けなくなります。既に 0:0 で作られたディレクトリが残っているためです。

services:
  app:
    image: php:8.4-cli-alpine
    working_dir: /workspace
    user: "1000:1000"
    volumes:
      - ./src:/workspace
    command: ["sleep", "infinity"]
docker compose up -d
docker compose exec app touch /workspace/cache/from-container.txt
docker compose exec app sh -c 'echo x > /workspace/cache/out.log'
touch: /workspace/cache/from-container.txt: Permission denied
sh: can't create /workspace/cache/out.log: Permission denied

GNU の touchcannot touch 'パス' と書くのに対して、busybox は パス: だけを置きます。原因も対象も同じですが、検索に投げる文字列としては別物です。この差が出るのは busybox を使う Alpine 系のイメージで、Debian や Ubuntu をベースにしたイメージならホスト側と同じ GNU の文言になります。

パッケージ管理も root を要求するので、ここで落ちます。

docker compose exec app apk add --no-cache git
ERROR: Unable to open log: Permission denied

終了コードは 99 でした。Permission denied が付いていても、これはバインドマウントではなくコンテナ内のログ書き込みで止まっています。対処は7章です。

6. 直し方: コンテナ側から所有者を回収する

コンテナのプロセスは root なので、コンテナ経由なら chown がそのまま通ります。user 指定を外した状態(2章の compose.yml)で実行します。

実行前に2点を確認してください。-R はマウント先の全体を書き換えます。

  • /workspace に何がマウントされているか。 このデモでは ./src だけですが、実プロジェクトの直下をマウントしている場合は .gitnode_modules まで所有者が変わります。.git の所有者が変わると Git が dubious ownership を報告して操作を止めることがあります。範囲を絞るなら /workspace ではなく /workspace/cache のようにサブディレクトリを指定してください
  • 自分の UID と GID。 下のコマンドは id -uid -g の出力をそのまま渡す形にしてあります。1000:1000 と直に書くと、UID が 1000 でない環境では自分が触れないファイルを作ることになります
docker compose exec app chown -R "$(id -u):$(id -g)" /workspace
stat -c '%u:%g %a %n' src src/cache src/cache/app.log
1000:1000 755 src
1000:1000 755 src/cache
1000:1000 644 src/cache/app.log

3件すべてが 1000:1000 です。2章では4件あったうち build.txt が減っているのは、4章で削除したためです。3章で止まっていた操作を打ち直すと、どちらも通ります。

touch src/cache/new.txt
rm src/cache/app.log

別解は WSL 側の sudo chown です。この環境ではパスワードの入力を求められます。-n を付けると入力を求めずに失敗するので、求められるかどうかだけを先に確かめられます。

sudo -n chown -R "$(id -u):$(id -g)" src
sudo: a password is required

sudo を使うならパスワードを打てばよく、着地点は同じです。コンテナ経由を主線にしたのは、Docker を起動している時点で root 権限のプロセスが手元にあり、入力を挟まずに済むためです。

7. 再発防止: compose.ymluser を書く / その代償

所有者を回収しても、user を書かないままなら次にコンテナが書いたファイルはまた 0:0 になります。src を作り直したうえで、user を入れた compose.yml で起動します。

次の rm -rf src は、2章で作ったデモディレクトリでのみ実行してください。 実プロジェクトの src を消します。 実行前に pwd~/projects/docker-volume-permission-denied-demo であることを確認してください。 既存プロジェクトへ再発防止だけを適用する場合は、src を消さずに compose.ymluser を足し、 以後コンテナが作るファイルの所有者を stat で確かめる形にしてください(既存の 0:0 は6章で回収します)。

pwd
rm -rf src && mkdir src
docker compose up -d
docker compose exec app sh -c 'mkdir -p /workspace/cache && echo generated > /workspace/cache/app.log'
stat -c '%u:%g %a %n' src src/cache src/cache/app.log
1000:1000 755 src
1000:1000 755 src/cache
1000:1000 644 src/cache/app.log

コンテナが作ったファイルが最初から 1000:1000 になり、WSL 側から rm も編集も通ります。

5章で見たとおり、パッケージ導入のように root を要求する操作が落ちるようになる点が代償です。単発なら --user root で戻せます。

docker compose exec --user root app apk add --no-cache git
(1/4) Installing libexpat (2.8.2-r0)
(2/4) Installing pcre2 (10.47-r1)
(3/4) Installing git (2.54.0-r0)
(4/4) Installing git-init-template (2.54.0-r0)
Executing busybox-1.37.0-r31.trigger
OK: 27.9 MiB in 45 packages

毎回このコンテナで使うものの置き場所は Dockerfile です。Dockerfile を作成します。

FROM php:8.4-cli-alpine

RUN apk add --no-cache git

compose.ymlimagebuild へ差し替えてください。

services:
  app:
    build: .
    working_dir: /workspace
    user: "1000:1000"
    volumes:
      - ./src:/workspace
    command: ["sleep", "infinity"]

ビルドして起動し、user を落としたまま git が使えることを確認します。

docker compose up -d --build
docker compose exec app id -u
docker compose exec app git --version
1000
git version 2.54.0

RUN はビルド時に走るので root で実行され、user の指定は起動後のプロセスにだけ効きます。バインドマウントへの書き込みも 1000:1000 のままです。Dockerfilecompose.yml の役割分担は Docker初心者向け: compose.yml と Dockerfile の違いを最小構成で理解する で扱っています。

8. /mnt/c に置いている場合は症状が出ない

ここまでは WSL のファイルシステム上(~/projects/...)での話です。プロジェクトを Windows 側(/mnt/c/...)に置いてバインドマウントすると、同じ手順でも Permission denied になりません。root で動くコンテナからファイルを作らせても、両側から見た所有者はこうなります。

1000:1000 777 /workspace
1000:1000 777 /workspace/cache
1000:1000 777 /workspace/cache/app.log

作ったのは root なのに 1000、パーミッションは 777 です。全員に書き込みが許されているため、3章で見た失敗とは無縁になります。

これは metadata オプションを付けずに Windows ドライブをマウントした場合の挙動です。WSL は既定でこの状態で、 所有者とパーミッションはマウント時の値を一律に返します。手元がその状態かは findmnt で確認できます。

findmnt -T /mnt/c -o FSTYPE,OPTIONS
FSTYPE OPTIONS
9p     rw,noatime,aname=drvfs;path=C:\;uid=1000;gid=1000;symlinkroot=/mnt/,cache=5,...

uid=1000;gid=1000 が入っていて metadata が無い状態です。所有者はここで固定されます。 /etc/wsl.confmetadata を有効にしている場合は Linux の所有者情報が NTFS 側に保存されるため、 /mnt/c でも3章と同じ食い違いが起こり得ます。その場合は置き場所で切り分けず、stat0:0 が出ているかで判断してください。

そのかわり chownchmod が黙って効きません。

docker compose exec app chown -R root:root /workspace
docker compose exec app chmod 700 /workspace/cache
docker compose exec app stat -c '%u:%g %a %n' /workspace /workspace/cache
1000:1000 777 /workspace
1000:1000 777 /workspace/cache

どちらのコマンドも終了コード 0 を返し、エラーも出しません。それでいて所有者は 1000、パーミッションは 777 のままです。権限を絞ったつもりで運用すると、実際には何も絞れていない状態になります。

切り分けの順序は、置き場所ではなく stat の結果が先です。/mnt/c 配下でも metadata が有効なら 0:0 が付き得るので、 「/mnt/c だから対象外」と決め打ちにはできません。stat がすべて 1000:1000(自分の UID)で、それでも Permission denied が出るなら、 Windows 側でファイルがロックされているなど別の要因になります。

flowchart TD
    A["Permission denied が出た"] --> C{"stat に 0:0 が残っているか"}
    C -->|"残っている"| E["コンテナ経由で chown して回収"]
    C -->|"すべて自分の UID"| M{"置き場所は"}
    M -->|"WSL のファイルシステム"| F["コンテナ側が非 root で root 権限を要求している"]
    M -->|"/mnt/c 配下"| D["この記事の原因ではない"]
    E --> G["compose.yml に user を書いて再発を止める"]
    F --> H["--user root か Dockerfile の RUN で入れる"]

9. named volume では、この症状として表に出ない

バインドマウントではなく named volume にすると、コンテナ内の所有者は root のままです。vol-compose.yml を作成して確認します。

services:
  app:
    image: php:8.4-cli-alpine
    working_dir: /workspace
    volumes:
      - appdata:/workspace
    command: ["sleep", "infinity"]

volumes:
  appdata:
docker compose -f vol-compose.yml up -d
docker compose -f vol-compose.yml exec app sh -c 'mkdir -p /workspace/cache && echo generated > /workspace/cache/app.log'
docker compose -f vol-compose.yml exec app stat -c '%u:%g %a %n' /workspace /workspace/cache /workspace/cache/app.log
0:0 755 /workspace
0:0 755 /workspace/cache
0:0 644 /workspace/cache/app.log

それでも困らないのは、この領域が WSL のプロジェクト配下に現れないからです。実体の場所を管理しているのは Docker です。

docker volume inspect docker-volume-permission-denied-demo_appdata --format '{{.Mountpoint}}'
/var/lib/docker/volumes/docker-volume-permission-denied-demo_appdata/_data

エディタで開く対象にならないため、この記事が扱う「WSL 側から編集も削除もできない」症状としては表に出ません。 権限の食い違いそのものが消えるわけではなく、root が作ったディレクトリへ user: "1000:1000" のコンテナが後から書けば、 5章と同じ Permission denied がコンテナの中で出ます。裏を返すと、ホスト側から中身を編集したいならバインドマウントを選ぶことになり、そのときは user の指定が必要です。volumes の書き方そのものは Docker初心者向け: compose.yml の中身を1項目ずつ読む入門 にあります。

docker compose -f vol-compose.yml down -v

10. 片付けと、対処

7章まで進めていれば src の中身はすべて自分の所有なので、そのまま消せます。3章や5章で止めた場合は 0:0 のファイルが残っていて、rm -rf は途中で止まる。

rm -rf src
rm: cannot remove 'src/cache/app.log': Permission denied

このとき stat の結果は実行前と同じで、1件も削除されていない状態です。片付けは所有者の回収から始めます。

回収に使うのは6章と同じ chown。ただし compose.ymluser: "1000:1000" を書いたままでは落ちる。 コンテナのプロセスも 1000 になり、0:0 のファイルには手が出せません。

chown: /workspace/cache/app.log: Operation not permitted
chown: /workspace/cache: Operation not permitted

user の行を外して docker compose up -d を打ち直してから回収してください。 最後の削除はデモディレクトリを丸ごと消すので、pwd と対象パスを目で確かめてから実行してください。

docker compose exec app chown -R "$(id -u):$(id -g)" /workspace
docker compose down
ls -d ~/projects/docker-volume-permission-denied-demo
rm -rf ~/projects/docker-volume-permission-denied-demo

症状ごとの対処は次の5件です。

  • ファイルの chmod を変えても直らない → 3章(見るのは親ディレクトリ)
  • 一部のファイルだけ保存できる → 4章(sed -i は所有者を書き換える)
  • コンテナの中でだけ書けない → 5章と7章(--user rootDockerfile
  • 所有者を戻したい → 6章
  • /mnt/c に置いていて出た → 8章(別の原因)

同じ書き込みの失敗でも、原文が Permission denied ではなく No space left on device なら原因は所有者ではありません。その場合の切り分けは Dockerで「no space left on device」を解決する(WSL2でDockerのディスクとマウント先を見分ける) にまとめてある。

同じシリーズのポート衝突編は Dockerで「port is already allocated」を解決する(WSL2/Windowsでの原因特定と対処) にあります。

シリーズ 2/4

このシリーズ

Dockerのエラーを原文から切り分ける

  1. 1. Dockerで「port is already allocated」を解決する(WSL2/Windowsでの原因特定と対処)
  2. 2. Dockerで「Permission denied」を解決する(WSL2のバインドマウントとUIDの食い違い) 現在の記事
  3. 3. Dockerで「localhostに繋がらない」を解決する(WSL2でコンテナの待受アドレスを見分ける)
  4. 4. Dockerで「no space left on device」を解決する(WSL2でDockerのディスクとマウント先を見分ける)