ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Dev Containers で Ruby や Python の実行環境をサクッと作れるようにしておく

みなさんこんにちは、ユニファの赤沼です。 この記事は Unifa Advent Calendar 2023 の10日目の記事です。

adventar.org

最近はポジション的にプロダクションのコードを書くことはほとんどなくなってしまいましたが、ChatGPTなどLLM界隈の動きもあって手元でコードを書いて試してみたいという機会が増えてきました。

Rubyのコードが動けばよいだけならローカルのターミナルで直接試すのでも良いんですが、端末固有の環境に影響を受けたり、ライブラリのバージョンの依存関係の問題などもあり、まっさらな環境を手軽に用意したいということで、最近(今更感もありますが) Dev Containers を使い始めたので Ruby や Python の実行環境の構築について書いてみたいと思います。

Dev Containers について詳しくはこちら。

code.visualstudio.com

Extention のインストール

Dev Containers とはなんぞやというそもそものところは上記の公式ページなどに譲るとして、環境を作っていきたいと思います。

まずは Dev Containers の Extention を VSCode にインストールします。

marketplace.visualstudio.com

インストールが完了すると左下の隅に青く Extention による表示が追加され、ここから Dev Containers の機能を実行できたりします。

必要なファイル

Dev Containers による環境を構築するのに必要なファイルは下記の通りです。

devcontainers.jsonDockerfile だけでも良いのですが、例えば Rails のアプリ開発などでアプリとは別にDB用のコンテナなども構成することを考え、 docker-compose.yml も使う形にしています。これらをプロジェクトルートの .devcontainer ディレクトリに配置します。それぞれのファイルの内容は後述します。

devcontainer.json

まず devcontainer.json の内容は下記のようにしています。

{
    "name": "${localWorkspaceFolderBasename}",
    "dockerComposeFile": "docker-compose.yml",
    "service": "app",
    "workspaceFolder": "/workspace",
    "features": {
        "ghcr.io/devcontainers/features/common-utils:2": {
            "installZsh": "true",
            "username": "vscode",
            "userUid": "1000",
            "userGid": "1000",
            "upgradePackages": "true"
        },
        "ghcr.io/devcontainers/features/aws-cli:1": {
            "version": "latest"
        }
    },
    "customizations": {
        "vscode": {
            "extensions": [
                "rebornix.Ruby",
                "ms-python.python",
                "ms-azuretools.vscode-docker",
                "github.copilot",
                "github.copilot-chat",
                "github.copilot-labs"
            ]
        }
    },
    "remoteEnv": {
        "AWS_CONFIG_FILE": "$HOME/.aws/config_stoken",
        "AWS_SHARED_CREDENTIALS_FILE": "$HOME/.aws/credentials_stoken"
    },
    "forwardPorts": [],
    "remoteUser": "vscode"
}

devcontainer.json の記述の仕方の詳細は下記のリファレンスを参照ください。

containers.dev

今回は "name": "${localWorkspaceFolderBasename}" とすることで .devcontainer ディレクトリの親ディレクトリの名前が環境の名前として使われるので、使用中に判別しやすくなります。

また、 features には様々な機能の設定を追加できます。どのような機能が追加可能かは下記から確認できます。

github.com

今回指定している common-utils は各種便利なツールを導入してくれるもので、コンテナ内でのユーザとして root ユーザ以外を設定したり、 wgetjq などのパッケージがインストールされます。

インストールされるパッケージなどの詳細は下記から確認できます。

github.com

AWS CLI も使うことが多いので併せて features でインストールされるように指定しています。

customizations.vscode.extentions ではインストールする VSCode の Extention を指定しています。すでに Extention がインストールされている環境では下記のように ~/.vscode-server/extensions でそのリストがみれますので、バージョン表記を除いた部分を指定します。

