🧑‍🔧

再現性を担保するAI分析環境:Docker+pyenv+Poetry導入事例と注意点

に公開

はじめに

AI/MLプロジェクトがどんどん複雑になる中で、再現性やポータビリティをちゃんと確保するためのMLOpsが大事だよね、という話はよく聞くようになりました。実際、その手段としてDockerで環境をコンテナ化したり、Poetryのようなモダンなパッケージ管理ツールを使ったりするのは、いまやほぼ当たり前になりつつあります。

MTECのAI研究プロジェクトでも、この流れを受けて「分析環境の再現性が担保されていること」が急務になっていました。ちょうどLLM系のOSSでPoetry採用の事例が増えてきたこともあり、DockerPoetryを軸にした新しい標準環境を作るプロジェクトを立ち上げました。

狙いはシンプルで、「バージョン管理されていないAMI」「一部の人しか知らない謎のセットアップ手順から卒業したい」 というところにあります。

この記事では、実際に構築した環境についての解説をしつつ、構築中でハマった罠と改善方法について紹介していきます。

分析環境について

DockerとPoetryを組み合わせた分析環境の構築事例は意外と少なく、ずいぶん四苦八苦しましたが、初期バージョンを作った際の構成についてまずは共有したいと思います。
環境の構築にあたり、まずはチームメンバーへのヒアリングを行い、分析環境に求める要件を以下のように定義しました。

【分析環境の要件】

  • インフラ: AWS EC2インスタンス(Linuxベース)上で動作し、CPU/GPUインスタンス双方に対応する。
  • インターフェース: JupyterLabをメインインターフェースとし、ブラウザからアクセス可能にする。
  • パッケージ管理: ライブラリ管理にはPoetryを採用し、依存関係を管理する。
  • Pythonバージョン: プロジェクトごとにユーザーが任意のPythonバージョンを利用可能にする。
  • カーネル切り替え: JupyterLab上で、プロジェクト(Poetry環境)ごとにカーネルを容易に切り替えられるようにする。
  • 運用効率の向上: インスタンス起動と同時にコンテナが立ち上がり、即座に分析を開始できる(シームレスな運用)。

特に「JupyterLab上でプロジェクトごとのカーネルを動的に切り替える」という要件の実現に最も苦労しました。通常、Jupyterはグローバルな環境を見にいきますが、これをPoetryの仮想環境ごとに認識させる必要がありまして...はっきりとやり方がわからなかったのでAIと協力しながら進めた記憶があります。


分析環境の使い方・構築フロー

1. Dockerのインストール(EC2インスタンス)

まず、ベースとなるEC2インスタンス(Amazon Linux 2023)にDockerを導入します。
※GPUインスタンスを利用する場合、AWS公式AMI「Deep Learning OSS Nvidia Driver AMI GPU PyTorch 2.6 (Amazon Linux 2023)」を使用すればドライバ周りの設定が済んでいるため、この手順はスキップ可能です。

# Dockerのインストールと起動
sudo yum install -y docker
sudo systemctl start docker

# ec2-userでもsudoなしでdockerコマンドを叩けるように設定
sudo usermod -a -G docker ec2-user
sudo systemctl enable docker

# グループ設定を反映させるため、一度ログアウトして再接続する
exit

2. Dockerネットワークの作成

コンテナのブリッジネットワークを作成しておきます。
※ここで作成するネットワーク設定が、後に解説する「罠」の1つになります。

# ブリッジネットワークの作成
docker network create my_bridge_network

3. Dockerイメージのビルド

後述するDockerfile等の資材を配置したディレクトリでイメージをビルドします。-tは、タグのオプションです。
(ここでは便宜上、イメージ名を my_jupyter_lab とします)

docker build . -t my_jupyter_lab

https://docs.docker.jp/engine/reference/commandline/build.html

4. Dockerコンテナの起動

初回のみ以下のコマンドを実行します。--restart alwaysを付与することで、EC2インスタンスの再起動時にも自動的にコンテナが立ち上がるようになります。

CPUインスタンスの場合:

# 注: このコマンドには後述する「罠」が含まれています(詳細は記事後半で)
docker run -it --network my_bridge_network -v $(pwd):/app -p 8888:8888 -p 8501:8501 --restart always my_jupyter_lab

GPUインスタンスの場合:
--gpus all オプションを追加します。

docker run -it --gpus all --network my_bridge_network -v $(pwd):/app -p 8888:8888 -p 8501:8501 --restart always my_jupyter_lab

https://docs.docker.jp/engine/reference/commandline/run.html

コンテナ起動後は、ブラウザから http://{EC2のIPアドレス}:8888でJupyterLabにアクセス可能です(ログインにはDockerfileで設定したTokenを使用)。以降の操作は、基本的にJupyterLab内のターミナルで行います。

Tips: JupyterLabのターミナルはデフォルトプロンプトが見づらいため、起動後に bash コマンドを打つとディレクトリ階層が見やすくなります。


Poetryを用いたプロジェクト管理フロー

本環境では、Poetryで作成した仮想環境(.venv)をJupyterのカーネルとして認識させる仕組みを取り入れています。Poetryそのものについては、下記の公式ドキュメントで確認していただけると幸いです。
https://python-poetry.org/docs/

新規プロジェクトの作成

コンテナ内のターミナルで以下の手順を実行します。

  1. Poetryプロジェクトを作成
  2. ipykernelを開発用依存として追加
  3. カーネル登録スクリプトを実行
# プロジェクト作成
poetry new --src {project_name}
cd {project_name}

# 依存関係のインストールとipykernelの追加
poetry install
poetry add -D ipykernel

# 【重要】JupyterLabにこのプロジェクトのカーネルを認識させる 
# 内容は後述
bash /app/ipykernel-run.sh

既存プロジェクトのセットアップ

既存のソースコードがある場合の手順です。

cd {project_name}
poetry init
poetry install
poetry add -D ipykernel
bash /app/ipykernel-run.sh

bash /app/ipykernel-run.shを実行した直後から、JupyterLabのランチャー画面やカーネル選択画面に、そのプロジェクト名のカーネルが表示されるようになります。

プロジェクトごとのPythonバージョンの切り替え

Poetryとpyenvを連携させることで、プロジェクトごとに異なるPythonバージョンを利用可能です。
例としてPython 3.12.2を使用する場合の手順を示します。

cd {project_name}

# 1. pyenvで対象バージョンをインストール
pyenv install 3.12.2

# 2. ローカルのバージョン指定
unset PYENV_VERSION
pyenv local 3.12.2

# 3. バージョン確認(システムデフォルトの3.10.0でないことを確認)
pyenv version

# 4. Poetry環境の再構築
poetry env remove python  # 既存の環境がある場合削除
poetry env use 3.12.2     # 3.12.2を指定して仮想環境作成
poetry install

# 5. 確認
poetry run python --version

技術的な詳細(Dockerfile・スクリプト解説)

本環境の設定ファイル群を解説します。設定ファイル群はカレントディレクトリにそのまま直置きすれば大丈夫です。

Dockerfile

