てっく・ざ・ぶろぐ!

とある大学院生のテックブログ

コンテナからNASへの書き込み権限エラー(#8)

はじめに

ある日、「研究室サーバーで起動したコンテナ上から、マウントしたNASに書き込みできない」という報告がありました。具体的にはPermission deniedが出ていて、以前は全く報告されていなかった事例です。

本記事では、ステップ・バイ・ステップでエラー解決の糸口を紹介します。デバッグに自信のある方は1章を読んで原因を推測してみてください。

1. エラーの再現

研究室サーバーでは全ユーザーがprimary グループとしてユーザー名と同じグループ(例:azuma)とsecondary グループとしてdockerグループに所属しています。

$ id
uid=2401(azuma) gid=2401(azuma) groups=2401(azuma),999(docker)

検証用に以下のようなDockerfileを用意しました。セキュリティには目を瞑ってください。

FROM ubuntu:latest

ARG UID=2401
ARG USERNAME=azuma

RUN apt update && apt upgrade -y && apt clean

RUN echo root:root | chpasswd && \
    useradd -m -s /bin/bash --uid ${UID} --groups sudo ${USERNAME} && \
    echo ${USERNAME}:${USERNAME} | chpasswd

RUN mkdir -p /tmp/nas/azuma

コンテナをビルドして起動します。

-uオプションで実行ユーザーとして自身を指定しています。

$ docker build -t azuma-ubuntu .

$ NAS_MOUNT_PATH=/mnt/nas/azuma
$ mkdir -p $NAS_MOUNT_PATH

$ docker run -it --rm \
  --name nas-write-test \
  -u $(id -u):$(id -g) \
  -v $NAS_MOUNT_PATH:/tmp/nas/azuma \
  azuma-ubuntu:latest /bin/bash

マウントしたディレクトリに対してコンテナ上から書き込みを行い、Permission deniedと表示されたのでエラーの確認は完了です。

azuma@479d704b616b:/$ echo "Hello World!" > /tmp/nas/azuma/hello.txt
bash: /tmp/nas/azuma/hello.txt: Permission denied

一方でrootユーザーからは対象のディレクトリに書き込むことができます。

azuma@308b7e25d385:/$ su
Password:
root@308b7e25d385:/# echo "Hello World!" > /tmp/nas/azuma/hello.txt
root@308b7e25d385:/# ls -ld /tmp/nas/azuma/
drwxrwxr-x 2 root 999 0 Oct 15 09:00 /tmp/nas/azuma/

最後に、ホストマシン上での対象ディレクトリの権限をチェックします。

$ ls -ld /mnt/nas/azuma
drwxrwxr-x 2 root docker 0 107 18:20 /mnt/nas/azuma

この時点で大まかな原因の予想がついたので、次章からは敢えて実行したコマンドと関連知識を掘り下げていきます。

2. コンテナの実行ユーザー指定

Dockerコンテナのデフォルトの実行ユーザーはrootuid=0 gid=0)です。

docs.docker.com

ほとんどの人が、DockerfileのUSER命令を使用してイメージビルド中に実行ユーザーを切り替えた経験があると思います。一方、docker runコマンドの-uオプションを使用することで、以下に示すようにコンテナを起動(し、コマンドを実行)する際のUSER命令を上書きできます。

--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]

どのユーザーでコンテナが起動されるのか理解できたので、改めてエラーの再現に利用したDockerfileとdocker runコマンドを確認します。

DockerfileのRUN命令の一部として、以下のコマンドが実行されています。

    useradd -m -s /bin/bash --uid 2401 --groups sudo azuma

これはユーザーazumaをUID2401で作成し、管理者権限を持つsudoグループに追加し、デフォルトシェルを/bin/bashに設定して、ホームディレクトリを作成するという意味です。-gオプションを指定しない場合はデフォルトで新しいユーザーと同じ名前のグループが作成されてプライマリグループになります。

次に、docker runコマンドの-uオプションのみ抜粋します。

$ docker run -u 2401:2401 azuma-ubuntu:latest /bin/bash

これは、コンテナ内のUID2401GID2401のユーザーでコンテナを起動することを意味しています。

ちなみに、-u 2401のようにユーザーIDを指定した場合は0-2147483647の整数値でなければならず、-u azumaのようにユーザー名を指定した場合は予めコンテナ内にユーザーが存在しなければならないそうです。

裏を返せば、Dockerfileでuseraddコマンドをコメントアウトしてユーザーを作成しない場合でも、-uオプションでユーザーIDを指定すれば、以下のようにユーザーが作成されるということです。

$ docker run -it -u 2401:2401 azuma-ubuntu:latest /bin/bash
groups: cannot find name for group ID 2401
I have no name!@a715efc4b574:/$ id
uid=2401 gid=2401 groups=2401

Dockerfileでuseraddコマンドを実行していましたが、これは必須ではなく、ユーザー名やホームディレクトリ、デフォルトシェルを作成・指定して便利にするためであると分かりました。

3. ファイルのアクセス権

今回のエラーはPermission deniedなので、ファイルのアクセス権について復習します。

まずは、ホストマシンから1章でNAS_MOUNT_PATHとして指定していたパスの詳細を表示します。

$ ls -la /mnt/nas/azuma
合計 4
drwxrwxr-x 2 root docker  0 1016 12:16 .
drwxrwxr-x 2 root docker  0 1015 10:33 ..
-rwxrwxr-x 1 root docker 13 1015 18:39 hello.txt

表記は左から、モード(ユーザー、グループ、その他のユーザー)、リンクの数、所有者、グループ、サイズ (バイト単位)、最終変更時刻、および名前です。

例えば、カレントディレクト.についてはrootユーザーとdockerグループが読み取り(r)/書き込み(w)/実行(x)可能で、その他のユーザーは読み取り(r)と実行(x)のみ可能です。

1章で確認した通り、全研究室メンバーのユーザーがdockerグループに所属しているため、ホストマシンからは問題なくディレクトリ内のファイルに書き込めます。

コンテナ内からもマウントした対象ディレクトリの詳細を取得してみます。

azuma@2e65c0193e67:/$ ls -la /tmp/nas/azuma/
total 8
drwxrwxr-x 2 root  999    0 Oct 16 03:16 .
drwxr-xr-x 3 root root 4096 Oct 16 03:43 ..
-rwxrwxr-x 1 root  999   13 Oct 15 09:39 hello.txt

GID999はホストマシン上でのdockerグループに該当していました。ホストマシンと同様、ユーザーがdockerグループに所属していれば書き込み(w)は可能なはずです。

4. NASのマウント

最後に、NASのマウント方法を確認します。

実例として研究室サーバーでは、/etc/fstabに以下のように追記することで起動時に自動でNASをマウントしています。

//192.168.1.xxx/data /mnt/nas cifs username=YOUR_USERNAME,password=YOUR_PASSWORD,gid=999,dir_mode=0775,file_mode=0775 0 0

オプションの内容は以下の通りです。詳細はmount.cifsを参照してください。

今回、ホストマシン上のdockerグループのGID999である前提で、全てのマシンの/etc/fstabに全く同じ内容を追記していました。しかし、3台のマシンを調査したところ、dockerグループのGIDはそれぞれ997/998/999でした。999の場合でもコンテナ内からの書き込み時にエラーが起きるのは事実ですが、997998の場合はホストマシンからの書き込みすらできません。

解決策としては

gid=arg sets the gid that will own all files or directories on the mounted filesystem when the server does not provide ownership information. It may be specified as either a groupname or a numeric gid. When not specified, the default is gid 0. The mount.cifs helper must be at version 1.10 or higher to support specifying the gid in non-numeric form. See the section on FILE AND DIRECTORY OWNERSHIP AND PERMISSIONS below for more information. https://linux.die.net/man/8/mount.cifs

から分かる通り、グループIDではなくgid=dockerのようにグループ名を指定するのがベターだと考えました。

5. エラーの解決

調査を通して分かったことをまとめます。

【2章】コンテナ起動時に指定するオプションによって、コンテナ内に既に存在しているユーザーを実行ユーザーとして指定できる

【3章】コンテナにマウントしたディレクトリが、コンテナ内でもホストマシンと同じ所有者・所有グループとして表示される

【4章】NASマウント時に所有グループとしてグループIDだけでなくグループ名も指定できる

最もシンプルな解決方法として「全てのユーザーをprimaryグループとしてdockerグループに所属させ、dockerグループがNASをマウントしたディレクトリを所有する」という方法があります。この方法であれば今まで通りの方法でユーザーがNASを利用できるようになりますが、Dockerに対する権限とNASに対する権限の切り分けが不可能です。また、あるユーザーが作成したディレクトリはdockerグループに所属する一般ユーザーからデフォルトで書き込み可能であることからも好ましくありません。実は、Ansibleによる構成管理を導入する以前はこの状態でした。

今回の解決策を下図に示します。

解決策のイメージ図

結論から言うと今回は、全ユーザーをサブグループとしてdockerグループとnas_accessグループ(NASにアクセス可能なグループ)の2つに所属させ、docker runコマンドで--group-addオプション*1を指定してもらう解決策を取ることにしました。

以下は、nas_accessグループのGID996である場合のコマンド実行例です。

$ docker run -it --rm \
  --name nas-write-test \
  -u $(id -u):$(id -g) \
  --group-add $(getent group nas_access | awk -F: '{print $3}') \
  -v $NAS_MOUNT_PATH:/tmp/nas/azuma \
  azuma-ubuntu:latest /bin/bash

groups: cannot find name for group ID 996
azuma@1556ffc1cf80:/$ id
uid=2401(azuma) gid=2401(azuma) groups=2401(azuma),996
azuma@1556ffc1cf80:/$ echo "Hello World!" > /tmp/nas/azuma/hello.txt
azuma@1556ffc1cf80:/$ cat !$
cat /tmp/nas/azuma/hello.txt
Hello World!

コンテナ内にGID996のグループは実際には存在しないため、groups: cannot find name for group ID 996というエラーが一瞬表示されます。エラーが気になる場合は、以下のようにDockerfile内でnas_accessグループを作成することで、--group-addオプションを使わずNASへの書き込みが可能になります(ただし、イメージビルド時にグループIDを引数として渡すため、同じイメージをグループIDが異なるホストマシン間で使い回すことはできません)。

FROM ubuntu:latest

ARG UID=2401
ARG USERNAME=azuma
ARG NAS_ACCESS_GID=996  # 追加

RUN apt update && apt upgrade -y && apt clean

RUN echo root:root | chpasswd && \
    # 追加
    groupadd -g ${NAS_ACCESS_GID} nas_access && \
    useradd -m -s /bin/bash --uid ${UID} --groups sudo,nas_access ${USERNAME} && \
    echo ${USERNAME}:${USERNAME} | chpasswd

RUN mkdir -p /tmp/nas/azuma

いずれにせよ、コンテナ上からマウントしたNASに書き込めないエラーは解消しました。nas_accessグループを作成やsecondaryグループの追加については、AnsibleのおかげでPlaybookを数行書き換えるだけで全マシンに適用されました。

Playbook変更の差分

