👥

Ubuntu runner の Docker でもユーザー名前空間でコンテナを分離(userns remap)

2022/12/07に公開

GitHub Actions の ubuntu-22.04 runner で gid が変更になった影響で、Docker コンテナを動かしているジョブが落ちるようになってしまいました。 (この辺は後述しますが DevContaner の uid/gid をホスト側に合致させる機能が使えなくなって権限関連でエラーになる)

上記のエラーは安直に回避したのですが「それはそれとして、コンテナから runner 側にファイルを書き出す方法は少し考えておいた方がよいかな」と思うようになりました。

というわけで今回は、「コンテナから runner(ホスト側)へファイルを書き出すときの問題点」と「ユーザー名前空間によるコンテナの分離を使うとどうなるのか」についてなど。

Ubuntu runner でコンテナからホスト側へファイルを書き出すときの問題点

コンテナ側からホスト側へ書き出す方法はいくつかありますが、ここではバインドマウントについて記述します。

Ubuntu runner を使っている場合に限らないのですが、コンテナでホスト側のディレクトリーをバインドマウントしたとき「コンテナ内の uid/gid とホスト側のアクセス権」の問題がでてきます。

たとえば、 ホスト側で id が 1000:100 なユーザーを利用している場合、普通に操作していると「コンテナ側でも 1000:100 で操作しないと権限のエラーになる」ことがあります。

図 1-1 ホストとコンテナで異なる uid/gid を使うとバインドマウントしたディレクトリーで権限関連のエラーになりやすい

$ mkdir chk

$ docker run -u guest -v "${PWD}:/mnt" --rm alpine:latest ash -c 'echo "hey, taishou" > /mnt/chk/hey.txt'
ash: can't create /mnt/chk/hey.txt: Permission denied

図 1-2 ホスト側で chk のグループ(今回 gid は一致している)に w の権限を追加

$ chmod g+w chk

$ docker run -u guest -v "${PWD}:/mnt" --rm alpine:latest ash -c 'echo "hey, taishou" > /mnt/chk/hey.txt'

$ cat chk/hey.txt
hey, taishou

これについての対応方法は検索するといくつか出てきて、上記の他に「コンテナ作成時にホスト側のユーザーにあわせて動的にユーザーを作成する」などの方法があります。

しかし、それらも「ホスト側で操作しているユーザーの uid/gid がすでにコンテナ側でも使われている」場合には「その id をファイルの読み書き用に使って問題ないのか?」などの問題が発生します。

たとえば、DevContaienr ではデフォルトユーザーの uid/gid をホスト側と合致させる仕組みが導入されていますが、これも gid のコンフリクトから逃れることはできていません。

ちなみに ubuntu-22.04 runner では docker グループの id が 122 から 123 に変更されたので、 alpine:latest イメージではコンフリクトしています[1]。よって、alpine:latest ベースのコンテナを devcontainer-ci などで利用していると上記機能が動作しなくなります。

図 1-3 alpine:latest では gid の 123 は利用されている

$ docker run --rm alpine:latest grep 122 /etc/group
$ echo "${?}"
1

$ docker run --rm alpine:latest grep 123 /etc/group
ntp:x:123:

ユーザー名前空間でコンテナを分離とは

この流れで書くと「バインドマウント用に uid/gid をなんかよい感じにしてくれるのかな」となりそうですが、そうではありません

uid/gid をよい感じにしてくれますが、どちらかというと「コンテナ内で root を使っているとホスト側でも root として振舞えてしまう」状態の回避が主な目的のようです。

コンテナ内からの権限昇格攻撃(privilege-escalation attack:一般的に、一般ユーザ権限で root に準じる権限を得られるようにする攻撃のこと)を防ぐベストな方法は、特権のないユーザ(unprivileged user)としてコンテナのアプリケーションを実行するよう設定することです。

図 2-1 root として読み書きできてしまう例

