コンテナ内でClojureを動かす

はじめに

ある日、こんな構成で作業していることに気づいた。

  • ホスト(Mac): Emacs 31.0.50 が動いている
  • コンテナ(Debian 12 bookworm / ARM64): Claude Code が動いている

Emacs はホスト側にいる。Claude Code はコンテナ内にいる。 そこで素朴な疑問が浮かんだ。

ホスト側の Emacs から、コンテナ内の Clojure に cider-jack-in できるのか?

これはただの好奇心ではなく、実用的な問いでもある。 コンテナ内で作業するなら、REPL駆動開発の恩恵もコンテナ内で受けたい。

本稿では、実装を進めながらやったことをそのまま記録していく。 うまくいかなかったことも含めて書く。

環境の確認

コンテナ内の状況を確認したところ、以下のことがわかった。

OS: Debian GNU/Linux 12 (bookworm)
アーキテクチャ: ARM64 (aarch64)
Java: 未インストール
Clojure: 未インストール
Leiningen: 未インストール

コンテナは python:3.13.6-slim-bookworm をベースに構築されており、 curl, git, build-essential などの基本ツールや Node.js, Docker CLI は入っているが、 JVM 系は何もない状態だ。

CIDER接続の仕組みを整理する

cider-jack-in は何をするか。シンプルに言えば:

  1. lein replclojure -M:cider/nrepl などのコマンドを 子プロセスとして起動 する
  2. そのプロセスが立ち上げた nREPL サーバーに接続する

ここで問題になるのは、「子プロセスをどこで起動するか」だ。 Emacs がホスト上にいる以上、何も考えなければホスト上でコマンドを実行しようとする。

解決策は主に2つある。

方法1: cider-connect(手動起動)

コンテナ内で nREPL サーバーを手動起動し、ポートをホストに expose した上で M-x cider-connectlocalhost:<port> に接続する方法。

確実に動くが、=jack-in= ではないため毎回手動で起動する手間がある。

方法2: TRAMP経由でcider-jack-in

Emacs 29 以降に標準搭載された tramp-container を使う方法。 Emacs 31 ならそのまま使える。

TRAMP でコンテナ内のファイルを開けば、 Emacs はそのバッファを「リモートホスト上のファイル」として扱う。

C-x C-f /docker:cc78495d33df:/path/to/project/

この状態で cider-jack-in を実行すると、CIDER が TRAMP を検知して コンテナ内でプロセスを起動してくれる。 ポートの expose は不要 で、TRAMP がトンネリングを担う。

今回はこの方法を採用する……つもりだった。

実装方針

確認してから Dockerfile に反映する、という流れを取る。

  1. 今動いているコンテナに直接 Java + Clojure CLI をインストール

    • openjdk-21-jdk を apt で入れる
    • Clojure CLI (deps.edn ベース) を公式スクリプトで入れる
  2. deps.edn プロジェクトを作り、nREPL 依存を追加する
  3. ホスト Emacs から TRAMP 経由で cider-jack-in を試す

    • /docker:cc78495d33df:/... でファイルを開く
    • cider-jack-in が通るか確認する
  4. 動作確認できたら Dockerfile に反映する

まず動かして、それから固める。

実装記録

Java のインストール

まず Java を入れる。=openjdk-21-jdk= を試みたが、Debian 12 bookworm のメインリポジトリには含まれていなかった。

$ apt-cache search openjdk
openjdk-17-jdk - OpenJDK Development Kit (JDK)
...(21 は見当たらない)

OpenJDK 21 は 2023年9月リリースで、bookworm(2023年6月リリース)のメインリポジトリには間に合っていない。 bookworm-backports を有効にすれば 21 を入れられるが、今回は 17 で進めることにした。

apt-get install -y openjdk-17-jdk

確認すると、実はすでにインストール済みだった。

openjdk version "17.0.19" 2026-04-21
OpenJDK Runtime Environment (build 17.0.19+10-1-deb12u2-Debian)

Clojure CLI のインストール

公式スクリプトでインストールする。

curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh
chmod +x linux-install.sh
sudo ./linux-install.sh
$ clojure --version
Clojure CLI version 1.12.5.1654

deps.edn プロジェクトの作成

/tmp/clj-repl/ にプロジェクトを作る。