4章で登場した/etc/fstabに関する修正点は、NAS利用者が居ないタイミングを見計らって1台ずつマウントし直しました。

$ vim /etc/fstab  # 次回起動時のために変更
$ umount -l /mnt/nas
$ mount -t cifs //192.168.1.xxx/data /mnt/nas -o username=YOUR_USERNAME,password=YOUR_PASSWORD,gid=nas_access,dir_mode=0775,file_mode=0775
$ ls -ld /mnt/nas
drwxrwxr-x 2 root nas_access 0 1015 10:33 /mnt/nas
$ echo "Hello World!" > /mnt/nas/azuma/hello.txt  # 成功

$ NAS_MOUNT_PATH=/mnt/nas/azuma
$ docker run -it --rm --name nas-write-test -u $(id -u):$(id -g) --group-add $(getent group nas_access | awk -F: '{print $3}') -v $NAS_MOUNT_PATH:/tmp/nas/azuma  azuma-ubuntu:latest /bin/bash
groups: cannot find name for group ID 996
azuma@286205c86ac7:/$ id
uid=2401(azuma) gid=2401(azuma) groups=2401(azuma),996
azuma@286205c86ac7:/$ echo "Hello Azuma!" > /tmp/nas/azuma/hello.txt
azuma@286205c86ac7:/$ cat !$  # 成功
cat /tmp/nas/azuma/hello.txt
Hello Azuma!

まとめ

「コンテナにマウントしたNASにコンテナ内から書き込めない」という問題に対して、丁寧に調査しながら解決策を示すことができました。また、調査する過程でLinuxやDockerの曖昧な知識を再確認できたことは大きな収穫でした。

余談ですが、dockerグループの権限はrootユーザーに匹敵するので、ホストのファイルシステム全体をコンテナにマウントすることで、任意のファイルを読み書きできます。セキュリティの観点からRootlessモードPodmanを導入したい気持ちはありますが、あくまでも研究室サーバーなので衝動を抑えつつ、自分の環境でこれらの技術をキャッチアップする必要があると考えました。

*1:docker composeの場合も同様にgroup_add属性を指定できますhttps://docs.docker.com/reference/compose-file/services/#group_add

LLM関連OSSのキャッチアップ(#7)

はじめに

先日Xで以下のようなポストを見かけました。

少なくとも私のTLは数年前からChatGPTをはじめとするLLM関連の話題で持ちきりです。しかし、新しいツールの名前を目にする機会はあっても「どのような特徴があるか」「どのように利用できるか」を手元で動かしながら学ぶ時間は確保できていませんでした。

本記事では先述のランキングサイトを参考に、可能な限り手元でLLM関連のOSSを動かして感想を残したいと思います。

huyenchip.com

(注)2024年9月20日時点でのstar_7dすなわち7日間のスター数が多い順に紹介します。

1. Mintplex-Labs/anything-llm

github.com

概要:closed LLM(OpenAIやAnthropicなど)やオープンソースLLM(Ollamaなど)とベクトルデータベースを自由に組み合わせることができ、ChatGPTのようなチャットUIを提供している。

手順

Desktop版をMacbookダウンロードすると、下図のようなチャットUIが利用できるようになった。

LLMをセットアップしていなかったのでエラーメッセージが出現

Workspace内ではドキュメントがコンテナ化されているため、コンテキストをクリーンに保てるとのこと。

ローカルLLMとしてOllamaを利用したいので、GitHubドキュメントを参考にしつつ、Ollamaとコンテナ間通信を追加してDocker版でセットアップし直した。今回はLLMとしてLlama 3 8Bを使用した。

# compose.yaml
services:
  anythingllm:
    image: mintplexlabs/anythingllm
    container_name: anythingllm
    ports:
    - "3001:3001"
    cap_add:
      - SYS_ADMIN
    environment:
    # Adjust for your environment
      - STORAGE_DIR=/app/server/storage
      - JWT_SECRET="************************"  # openssl rand -base64 24
      - LLM_PROVIDER=ollama
      - OLLAMA_BASE_PATH=http://ollama:11434
      - OLLAMA_MODEL_PREF=llama3
      - OLLAMA_MODEL_TOKEN_LIMIT=4096
      - EMBEDDING_ENGINE=ollama
      - EMBEDDING_BASE_PATH=http://ollama:11434
      - EMBEDDING_MODEL_PREF=nomic-embed-text:latest
      - EMBEDDING_MODEL_MAX_CHUNK_LENGTH=8192
      - VECTOR_DB=lancedb
      - WHISPER_PROVIDER=local
      - TTS_PROVIDER=native
      - PASSWORDMINCHAR=8
      # Add any other keys here for services or settings
      # you can find in the docker/.env.example file
    volumes:
      - anythingllm_storage:/app/server/storage
    restart: always

  ollama:
    image: ollama/ollama
    container_name: ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_storage:/root/.ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

volumes:
  anythingllm_storage:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: $HOME/.anythingllm  # mkdir ~/.anythingllm
  ollama_storage:
    driver: local
    driver_opts:
      type: none
      o: bind
      device: $HOME/.ollama  # mkdir ~/.ollama

起動させたら、ollama runでモデルをダウンロードしつつプロンプトを入力してみる(もちろんollama pullでもOK)。

$ docker compose up -d
# 省略
[+] Running 2/2
 ✔ Container anythingllm  Started                                                                                                                                                     0.8s
 ✔ Container ollama       Started

$ docker exec -it ollama ollama run llama3
pulling manifest
# 省略
verifying sha256 digest
writing manifest
success
>>> Hello!
Hello! It's nice to meet you. Is there something I can help you with, or would you like to chat?

>>> /bye

Webブラウザ<サーバーのIPアドレス>:3001にアクセスすると、Anything LLMを利用できる。

ユーモアを理解できるか試してみる

GPU*1を使用した場合、上図のような簡単な質問に対しては1~2秒くらいで回答が返ってきた。

GitHubリポジトリを丸ごとインポートしてWorkspaceにドキュメントとして移動させつつ、特定のファイルをピン留めして質問することもできた。

任意のGitHubリポジトリ名とレート制限対策のPATを入力

ピン留めを行うことでコンテキストウィンドウに全文挿入されるらしい

ツールの特徴について問うメンテナーと概ね正しく答えるLLM with RAG

「My Documents」モーダルからファイルをアップロードする場合は問題なかったが、ChatGPTのようにチャット画面の📎ボタンからファイルをアップロードするとなぜか正しく送信できなかった。

感想:マルチユーザーもサポートされており、(マシンリソースがあれば)研究室でセルフホストするのに最適だと感じた。

2. lobehub/lobe-chat

github.com

概要:モダンなデザインが特徴であるAIチャットフレームワークで、検索・要約などの便利なプラグインシステムや、〇〇アシスタント・××エキスパートなどが揃う(GPTsライクな)エージェントストアが利用できる。

手順

Google AI用の環境変数を指定してDockerでデプロイしてみる。事前にGoogle AI Studioで無料のAPIキーを取得しておく必要がある。

1分ほどでAPIキーが作成できて、手軽にcurlで試せるのも魅力

取得したAPIキーを利用して、Macbookで以下のコマンドを実行しコンテナを起動する。

$ docker run -d -p 3210:3210 \
-e ENABLED_OPENAI=0 \
-e GOOGLE_API_KEY=*************************************** \
--name lobe-chat \
lobehub/lobe-chat

localhost:3210にアクセス(可愛い...?)

gemini-1.5-flash-latestを使用してPythonコードを書かせる様子

プラグインストアで試しに「Git OSS Stats」をインストール

lobehub/lobe-chatを分析させた結果(かなり便利)

アシスタントとして試しに「Fitness AI Trainer」を追加

ロール指定なしで、いきなり踏み込んだ質問ができるのは便利

感想プラグインとアシスタントはお気に入りを探したくなるし、全体的にUIが洗練されていて非常に使いやすかった。

3. OpenBMB/MiniCPM

github.com

概要:マルチモーダルLLM(入力:画像&動画&テキスト、出力:テキスト)であり、画像理解でGPT-4o miniやGemini 1.5 Proなどを上回る。

手順

NVIDIA GPUの場合、以下の手順で実行すればREADMEが前提としている最低限の環境構築ができる。

$ docker run -it --rm --gpus all nvidia/cuda:12.6.1-cudnn-devel-ubuntu22.04 /bin/bash
$ apt-get update && apt-get install -y git vim wget
$ mkdir -p ~/miniconda3 && wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda3/miniconda.sh && bash ~/miniconda3/miniconda.sh -b -u -p ~/miniconda3 && rm ~/miniconda3/miniconda.sh && ~/miniconda3/bin/conda init bash && source ~/.bashrc

ただし、[Install]の手順だけではFlashAttentionが入っていないとエラーが出てしまうので、[Multi-turn Conversation]を実行する前にpip install flash-attn --no-build-isolationする必要がある。 実際に約17GBのGPUメモリで1番高性能なモデルが動かせて、推論自体は画像&質問を入力する場合5秒ほど、それを履歴として追加質問をする場合に3秒ほどだった。

PhotoACから適当に拾った入力画像(リャマ)

質問・回答と応答時間の例

感想iPad Proでモデルを動かしているデモ動画が印象的だった。

4. ollama/ollama

github.com

概要:Goで書かれた言わずと知れたローカルLLMの実行ツール。

手順

今回はGemma 2 27B(16GB)をNVIDIA GPUとDockerで動かしてみた。質問はLLM Benchmarksを参考にした。

$ mkdir ollama
$ docker run -d --gpus=all -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama
$ docker exec -it ollama ollama run gemma2:27b
# 省略
success
>>> Argue for and against the use of kubernetes in the style of a haiku.
## For Kubernetes:

Orch, scale, deploy fast.
Containers dance, self-healing.
Power flows, unbound.

## Against Kubernetes:

Complex beast to tame.
Learning curve, steep as a cliff.
Simpler paths exist?


>>> Sally (a girl) has 3 brothers. Each brother has 2 sisters. How many sisters does Sally have?
Sally has **2** sisters.

Here's why:

All three of Sally's brothers share the same two sisters – Sally and her other sister.


Let me know if you'd like to try another riddle!

ローカルLLMとして十分すぎる性能と速度が出た。8KトークンのGemma 2よりも長いコンテキストを扱える128KトークンのQwen2.5などを含む、非常に多くのモデルをサポートしている(library)。

GGUF形式やSafetensors形式のモデルのインポートもサポートされている。

Modelfileというファイルを作成してプロンプトをカスタマイズすることもできるので、以下のようなファイルを作成してモデルを動かしてみた。

FROM gemma2:27b

# set the temperature to 1 [higher is more creative, lower is more coherent]
PARAMETER temperature 1

# set the system message
SYSTEM """
You are Goku from Dragon Ball. Answer as Goku, the assistant, only, and respond exclusively in Japanese.
"""

今回は扱わなかったが、ADAPTERを指定すれば(Q)LoRAアダプターをモデルに適用できる。

$ docker cp Modelfile ollama:/tmp/
$ docker exec -it ollama /bin/bash

$ cd /tmp
$ ollama create goku -f ./Modelfile
$ ollama run goku
>>> がんばれカカロット...おまえがナンバー1だ!!
おおおお!ありがとう!お前も頑張れ! Saiyanのプライドを胸に、最強を目指して突き進むぜ!🔥💪

【比較】GPT-4oによる回答

感想:単純にモデルを動かす以外にも、インポートや豊富なカスタマイズができて便利なのでもっと深掘りしてみたい。

5. f/awesome-chatgpt-prompts

github.com

概要:ChatGPTをより良く使うためのプロンプト集。

手順

READMEに「Act as a 〇〇」のプロンプト例が大量に並んでいる。

〇〇として、Linux Terminal、English Translator and Improver、Math Teacher、Prompt Generator、Prompt Enhancer、Python interpreterなど普段から利用できそうなものから特定の職業・ドメインに成り切るものまで揃っていた。

「Act as a Regex Generator」を検証してみる。

プロンプトを使用した場合

プロンプトを使用しない場合

どちらも得られる正規表現は同じだが、サービスの中にLLMを組み込む際には「Do not write explanations or examples of how the regular expressions work; simply provide only the regular expressions themselves.」のようなプロンプトが必要であり、ケーススタディを通してその雰囲気を感じることができた。

お気に入り① 「Act as a Emoji Translator」

お気に入り② 「Act as a Linux Terminal」

感想:遊んでいるだけで面白かったし、プロンプトエンジニアリングのモチベーションが上がった。

6. mem0ai/mem0

github.com

概要:ユーザー・セッション・エージェントレベルで記憶を保持し、ユーザーとの対話を通して適応的にパーソナライゼーションを実現できるAIのメモリレイヤー。

手順

ドキュメントにあるカスタマーサポートエージェントの例をアレンジして検証した。

様々なLLMやEmbeddier(LLMの種類よりもやや少ない?)をサポートしているが、今回はOpenAIを使用した。

遂にOpenAIに課金

$ docker run -d -p 6333:6333 qdrant/qdrant
$ python -m venv venv
$ . ./venv/bin/activate
$ pip install openai mem0ai
$ vim main.py  # ドキュメントのコード例をコピー&ペースト
$ python main.py
I'm sorry to hear that your order hasn't arrived yet. I'd be happy to assist you in resolving this issue. Could you please provide me with your order number and any other relevant details?%

コード例のクエリを実行してもユーザーレベルで過去の記憶が保持されているか分かりにくかったので、Pythonの対話モードを使用して2人がそれぞれ途中で別のコンテキストを挟みながらサポートを受けるシミュレーションを行った。

Results of the Multiple Customer Queries Version …

Jane Doeが最初に#12345の注文が届かないと問い合わせているが、最後に#12345について問い合わせると「How can I assist you with your order today, Jane?」と聞き返されており記憶が不十分だった。

Claude 3.5 Sonnetに書いてもらったMermaidのプレビュー

また、メモリの内容を確認したところ予想よりも少なく、サイズを調整できるのか気になった。

ChatGPT 4oに整形してもらったJane Doeのメモリの中身

感想ユースケースとしてカスタマーサポートやヘルスケア、生産性管理などが挙げられていて需要が大きいと感じた反面、今回のような複雑なクエリに対する例はドキュメントでも紹介されておらず性能評価が重要だと考えた。

7. ComposioHQ/composio

github.com

概要:AIエージェントと100以上のアプリケーション(GitHub、Slack、Gmailなど)を連携するためのプラットフォーム(ツールセット)。

手順

🚀 Quick Start - ComposioPythonを選択し、GitHubリポジトリにスターを付けてみる。

$ python -m venv venv
$ . ./venv/bin/activate
$ pip install composio_core composio_openai
$ composio add github
#省略
✔ Authenticated successfully!
> Adding integration: Github...
#省略
⚠ Waiting for github authentication...
✔ github added successfully with ID: ********-****-****-****-************

Composioのアカウント(無料)を持っていなかったので作成してから、GitHubの認証を行った。

以下のコマンドで使用するCOMPOSIO_API_KEYはログインした状態でhttps://app.composio.dev/settingsから取得できる。

$ vim main.py  # 公式ドキュメント通り
$ COMPOSIO_API_KEY=*** OPENAI_API_KEY=sk-*** python main.py
[2024-09-24 12:57:47,298][INFO] Logging is set to INFO, use `logging_level` argument or `COMPOSIO_LOGGING_LEVEL` change this
[2024-09-24 12:57:52,519][INFO] Executing `GITHUB_ACTIVITY_STAR_REPO_FOR_AUTHENTICATED_USER` with params={'owner': 'composiohq', 'repo': 'composio'} and metadata={} connected_account_id=None
[2024-09-24 12:57:53,919][INFO] Got response={'successfull': True, 'data': {'details': 'Action executed successfully'}, 'error': None} from action=<composio.client.enums._action.Action object at 0x112a780e0> with params={'owner': 'composiohq', 'repo': 'composio'}
[{'successfull': True, 'data': {'details': 'Action executed successfully'}, 'error': None}]

自然言語の指示でGitHubリポジトリにスターを付けられる

Quick Startの2はGoogle Calendar、3はGmailだが、時間の都合でスキップした。

感想:認証やフレームワークが抽象化されていてツールごとの開発を行わなくて良いため大幅なスピードアップにつながると感じた。

8. langflow-ai/langflow

github.com

概要Pythonベースのローコードアプリケーション構築ツールで、RAGやマルチエージェントAIをドラッグ&ドロップで作成できる。

手順

クラウドサービスやHugging Faceスペースも提供されているが、ローカルで試してみた。

公式ドキュメントに従ってpipでインストールしてLangflowサーバーを起動する。

$ pip install langflow -U
$ langflow run
Starting Langflow v1.0.18...
# 省略
╭───────────────────────────────────────────────────────────────────╮
│ Welcome to ⛓ Langflow                                             │
│                                                                   │
│                                                                   │
│ Collaborate, and contribute at our GitHub Repo 🌟                 │
│                                                                   │
│ We collect anonymous usage data to improve Langflow.              │
│ You can opt-out by setting DO_NOT_TRACK=true in your environment. │
│                                                                   │
│ Access http://127.0.0.1:7860                                      │
╰───────────────────────────────────────────────────────────────────╯

リッチなテンプレートが用意されている(アイコンの雰囲気が好み)

Vector Store RAGテンプレートを選択した場合

OpenAIのAPIキーを入力するだけで試せると思ったが、デフォルトのベクトルDBにAstraDBというサーバーレスサービスがセットされていて面倒になってしまったのでスキップした。

感想:Difyに似ているものの、チャットのメモリ管理周りのドキュメントが整備されていて他のツールと差別化できている印象だった。

9. comfyanonymous/ComfyUI

github.com

概要:Stable Diffusionなどの拡散モデルのパイプラインを実行できるバックエンドを含むGUIツール。

手順:本記事ではLLMにフォーカスするため、省略。

感想:全然ドキュメントを読めていないが、バックエンドをGPUマシン、GUIMacBookで触ることができれば理想的だと思った。

10. langgenius/dify

github.com

概要:直感的なインターフェイスでAIワークフローやRAGパイプライン、エージェント機能など構築できるLLMアプリ開発プラットフォーム。

手順

READMEにしたがって、以下のコマンドでローカルDifyサーバーを立てた(docker-compose.yamlの中身には、フロントエンド、バックエンド、DB、Redis、Nginx、ベクトルデータベース、MinIOなどが含まれていた)。

$ git clone https://github.com/langgenius/dify.git
$ cd dify/docker
$ cp .env.example .env
$ docker compose up -d

http://localhost/installからWeb UIの「管理者アカウントの設定」に案内された。Getting Startedとしては公式ドキュメントよりも以下の記事が分かりやすかった。

Dify で RAG を試す|npaka

注意点として、記事ではRerankモデルとしてCohereのAPI有料)が利用されていたため、代わりにローカルGPUマシンでXInferenceサーバーをDockerでセットアップしてbge-reranker-v2-m3というモデルを起動した。