$ ls /etc/docker/
ls: ディレクトリ '/etc/docker/' を開くことが出来ません: 許可がありません

$ docker run -u guest -v "/etc/docker:/mnt" --rm alpine:latest ash -c 'echo "hey, taishou" > /mnt/hey.txt'
ash: can't create /mnt/hey.txt: Permission denied

$ docker run -v "/etc/docker:/mnt" --rm alpine:latest ash -c 'echo "hey, taishou" > /mnt/hey.txt'

$ sudo cat /etc/docker/hey.txt
hey, taishou

上記の挙動に対して、ユーザー名前空間を使うと「コンテナ内での uid は 0」だけど「ホスト側では異なる uid(ホスト側で使われていない uid)が割り当てられる」ようにできます。

もしもプロセスが名前空間の外に権限を昇格させようとしても、ホスト上におけるこのプロセスは、権限を持たない遙かに大きな UID として実行しています。つまり、ホスト上の実際のユーザとしては動作していないのです。つまり、このプロセスはホスト・システム上で全く権限を持ちません。

バインドマウントの問題点とユーザー名前空間

バインドマウントの問題点としては「uid/gid の違いによる権限のエラー」と「uid/gid のコンフリクト」がありました。

一方でユーザー名前空間は権限を抑制すためのものですが、別の見方をすると「ホストとコンテナに存在していない新しい uid/gid が割り当てられる」のでコンフリクトを回避しやすいという利点もあります。

また、ユーザー名前空間で割り当てられた id は「ホスト側では権限のないユーザー」という扱いですが、権限の調整はできます(chown で id を番号で指定するなど)。

  1. Docker ホスト上のどこかに対し、権限のないユーザが書き込む必要がある場合は、適切な場所に対する権限(パーミッション)を調整する必要があります。これは Docker によって自動的に作成される dockremap を使う場合でも同様ですが、設定を変更し、 Docker の再起動をした後でないと権限を変更できません。

よって、バインドマウントの問題点はユーザー名前空間を使うと(少し追加設定が必要ですが)回避できるかと思われます。

Ubuntu runner の Docker でユーザー名前空間は使えるのか?

バインドマウントの問題はユーザー名前空間で回避できそうな感じです。では、Ubuntu runner の Docker 環境で利用できるのかというと…。

ちょっと検索したかぎりではドキュメントとして「使える or 使えない」という情報はなさそうでした。そこで簡単なワークフローを作って調べてみたところ「サブオーディネイト UID と GID は設定されている」が「機能は有効化されてない」状態でした。

リスト 4-1 関連情報を表示するステップ

      - name: snif
        run: |
          id
          echo ---
          ls -l /etc/subuid /etc/subgid
          echo ---
          cat /etc/subuid
          echo ---
          cat /etc/subgid
          echo ---
          sudo ls -l /etc/docker/
          sudo cat /etc/docker/daemon.json
          echo ---
          docker info
          echo ---
          docker image ls
          echo ---
          sudo systemctl status docker
        shell: bash

実行結果の表示

uid=1001(runner) gid=123(docker) groups=123(docker),4(adm),101(systemd-journal)
---
-rw-r--r-- 1 root root 45 Dec  1 11:22 /etc/subgid
-rw-r--r-- 1 root root 45 Dec  1 11:22 /etc/subuid
---
runneradmin:100000:65536
runner:165536:65536
---
runneradmin:100000:65536
runner:165536:65536
---
total 8
-rw-r--r-- 1 root root  83 Dec  1 11:24 daemon.json
-rw------- 1 root root 244 Nov 27 21:13 key.json
{ "exec-opts": ["native.cgroupdriver=cgroupfs"], "cgroup-parent": "/actions_job" }
---
Client:
 Context:    default
 Debug Mode: false
 Plugins:
  buildx: Docker Buildx (Docker Inc., 0.9.1+azure-2)
  compose: Docker Compose (Docker Inc., 2.12.2+azure-1)

