🪤

Pythonコンテナで非rootユーザーを使おうとしてハマった話

2024/01/23に公開

背景

こちらの記事で紹介したStreamlitの開発コンテナテンプレートを作るときに、
コンテナ内のユーザーを非rootにするために、DockerFile作成でかなりハマったので、その内容をまとめました。

結論

上記の記事で紹介したリポジトリのDockerFileが結論ですが、
コンテナ内のユーザーを非rootにする場合、

  • 作成したユーザーのディレクトリでPATHを通す
  • pip installの際に--userオプションをつける

の2点を行う必要があるようです。

https://github.com/0msys/streamlit-template-devcontainer/blob/main/Dockerfile


経緯

なぜ非rootユーザーを使うのか

ググると色々な記事が出てくるのですが、
大きく分けて2つの理由があるようです。

  1. セキュリティ
    • rootユーザーでコンテナを実行すると、コンテナ内でroot権限が使えてしまう
  2. ファイルの所有権
    • rootユーザーでコンテナを実行すると、コンテナ内で作成したファイルの所有権がrootになってしまう
      • そのため、ホスト側でファイルを編集する際に、root権限が必要になってしまう

1はまあ個人開発であれば、そこまで気にしなくてもいいかなと思いますが、
2はGitが動かないなど結構実害があるので、非rootユーザーを使うことにしました。

devcontainerとdocker compose

今回テンプレートを作成するにあたり、
開発時はdevcontainerを使い、完成したアプリはdocker composeで動かすことができるようにしようと考えました。

理由としては、

開発時はdevcontainerを使うことでVS Codeの拡張機能も同時に管理したいですし、
開発に必要なライブラリがインストールできるようにsudo権限もつけたいです。
ファイルの変更をホスト側に反映もさせたいので、mountも必要です。

逆に完成したアプリは、
devcontainerの拡張機能は不要ですし、
sudo権限も必要ないですし、
mountも不要です。

ということで、開発時はdevcontainerを使い、完成したアプリはdocker composeで動かすことにしました。

これを実現するために、DockerFileのマルチステージビルドを使いました。

DockerFileのマルチステージビルド

DockerFileのマルチステージビルドは、
複数のDockerFileを1つのDockerFileにまとめることができる機能です。

今回は、
1つ目のステージで開発時・完成時共通の処理を行い、
2つ目のステージで開発時のみ、完成時のみの処理をそれぞれ行うようにしました。

Dockerfile
# 1つ目のステージ(開発時・完成時共通)
FROM python:latest as base

# ユーザー作成
ARG USERNAME=pyuser
ARG USER_UID=1000
ARG USER_GID=$USER_UID

RUN groupadd --gid $USER_GID $USERNAME \
    && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME

USER $USERNAME

# ディレクトリ作成
WORKDIR /workspace

# pipとsetuptoolsのアップデート
RUN pip install --upgrade pip && \
    pip install --upgrade setuptools


# 2つ目のステージ(開発時のみ)
FROM base as dev

USER root

# sudoのインストール
RUN apt-get update \
    && apt-get install -y sudo \
    && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
    && chmod 0440 /etc/sudoers.d/$USERNAME

USER $USERNAME

CMD [ "bash" ]

# 2つ目のステージ(完成時のみ)
FROM base as prd

USER $USERNAME

# ディレクトリの内容をコピー
COPY . /workspace

# ライブラリのインストール
RUN pip install -r requirements.txt

# アプリの実行
CMD ["streamlit", "run", "src/Home.py", "--server.port", "8501"]

ハマった点

上記のDockerFileを使用して、devcontainerとdocker composeの両方で動作確認をしたところ、
devcontainerではコンテナ起動後にpip install -r requirements.txtを実行し、streamlit run src/Home.pyで問題なく動作しましたが、
docker composeでは以下のエラーが発生しました。

Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "streamlit": executable file not found in $PATH: unknown

エラーの内容を見るとstreamlitが見つからないということなので、pip installが失敗していることはわかるのですが、
devcontainerでは問題なく動作しているので、なぜdocker composeでは失敗するのか全然わかりませんでした。

色々と調べてみたもののあまり情報がなく、仕方なくChatGPTに質問してみたところ、
pip installはシステム全体にインストールされるため、非rootユーザーに対してインストールするときは--userオプションをつける必要があるとのことでした。

なるほどと思い下記のようにDockerFileを修正して、再実行しました。

Dockerfile
< RUN pip install --upgrade pip && \
<    pip install --upgrade setuptools
> RUN pip install --user --upgrade pip && \
>    pip install --user --upgrade setuptools
---
< RUN pip install -r requirements.txt
> RUN pip install --user -r requirements.txt

ところが全く状況は改善せず、同様のエラーが発生しました。
どういうことなんだとChatGPTに再度質問してみたところ、
--userオプションをつけた場合、/home/$USERNAME/.localにPATHを通す必要があるから、
DockerFileに以下の内容を追加する必要があるとのことでした。

Dockerfile
+ ENV PATH=/home/$USERNAME/.local/bin:$PATH

確かにuserのディレクトリにインストールされているので、PATHを通さないと見つからないのは納得です。
ということで、言われたとおりにDockerFileを修正して、再実行しました。

・・・が、やはり同様のエラーが発生しました。。。

どういうことだと再度ChatGPTを問い詰めたところ、
PATHを通すなら以下の内容も書かないとダメだよって言われました。。。

Dockerfile
+ ENV PATH=$PYTHONUSERBASE/bin:$PATH

PATHを通すんだから言われてみれば当たり前だよなと思いましたが、
なぜ最初から教えてくれなかったのか。。。

ここまでやってやっと動作しました。


まとめ

結果的にちゃんと動くものができたので良かったのですが、
結局なぜdevcontainerでは最初からうまくいったのかは謎のままです。。。

おそらく、コンテナ作成時にRUNpip installを実行するのと、コンテナ作成後にコンテナ内でpip installを実行するのとでは、何らかの違いがあるのだと思います。

また、devcontainerではPATHを通さなくても動作していたので、そちらも謎です。
devcontainer周りでは、VS Codeが結構黒魔術で色々やってくれていたりするので(compose.ymlでPortのフォワーディング設定をしてなくても、アプリが起動したら勝手にやってくれたりする)、今回もその類かもしれません。

「コンテナは非rootユーザー使えよ」って記事は結構出てくるのですが、
具体的にどうやって実現するのかはあまり出てこないので、
今回の記事やリポジトリが少しでも誰かの役に立てれば幸いです。

Discussion