;; /tmp/clj-repl/deps.edn
{:paths ["src"]
 :deps {}
 :aliases
 {:cider/nrepl
  {:extra-deps {nrepl/nrepl {:mvn/version "1.3.1"}
                cider/cider-nrepl {:mvn/version "0.52.1"}}
   :main-opts ["-m" "nrepl.cmdline"
               "--middleware" "[cider.nrepl/cider-middleware]"
               "--port" "7888"]}}}

依存関係を事前ダウンロードし、nREPL が起動することを確認した。

nREPL server started on port 7888 on host localhost - nrepl://localhost:7888

CIDER のインストール

ホスト Emacs には CIDER が入っていなかった。=package.el= でインストールする。

M-x package-refresh-contents RET
M-x package-install RET cider RET

cider-jack-in の試み(失敗)

/docker:c4379881374a:/tmp/clj-repl/ を TRAMP で開き、=cider-jack-in= を実行した。

エラー1: 直接 TCP 接続の失敗

[nREPL] Direct connection to c4379881374a:45409 failed;
try setting 'nrepl-use-ssh-fallback-for-remote-hosts' to t

CIDER がコンテナの nREPL ポートに直接 TCP 接続しようとして失敗した。 エラーメッセージの通り、=nrepl-use-ssh-fallback-for-remote-hosts= を t に設定して再試行した。

エラー2: SSH トンネルの失敗

[nREPL] SSH port forwarding failed.
Check the '*nrepl-tunnel %s*' buffer

SSH フォールバックは SSH トンネルを張ろうとするが、=/docker:= TRAMP は docker exec で動いており SSH を使わない。 そのため SSH トンネルも失敗する。

結論: cider-jack-in は Docker TRAMP では動作しない

CIDER の TRAMP 統合は SSH ベースの TRAMP メソッドを前提としている。 /docker: メソッドでは、nREPL サーバーの起動(=docker exec= 経由)はできるが、 その後の接続を TRAMP 経由でトンネリングする仕組みがない。

cider-connect への切り替え

方針を変更し、=cider-connect= で進めることにした。

  1. コンテナ内で nREPL をバックグラウンド起動(port 7888)
  2. ホスト側でポートをフォワード
  3. cider-connectlocalhost:7888 に接続

ポートフォワードには socat を使う。

socat のインストール(Mac 側)

brew install socat を実行する前に、サプライチェーン攻撃のリスクを確認した。

Formula の検証

brew cat socat は homebrew/core が tap されていないため動作しない。 代わりに直接 Formula を取得する。

curl -s https://raw.githubusercontent.com/Homebrew/homebrew-core/HEAD/Formula/s/socat.rb

Formula に記載されている SHA256 を公式サイトのチェックサムと照合した。

確認元 SHA256
Homebrew Formula f68b602c80e94b4b7498d74ec408785536fe33534b39467977a82ab2f7f01ddb
dest-unreach.org f68b602c80e94b4b7498d74ec408785536fe33534b39467977a82ab2f7f01ddb

完全一致。ソースの改ざんはない。

sandbox-exec によるインストール時のサンドボックス化

SHA256 が一致していても、Formula 自体(シェルスクリプト)が悪意のあるコードを実行する可能性は残る。 そこで、=brew install= 時のファイルシステムアクセスを OS レベルで制限することにした。

macOS 標準の sandbox-exec を使い、以下の方針でプロファイルを作成した。

  • ベースは =allow default=(deny-all は brew のビルドを壊しやすい)
  • ホームディレクトリへの書き込みを全禁止
  • Homebrew に必要なパス(=/opt/homebrew=, キャッシュ, ログ, 一時ディレクトリ)のみ再許可
  • ~/.ssh, ~/.gnupg, ~/.aws などの機密ファイルへの読み取りも禁止
;; brew.sb(抜粋)
(allow default)

