自前のJupyterLab/HubでSageMath Docker Imageを利用する方法

# 前口上（興味ない方はスキップしましょう）

SageMathはPythonベースのOSS数学システムです。JupyterLab/Hubとの組合せで計算しながら勉強・研究ノートを作るのが非常にやりやすくなったので重宝しています。（$\LaTeX$ コマンドがほぼ自由に使えるMarkdown素晴らしい！）

MathematicaやMaple、Maximaでも古くからnotebookの機能はありましたが、Jupyterほど標準化しておらず、また、機能的にもあまり満足のできるものではありませんでした。仕方なくこれらのシステムは計算に専念させるという使い方を長らくしてきた身としてはSageMath + Jupyterは非常に新鮮でした。

さて、そんなSageMathですが、最新版を手軽に（しかも**安定的に！**）利用できる環境は意外と少ないのです。[公式ドキュメント](https://doc.sagemath.org/html/en/installation/index.html)によれば

- 3-manifolds projectが用意したMacOS向けバイナリを使う
- Arch Linuxの標準パッケージを使う
- Docker imageを使う
- Conda-forgeを使う

でしょうか。Windowsに至ってはWSL2をインストールして（要するにWindows上にLinux環境を構築して）導入するという見捨てられた感とでもいいましょうか…（笑）
Linuxの各ディストリビューションからも標準パッケージでSageMathは提供されていますが、コンスタントに最新版を追いかけているのは日本では利用者が少なそうな（？）Arch Linuxのみ。

Conda-forgeがあるならそれでユニバーサルに使えるんじゃないのか？ とも思えますが、どうなんでしょう？（私は使ったことがない）　過去にSageMathはLinux x86_64向けのコンパイル済みバイナリファイルをtarballで提供していた時期もありましたが、同じUbuntuのバージョンでもIntel Xeon CPUの世代違いで動いたり動かなかったりと不安定でした[^1]。そういう過去があるだけに、あまり信用ができない…

そこへ来てDockerイメージならばかなり期待できる。なにしろビルド時と利用時で同じ共有ライブラリ環境にできますからね。よほど病的な問題でない限りはうまく動くはず。

最終的にはソースコードから全ビルドするという手もありますが、ビルド環境用にコンパイラやら依存ライブラリのヘッダファイルやら関連ファイルをインストールする必要があります。また、巨大なプロジェクトなのでビルドにかなりの時間を要します。（割と新しいXeonマシンで12並行実行でも40分程度かかります。**Raspberry Pi 4 8GBでは4並行実行で7時間程度**もかかります！）

いや、それ以上にそもそも[ビルドをどうやってやるのか](https://doc.sagemath.org/html/en/installation/source.html)などの学習コストがバカになりません。ビルド時のエラーにも自分で対応しなければならなくなります。そういう作業が趣味というのでない限りは茨の道です。

というわけで、Dockerコンテナに注目します。

[^1]: SageMathはPython上で動くが、内部のエンジンや利用している数学パッケージの多くがCだったりC++だったりで書かれていて、コンパイル時に前提としていたライブラリの有無やバージョン違いなどの問題を解決できていないのだと思われる。SageMathほど巨大な寄せ集めだとこれらを完全解決するのは無理があるだろう。


# 1. 概要

SageMathは公式プロジェクトがDockerイメージを用意していてくれているのでこれを利用します。SageMathのDockerイメージは[公式のDocker Hub](https://hub.docker.com/r/sagemath/sagemath)で提供されています。

READMEを読むと分かりますが、SageMathのイメージ内にJupyterLabがインストール済みであり、**自前環境のJupyterLabを使う必要がない人はおとなしくSageMath付属のJupyterLabを使いましょう**。私の場合は[自前のJupyterHub環境](https://qiita.com/yksantaro/items/8f683bc9a8f4bef1d051)を用意しているので、その環境からSageMathコンテナを起動するようにしていきます。

SageMathを自前のJupyterから利用する場合は公式ドキュメントの"[Setting up SageMath as a Jupyter kernel in an existing Jupyter notebook or JupyterLab installation](https://doc.sagemath.org/html/en/installation/launching.html#setting-up-sagemath-as-a-jupyter-kernel-in-an-existing-jupyter-notebook-or-jupyterlab-installation)"にしたがってJupyter kernelを準備するのが基本です。しかしながら、**コンテナの場合はユーザーやディレクトリ構成が、一般的にはホストOSとコンテナで異なるので、この問題を解決する**のが本ドキュメントの主題となります。

# 2. 通常構成と実現したい構成

例えば自前のJupyterLab/Hubにおいて、IPythonカーネル、Wolfram Engineカーネル、SageMathカーネルを運用する状況を考えてみましょう。Dockerイメージを使わない場合は以下のような構成となります。JupyterLab/Hub含むすべてのプロセスはホストOS上で動きます。

【図1: コンテナを使わないJupyter構成】
```mermaid
flowchart TB
  subgraph J["JupyterLab/Hub"]
    PK["IPython<br>Kernel"]
    SK["SageMath<br>Kernel"]
    WK["Wolfram<br>Engine<br>Kernel"]
  end
  PK --"Data I/O"--- P["IPython<br>Process"]
  WK --"Data I/O"--- W["Wolfram<br>Engine<br>Process"]
  SK --"Data I/O"--- S["SageMath<br>Process"]
  P --"IPython<br>File I/O"--- LV[("User Storage<br>(~/notebook)")]
  W --"Wolfram<br>Engine<br>File I/O"--- LV
  S --"SageMath<br>File I/O"--- LV
  J --"Jupyter<br>File I/O"--- LV
```

Jupyterは各カーネルに対応する実体プロセスを起動し、カーネルはそのプロセスと通信してセル入出力のためのやり取り（Data I/O）を行います。また、ファイル操作が必要な場合はJupyterLab/Hubおよび各プロセスは直接ユーザー・ストレージにアクセスしてFile I/Oを行います。

さて、SageMathをDockerコンテナにした場合は以下のような構成に仕立て上げないと標準的な操作すらままならなくなります。

【図2: コンテナを使ったJupyter構成】
```mermaid
flowchart TB
  subgraph J["JupyterLab/Hub"]
    PK["IPython<br>Kernel"]
    SK["SageMath<br>Kernel"]
    WK["Wolfram<br>Engine<br>Kernel"]
  end
  subgraph DS["SageMath Container"]
    direction RL
    S["SageMath<br>Process"]
    CV[("User Storage<br>(/home/sage)")]
  end
  PK --"Data I/O"--- P["IPython<br>Process"]
  WK --"Data I/O"--- W["Wolfram<br>Engine<br>Process"]
  SK --"Data I/O"--- S["SageMath<br>Process"]
  P --"IPython<br>File I/O"--- LV[("User Storage<br>(~/notebook)")]
  S --"SageMath<br>File I/O"--- LV
  W --"Wolfram<br>Engine<br>File I/O"--- LV
  J --"Jupyter<br>File I/O"--- LV
```

見ての通り、SageMathコンテナ内ではsageユーザーしかおらず、ホームディレクトリも/home/sageです。このままではSageMathプロセスはコンテナ内でしかファイル操作ができません。Jupyterで扱うためにはSageMathコンテナ内のSageMathプロセスがコンテナ外のJupyterとデータ通信できないといけませんし、Jupyterユーザーのストレージがコンテナ内のSageMathプロセスから読み書きできないといけません。

幸いなことに、**Jupyter KernelとプロセスのData I/Oは（connection_fileと名付けられた）ファイル経由で行われ、Jupyterユーザーのストレージもコンテナ内から扱えればよいので、Dockerのvolume mountで解決できます**。


# 3. 準備手順

以降は2023年2月現在の最新環境である**SageMath 9.7 Docker image**を対象として手順を記載しています。また、Dockerエンジン自体のセットアップについては割愛します。

## 3.1. 公式Dockerイメージのpull、動作確認

docker runする前にあらかじめイメージをpullしておきます。（いきなりrunしても問題ないですが一応…）

```bash
docker pull sagemath/sagemath:9.7
```

pullが上手く行けば、runしてコンソールSageMathの動作確認します。

```
docker run --rm -it sagemath/sagemath:9.7
```

以下のようにそのままコンソールでSageMathが起動します。

```
┌────────────────────────────────────────────────────────────────────┐
│ SageMath version 9.7, Release Date: 2022-09-19                     │
│ Using Python 3.10.5. Type "help()" for help.                       │
└────────────────────────────────────────────────────────────────────┘
sage: var('x,y')
(x, y)
sage: expand((x + y)^3)   # 正しく式展開できるか確認
x^3 + 3*x^2*y + 3*x*y^2 + y^3
sage: exit                # とりあえず正しく動いているみたいなので終了
```

## 3.2. Jupyter kernel関係ファイルの取り出し

SageMathイメージ内のJupyter Kernel関連ファイルを取り出して、自前のJupyterLab/Hub内に配置します。

```bash
# SageMath dockerのbashを起動する
# 作業ユーザーのホームディレクトリ内にコピー先を作り、データをコピーする
mkdir ~/tmp
# ~/tmp をコンテナ内の /home/sage/tmp にvolume mount してコンテナを起動する
docker run -it --rm -v ~/tmp:/home/sage/tmp sagemath/sagemath:9.7 bash
```

以降はコンテナ内（起動したbash）での作業になります。

```bash
# Jupyter kernelディレクトリを探す
cd /
find -name kernels -print
# findの結果から /home/sage/sage/local/var/lib/sage/venv-python3.10.5/share/jupyter/kernels だと分かる。
cd /home/sage/sage/local/var/lib/sage/venv-python3.10.5/share/jupyter/kernels
ls -l
# sagemathディレクトリが存在することを確認してから、ディレクトリごとコンテナ外にコピーする。
cp -r sagemath ~/tmp/
ls -l ~/tmp/sagemath
# lsの結果を見ると分かるが、Jupyter用アイコンへのリンクが間違っている場合がある。
# Jupyter用アイコンも必要ならfindで探してこれもコピーする。
exit
```

ホストOSにコピーされたkernel関連ファイルをJupyterLab/Hubのkernel置き場にコピーします。  
以下のコマンドは[「JupyterHubのminimalな構築手順」](https://qiita.com/yksantaro/items/8f683bc9a8f4bef1d051)で構築したJupyterディレクトリを元にしています。

```bash
cd ~/tmp
sudo cp -r sagemath /opt/local/python/share/jupyter/kernels/
```

以上により、kernel関連ファイルの配置は完了です。続いてkernelファイルをDockerコンテナ用に修正します。

## 3.3. Dockerコンテナ向けkernelファイルの作成

以下の4つのファイルを準備します。

| ファイル名      | 説明 |
| ---            | --- |
| kernel.json    | SageMath用Jupyter kernel設定 |
| logo-64x64.png | Jupyter用アイコン その1 |
| logo.svg       | Jupyter用アイコン その2 |
| sage-docker    | 実行形式シェルスクリプト（本節で作成） |

kernel.json と sage-docker により、2.で説明したData I/OとFile I/Oの問題を解決します。それぞれのファイルを以下のように修正／作成します。

```:kernel.json
{"argv": ["/opt/local/python/share/jupyter/kernels/sagemath/sage-docker", "{connection_file}"], "display_name": "SageMath 9.7", "language": "sage"}
```

上記ファイルの「/opt/local/python/share/jupyter/kernels/sagemath/sage-docker」は以下で作成する**sage-docker実行形式シェルスクリプトへのフルpathです。（自分の環境に合わせて修正してください）**

```bash:sage-docker
#!/bin/bash
/usr/bin/docker run --rm --network=host -u $(id -u ${USER}):$(id -g ${USER}) -v $1:$1 -v ${HOME}:${HOME} -e HOME -w `pwd` yksantaro/sagemath_arm64:9.7 /home/sage/sage/local/var/lib/sage/venv-python3.10.5/bin/sage --python -m sage.repl.ipython_kernel -f $1
```

ファイルの作成が完了したら、"sage-docker"に実行権を与えておきます。また、**Jupyterのログインユーザー権限でdockerコマンドが実行できるようにしておく必要があります**。

```bash
cd /opt/local/python/share/jupyter/kernels/sagemath/
# sage-dockerへの実行権
sudo chmod 755 sage-docker
# dockerの実行権設定。dockerグループに参加させる（ユーザー毎に権限を与える必要がある）
# 実行権がないとユーザー権限ではdockerを実行できない
sudo gpasswd -a yksantaro docker
```

:::note warn
Dockerの一般ユーザーへの実行権限付与については、セキュリティ面を十分に考慮の上実施してください。（私の環境では利用者が自分だけだったり、十分に知識のある同僚開発者だったりするので、難しいことを考える必要がないだけです）
:::

以上により、準備は完了です。Jupyterの再起動等しなくともSageMath Dockerコンテナが利用できるようになっています。JupyterLabを起動／JupyterHubにログインして正常に利用できるか確認してください。

## 3.4. 【おまけ】kernel.jsonとsage-dockerについての補遺

sage-dockerを読むと分かるかと思いますが、SageMath Dockerコンテナ内で実行されているのは、

```bash
/home/sage/sage/local/var/lib/sage/venv-python3.10.5/bin/sage --python -m sage.repl.ipython_kernel -f $1
```

です。この内容は取り出した kernel.json にもともと書かれていたプロセス起動コマンドです。ここで、シェルの引数 $1 には、Jupyter Spawnerが生成した{connection_file}の実際のPath（ホームディレクトリ内に作成されているはず）が渡されます。この{connection_file}がJupyterとsageのData I/Oの役割を果たします。

JupyterHubのSingle user spawnerはkernel.jsonの内容に基づき、実際のログインユーザーの権限で（ログイン時に）プロセスを実行します。したがって**プロセス（docker）はユーザー権限で実行できなければならず、dockerが読み書きするvolume mountファイルもそのユーザー権（root権限にあらず）で読み書きできないといけません**。したがって、sage-docker内のdocker起動オプションに

```bash
-u $(id -u ${USER}):$(id -g ${USER}) -v $1:$1 -v ${HOME}:${HOME} -e HOME -w `pwd`
```

を与えているのです。

本来ならばこれらのコマンドを直接kernel.jsonに書きたいところですが、**jsonファイル内で起動ユーザー情報を環境変数から取得できないため、sage-dockerなるシェルのwrapperを嚙ましている**というわけです。

:::note info
私のJupyterHubでは「~/notebook」を作業ディレクトリと設定しているので、$HOMEをホストOSとコンテナで共有することでうまくいっています。別の作業ディレクトリを設定している場合はそれに合わせて適切にsage-dockerを修正してください。
:::

以上で完了です。お疲れ様でした。