$ ls -l ~/.vscode-server/extensions
total 44
drwxr-xr-x  7 vscode vscode 4096 Dec  6 13:09 amazonwebservices.aws-toolkit-vscode-2.1.0
-rw-r--r--  1 vscode vscode 5394 Dec  6 13:09 extensions.json
drwxr-xr-x  5 vscode vscode 4096 Dec  6 13:09 github.copilot-1.140.0
drwxr-xr-x  5 vscode vscode 4096 Dec  6 13:09 github.copilot-chat-0.10.2
drwxr-xr-x  4 vscode vscode 4096 Dec  6 13:09 github.copilot-labs-0.17.1121
drwxr-xr-x  4 vscode vscode 4096 Dec  6 13:09 ms-azuretools.vscode-docker-1.28.0
drwxr-xr-x 13 vscode vscode 4096 Dec  6 13:09 ms-python.python-2023.20.0
drwxr-xr-x  4 vscode vscode 4096 Dec  6 13:09 ms-python.vscode-pylance-2023.11.10
drwxr-xr-x  7 vscode vscode 4096 Dec  6 13:09 rebornix.ruby-0.28.1
drwxr-xr-x  6 vscode vscode 4096 Dec  6 13:09 wingrunr21.vscode-ruby-0.28.0

Extention の画面右下の Identifier の表示からも確認できます。

Dockerfile

続いて Dockerfile を用意します。

今回は Ruby と Python が使える環境を用意したかったので、 Dev Containers のリポジトリに用意されている下記の内容をベースとしています。

github.com

ベースとしているイメージは Docker オフィシャルの Ruby 用イメージです。

github.com

これに Python の実行環境を追加するために、 Docker オフィシャルの Python 用イメージの Dockerfile の内容をほぼそのまま追記しています。

github.com

結果として Dockerfile の内容は下記のようにしています。

# [Choice] Ruby version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.2, 3.1, 3.0, 3-bookworm, 3.2-bookworm, 3.1-bookworm, 3-bullseye, 3.2-bullseye, 3.1-bullseye, 3.0-bullseye, 3-buster, 3.2-buster 3.1-buster, 3.0-buster
ARG VARIANT=3-bookworm
FROM ruby:${VARIANT}

RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
    # Remove imagemagick due to https://security-tracker.debian.org/tracker/CVE-2019-10131
    && apt-get purge -y imagemagick imagemagick-6-common 

# ensure local python is preferred over distribution python
ENV PATH /usr/local/bin:$PATH

# http://bugs.python.org/issue19846
# > At the moment, setting "LANG=C" on a Linux system *fundamentally breaks Python 3*, and that's not OK.
ENV LANG C.UTF-8