(deny file-write*
  (subpath (param "HOME"))
)
(allow file-write*
  (subpath "/opt/homebrew")
  (subpath (string-append (param "HOME") "/Library/Caches/Homebrew"))
  (subpath (string-append (param "HOME") "/Library/Logs/Homebrew"))
  (subpath "/private/tmp")
  (regex #"^/private/var/folders/")
)
(deny file-read*
  (subpath (string-append (param "HOME") "/.ssh"))
  (subpath (string-append (param "HOME") "/.gnupg"))
  (subpath (string-append (param "HOME") "/.aws"))
)

実行は以下のようにする。

sandbox-exec -D HOME="$HOME" -f brew.sb brew install socat

動作確認

インストールは正常に完了した。

==> Pouring socat--1.8.0.3.arm64_sonoma.bottle.tar.gz
🍺  /opt/homebrew/Cellar/socat/1.8.0.3: 16 files, 970.9KB

機密ファイルのブロックも確認できた。

$ sandbox-exec -D HOME="$HOME" -f brew.sb cat ~/.ssh/id_rsa.pub
cat: /Users/sximada/.ssh/id_rsa.pub: Operation not permitted

sandbox-exec は Apple が非推奨としているが、現時点では動作する。 より堅牢にするなら Lima や OrbStack などの VM 内でパッケージ管理するのが筋だろう。

ポートフォワードと cider-connect

nREPL のバージョン不一致

最初のタイムアウトエラーはバージョン不一致が原因だった。

Sync nREPL request timed out (op clone ...) after 10 secs

cider-jack-in の残骸プロセスを調べると、CIDER 1.22.0 が自動注入するバージョンは nrepl 1.7.0 / cider-nrepl 0.59.0 だとわかった。 一方、こちらの deps.ednnrepl 1.3.1 / cider-nrepl 0.52.1 を指定していた。

deps.edn のバージョンを CIDER に合わせて修正した。

{:extra-deps {nrepl/nrepl {:mvn/version "1.7.0"}
              cider/cider-nrepl {:mvn/version "0.59.0"}}}

macOS のファイアウォールダイアログ

socat 起動時に macOS から「着信接続を許可するか」というダイアログが表示された。

ここで注意が必要だった。デフォルトの TCP-LISTEN:7888 は全インターフェース (=0.0.0.0=)でリスンするため、同じネットワーク上の他のマシンからも nREPL に 接続できてしまう。

bind=127.0.0.1 を追加してローカルホスト限定にしてから許可するのが正しい手順だ。

一度拒否してしまった場合は、以下で変更できる。

システム設定 → ネットワーク → ファイアウォール → オプション
(socat を「許可」に変更)

socat のクォート問題

EXEC: に渡すコマンドは、シェルのクォート処理によって意図せず分割される場合がある。

E EXEC: wrong number of parameters (3 instead of 1)

ダブルクォートで囲む方法もあるが、スクリプトに切り出す方が確実だ。

cat > /tmp/nrepl-tunnel.sh << 'EOF'
#!/bin/bash
docker exec -i c4379881374a socat STDIO TCP:localhost:7888
EOF
chmod +x /tmp/nrepl-tunnel.sh

socat TCP-LISTEN:7888,bind=127.0.0.1,fork EXEC:/tmp/nrepl-tunnel.sh
socat TCP-LISTEN:7888,bind=127.0.0.1,fork "EXEC:docker exec -i c4379881374a socat STDIO TCP:localhost:7888"

Emacs から接続する。

M-x cider-connect RET
Host: localhost RET
Port: 7888 RET

接続成功

;; Connected to nREPL server - nrepl://localhost:7888
;; CIDER 1.22.0-snapshot, nREPL 1.7.0
;; Clojure 1.12.5, Java 17.0.19
user>

ホストの Emacs からコンテナ内の Clojure REPL に接続できた。

動作確認として REPL でいくつか評価した。

user> (+ 1 2)
3
user> (System/getProperty "os.name")
"Linux"
user> (System/getProperty "os.arch")
"aarch64"

os.name"Linux"=、=os.arch"aarch64" であることから、 評価がホストではなくコンテナ内で行われていることが確認できた。

Dockerfile への反映

動作確認が取れたので、コンテナイメージの Dockerfile に追記した。

RUN apt-get install -y --no-install-recommends \
        openjdk-17-jdk \
        socat

RUN curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh \
    && chmod +x linux-install.sh \
    && ./linux-install.sh \
    && rm linux-install.sh

ビルドが通ることを確認した。

まとめ

当初は cider-jack-in + TRAMP(=/docker:= メソッド)で接続できると考えていたが、 実際には動作しなかった。原因は CIDER の SSH フォールバックが /docker: TRAMP では 機能しないことだ。

最終的に採用した構成は以下の通り。

Mac: socat (LISTEN:7888, bind=127.0.0.1)
  └─ /tmp/nrepl-tunnel.sh
       └─ docker exec → コンテナ: socat (STDIO↔TCP:7888)
                                      └─ nREPL server (port 7888)
                                     Emacs: M-x cider-connect

手間はかかったが、ホストの Emacs からコンテナ内の Clojure REPL に 接続してREPL駆動開発ができる状態になった。

作成日