Server:
 Containers: 0
  Running: 0
  Paused: 0
  Stopped: 0
 Images: 20
 Server Version: 20.10.21+azure-1
 Storage Driver: overlay2
debian           11                                 c31f65dd4cc9   3 weeks ago     124MB
node             14-alpine                          87112681acad   3 weeks ago     121MB
node             16-alpine                          c4ee3c9d7bc1   3 weeks ago     116MB
node             18-alpine                          0fa08f92e64b   3 weeks ago     167MB
alpine           3.16                               bfe296a52501   3 weeks ago     5.54MB
moby/buildkit    latest                             383075513bdc   3 weeks ago     142MB
ubuntu           22.04                              a8780b506fa4   4 weeks ago     77.8MB
ubuntu           20.04                              680e5dfb52c7   6 weeks ago     72.8MB
ubuntu           18.04                              71eaf13299f4   6 weeks ago     63.1MB
alpine           3.14                               dd53f409bf0b   3 months ago    5.6MB
alpine           3.15                               c4fc93816858   3 months ago    5.58MB
alpine           3.10                               e7b300aee9f9   20 months ago   5.58MB
---
● docker.service - Docker Application Container Engine
     Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
     Active: active (running) since Tue 2022-12-06 06:22:24 UTC; 2min 0s ago
TriggeredBy: ● docker.socket
       Docs: https://docs.docker.com
   Main PID: 884 (dockerd)
      Tasks: 10
     Memory: 115.4M
        CPU: 1.018s
     CGroup: /system.slice/docker.service
             └─884 /usr/bin/dockerd -H fd:// --containerd /var/run/containerd/containerd.sock

Dec 06 06:22:23 fv-az254-348 dockerd[884]: time="2022-12-06T06:22:23.281340587Z" level=info msg="[graphdriver] using prior storage driver: overlay2"
Dec 06 06:22:23 fv-az254-348 dockerd[884]: time="2022-12-06T06:22:23.531484705Z" level=info msg="Loading containers: start."
Dec 06 06:22:23 fv-az254-348 dockerd[884]: time="2022-12-06T06:22:23.980471445Z" level=info msg="Default bridge (docker0) is assigned with an IP address 172.17.0.0/16. Daemon option --bip can be used to set a preferred IP address"
Dec 06 06:22:24 fv-az254-348 dockerd[884]: time="2022-12-06T06:22:24.096312865Z" level=info msg="Loading containers: done."
Dec 06 06:22:24 fv-az254-348 dockerd[884]: time="2022-12-06T06:22:24.251196762Z" level=warning msg="Not using native diff for overlay2, this may cause degraded performance for building images: kernel has CONFIG_OVERLAY_FS_REDIRECT_DIR enabled" storage-driver=overlay2
Dec 06 06:22:24 fv-az254-348 dockerd[884]: time="2022-12-06T06:22:24.251684467Z" level=info msg="Docker daemon" commit=3056208812eb5e792fa99736c9167d1e10f4ab49 graphdriver(s)=overlay2 version=20.10.21+azure-1
Dec 06 06:22:24 fv-az254-348 dockerd[884]: time="2022-12-06T06:22:24.252345573Z" level=info msg="Daemon has completed initialization"
Dec 06 06:22:24 fv-az254-348 systemd[1]: Started Docker Application Container Engine.
Dec 06 06:22:24 fv-az254-348 dockerd[884]: time="2022-12-06T06:22:24.303794070Z" level=info msg="API listen on /run/docker.sock"
Dec 06 06:24:22 fv-az254-348 dockerd[884]: time="2022-12-06T06:24:22.145353585Z" level=info msg="Layer sha256:d3e084821e53ef189b21c70b25fbbfdf9fc2c5850e0d3aae71cc219fd4a40c93 cleaned up"

ユーザー名前空間を有効化するときの注意点