# runtime dependencies
RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
        libbluetooth-dev \
        tk-dev \
        uuid-dev \
    ; \
    rm -rf /var/lib/apt/lists/*

ENV GPG_KEY 7169605F62C751356D054A26A821E680E5FA6305
ENV PYTHON_VERSION 3.12.0

RUN set -eux; \
    \
    wget -O python.tar.xz "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz"; \
    wget -O python.tar.xz.asc "https://www.python.org/ftp/python/${PYTHON_VERSION%%[a-z]*}/Python-$PYTHON_VERSION.tar.xz.asc"; \
    GNUPGHOME="$(mktemp -d)"; export GNUPGHOME; \
    gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$GPG_KEY"; \
    gpg --batch --verify python.tar.xz.asc python.tar.xz; \
    gpgconf --kill all; \
    rm -rf "$GNUPGHOME" python.tar.xz.asc; \
    mkdir -p /usr/src/python; \
    tar --extract --directory /usr/src/python --strip-components=1 --file python.tar.xz; \
    rm python.tar.xz; \
    \
    cd /usr/src/python; \
    gnuArch="$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)"; \
    ./configure \
        --build="$gnuArch" \
        --enable-loadable-sqlite-extensions \
        --enable-optimizations \
        --enable-option-checking=fatal \
        --enable-shared \
        --with-lto \
        --with-system-expat \
        --without-ensurepip \
    ; \
    nproc="$(nproc)"; \
    EXTRA_CFLAGS="$(dpkg-buildflags --get CFLAGS)"; \
    LDFLAGS="$(dpkg-buildflags --get LDFLAGS)"; \
    make -j "$nproc" \
        "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \
        "LDFLAGS=${LDFLAGS:-}" \
        "PROFILE_TASK=${PROFILE_TASK:-}" \
    ; \
# https://github.com/docker-library/python/issues/784
# prevent accidental usage of a system installed libpython of the same version
    rm python; \
    make -j "$nproc" \
        "EXTRA_CFLAGS=${EXTRA_CFLAGS:-}" \
        "LDFLAGS=${LDFLAGS:--Wl},-rpath='\$\$ORIGIN/../lib'" \
        "PROFILE_TASK=${PROFILE_TASK:-}" \
        python \
    ; \
    make install; \
    \
# enable GDB to load debugging data: https://github.com/docker-library/python/pull/701
    bin="$(readlink -ve /usr/local/bin/python3)"; \
    dir="$(dirname "$bin")"; \
    mkdir -p "/usr/share/gdb/auto-load/$dir"; \
    cp -vL Tools/gdb/libpython.py "/usr/share/gdb/auto-load/$bin-gdb.py"; \
    \
    cd /; \
    rm -rf /usr/src/python; \
    \
    find /usr/local -depth \
        \( \
            \( -type d -a \( -name test -o -name tests -o -name idle_test \) \) \
            -o \( -type f -a \( -name '*.pyc' -o -name '*.pyo' -o -name 'libpython*.a' \) \) \
        \) -exec rm -rf '{}' + \
    ; \
    \
    ldconfig; \
    \
    python3 --version

# make some useful symlinks that are expected to exist ("/usr/local/bin/python" and friends)
RUN set -eux; \
    for src in idle3 pydoc3 python3 python3-config; do \
        dst="$(echo "$src" | tr -d 3)"; \
        [ -s "/usr/local/bin/$src" ]; \
        [ ! -e "/usr/local/bin/$dst" ]; \
        ln -svT "$src" "/usr/local/bin/$dst"; \
    done

# if this is called "PIP_VERSION", pip explodes with "ValueError: invalid truth value '<VERSION>'"
ENV PYTHON_PIP_VERSION 23.2.1
# https://github.com/pypa/get-pip
ENV PYTHON_GET_PIP_URL https://github.com/pypa/get-pip/raw/4cfa4081d27285bda1220a62a5ebf5b4bd749cdb/public/get-pip.py
ENV PYTHON_GET_PIP_SHA256 9cc01665956d22b3bf057ae8287b035827bfd895da235bcea200ab3b811790b6

RUN set -eux; \
    \
    wget -O get-pip.py "$PYTHON_GET_PIP_URL"; \
    echo "$PYTHON_GET_PIP_SHA256 *get-pip.py" | sha256sum -c -; \
    \
    export PYTHONDONTWRITEBYTECODE=1; \
    \
    python get-pip.py \
        --disable-pip-version-check \
        --no-cache-dir \
        --no-compile \
        "pip==$PYTHON_PIP_VERSION" \
    ; \
    rm -f get-pip.py; \
    \
    pip --version

CMD ["python3"]

docker-compose.yml

最後に docker-compose.yml を用意します。

今回はシンプルに先程の Dockerfile をベースとしたコンテナを立ち上げているだけで、 volume のマウントだけしています。

git や AWS CLI を使う際にローカルのホームディレクトリにある認証情報をそのまま使いたかったため、コンテナ内のユーザのホームディレクトリにマウントしています。

version: '3.8'
services:
  app:
    build: .
    tty: true
    stdin_open: true
    volumes:
      - ../:/workspace:cached
      - ${HOME}/.ssh:/home/vscode/.ssh
      - ${HOME}/.aws:/home/vscode/.aws

Gemfile

私の場合は Ruby のコードを書くことが多いので、 Gemfile もついでに追加し、ひとまず Rails が使えるようにしておきます。

ちなみに Rails のバージョン指定は執筆時点の Rails Tutorial で使用されていたバージョンに合わせています。

# frozen_string_literal: true

source "https://rubygems.org"

gem "rails", "7.0.4.3"

コンテナ起動

ここまでで必要なファイルが揃ったのでコンテナを立ち上げてみます。

画面左下の青い Extention 表示をクリックすると下記のようなメニューが表示されるので、 Reopen in Container を選択します。

もしくはファイルが揃っている状態で VSCode を起動すると下記のような表示が出るので、ここから Reopen in Container を選択しても同様になります。

初回のビルドは少し時間がかかるかと思います。 特に今回のケースでは Python のビルドで結構時間がかかります。 2回目以降の起動はあまり時間はかからずすぐに起動できるかと思います。

起動が終わると左下の Extention の表示のところに devcontainer.json で指定した環境名が表示されます。

VSCode のターミナルから Ruby と Python も使えるようになっています。

bundle install を実行すれば rails も使えるようになります。

vscode ➜ /workspace $ bundle install
Fetching gem metadata from https://rubygems.org/..........
〜〜〜中略〜〜〜
Bundle complete! 1 Gemfile dependency, 45 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
vscode ➜ /workspace $ 
vscode ➜ /workspace $ bundle exec rails -v
Rails 7.0.4.3

ボリュームマウントした .ssh の認証情報や、 features で指定した AWS CLI なども使えるようになっています。

vscode ➜ /workspace (main) $ ssh -T git@github.com
Hi h-akanuma! You've successfully authenticated, but GitHub does not provide shell access.
vscode ➜ /workspace (main) $ 
vscode ➜ /workspace (main) $ aws --version
aws-cli/2.14.5 Python/3.11.6 Linux/6.4.16-linuxkit exe/aarch64.debian.12 prompt/off

テンプレートリポジトリ化

ここまでに用意した .devcontainer ディレクトリの内容を GitHub のリポジトリにプッシュし、そのディレクトリをテンプレートリポジトリとして設定しておくことで、このリポジトリをテンプレートとして新しいリポジトリを作ることができるようになります。

該当のリポジトリの設定画面から Template repository にチェックを入れるだけで設定できます。

すると Use this template ボタンから Create a new repository が選択できるようになります。

リポジトリの作成画面からリポジトリ名を入力すれば新しいリポジトリが作成されます。

Rails アプリを立ち上げるまでをやってみる

上記でテンプレートを元に作成したリポジトリから試しに Rails アプリを立ち上げるところまでをやってみます。

まずはローカルに clone します。

$ git clone git@github.com:h-akanuma/devcontainer_rails_sample.git

そして VSCode で上記のディレクトリを開き、 Reopen in Container でコンテナを立ち上げます。

立ち上がったら bundle install を実行して rails を使えるようにします。

vscode ➜ /workspace $ bundle install

rails new で Rails のアプリを作成します。

vscode ➜ /workspace (main) $ bundle exec rails _7.0.4.3_ new sample_app

作成した Rails アプリのディレクトリで rails server を実行します。

vscode ➜ /workspace/sample_app (main) $ bundle exec rails server
=> Booting Puma
=> Rails 7.0.8 application starting in development 
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.6.7 (ruby 3.2.2-p53) ("Birdie's Version")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 3374
* Listening on http://127.0.0.1:3000
Use Ctrl-C to stop

すると画面右下に下記のような表示が出て、 Open in Browser を選択すると、 Rails アプリが使用しているポート 3000 を参照する形でブラウザでアクセスできます。

PORTS タブには現在転送されているポートのリストが表示されるので、そのリストから地球(?)マークをクリックすることでも同様になります。

ブラウザには下記のように Rails アプリによる画面が表示されます。

作成された Rails アプリも含めて git にコミットしたいところですが、 Rails アプリのディレクトリ内にも .git ディレクトリがあるため、サブモジュールのような形になりそのままでは Rails アプリのディレクトリを元の git リポジトリのコミットに含めることができません。なので Rails アプリのディレクトリ内の .git ディレクトリを削除します。

vscode ➜ /workspace $ rm -rf sample_app/.git

この状態で git add しようとすると下記のようなエラーになります。

vscode ➜ /workspace $ git add sample_app/
fatal: detected dubious ownership in repository at '/workspace'
To add an exception for this directory, call:

        git config --global --add safe.directory /workspace

エラーメッセージに表示されている git コマンドを実行して、workspace を safe.directory に設定します。

vscode ➜ /workspace $ git config --global --add safe.directory /workspace

これによって git addgit commit も行えるようになります。

vscode ➜ /workspace (main) $ git add sample_app/
vscode ➜ /workspace (main) $ git commit -m 'First commit'

あとは git push すればOKなので、一連の開発サイクルとしては回せるようになったかと思います。

vscode ➜ /workspace (main) $ git push origin main

まとめ

まだ色々と手探りでやってみているところですが、何か新しいことを試したいときにサクッと新しい環境を用意できるのはとても便利です。少し前に GitHub Codespace を使っていましたが、無料では月間60時間までの制限もあるので、 Dev Containers であればそういったことも気にせず、ローカルボリュームのマウントなどもできるので良い感じです。

実際のプロダクトの環境はだいぶ複雑なので簡単には適用できないかもしれませんが、いずれは活用していけると良いかと思っています。

ユニファでは開発環境の改善も含めて共に保育をHackしてくれる仲間を募集中です!少しでも興味をお持ちいただけた方は、ぜひ一度カジュアルにお話しましょう!

unifa-e.com