$ mkdir ~/.xinference
$ docker run --name xinference -d -p 9997:9997 -e XINFERENCE_HOME=/data -v $HOME/.xinference:/data --gpus all xprobe/xinference:latest xinference-local -H 0.0.0.0
# <実行したサーバーのIPアドレス>:9997 から起動するモデルを選択する

実行中のRerankモデル一覧(1クリックでデプロイ可能)

DifyにモデルプロバイダとしてXorbits Inferenceを追加

LLMとText EmbeddingにOpenAI、RerankにXorbits Inferenceを使用

記事のようにゼロからワークフローを構築しても良いが、便利なテンプレートが多数用意されているので今回は「Knowledge Retreival + Chatbot」を使用した。

豊富なテンプレート一覧から「Knowledge Retreival + Chatbot」を選択

記事の通りにナレッジを作成(今回はDetaGemmaのテクニカルペーパーを使用)して、「Knowledge Retrieval」ノードの「ナレッジ」として追加することで簡単にRAGを構築することができた。

引用を示しながらナレッジをベースにしてLLMが回答する様子

Difyはこの時点で、「アプリ上での直接実行」「ウェブサイト埋め込み」「APIアクセスのセットアップ」が完了しているだけでなく、モニタリング用のダッシュボードも既に用意されていた。