runner 上ではユーザー名前空間が有効化されていないので、何らかの方法で有効化することになります。有効化の方法は後述しますが、その前に少し注意点など。

先に挙げたようにホスト側から見るとコンテナ側の uid/gid が変更されるので、それに関連した副作用(権限の問題)が出てきます。

この再割り当てはコンテナに対して透過的です。しかし、コンテナが Docker ホスト上のリソースに対してアクセスを必要とするような場合は、状況によっては導入がいささか複雑になります。たとえばホスト上のファイルシステムの領域にバインド・マウントする方法では、システム・ユーザは書き込みができません。セキュリティの観点からは、これらの状況を避けるのがベストでしょう。

また、ホスト側では各種オブジェクトの管理方法(保存ディレクトリー)が変わるので、イメージのキャッシュなどが利用できなくなります。

これらの手順に従い、 userns-remap を無効化したら、有効化後に作成したリソースには一切できなくなります。(訳者注:userne-remap を有効化時、無効化時、 /var/lib/docker/ 以下の異なるディレクトリに Docker オブジェクトを保存します。そのため、有効化する前にあったコンテナやイメージはは有効化によって見えなくなりますし、無効化によっても有効化時のコンテナやイメージが見えなくなります)

その他、いくつか互換性に制限があるようです。

今回はジョブを動かすための隔離された環境なので影響は少なそうに思えますが、実際には Docker container action が動かないなどの問題が出てきます[2]。よって「必要なときだけ有効化する」などの対策を考えておいた方がよさそうです。

Ubuntu runner の Docker でユーザー名前空間を有効化/無効化

注意点も確認したので実際に有効化と無効化を試してみます。

runner 上の Docker 環境では前述のようにある程度の設定はされているので、あとは下記の手順で有効化すればよさそうです。

dockerd の開始時に --userns-remap フラグを有効化するか、以下の手順にある、デーモンが使う設定ファイル daemon.json の設定を変更できます。

今回は /etc/docker/daemon.json を書き換えて docker.service を再始動してみます。

まずは有効化してみます。

リスト 6-1 "userns-remap":"runner:docker" を指定し docker.service を再始動するステップ

      - name: enable
        run: |
          SW_JSON=""
          SW_JSON="$(sudo cat /etc/docker/daemon.json | jq '.+{"userns-remap":"runner:docker"}')"
          echo "${SW_JSON}" | sudo bash -c 'cat -- > /etc/docker/daemon.json'
          sudo cat /etc/docker/daemon.json
          sudo systemctl restart docker || sudo journalctl -xeu docker.service
          echo ---
          docker info
          echo ---
          docker image ls
          echo ---
          sudo systemctl status docker
        shell: bash

図 6-1 実行すると Security Options に userns が追加される(長いので省略しています)

<snip...>

 Security Options:
  apparmor
  seccomp
   Profile: default
  userns
  cgroupns

<snip...>

(もう少し苦労するかと予想していたのですが)有効化できました。無効化は逆に定義を戻して再始動すれば実施できます。

リスト 6-2 "userns-remap":"runner:docker" を削除し docker.service を再始動するステップ

      - name: disable
        run: |
          SW_JSON=""
          SW_JSON="$(sudo cat /etc/docker/daemon.json | jq 'del(."'"userns-remap"'")')"
          echo "${SW_JSON}" | sudo bash -c 'cat -- > /etc/docker/daemon.json'
          sudo cat /etc/docker/daemon.json
          sudo systemctl restart docker || sudo journalctl -xeu docker.service
          echo ---
          docker info
          echo ---
          docker image ls
          echo ---
          sudo systemctl status docker
        shell: bash

図 6-2 実行すると Security Options から userns が外れる(長いので省略しています)

<snip...>

 Security Options:
  apparmor
  seccomp
   Profile: default
  cgroupns

<snip...>

なお、有効化と無効化を実施したあとに Docker container action を動かしてみましたが、想定した通りに動作することも確認してあります。