Ubuntuをベースに、日本語環境、AWS CLI、pyenv、Poetry、JupyterLabをセットアップします。ENTRYPOINTとして、JupyterLabの設定用シェルスクリプトを指定しているのですが、ここはCMDだとうまく動かなかった気がします(参考までに)。
補足:apt-get cleanだけだとキャッシュファイルが十分に削除されない場合があるので、&& rm -rf /var/lib/apt/lists/*を追加すると良いそうです。
https://dev.classmethod.jp/articles/apt-get-magic-spell-in-docker/

Dockerfile
# ベースイメージの指定
# CPUの場合
FROM ubuntu:24.04
# GPUの場合
# FROM nvidia/cuda:12.6.0-devel-ubuntu24.04

# 日本語環境を整える
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm

# 必要なパッケージのインストール
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
    curl \
    git \
    build-essential \
    libssl-dev \
    zlib1g-dev \
    libbz2-dev \
    libreadline-dev \
    libsqlite3-dev \
    libffi-dev \
    liblzma-dev \
    python3-pip \
    unzip \
    nano \
    locales \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# 日本語ロケールの生成
RUN locale-gen ja_JP.UTF-8

# 作業ディレクトリの設定
WORKDIR /app

# awscliのインストール
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \
    unzip awscliv2.zip && \
    ./aws/install && \
    rm -rf awscliv2.zip aws

# pyenvのインストール
RUN curl https://pyenv.run | bash

# 環境変数を設定
ENV PATH="/root/.pyenv/bin:/root/.pyenv/shims:${PATH}"
RUN echo 'export PYENV_ROOT="/root/.pyenv"' >> /root/.bashrc \
    && echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> /root/.bashrc \
    && echo 'eval "$(pyenv init --path)"' >> /root/.bashrc \
    && echo 'eval "$(pyenv init -)"' >> /root/.bashrc \
    && echo 'eval "$(pyenv virtualenv-init -)"' >> /root/.bashrc

# Pythonのインストール
RUN /bin/bash -c "source /root/.bashrc && pyenv install 3.10.0 && pyenv global 3.10.0 && \
    pip install --upgrade pip setuptools"

# poetryのインストール
RUN curl -sSL https://install.python-poetry.org | python3 - \
    && export PATH="/root/.local/bin:$PATH" \
    && poetry config --list \
    && poetry config virtualenvs.in-project true

# PATH環境変数の更新
ENV PATH="/root/.local/bin:${PATH}"

# 環境変数の設定
ENV JUPYTER_IP="0.0.0.0"
# jupyter install
RUN python -m pip install jupyterlab

# Jupyter Notebookの設定
RUN mkdir -p /root/.jupyter && \
    echo "c.NotebookApp.open_browser = False" >> /root/.jupyter/jupyter_notebook_config.py && \
    echo "c.NotebookApp.port = 8888" >> /root/.jupyter/jupyter_notebook_config.py && \
    echo "c.NotebookApp.token = 'change-me'" >> /root/.jupyter/jupyter_notebook_config.py && \
    echo "c.NotebookApp.ip = '0.0.0.0'" >> /root/.jupyter/jupyter_notebook_config.py && \
    echo "c.NotebookApp.notebook_dir = '/app'" >> /root/.jupyter/jupyter_notebook_config.py

# ポートを公開
EXPOSE 8888 8501

# エントリーポイントの設定
ENTRYPOINT ["/bin/bash", "/app/jupyter.sh"]

起動スクリプト (jupyter.sh)

コンテナ起動時に毎回実行されるスクリプトです。
環境変数のロードに加え、後述するipykernel-run.shを呼び出すことで、コンテナ再起動時に既存プロジェクトのカーネル登録状態を自動復旧させます。

jupyter.sh
#!/bin/bash

# 環境変数の設定
export PATH="/root/.pyenv/bin:/root/.pyenv/shims:/root/.local/bin:${PATH}"
source /root/.bashrc

# pyenvとpoetryの初期化
eval "$(pyenv init --path)"
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
export PATH="/root/.local/bin:$PATH"

# IPythonカーネルのインストール
chmod +x /app/ipykernel.sh
/app/ipykernel.sh

# pyenvの割り当て
pyenv local 3.10.0
# JupyterLabの起動
jupyter lab --ip=${JUPYTER_IP} --port=8888 --no-browser --NotebookApp.token='' --allow-root #&

# 追加で実行したいコマンドがあればここに追記

カーネル自動登録スクリプト (ipykernel-run.sh)

このスクリプトが本環境の核心部分です。
/app配下の全ディレクトリ(各Poetryプロジェクト)をスキャンし、.venvが存在する場合、その仮想環境内のPythonを使ってipykernelをインストール&登録します。
これにより、「Poetryで環境を作るだけで、JupyterLabからカーネルとして選択可能になる」 という要件を実現しています。

ipykernel-run.sh
#!/bin/bash

# --- 変数定義 ---

# 各プロジェクトが格納されている親ディレクトリのパス
PROJECT_DIR=/app
# Jupyterカーネルがインストールされているディレクトリのパス
# pyenv で管理している Python 3.10.0 のユーザーカーネルディレクトリを指しています
INSTALLED_DIR=/root/.pyenv/versions/3.10.0/share/jupyter/kernels


# --- 準備 ---

# PROJECT_DIR 内のディレクトリ/ファイル名の一覧を配列'projects'に格納します
projects=($(ls ${PROJECT_DIR}))
# INSTALLED_DIR 内の既存カーネル名(ディレクトリ名)の一覧を配列'installs'に格納します
installs=($(ls ${INSTALLED_DIR}))


# --- 既存カーネルの全削除 ---

# 一旦全部消す: 登録済みのJupyterカーネルを一度すべて削除します。
# これにより、削除されたプロジェクトのカーネルなどが残るのを防ぎ、環境をクリーンな状態にします。
for install in ${installs[@]}; do
    # -rf オプションで、ディレクトリとその中身を強制的に再帰削除します
    rm -rf "${INSTALLED_DIR}/${install}"
done


# --- 各プロジェクトのカーネルを再登録 ---

# 'projects' 配列内の各プロジェクトについてループ処理を実行します
for project in ${projects[@]}; do
    # PROJECT_DIR/${project} がディレクトリである場合のみ処理を実行します
    # (ファイルなどが誤って処理されるのを防ぐため)
    if [ -d "${PROJECT_DIR}/${project}" ]; then
        # プロジェクト内の仮想環境(.venv)にあるPythonを使い、ipykernelをJupyterに登録します
        #   -m ipykernel install : ipykernelモジュールを使ってカーネルをインストールするコマンド
        #   --name "${project}" : カーネルの表示名をプロジェクト名に設定します
        #   --user              : ユーザー個別の領域(このスクリプトではINSTALLED_DIR)にカーネルをインストールします
        ${PROJECT_DIR}/${project}/.venv/bin/python -m ipykernel install --name "${project}" --user
    fi
done

罠にハマった部分(トラブルシューティング)

半年前にこの分析環境をリリースし、しばらくは順調に使ってもらっていました。しかしながら、運用を続ける中で「不具合なし」とはいかず……。
チームメンバーから報告があった主な不具合は以下の2点です。

  1. コンテナにうまく接続できない(再起動ループに陥る)
  2. 特定の環境からアクセスできない(主にAmazon Workspaces利用者)

調査の結果、原因はどちらもDockerのネットワークやボリュームマウントに関する比較的初歩的なものでした。それぞれの事象について、発生した不具合から対策までを解説します。


コンテナにうまく接続できない

【不具合】

「コンテナに繋がらなくなった」という報告を受けサーバーを確認したところ、コンテナ自体は存在しているものの、起動と停止を短時間で繰り返す「再起動ループ(Restart Loop)」の状態に陥っていました。

【原因】

Dockerコンテナの起動コマンドにおいて、ボリュームマウントのパス指定に$(pwd)を使用していたことが原因でした。

当初、コンテナ起動コマンドには--restart alwaysオプションを付与し、サーバー再起動時やプロセスダウン時に自動復旧するように設定していました。また、ボリュームマウントにはカレントディレクトリを展開する$(pwd)を使用していました。

# 問題のあったコマンド例
docker run ... -v $(pwd):/app ... --restart always ...

手動でコマンドを叩いた際は、適切なディレクトリ(例:/home/ec2-user/pyproject)にいたため問題ありませんでした。しかし、EC2インスタンス自体の再起動時(Dockerデーモンによる自動起動時) には、コマンド実行時のカレントディレクトリという概念が存在しません(あるいはルートディレクトリ等になります)。

その結果、以下のフローで不具合が発生しました。

  1. EC2再起動後、Dockerデーモンがコンテナを自動起動しようとする。
  2. しかし、オリジナルの実行時の$(pwd)のパス解決が意図通りに行われず、空のディレクトリや不正なパスが/appにマウントされる。
  3. /appにあるはずのjupyter.shや設定ファイルが見つからず、JupyterLabの起動プロセスがエラーで終了する。
  4. --restart alwaysの設定により、Dockerが即座にコンテナを再起動させる。
  5. 3〜4が無限に繰り返される

【対策】

環境変数や実行場所に依存する$(pwd)の使用をやめ、絶対パスで明記するように変更しました。

# 修正前:修正前:実行場所や再起動時のコンテキストによってマウント先が変わってしまう
- docker run -it --network my_bridge_network -v $(pwd):/app -p 8888:8888 -p 8501:8501 --restart always my_jupyter_lab

# 修正後:絶対パス指定により、いつどこで起動されても確実にファイルをマウントする
+ docker run -it --network my_bridge_network -v /home/ec2-user/pyproject:/app -p 8888:8888 -p 8501:8501 --restart always my_jupyter_lab

なお、GPUインスタンスを使用している場合は、以下のように--gpus allオプションも忘れずに付与します。

docker run -it --gpus all --network my_bridge_network -v /home/ec2-user/pyproject:/app -p 8888:8888 -p 8501:8501 --restart always my_jupyter_lab

特定の環境からアクセスできない(主にAmazon Workspaces)

【不具合】

「オフィスや在宅用PCからは繋がるが、会社のAmazon Workspaces(VDI)環境からのみ分析環境にアクセスできない」という事象が発生しました。サーバーのセキュリティグループ(SG)ではアクセスを許可しているにも関わらず、通信がタイムアウトしてしまう状態でした。

【原因】

Dockerが作成したカスタムブリッジネットワーク(my_bridge_network)のIPアドレス帯が、Amazon Workspacesが使用しているIPアドレス帯と競合していたことが原因でした。

Dockerでdocker network createをオプションなしで実行すると、デフォルトで172.17.0.0/16から順にプライベートIPアドレス帯が割り当てられます。

今回のケースでは、偶然にも以下の競合が発生していました。

  • Dockerネットワーク: 自動割り当てで172.x.0.0/16を使用。
  • Amazon Workspaces: クライアント側のネットワークとして172.x.0.0/16を使用(AWSのVPC設計でよく使われる帯域)。

これにより、サーバー側(あるいは経路上のルーティング)で、Workspacesからの戻りパケットを「Dockerコンテナ宛の通信」と誤認してしまい、正しくクライアントにレスポンスが返らない(パケットが迷子になる)状態が発生していました。

【対策】

Dockerネットワーク作成時に--subnetオプションを使用し、社内ネットワークやAWS環境で使用していないIPレンジ(よくある例:192.168.100.0/24など)を明示的に指定しました。

# 修正前:デフォルト設定(172.x.x.x)が割り当てられ、既存NWと競合リスクがある
- docker network create my_bridge_network 

# 修正後:サブネットを明示的に指定し、競合を回避する
# (例として 192.168.100.0/24 を指定していますが、環境に合わせて変更してください)
+ docker network create --subnet=192.168.100.0/24 my_bridge_network

このように、社内インフラやVPCと連携するDocker環境を構築する際は、IPアドレスの自動割り当てに頼らず、ネットワーク設計に基づいたサブネット指定を行うことが重要です。

まとめ

今回は、DockerとPoetryを組み合わせた分析環境の構築事例を紹介しました。
記事の内容を簡単にまとめると、下記の3点になります。

  • [Docker, Poetry, pyenv] を組み合わせることで、プロジェクトごとに独立したクリーンな環境を実現。
  • カーネル登録の自動化スクリプト により、JupyterLab上での環境切り替えをシームレスに。
  • 絶対パス指定サブネット指定 は、忘れずに。

Dockerfileの記述やDocker-Composeの導入など、まだ最適化の余地はありますが、一旦はチーム内での分析環境の標準化ができました。
今後は、下記の記事などを参考にしつつ、Poetryよりも高速と話題の uv を用いた環境構築にも挑戦したいと考えています。
https://zenn.dev/mkj/articles/3aaa36d6f35c08

MTEC Tech Blog

Discussion