# APIリクエストの例
$ curl -X POST 'http://localhost/v1/chat-messages' \
--header 'Authorization: Bearer <YOUR_API_KEY>' \
--header 'Content-Type: application/json' \
--data-raw '{
    "inputs": {},
    "query": "What is Data Commons?",
    "response_mode": "streaming",
    "user": "azuma"
}';
#省略
data: {"event": "node_finished", "conversation_id": "acecb1f4-cc0a-4d16-8ced-f66956e19c67", "message_id": "540c1696-3511-494f-97ab-b177d2bad0ca", "created_at": 1727147646, "task_id": "ed4b7547-7b65-4117-9823-f94df5fc8b2f", "workflow_run_id": "44919eb1-2702-41a8-81cf-2df1a7c24748", "data": {"id": "51693827-de76-40ad-b292-2b32d3126cf4", "node_id": "1711528915811", "node_type": "knowledge-retrieval", "title": "Knowledge Retrieval", "index": 2, "predecessor_node_id": "1711528914102", "inputs": {"query": "What is Data Commons?"}, "process_data": null, "outputs": {"result": [{"metadata": {"_source": "knowledge", "position": 1, "dataset_id": "536a3abe-8392-488f-b65d-178ed227f256", "dataset_name": "DataGemma-FullPape...", "document_id": "2efb709e-b967-4cab-88ef-c9f6005a9f1d", "document_name": "DataGemma-FullPaper.pdf", "document_data_source_type": "upload_file", "segment_id": "7ebae28e-ff12-495f-9b99-34c70a897cd7", "retriever_from": "workflow", "score": 0.9980077147483826, "segment_hit_count": 1, "segment_word_count": 1725, "segment_position": 7, "segment_index_node_hash": "ca757a7b80f202a20e26684e64a07655096ff2384ccda5ccab83b1ef8606cb3e"}, "title": "DataGemma-FullPaper.pdf", "content": "formats from only a handful of examples in training data.\r\n9 This paper builds upon LIMA utilizing\r\nsmall set training for RIG and RAG explorations with Data Commons KGs. #省略
#省略

Difyの監視画面の例

感想:ワークフローには他のノードもあり、変数と組み合わせることでDifyだけでさらに複雑なシステムを構築できるのが魅力だと感じた。

まとめ

トレンドの上位を中心に、手元でLLM関連のOSSを動かしてみました。

LLMの理論は『大規模言語モデル入門』という本を読んで理解することができましたが、プロダクト視点は全く持っていなかったので大まかに雰囲気を掴むことができて良かったです。

特にollama/ollamaはゲームチェンジャーだと思いました。Go実装なので近いうちにソースコードも読んでみたいです。

エンジニアとして流行は常にキャッチアップしたいので、10個とは言わず3個くらい定期的に触る習慣をつけたいです。

*1:NVIDIA GPUとして、今回はNVIDIA RTX 6000 Ada Generationを最大3枚使用しました。

Cloudflare TunnelでVPNレスSSH接続(#6)

はじめに

ある研究室メンバーが自宅から研究室VPNに接続できず、サーバーにSSH接続できないという事例がありました。

調査してもらったところ、VPN接続のために開放する必要があるポート*1が防がれている可能性が高いと考えました。また、賃貸に住んでいるため勝手にポート開放することもできず、自宅から研究できないことに不便を感じているとのことでした。

本記事では、Cloudflare Tunnelを導入して上記のような環境でもVPNなしで安全に研究室サーバーにSSH接続する方法を紹介します。

1. Cloudflare Tunnelの導入

developers.cloudflare.com

Cloudflare Tunnelは、パブリックIPアドレスを公開することなく、プライベートなサーバーに安全にアクセスできるようになるトンネリングサービスです。具体的には、下図のようにcloudflaredという軽量なデーモンを起動してCloudflareのネットワークとの間に暗号化されたトンネルを作成します。

出典:Cloudflare Tunnel | Cloudflare Zero Trust docs

図ではブラウザからのHTTPリクエストとなっていますが、HTTP Webサーバーに限らずSSHサーバーやリモートデスクトップにも対応しています。

また、DDoS攻撃対策やWAFなど、Cloudflareのセキュリティ機能が適用されます。

Cloudflare Tunnelの導入手順は公式ドキュメントや多くの記事で紹介されているため、簡単なメモに留めます。

Cloudflareダッシュボードの「Zero Trust」→「Networks」→「Tunnels」→「Create a tunnel」

適当なトンネル名を入力

Raspberry PiUbuntuを利用しているので「Debian」「arm64-bit」を選択し、コマンドを実行

ターミナル上でcloudflaredのサービスが起動しているか確認

ダッシュボード上でもコネクターの状態が「Connected」になっていることを確認

任意のサブドメインとCloudflareで管理しているドメインを入力し、SSH接続でlocalhost:22をURLとして指定

作成したトンネルが一覧に表示されていることを確認(かつてのK8s on Raspberry Piの姿も...)

ここまでで、Cloudflare Tunnelの導入は完了です。

2. Cloudflare Accessの導入

次にCloudflare Accessを導入します。

developers.cloudflare.com

Cloudflare Tunnelを単体で利用する場合、SSHサーバーが全世界に公開された状態になります。その結果、パスワードのブルートフォース攻撃や正当なユーザーの秘密鍵盗難など不正アクセスのリスクが高まります。SSHの認証方式(パスワード・公開鍵)に依存するのは避けるべきです。

出典:トンネリング・ソフトの安全確保 | Argo Tunnel | Cloudflare

Cloudflare Accessを利用することで、アクセスポリシーを適用して誰がアプリケーション(今回はSSHサーバー)に到達できるかを制御できます。今回はEmailを使用しますが、他にも公式ドキュメントで示されているような様々な認証方法があります。

Cloudflare Accessの導入手順も、以下の簡単なメモに留めます。

Cloudflareダッシュボードの「Zero Trust」→「Access」→「Applications」→「Add an application」

Cloudflare Tunnelと同じサブドメインドメインを入力

アプリケーションへのグループ(未作成)の割り当てが必要

別タブで「Access」→「Access Groups」→「Add a group」

今回は「Include」ルールとして対象者のメールアドレスを設定

元のタブで、作成したグループを「Include」として割り当てる

今回は「Setup」はデフォルトのままにする

作成したアプリケーションが一覧に表示されていることを確認

以上でCloudflare AccessとCloudflare Tunnelを併用できるようになりました。

3. 動作検証

動作検証を行います。

手元のマシン(Macbookなど)にもcloudflaredが必要なので、公式ドキュメントを参考にして事前にインストールしてください。

~/.ssh/configに以下を追記します。

Host hogelab
  HostName <YOUR_SUBDOMAIN>.<YOUR_DOMAIN>
  ProxyCommand cloudflared access ssh --hostname %h

ssh hogelabを実行すると、ブラウザーが起動してメールアドレスを入力できるので、メールアドレスに届いた数字を入力するとアクセスが許可されます。

メールアドレス認証のフロー

ターミナルに戻ると、無事にSSH接続できていることが確認できます。

余談ですが、今回のように踏み台サーバーにSSH接続を行う場合はProxyJumpを使うと簡単に多段SSHできて便利です。詳細は以下の記事を参考にしてください。

多段 ssh するなら ProxyCommand じゃなくて ProxyJump を使おう

まとめ

Cloudflare TunnelとCloudflare Accessを導入して、VPNを利用できない場合でも安全に研究室サーバーにSSH接続できるようになりました。

非常に便利かつ無料なサービスなので、Cloudflareで管理しているドメインがある人は積極的に活用してみてください。ちなみに私はCloudflare Tunnel利用がきっかけでお名前.comからCloudflareにドメインを移管した過去があります。

*1:L2TP/IPSecの場合はUDP 500/1701/4500, IP(ESP)50のポートを開放する必要があります

PrometheusとGrafanaを導入(#5)

はじめに

ある日、特定の研究室サーバーに対してSSH接続できなくなったことがありました。このような問題は今回が初めてではなく、似たような事例が発生しては「面倒なので再起動する」という対応を取っていました。

一方、少しでも手掛かりがあるならば根本的に問題を解決してみたいと以前から考えていました*1

本記事では、監視の定番であるPrometheus・Grafana・Alertmanagerを導入する方法を紹介します。いずれも私が以前所属していた研究室で導入した経験があるため、Getting Startedというよりは導入の改善に焦点を当てたいと思います。

構築する監視基盤のアーキテクチャ

1. Prometheusによる監視

1-1. Node exporter

まず、ハードウェアやOSのメトリクスを公開するためにNode exporterを監視対象ノードにインストールします。

github.com

インストールを行う際、バイナリインストールは軽量かつシンプルでサービス化しやすいというメリットがあります。しかし、バイナリはアップデートやドキュメント化が個人的に面倒だと感じるので、Dockerでのインストールをオススメします。

infra@DLBox-Alpha:~$ docker run -d  \
--net="host"  \
--pid="host" \
--name=node-exporter \
--restart=always \
-v "/:/host:ro,rslave"  \
quay.io/prometheus/node-exporter:latest  \
--path.rootfs=/host

基本はREADMEの通りですが、--restartオプションを使用することで再起動ポリシーを指定しています。ここではalwaysとして終了ステータスに関係なく常にコンテナを再起動させます。

興味深いのはコンテナ内からホストを監視しているという点です。ホスト側のネットワークやPID名前空間を使用し、/ディレクトリをマウントした上で--path.rootfsオプションによってホストのルートディレクトリがマウントされている場所をを指定しています。

エンドポイントを叩いてメトリクスを取得できれば確認完了です。

infra@DLBox-Alpha:~$ curl -s localhost:9100/metrics | head
# HELP go_gc_duration_seconds A summary of the pause duration of garbage collection cycles.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 1.7022e-05
go_gc_duration_seconds{quantile="0.25"} 3.3243e-05
go_gc_duration_seconds{quantile="0.5"} 3.9886e-05
go_gc_duration_seconds{quantile="0.75"} 4.5357e-05
go_gc_duration_seconds{quantile="1"} 8.0594e-05
go_gc_duration_seconds_sum 4.64771644
go_gc_duration_seconds_count 95781
# HELP go_goroutines Number of goroutines that currently exist.

1-2. Blackbox exporter

Blackbox exporterについては、私にとって今回が初めての導入です。一言で言うと、外形監視を行います。

github.com

入門 監視 ―モダンなモニタリングのためのデザインパターン』という本では、ユーザー視点での監視が重要だと述べられていました。研究室サーバーのユーザー視点に立つと、(はじめにで紹介したような)サーバーにSSH接続できない状況は可能な限りゼロにしたいです。Blackbox exporterはSSHを含め、HTTP/HTTPSDNSTCP/ICMP/gRPCのエンドポイント監視をサポートしています。

通常、exporterは監視対象サーバーで動かしますが、Blackbox exporterは監視サーバーRaspberry Pi)にインストールして動作させます。

infra@pi-monitor:~$ docker network create monitoring-network

infra@pi-monitor:~$ docker run -d \
-p 9115:9115/tcp \
--name blackbox-exporter \
--network monitoring-network \
-v /etc/blackbox_exporter/blackbox.yml:/config/blackbox.yml \
--restart always \
quay.io/prometheus/blackbox-exporter:latest \
--config.file=/config/blackbox.yml

monitoring-networkというネットワークを作成し接続する理由は、後ほどPrometheusサーバーをインストールする時に分かりますが、コンテナ間通信を行うためです。

また、監視にはssh_bannerを使用するので、GitHubリポジトリ内のblackbox.ymlをそのまま設定ファイル(/etc/blackbox_exporter/blackbox.yml)として問題なさそうです*2

Blackbox exporter自身のメトリクスが取得できれば、起動していることが確認できます。

infra@pi-monitor:~$ curl -s localhost:9115/metrics
# 省略

1-3. GPU exporter

NVIDIA GPUのメトリクスを公開するexporterとしては以下の2つが有名です。

github.com

github.com

両者ともにGitHubのスター数は800ほど(執筆時点)と拮抗していますが、前者は個人OSSであり「メンテナンス時間の確保が難しい」と明記されています。また、以前はGrafanaダッシュボードのデザインがカッコいいという理由だけで前者を採用しましたが、GPUのUUID*3ダッシュボードを変数フィルタリングしなければならず、Forkして独自の修正を行なっていました。

今回はNVIDIAがメンテナンスを行なっている後者を採用します。コンテナが起動したら、これまでと同様にメトリクスを取得できるかを確認してください。

infra@DLBox-Alpha:~$ docker run -d \
--gpus all \
--restart always \
-p 9400:9400 \
--name dcgm-exporter \
nvcr.io/nvidia/k8s/dcgm-exporter:3.3.7-3.5.0-ubuntu22.04

infra@DLBox-Alpha:~$ curl -s localhost:9400/metrics
# 省略

1-4. Prometheus

いよいよPrometheusサーバー自身を監視サーバーRaspberry Pi)にインストールします。

github.com

infra@pi-monitor:~$ docker run -d \
-p 9090:9090 \
--name prometheus \
--network monitoring-network \
-v /etc/prometheus:/etc/prometheus \
-v /etc/hosts:/etc/hosts:ro \
--restart always \
prom/prometheus \
--config.file=/etc/prometheus/prometheus.yml \
--web.enable-lifecycle

/etc/hostsを読み込み専用でマウント*4しているのは、監視対象をホスト名で指定して名前解決を行った方がGrafanaダッシュボードの変数フィルタリングが理解しやすくなるためです。

私の場合はホストマシンの/etc/prometheus/prometheus.ymlに以下のような設定ファイルを置いてマウントしています。

global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'prometheus'
    scrape_interval: 5s
    static_configs:
      - targets: ['localhost:9090']

  - job_name: 'node-exporter'
    scrape_interval: 15s
    static_configs:
      - targets:
        - 'DLBox-Alpha:9100'
        - 'DLBox-II:9100'
        - 'DLBox-Gamma:9100'

  - job_name: 'blackbox'
    scrape_interval: 15s
    metrics_path: /probe
    params:
      module: [ssh_banner]
    static_configs:
      - targets:
        - 'DLBox-Alpha:22'
        - 'DLBox-Gamma:22'
        - 'DLBox-II:22'
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: blackbox-exporter:9115  # The blackbox exporter's real hostname:port.
  - job_name: 'blackbox-exporter'  # collect blackbox exporter's operational metrics.
    static_configs:
      - targets: ['blackbox-exporter:9115']

  - job_name: 'dcgm-exporter'
    scrape_interval: 15s
    static_configs:
      - targets:
        - 'DLBox-Alpha:9400'
        - 'DLBox-Gamma:9400'
        - 'DLBox-II:9400'

ここでいうDLBox-**は監視対象のGPUサーバーのホスト名なので各自変更してください。

内容はほとんどREADMEの通りですが、PrometheusコンテナもBlackbox exporterコンテナもmonitoring-networkに接続しているため、blackbox-exporter:9115のようにコンテナ名で名前解決できるのがポイントです。

コンテナが起動したら、ブラウザからhttp://<Raspberry PiのIPアドレス>:9090にアクセスしてPrometheusのWeb UIを確認します。

ダークテーマがカッコいい

[Status]タブ→[Targets]を選択して、監視対象の情報を一覧表示してみます。

Stateが全てUPになっていることを確認

[Graph]タブで簡単な可視化ができますが、この後のGrafanaダッシュボードの感動を最大化するため、いつも通りスキップします。

2. Grafanaによる可視化

Prometheusをインストールした監視サーバーRaspberry Pi)に、Grafanaもインストールします。

grafana.com

infra@pi-monitor:~$ docker run -d \
--name=grafana \
--network monitoring-network \
-p 3000:3000 \
--restart always \
grafana/grafana-enterprise

コンテナが起動したら、ブラウザからhttp://<Raspberry PiのIPアドレス>:3000にアクセスしてadmin:adminでサインインします。

初めての方は、公式ドキュメントを参考にダッシュボード作成に入門するのもオススメです。今回は既存の美しいダッシュボードをインポートして利用します(参考)。

以下は作成するダッシュボードとそのID、実際のスクリーンショットです。

[1] Node Exporter Full(ID: 1860)

障害が発生していない時に眺めても新しい発見があって面白い

[2] NVIDIA DCGM Exporter(ID: 15117)

個人的にはGPU Framebuffer Memory Usedというメトリクスがかなり有用

[3]【任意】Prometheus Blackbox Exporter(ID: 7587)

HTTP以外だと情報がDurationとUPくらいなので個人的には不要

なお、ダッシュボードを作成する際にはData Soruceを指定する必要があります。今回はPrometheusを指定しますが、monitoring-networkに接続したコンテナ間通信なのでserver URLは迷わずhttp://prometheus:9090を指定できます。

さて、美しいダッシュボードは完成しましたが、ダッシュボードを眺めることが本来の目的ではありません。次の章でいよいよアラート通知の仕組みを整えます。

3. Alertmanagerによるアラート通知

最後に、Alertmanagerを監視サーバーRaspberry Pi)にインストールします。

github.com

infra@pi-monitor:~$ docker run -d \
--name alertmanager \
--network monitoring-network \
--restart always \
-v /etc/alertmanager:/etc/alertmanager \
-p 9093:9093 \
quay.io/prometheus/alertmanager \
--config.file=/etc/alertmanager/alertmanager.yml \
--web.external-url=http://<Raspberry PiのIPアドレス>:9093

Alertmanagerの設定ファイルは以下の通りで、Slackの#infra#emergency-infraという2つのチャンネルに重大度(severity)別に通知するようにしました。

# /etc/alertmanager/alertmanager.yml
global:
  slack_api_url: https://hooks.slack.com/services/*****
  resolve_timeout: 5m

route:
  receiver: 'default'
  group_by: ['alertname']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: 'slack-critical'
    - match:
        severity: warning
      receiver: 'slack-warning'

receivers:
  - name: 'default'

  - name: 'slack-critical'
    slack_configs:
      - channel: '#emergency-infra'
        username: 'Alertmanager'
        send_resolved: true
        icon_emoji: ':prometheus:'
        text: '{{ if eq .Status "firing" }}{{ .CommonAnnotations.description }}
          {{else}}{{ .CommonAnnotations.resolved_description }}{{end}}'

  - name: 'slack-warning'
    slack_configs:
      - channel: '#infra'
        username: 'Alertmanager'
        color: '{{ if eq .Status "firing" }}warning{{ else }}good{{ end }}'
        send_resolved: true
        icon_emoji: ':prometheus:'
        text: '{{ if eq .Status "firing" }}{{ .CommonAnnotations.description }}
          {{else}}{{ .CommonAnnotations.resolved_description }}{{end}}'

infraチャンネルでドキドキしたくないので、発火した状態でも色は黄色(danger)にしているのが些細な工夫点です。

それから、アラートルールについては公式ドキュメントを参考に以下のように設定しました。SSH接続ができない状態が5分続いたらcritical、exporterがダウンしたらwarningとして定義しています。インフラは好きですが、別にアラートが好きという訳ではないのでアラート疲れを回避すべく最小限にしました。

# /etc/prometheus/alert_rules.yml
groups:
- name: instance
  rules:
  - alert: InstanceDown
    expr: up == 0
    for: 5m
    labels:
      severity: warning
    annotations:
      summary: "Instance {{ $labels.instance }} down"
      description: "{{ $labels.instance }} of job {{ $labels.job }} has been down for more than 5 minutes."
      resolved_description: "{{ $labels.instance }} of job {{ $labels.job }} is now back up."

- name: ssh connection
  rules:
  - alert: SSHConnectionDown
    expr: probe_success{job="blackbox"} == 0
    for: 5m
    labels:
      severity: critical
    annotations:
      summary: "SSH connection to {{ $labels.instance }} failed"
      description: "SSH connection to {{ $labels.instance }} for job {{ $labels.job }} has been down for more than 5 minutes."
      resolved_description: "SSH connection to {{ $labels.instance }} for job {{ $labels.job }} is now restored."

descriptionresolved_descriptionを分けたのは、Slackの通知でアラートが解決した場合にも同じメッセージが添付されていると思わず2度見してしまうからです。

Prometheusの設定ファイルにはalerting:rule_files:の項目を追加する必要があります。

# /etc/prometheus/prometheus.yml
global:
  # 省略

alerting:
  alertmanagers:
    - static_configs:
        - targets:
          - alertmanager:9093

rule_files:
  - "/etc/prometheus/alert_rules.yml"

scrape_configs:
  # 省略

上記の変更が完了したら、docker restart prometheusでPrometheusサーバーを再起動します。試しに任意の監視対象サーバーでGPU exporterを一時停止してみましょう。

infra@DLBox-II:~$ docker pause dcgm-exporter
# Slack通知が届くまで5分ほど待つ
infra@DLBox-II:~$ docker unpause dcgm-exporter
# Slack通知が届くまで5分ほど待つ

#infraチャンネルにFIRINGとRESOLVEDの通知が届く

SSH接続ができない状況のアラート通知については、(ユーザーに迷惑をかけたくないので)Prometheusの設定ファイルをダミーのホスト名に変更することによってテストします。

infra@pi-monitor:~$ sed -i 's/DLBox-II:22/DLBox-III:22/' /etc/prometheus/prometheus.yml
infra@pi-monitor:~$ curl --request POST "http://localhost:9090/-/reload"
# Slack通知が届くまで5分ほど待つ
infra@pi-monitor:~$ sed -i 's/DLBox-III:22/DLBox-II:22/' /etc/prometheus/prometheus.yml
infra@pi-monitor:~$ curl --request POST "http://localhost:9090/-/reload"
# Slack通知が届くまで5分ほど待つ

#emergency-infraチャンネルにもFIRINGとRESOLVEDの通知が届く

このAlertmanagerによるアラート通知をきっかけに、これからはPrometheusやGrafanaなどを駆使して障害対応に取り組もうと思います。

まとめ

本記事では、研究室サーバーにPrometheus・Grafana・Alertmanagerを導入し、監視の枠組みを完成させました。

インストール自体は単純作業ですが、PrometheusはKubernetesとも親和性が高いので興味を持った方はぜひ以下の本も読んでみてください。非常に分かりやすく、面白いです。

booth.pm

監視については、しばらく運用して課題が見つかったら改善して記事にしようと思います。

*1:今回は再起動後に/var/log/auth.log.1や/var/log/syslog.1を調べてみましたが原因までは分かりませんでした。

*2:level=error msg="Error loading config" err="error parsing config file: yaml: unmarshal errors:\n line 42: field labels not found in type config.plain"というエラーが出た場合は、該当するssh_banner_extractモジュールのlabelsフィールドをコメントアウトしてみてください。

*3:UUIDはnvidia-smi -LでGPU一覧とともに表示されます。

*4:監視対象でない対応付けも含まれているため、意図せず名前解決される可能性があります。その場合は、dockerコマンドの--add-hostオプションを検討してください。 https://docs.docker.com/reference/cli/docker/container/run/#add-host

DockerイメージをApptainerから利用(#4)

はじめに

先日、初めてTSUBAME4というスーパーコンピュータでで実験用のプログラムを実行する機会がありました。

普段は研究室サーバーでDockerを利用して環境の分離を実践しているにも関わらず、本番だけTSUBAME4上のけしからんPythonを使って実験データを収集することは再現性の観点からも好ましくありません。

本記事では、HPC向けコンテナ環境であるApptainerからDockerを使用してスムーズにPythonプログラムを実行する方法を紹介します。

1. Apptainerとは

以下の公式ユーザーガイドと重複するため割愛させてください。

apptainer.org

「Dockerで十分じゃないか」と思うかもしれませんが、TSUBAME4のようなスーパーコンピュータにはコンテナ環境としてApptainer(旧Singularity)しか整備されていない場合がほとんどです。

公式ユーザーガイドに挙げられていたApptainerの4つのメリットのうち、(ピンと来なかった1つを除いた)3つを紹介します。

  1. GPU/高速ネットワーク/並列ファイルシステムをデフォルトで容易に利用可能(分離より統合
  2. 単一ファイルのSIFコンテナ形式は、持ち運びや共有が容易(ポータビリティ
  3. コンテナ内部でも外部と同じユーザーであり、デフォルトではホストシステム上で追加の権限を得ることはできない(シンプルで効果的なセキュリティモデル

1.について、コンテナ内からデフォルトでGPUを利用できるのはDockerと比較して便利だと感じました。2.については、Dockerでもわざわざレイヤーを1つずつ移動する人はいないし、レジストリを利用すれば問題にならないと思いました。3.はDockerユーザーの思考と相性が悪いと思いました。デフォルトでユーザーのホームディレクトリが丸ごとコンテナにマウントされていたのを初めて見た時には思わず二度見してしまいました。

これらの特徴によって、TSUBAME4をはじめとするスーパーコンピュータで、Dockerネイティブな人がApptainerを利用する心理的ハードルは依然として高いのが現状です。

2. なぜコンテナ環境が必要か

「なぜ必要か」の前に、「何がしたかったか」と「何ができなかったか」を説明します。

以下は、研究室のGPUサーバーで利用しているDockerfileを簡略化した例です。

FROM nvidia/cuda:12.1.0-cudnn8-runtime-ubuntu22.04

RUN apt-get update && \
    apt-get -y upgrade && \
    apt-get install -y \
        build-essential \
        curl \
        git \
        jq \
        libbz2-dev \
        libffi-dev \
        libgdbm-dev \
        liblzma-dev \
        libncurses5-dev \
        libnss3-dev \
        libreadline-dev \
        libsqlite3-dev \
        libssl-dev \
        wget \
        zlib1g-dev && \
    apt-get clean

ENV PYTHON_VERSION=3.12.5
ENV PATH=/root/.local/bin:/root/.pyenv/shims:/root/.pyenv/bin:$PATH

RUN set -ex && \
    curl https://pyenv.run | bash && \
    pyenv install $PYTHON_VERSION && \
    pyenv global $PYTHON_VERSION && \
    curl -sSL https://install.python-poetry.org | python -

WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry install

COPY src/ ./src/

親イメージにはnvidia/cudaを使用していています。まず、apt-getで必要なパッケージをインストールします。それから、pyenvで指定したバージョンのPythonをインストールして、Poetryでプロジェクトの依存関係をインストールします。残りはpoetry run pythonというコマンドでPythonプログラムを実行するだけです。

次に、TSUBAME4でのPythonプログラム実行を考えます。

コンテナ環境を利用せず、指定したバージョンのPythonをインストールすることは結論から言うと難しいです*1。なぜなら、現時点でTSUBAME4にインストールされているPythonバージョンは1つのみであるためです。

[uc0xxxx@login1 ~]$ python --version
Python 3.9.18

管理者がインストールした「けしからんPython」を使用したくないユーザーは、次にpyenvの導入を検討すると思います。

[uc0xxxx@login1 ~]$ curl https://pyenv.run | bash
# 省略
[uc0xxxx@login1 ~]$ pyenv install --list | grep -e '^  3.12'
  3.12.0
  3.12-dev
  3.12.1
  3.12.2
  3.12.3
  3.12.4
  3.12.5
[uc0xxxx@login1 ~]$ pyenv install 3.12.5
Downloading Python-3.12.5.tar.xz...
-> https://www.python.org/ftp/python/3.12.5/Python-3.12.5.tar.xz
Installing Python-3.12.5...

BUILD FAILED (Red Hat Enterprise Linux 9.3 using python-build 2.4.10)

Inspect or clean up the working tree at /tmp/python-build.20240820171118.832519
Results logged to /tmp/python-build.20240820171118.832519.log

Last 10 log lines:
# 省略
make: /bin/sh: Operation not permitted
make: *** [Makefile:2738: Python/hashtable.o] Error 127
make: *** Waiting for unfinished jobs....

残念ながら、pyenvで指定バージョンのPythonをインストールすることですら権限の関係で一筋縄ではいきませんでした。Ubuntuでは一般ユーザーでもインストールできたことと、検索しても類似のエラーが出てこないことから、潔く諦めました*2

私にroot権限はないのでソースビルドも難しく、ここでコンテナ環境を利用する機運が高まりました。つまりコンテナ環境が必要な理由は、自分が指定したPythonバージョンを利用するためです。

3. DockerイメージをApptainerから利用する

TSUBAME4のコンテナ環境としてはApptainerしか整備されていないため、Apptainerを利用します。

Apptainerの定義ファイルを書いてコンテナをビルドし、プログラムを実行するのも1つの方法です。その際は、以下の公式ユーザーガイドをしっかり確認してください。

apptainer.org

ただしユーザーガイドの説明は長く、そもそもApptainerがDockerほど普及していないことからモチベーションが低下したため、この方法は採用しませんでした。

仮に気になった論文のソースコードが公開されていたとします。GitHubリポジトリを覗きに行ったらApptainerの.defファイルだけ置かれていた状況を想像してみてください。大変恐ろしいです。

代わりに、ApptainerからDockerイメージを利用してコンテナをビルドする方法を採用しました。概要は以下の公式ユーザーガイドで紹介されています。

apptainer.org

例えばTSUBAME4で適当なインタラクティブジョブを実行して、パブリックなコンテナを実行することができます。

[uc0xxxx@login2 ~]$ qrsh -l gpu_1=1 -l h_rt=0:10:00
[uc0xxxx@r17n10 ~]$ apptainer run docker://sylabsio/lolcow:latest

実行結果(牛がカラフルで嬉しい)

まずは、利用するDockerイメージを何らかのレジストリにpushする必要があります。プライベートなレポジトリが利用できることと、料金を最小限に留めることを重視した結果、GitHub Container RegistryDocker Hubの2つが候補として挙げられました。

ソースコードGitHubで管理しているため、GitHub Actionsで最新のDockerイメージをビルドし、GitHub Container RegistryまたはDocker Hubにpushすることを検討していました。しかし、イメージサイズが10GBを超えているせいなのか、ワークフローの実行中に以下のようなディスク容量不足のエラーが起きてしまいました。

ワークフローの実行結果

GitHub Actionsの利用は一旦諦め、ローカルでビルドしたDockerイメージをpushしようと考えた時に既にGitHub全体に対するモチベーションが低下していたため、Docker Hubを採用しました*3。Docker Hubにイメージをpushする方法は公式ドキュメントを参考にしてください。

なんだかんだ言って使いやすいDocker Hubにイメージをpush

ここからが本題のApptainerです。

まずは、Apptainerイメージをキャッシュするディレクトリ(デフォルトは$HOME/.apptainer/cache)を指定します。TSUBAME上でホームディレクトリは1ユーザーあたり25GiBしか利用できないため、グループディスクの利用を強くオススメします。以下のようなコマンドを実行してApptainerコンテナを作成します。

[uc0xxxx@login2 ~]$ mkdir /gs/bs/tga-hogehoge/uc0xxxx/piyopiyo
[uc0xxxx@login2 ~]$ export APPTAINER_CACHEDIR=/gs/bs/tga-hogehoge/uc0xxxx
[uc0xxxx@login2 ~]$ apptainer build \
--docker-login \
--force \
-s /gs/bs/tga-hogehoge/uc0xxxx/piyopiyo \
docker://<REPOSITORY>:<TAG>

Enter Docker Username:
Enter Docker Password:
INFO:    Starting build...
# 省略
INFO:    Creating sandbox directory...
INFO:    Build complete: /gs/bs/tga-hogehoge/uc0xxxx/piyopiyo/

--docker-loginオプションを利用することでdocker loginコマンドのようにインタラクティブレジストリにログインすることができます。--forceオプションは既存のイメージを強制的に上書きします。また、-s--sandbox)オプションによってコンテナ内で書き込み可能になりますが、デフォルトでは読み込みだけ可能なSIFフォーマットになる(apptainer build ***.sifを実行する)ので用途によって使い分けるべきだと思いました。

その他のビルドオプションについては、公式ユーザーガイドを参照してください。

対象のディレクトリ内を確認します。

[uc0xxxx@login2 ~]$ ls /gs/bs/tga-hogehoge/uc0xxxx/piyopiyo/
app  boot                        dev          etc   lib    lib64   media  NGC-DL-CONTAINER-LICENSE  proc  run   singularity  sys  usr
bin  cuda-keyring_1.0-1_all.deb  environment  home  lib32  libx32  mnt    opt                       root  sbin  srv          tmp  var
[uc0xxxx@login2 ~]$ ls /gs/bs/tga-hogehoge/uc0xxxx/cache/
blob  library  net  oci-tmp  oras  shub

それでは最後に、コンテナと対話*4します。適当なインタラクティブジョブを開始してapptainer shellコマンドを実行します。

[uc0xxxx@login2 ~]$ qrsh -l gpu_1=1 -l h_rt=0:10:00
[uc0xxxx@r17n10 ~]$ apptainer shell -f --no-home -w --nvccli /gs/bs/tga-hogehoge/uc0xxxx/piyopiyo
INFO:    User not listed in /etc/subuid, trying root-mapped namespace
INFO:    Using fakeroot command combined with root-mapped namespace
WARNING: Skipping mount /etc/localtime [binds]: /etc/localtime doesn't exist in container

Dockerネイティブな人の心理的ハードルを下げるために重要なオプションとして、-f--fakeroot)と--no-homeがあります。

fakerootは、一般ユーザーがコンテナをrootユーザーとして実行するのに必要です。no-homeについては、Apptainerがデフォルトでユーザーのホームディレクトリなど*5をマウントするというありがた迷惑な機能を無効化するためのオプションです。この--no-homeオプションを付けない場合、/rootディレクトリ以下が丸ごとapptainerコマンドを実行したユーザーのホームディレクトリで上書きされ*6、せっかくDockerイメージでインストールしたpyenvpoetrypythonが実行できなくなります。

-w--writable)オプションはファイルシステムを読み書き可能にする場合に必要です。データセットのダウンロードなどで書き込みができないのは困ります。

--nvccliオプションについては、似たものとして--nvオプションがあります。詳細は公式ドキュメントに委ねますが、--nvがホストのCUDAライブラリをコンテナにバインドするのに対して、--nvccliNVIDIAによってメンテナンスされているnvidia-container-cliを使用してセットアップを行うことでコンテナ内のバージョンのCUDAライブラリが使用できると個人的に解釈しています。

コマンドの実行に成功すると、Apptainer>に続けて入力できるのでPythonバージョンとGPUを使用可能かどうか確認します。

Apptainer> nvidia-smi
Wed Aug 21 09:29:53 2024
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 545.23.08              Driver Version: 545.23.08    CUDA Version: 12.3     |
|-----------------------------------------+----------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |         Memory-Usage | GPU-Util  Compute M. |
|                                         |                      |               MIG M. |
|=========================================+======================+======================|
|   0  NVIDIA H100                    On  | 00000000:04:00.0 Off |                    0 |
| N/A   23C    P0              69W / 699W |      4MiB / 95830MiB |      0%      Default |
|                                         |                      |             Disabled |
+-----------------------------------------+----------------------+----------------------+

+---------------------------------------------------------------------------------------+
| Processes:                                                                            |
|  GPU   GI   CI        PID   Type   Process name                            GPU Memory |
|        ID   ID                                                             Usage      |
|=======================================================================================|
|  No running processes found                                                           |
+---------------------------------------------------------------------------------------+
Apptainer> cd /app
Apptainer> poetry run python
Python 3.12.5 (main, Aug 21 2024, 06:37:37) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import torch
>>> torch.cuda.is_available()
True
>>>

Pythonバージョンは指定した3.12.5であり、GPUも使用可能であることが確認できました。

今回はインタラクティブジョブとして実行しましたが、バッチジョブで実行する場合にはapptainer run(またはapptainer exec)の引数としてpythonコマンドなどを実行することになります。

まとめ

ApptainerからDockerを利用して、pyenvとPoetryでPythonプログラムを実行する方法を紹介しました。

Apptainerに関する知見の共有はまだまだ少ないと感じたので、公式ユーザーガイド(なかなか奥が深い)を読んで学んだことがあれば、また記事にしたいと思います。

いつかApptainerが脚光を浴びる時代も来るかもしれません。

*1:minicondaユーザーの場合はPythonバージョンを指定して仮想環境を作成できそうですhttps://www.t4.gsic.titech.ac.jp/docs/handbook.ja/freesoft/#miniconda

*2:衝撃的なことに、後ほどログインノードではなく計算ノードでpyenv install 3.12.5を実行することでインストールに成功しました(本記事の価値が無事に半減しました)

*3:執筆時点でDocker Hubの無料枠ではプライベートレポジトリを1つまでしか作成できないため、複数のプライベートレポジトリを利用したい場合はGitHub Container Registryをおすすめします

*4:コンテナを実行する方法はshell、exec、runの3種類ありますが、今回は理解が捗るようにshellを利用します Quick Start — Apptainer User Guide main documentation

*5:Apptainerのデフォルトのバインドマウント一覧 Bind Paths and Mounts — Apptainer User Guide main documentation

*6:--no-homeオプションでもカレントディレクトリがマウントされるため、カレントディレクトリもマウントしたくなければ--no-mount home,cwdのように両方無効化すべきだと書かれていましたが、なぜか--no-homeを指定した場合と同じ挙動でした Bind Paths and Mounts — Apptainer User Guide main documentation

Dockerオブジェクトに自動でラベル付与(#3)

はじめに

#1では、肥大化するDockerのストレージの対策としてSSDの増設と/var/lib/dockerディレクトリからの移行を紹介しました。

今回は4TBのSSDを増設しましたが、容量は決して無限ではありません。特にML系のイメージはサイズが大きく、作成したDockerオブジェクト(イメージ、コンテナ、ボリューム、etc.)を放置すると数年後には同じ運命を辿ってしまいます。

本記事では、持続可能*1なDockerオブジェクトの管理を目的として、管理者ラベルの自動付与スクリプトの設定を紹介します。

1. 要件定義

目的は、研究室サーバー上で作成されたDockerイメージ・コンテナの管理者を明確にすることです。

そもそも、#1でDockerのストレージ容量がひっ迫してしまった原因は主に以下の2点だと考えられます。

  1. 単純に使用済みのイメージ・コンテナを消し忘れる
  2. 自分が作成したイメージ・コンテナかどうか怖くて削除できない

1.については、人間なので仕方ないと思います。今は使わないイメージ・コンテナがあっても、近いうちに利用する可能性があれば消さずに取っておきたいものですし、研究に集中しすぎると研究以外のことはなおざりになってしまいがちです。

問題があると考えたのは、2.です。研究室のNotionには、以前からDockerイメージ・コンテナ作成時のテンプレートのようなものが紹介されていました。何割かの学生はテンプレートに沿って自身のユーザー名をコンテナ名に含めていましたが、一方でイメージの管理者が不明であったり、ルールが十分に浸透していなかったりという不完全さがありました。

手っ取り早い対策として、Notion上のドキュメントを完全なものにする(e.g., イメージ名とコンテナ名にユーザー名を含めるように明記する)ということも考えられますが、ターミナルとNotionを頻繁に行き来するのは依然として面倒、かつ認知負荷が高いままです。

また、ルールを策定したとしても浸透率が100%になることはないですし、ルールに違反するイメージ・コンテナを監視してSlackに通知する方法はアラート疲れの原因となりそうです。

多くのアイデアを比較検討した結果、2章で紹介するラベルをメタデータとして、3章で紹介するエイリアスによって自動的に付与する方法を採用しました。4章では、それでもカバーできないケースの対応を行っています。

2. Dockerオブジェクトのラベル

ラベルは、 Dockerオブジェクトに対してメタデータを設定する仕組みです。

docs.docker.com

ラベルは名前やタグと違って、そのオブジェクトが存在する間は不変なので、意図せず上書きされることも無さそうです。

以下はコンテナにラベルを付与する例です。

$ docker run -d --name busybox-container --label "foo=bar" busybox sleep 3600
5e19a20ba247ccaee2c6bbf18a853b3cc0717650f8b764376a25754aa09ae7b1
$ docker inspect busybox-container | jq '.[0].Config.Labels'
{
  "foo": "bar"
}
$ docker ps --filter "label=foo=bar"
CONTAINER ID   IMAGE     COMMAND        CREATED              STATUS              PORTS     NAMES
5e19a20ba247   busybox   "sleep 3600"   About a minute ago   Up About a minute             busybox-container

イメージについては、docker buildコマンドの--labelオプションだけでなく、Dockerfileに以下のように記述することでラベルを付与することもできます。

LABEL foo=bar

フィルタリング機能を利用すれば、CLI上で特定のユーザーが作成したイメージ・コンテナを手軽に確認できるだけでなく、Docker Engine SDKsを利用してより複雑な条件で絞り込むこともできそうです。

3. ラベル自動付与のスクリプト

ラベルを付与すると、簡単にフィルタリングできて便利だということが分かりました。しかし、先述したラベルの付与方法をドキュメント(e.g., Notion)に記載し、全員に意識してもらうのはベストプラクティスとは程遠いと考えました。

ChatGPTとの長きにわたる壁打ちの結果、以下のようなスクリプトdockerコマンドのラッパーを作成することにしました。

#!/bin/bash

USER_NAME=$(whoami)

if [[ $1 == "build" ]]; then
  shift
  exec docker build --label "maintainer.xxxlab=$USER_NAME" "$@"
elif [[ $1 == "run" ]]; then
  shift
  exec docker run --label "maintainer.xxxlab=$USER_NAME" "$@"
else
  exec docker "$@"
fi

maintainerキーは単純で重複する可能性が高いので、maintainer.xxxlabをキーとしました。名前の衝突を避けるため、一般的には接頭辞に逆DNS表記が利用される*2そうです。

今回は、イメージ作成時のdocker buildコマンドとコンテナ作成・実行時のdocker runコマンドを対象として--labelオプションを追加しています。

引数を1つずらすshiftコマンドや、"$1" "$2" …と等価である"$@"は使用したことがなかったので勉強になりました。

先述のスクリプト/usr/local/bin/docker-wrapperとして保存し、実行権限を付与します。それから、ユーザー全体に適用されるようにスタートアップファイル*3として追加します。

$ chmod +x /usr/local/bin/docker-wrapper
$ echo "alias docker=/usr/local/bin/docker-wrapper" > /etc/profile.d/docker_alias.sh
$ exit

再度シェルにログインして、明示的にラベルは付与せずにDockerイメージ・コンテナを1つずつ作成してみます。

$ alias
alias docker='/usr/local/bin/docker-wrapper'
# (省略)
$ docker build -t my-ubuntu - <<EOF
FROM ubuntu:latest
RUN apt-get update && apt-get install -y curl
CMD ["bash"]
EOF
$ docker run -d --name busybox-container --label "foo=bar" busybox sleep 3600

次に、ユーザー名でフィルタリングを行います。

$ docker images --filter "label=maintainer.xxxlab=azuma"
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
my-ubuntu    latest    f538b2998179   3 minutes ago   125MB
$ docker ps --filter "label=maintainer.xxxlab=azuma"
CONTAINER ID   IMAGE     COMMAND        CREATED              STATUS              PORTS     NAMES
e330e1ee116c   busybox   "sleep 3600"   About a minute ago   Up About a minute             busybox-container

無事にユーザー名のラベルを自動付与することができました。

4. ラベルが自動付与されないケースの対処

先述したdockerコマンドラッパーを数週間ほどテストしてみましたが、どうやら指定ラベルが付与されていないイメージ・コンテナが作成されているようでした。

原因を調査したところ、そのようなオブジェクトはDocker Composeを利用して作成されていました。完全に考慮し忘れていました。

また、一般的にエイリアスシェルスクリプトのような非インタラクティブシェルで展開されません。つまり、シェルスクリプトを利用してdockerコマンドを実行しているユーザーは自動ラベル付与の恩恵を受けられないということです。man bashの中に以下のような一文があります。

Aliases are not expanded when the shell is not interactive, unless the expand_aliases shell option is set using shopt (see the description of shopt under SHELL BUILTIN COMMANDS below).

スクリプトshopt -s expand_aliasesを追加するか、bash -o expand_aliasesとオプション実行することで非インタラクティブモードでも無理矢理エイリアスを展開することができます。さらに、bashコマンドに-lオプションを付けることで、Bashをログインシェルとして起動したかのように動作させる(/etc/profileを読み込ませる)ことができます。

makeでdockerコマンドを実行しているユーザーの場合は、さらにMakefileSHELL=/bin/bash -lを追記してシェルを指定しなければいけません。

これは完全にオーバーエンジニアリングであり、避けるべきです。

dockerコマンドをインタラクティブシェルで実行しているユーザーには自動ラベル付与を提供し、それ以外のユーザーにはシンプルに以下の一行をDockerfileに追加してもらうことにしました。

LABEL maintainer.xxxlab=<Linuxユーザー名>

結局2章の方法に回帰してしまい悔しいですが、全てのケースに対応するのは想定よりも遥かに難しいタスクだったため断念しました。

まとめ

持続可能なDockerオブジェクトの管理を目標に掲げ、(一般的なdockerコマンドのユーザーには)ユーザー名を値とした管理者ラベルを自動で付与することができました。

完全なツールを作成することはできませんでしたが、オーバーエンジニアリングにならない範囲でDockerオブジェクトの運用方針を周知していこうと思います。

Bashをはじめとするシェルは非常に奥が深く、勉強になりました。

*1:持続可能なエンジニアリングの象徴として、各記事の冒頭には見覚えのある雰囲気のアイコンが掲載されています

*2:Docker object labels | Docker Docs

*3:The Bash Shell Startup Files

GitHub ActionsとAnsibleでGitOpsな構成管理(#2)

はじめに

研究室で「複数サーバーへのユーザー追加を自動化したい」という要望がありました。

本記事では、AnsibleとGitHub Actions(self-hosted runner)を使用して認知負荷が低いGitOpsな構成管理を実現する方法を紹介します。

1. 要件定義

目的は、面倒なユーザー追加のオペレーションを自動化することです。研究室に新しいメンバーがjoinした際の既存のオペレーションを以下に示します。

既存のユーザー追加のオペレーション

  1. 対象のサーバーにSSH接続する
  2. rootユーザー(あるいはroot権限を持つユーザー)に切り替える
  3. useraddコマンドでユーザーを追加する(人数分繰り返す)
  4. usermodコマンドでユーザーをdockerグループに追加する(人数分繰り返す)
  5. SSH接続を切断する

1年に1回とはいえ、手作業でこのオペレーションを行うのは大変です。さらに対象のサーバーが複数台ある場合は、この作業を台数分だけ繰り返さなければなりません。

機能要件として、管理者が1箇所を直感的に編集するだけで自動的に全てのサーバーが変更(ユーザーの追加や削除など)されるようにしたいです。

2. 技術選定

2-1. LDAP

調査を行ったところ、ディレクトリサービスを使用してネットワークに接続した資源(ユーザー情報など)を統一管理するという方法がありました。LDAP(Lightweight Directory Access Protocol)は、そのディレクトリサービスに接続するための標準プロトコルです。

また、LDAPを導入してサーバーのユーザー管理を行なっている企業のテックブログもありました。

engineering.dena.com

OpenLDAPは、LDAPを実装したオープンソースのソフトウェアの1つです。公式ドキュメントもありますが、実際に導入する際には以下の記事(3部構成)が最も分かりやすいと思います。

server-network-note.net

イメージとしては、LDAPサーバーが電話帳、LDAPクライアントが電話帳の利用者の役割を果たします。電話帳には多くの人々の連絡先情報が集中管理されており、利用者は電話帳から必要な情報を検索したり、新しい情報を追加したり、既存の情報を更新したりすることができます。

さて、LDAPを導入することになった場合、先生または学生のうち最低でも1人は技術的なキャッチアップが必要になります。本来の目的が研究であることや、インフラに興味のある学生がjoinするとは限らないことから、技術的には面白そうだと思いながらもLDAPの採用は見送ることになりました。

2-2. Ansible

もう1つの候補として、Ansibleが挙げられます。Ansibleは言わずと知れたオープンソースの構成管理ツールです。Inventoryに書かれた管理対象ノードに対して、YAML形式のPlaybookに従ってオペレーションが実行されます。

Ansibleの仕組み(引用:Getting started with Ansible — Ansible Community Documentation

一般的には、管理者がコントロールノードでAnsibleコマンドを実行することで管理対象ノードに設定が適用されます。

研究室内のサーバーに限らず、VPN接続した自分のPC(MacBookなど)をコントロールノードにしても良いのですが、依然として管理者がAnsibleコマンドの使い方を覚える必要があります。

この認知負荷を下げるための工夫として、2つの方法が考えられました。

  1. ansible-pullを全ての監視対象ノードに導入する
  2. VPN内のノードにGitHub Actionsのself-hosted runnerを構築する

両者の共通点は、GitリポジトリをSingle Source of TruthとするGitOpsの考え方に基づいている点です。

GitOpsはKubernetes環境でよく採用されます*1が、Ansibleでもシステムの望ましい状態を宣言的に記述できるため同様にGitOpsを採用できるはずです。

GitOpsを採用することで、管理者がGitリポジトリにPlaybookの変更をpushするだけで適用されるだけでなく、誰がいつどのような設定を適用したのか把握でき可視性が向上します。

1.のansible-pullというコマンドラインツールは、Ansibleの公式ドキュメントを眺めていた時に偶然発見しました。

Used to pull a remote copy of ansible on each managed node, each set to run via cron and update playbook source via a source repository. This inverts the default push architecture of ansible into a pull architecture, which has near-limitless scaling potential.

と書かれているように、AnsibleのPush型のアーキテクチャをPull型に反転させることができそうです。

Push型アーキテクチャとPull型アーキテクチャ

公式ドキュメントには、監視対象となる各ノード上でansible-pullを実行すると書かれています。この場合、全ての監視対象ノードにansibleのインストールとcronの設定が必要になり、ノード数が多い場合は導入コストの増加が見込まれます*2

それから、2.のGitHub Actionsはお馴染みのツールです。

一般的なGitHub-hosted runnerを利用する場合は、VPN内へのインバウンド接続を許可するためにSSH秘密鍵VPNのパスワードをSecretsに保存しなければなりません。一方でself-hosted runnerを利用する場合はクラウドサービスやローカルマシン上でワークフローを実行することができ、GitHubにクレデンシャルを保存する必要もありません。ただし、runnerマシンのメンテナンスコストは自己負担となるため注意が必要です。

これまでに2つのアイデアを紹介しました。個人的にはansible-pullを導入する方が技術的に面白いと思いましたが、研究室のために少し冷静になって考えました。メンテナンスコスト(runnerマシンのみ vs 全監視対象ノード)や実行頻度(1年に数回)を考慮して、総合的にself-hosted runnerを構築するのがベターだという結論に至りました。

3. self-hosted runnerの構築

以前、監視対象ノードとは別にRaspberry Piで監視サーバーを構築していたので、このマシンにself-hosted runnerを構築することにしました。

Raspberry Pi 4BとPironmanによる監視サーバー

GitHubで適当なリポジトリを1つ作成し、SettingsActionsRunnersを開いてNew self-hosted runnerボタンをクリックします。表示されているいくつかのコマンドを実行するだけでインストールと設定が完了するので本当に便利です。

Raspberry Piを使用するのでARM64を選択

./run.shで実行しても良いのですが、いずれサービスとして起動したい場合は、以下の公式ドキュメントを参照してください。シェルスクリプトが提供されていて、systemctlコマンドを直接実行することなくサービスの起動・ステータス確認・削除ができます。

docs.github.com

サービスとして有効化されているかsystemctlコマンドでも確認

公式ドキュメントに従ってrunnerマシンにAnsibleをインストールしたら、runnerマシンから監視対象ノードにSSH接続できるように公開鍵の登録などを完了させておきます。

self-hosted runner上でAnsilbeを実行し、監視対象ノード上でHello Worldをファイル出力できるか検証してみます。ディレクトリ構造はシンプルです。

$ tree -a -L 3 -I '.git|README.md' .
.
├── .github
│   └── workflows
│       └── test.yaml
├── hosts
└── playbook.yaml

ワークフローファイルである.github/workflows/test.yamlは以下の通りです。直接ansible-playbookコマンドを実行しても良かったのですが、可読性が上がりそうなのでdawidd6/action-ansible-playbookを使用しました。

name: Test Action
on:
  push:
    branches:
      - main

jobs:
  deploy:
    # https://docs.github.com/ja/actions/hosting-your-own-runners/managing-self-hosted-runners/using-self-hosted-runners-in-a-workflow#using-custom-labels-to-route-jobs
    runs-on: [self-hosted, linux, ARM64]
    steps:
      - uses: actions/checkout@v4
      - name: Run playbook
        uses: dawidd6/action-ansible-playbook@v2
        with:
          playbook: playbook.yaml
          directory: ./
          options: |
            --inventory hosts
            --verbose

Inventoryファイルであるhostsには、以下のように適当な監視対象のホストを追加します。エイリアスfoo)やホストに接続する際に使用するユーザー名(bar)は適宜変更してください。

[all]
foo ansible_host=192.168.1.xxx ansible_user=bar

playbook.yamlは以下の通りです。

- name: Write "Hello World" to a file
  hosts: all
  tasks:
    - name: Create or overwrite ~/results.txt with "Hello World"
      copy:
        content: "Hello World\n"
        dest: "{{ ansible_env.HOME }}/results.txt"

検証なのでmainブランチに直接pushさせていただきます。

追加したself-hosted runner上でワークフローが実行された

指定したホストのホームディレクトリ上にもHello Worldと書かれたresults.txtというファイルが存在しているはずです。

4. ユーザー追加の自動化

先述のシンプルなHello Worldを、ユーザー追加のPlaybookに変更して完成です。

このタイミングで、ディレクトリ構成もAnsibleのベストプラクティス*3になるべく沿うようにしました。

$ tree -a -L 4 -I '.git|README.md' .
.
├── .github
│   └── workflows
│       └── test.yaml
├── hosts
├── roles
│   └── user
│       ├── tasks
│       │   └── main.yaml
│       └── vars
│           └── main.yaml
├── servers.yaml
└── site.yaml

メインとなるroles/user/tasks/main.yamlは以下の通りです。ansible.builtin.userモジュールを使用します。

---
- name: Ensure users are present
  ansible.builtin.user:
    name: "{{ item.name }}"
    uid: "{{ item.uid }}"
    password: "{{ item.password | password_hash('sha512') }}"
    update_password: "on_create"
    append: true
    groups: "docker"
    state: "present"
  with_items: "{{ users }}"

キーとなるパラメータはpasswordupdate_passwordの2つです。passwordについてLinuxでは暗号化ハッシュを指定する必要があり不便なので、password_hashフィルタを利用します。update_password: "on_create"(デフォルトは"always")は、ユーザーが変更したパスワードを初期パスワードで上書きしないために必須です。GitHub Actionsでワークフローが何回実行されても、ユーザーが存在している状態は変わらないという冪等性を担保します。

なおservers.yamlでは、hostsに書かれた[servers]グループとuserロールのマッピングのみを行います。

---
- hosts: servers
  become: true
  roles:
    - user

特権ユーザーになるためにbecome: trueとしていますが、vars: ansible_become_password:GitHubのSecretsから環境変数として読み込むことは面倒だったので、監視対象ノードで以下のコマンドを実行することで省略することができました。

# 実行ユーザーが`infra`の場合
$ echo "infra ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/infra

追加するユーザーの情報はroles/user/vars/main.yamlに追記していきます。

---
users:
  - name: foo
    uid: 2147483646
    password: foo
  - name: bar
    uid: 2147483647
    password: bar

このYAMLファイルを変更してmainブランチにpushするだけで、全ての監視対象ノードでユーザーが存在している状態になります。

3台の監視対象ノードでfooとbarというユーザーが作成された

なお、ユーザー削除については研究データを長期保存するという特性上、ほとんどニーズが無いので今回は敢えてPlaybookに含めませんでした。

まとめ

Ansibleとself-hosted runnerを使用して、ユーザー追加をはじめとした構成管理を簡単に行えるようになりました。

runnerマシンは、通常のコントロールノードとして各種ツールのインストールやOSアップデートにも活用する予定です。とても楽しみです。

*1:代表的なGitOpsツールにArgoCDFluxなどがあります

*2:Pull型を採用する場合でも、初期設定のみPush型のアーキテクチャを利用すれば導入コストを削減できそうです

*3:v2.9ではBest Practicesとして紹介されていましたが、最新のv10ではSample directory layoutという表現に変更されていました