分離されたコンテナから runner (ホスト側)へファイルを書き込む

分離されたコンテナからホスト側へファイルを書き込む場合、書き出し先に権限を設定する必要があります。このときホスト側から見える(割り当てられる) uid/gid の番号が必要になります。

割り当ては下記のような法則になります(今回の設定では 165536 から割り当てられる)。

UID 231072 は名前空間内(この例では、コンテナ内のことです)では UID が 0root )として割り当てられます。 231073 は UID 1 として割り当てられ、以降も同様です。

これにあわせて「コンテナの guest(405:100)ユーザー用にホスト側ディレクトリーのオーナーを変更」してから書き出してみます。

リスト 7-1 コンテナを実行し runner 側へファイルを書き出すステップ

      - name: run
        run: |
          mkdir chk
          # 165941 = 165536 + 405
          # 165636 = 165536 + 100
          # 405(guest)
          # 100(users)
          sudo chown "165941:165636" chk
          docker run -u guest -v "${PWD}:/mnt" --rm alpine:latest \
            ash -c 'id && echo "hey taishou" > /mnt/chk/hey.txt'
          ls -l chk
          cat chk/hey.txt
        shell: bash

図 7-1 書き出した結果

uid=405(guest) gid=100(users) groups=100(users)
total 12
drwxr-xr-x 2 165941 165636 4096 Dec  4 11:26 .
drwxr-xr-x 5 runner docker 4096 Dec  4 11:26 ..
-rw-r--r-- 1 165941 165636   12 Dec  4 11:26 hey.txt
hey taishou

コンテナから guest(405:100) ユーザーでファイルを書き出すと 165941:165636 が操作してる状態になることが確認できました。

この例では 405:100 のユーザーで試したので id の計算が面倒ですが、「コンテナの中では root (0:0) でもいいかな」という場合はもう少し手軽に使えるかと思います[3]

考慮点

ユーザー名前空間をこのような目的で有効化していもよいのか?

ユーザー名前空間は権限昇格に対応するのが主な目的と思われるので、別の目的で有効化していると本来の用途で利用したいときにやりにくくなるかもしれません。

runner 更新時の互換性

runner 上のユーザー名前空間利用については、とくにドキュメント化されたものではないので runner 更新時に動作しなくなる可能性はあります。また、今回は /etc/subuid などは既に設定されている値を流用していますが、これが変更される可能性もあります。

実際に利用する場合は、runner はイメージのタグでピン止めしておくか、ubuntu-latest を使うときはできるだけ影響を受けないように考慮しておく方がよさそうです。

GitHub としては自動アップデートされる ubuntu-latest が推奨のようですが対応時期を GitHub に決められてしまうので仕事で使用している場合などいきなり動かなくなると困る場合はバージョンを固定しておくのが推奨です

おわりに

Ubuntu runer の Docker でユーザー名前空間を利用し uid/gid のコンフリクトを軽減させつつ、コンテナからファイルを書き出してみました。

機能をピンポイントで有効化させる必要があるなど面倒なところもありますが、利用できることは確認できたので機会があれば使っていこうかと思います。

脚注
  1. Ubuntu runner としては gid のコンフリクトは(破壊的な)変更とみていぽかったので、変更点はテストリポジトリを作って調べました。 ↩︎

  2. Docker container action を動かすには runner がキャッシュしているイメージが必要なのですが、ユーザー名前空間を有効化すると既存のキャッシュが使えないので動かなくなります。また、キャッシュを移動してもやはり権限関連で動かないと思われます(Docker 用のソケットをマウントしているのでホスト側の root 権限が必要そう)。 ↩︎

  3. 実はコンテナの root をホスト側の 1000 などにマッピングする方法もあるようですが「ホスト側では権限がないユーザーにマッピングさせる」で考えるとあまりよくはないのかなと。

    ↩︎
GitHubで編集を提案

Discussion