手元のPCから自然言語でVPSのコンテナを操作する基盤を作った(Rootless Podman × MCP)
IT企業で働く、しがないサラリーマンです。
「手元のPCから自然言語で、VPS上のコンテナを安全に操作できたら気持ちよくない?」という思いつきを、週末にVPSで形にしてみました。
ポイントは、動くことより「攻撃されても被害を最小化すること」を最優先で設計したところです。
この記事では、構成・なぜPodmanなのか・塞ぐべき3つの穴・構築手順・ハマったところまで、一通りまとめます。
作ったもの
手元PCで自然言語を打つと、MCP(Model Context Protocol)経由でVPS上のRootless Podmanが動き、コンテナを操作してくれる——という基盤です。
手元PC(Mac / MCPクライアント + LLM)
| SSHトンネル(暗号化・localhostのみ)
v
VPS(AlmaLinux 10)
+- MCPサーバ(podman-mcp-server / localhostバインド)
+- Rootless Podman(rootなしでコンテナ実行)
MCPサーバのポートは外部に一切公開せず、SSHトンネル経由でのみ到達できるようにしています。
なぜ Docker ではなく Podman なのか
最大の理由は「rootless を後付けではなく前提にできる」ことです。
この基盤で一番こわいリスク=権限昇格(後述の①縦)を、設計段階から抑え込めます。
| 観点 | Docker | Podman | この基盤での意味 |
|---|---|---|---|
| デーモン | dockerd(常時 root で常駐) | デーモンレス(実行時のみ) | 常駐 root プロセスという攻撃面が無い |
| 権限モデル | docker グループ ≒ 実質 root | rootless が標準 | ①縦(権限昇格)を設計段階で抑制 |
| ホスト脱出 | デーモン経由のリスク | ユーザ権限内で完結 | 破られても mcprun 権限で止まる |
| systemd常駐 | 追加設定が必要 | 親和性が高い(Quadlet等) | 常駐化が素直に書ける |
| 互換性 | — | Docker CLI / OCI 互換 | 学習コスト低・既存イメージ流用可 |
| RHEL系 | — | 標準採用(dnf で導入) | AlmaLinux 10 でそのまま入る |
dockerグループ問題
Docker の docker グループ付与は「実質 root 権限を渡す」有名な権限昇格経路です。
Podman はそれ自体が不要で、一般ユーザのまま完結します。
設計の軸:塞ぐべき3つの穴
設計のよりどころにした3つのリスクです。
各STEPはこのいずれかに対応しています。
| リスク | 内容 | 対策 |
|---|---|---|
| ①縦(権限昇格) | コンテナからホストへ脱出される | Rootless Podman |
| ②逆流(注入) | 悪意ある指示・イメージでツールを勝手に実行される | 人間承認 + digest固定 |
| ③横(踏み台) | 侵入後、社内網やクラウドメタデータへ横移動される | egress遮断 |
構築手順
STEP 1. 初期設定
専用ユーザを作り、SSHは鍵認証のみ、ファイアウォールはSSHだけ通す最小構成にします。
sudo useradd -m mcprun # コンテナ実行用の専用ユーザ
# /etc/ssh/sshd_config: PasswordAuthentication no (鍵認証のみ)
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --reload # 開けるのは SSH だけ
STEP 2. Rootless Podman 導入
sudo dnf install -y podman
sudo loginctl enable-linger mcprun # ログアウト後もサービス維持
systemctl --user enable --now podman.socket # Podman REST API ソケット
podman --version # 例: 5.8.2
Rootless とは
一般ユーザ権限でコンテナを動かす方式です。
万一コンテナを破られても、奪われるのは mcprun 権限までで、root は取られません。(①縦の対策)
STEP 3. MCPサーバを常駐化する
manusa/podman-mcp-server(Go・Apache-2.0)を localhost のみで待受させ、systemd user サービスで常駐させます。
~/.config/systemd/user/podman-mcp.service
[Unit]
Description=Podman MCP Server (localhost only)
After=podman.socket
Wants=podman.socket
[Service]
ExecStart=/bin/bash -c 'exec /usr/local/bin/podman-mcp-server \
--port 8000 --podman-impl api < <(tail -f /dev/null)'
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now podman-mcp.service
curl -s -m 2 -o /dev/null -w 'http=%{http_code}\n' http://127.0.0.1:8000/sse
ハマりどころ①:起動直後に即終了(status=0)
systemd は stdin に /dev/null を渡すため、MCPサーバが stdin の EOF を検知して「正常終了」してしまいます(HTTPモードでも発生)
解決: < <(tail -f /dev/null) で EOF の来ない stdin を食わせ続けます。
上の ExecStart がその対策版です。
STEP 4. 横移動(踏み台化)を遮断する
侵入されても外へ出ていけないよう、出口(egress)を絞ります。
firewalld の direct ルールを使います。
UID_MCP=$(id -u mcprun)
# クラウドメタデータ(169.254.169.254)を遮断
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter \
OUTPUT 0 -d 169.254.169.254 -j DROP
# 例外:ループバック・コンテナサブネット(優先度0=先に評価)
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter \
OUTPUT 0 -o lo -j ACCEPT
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter \
OUTPUT 0 -d 10.88.0.0/16 -j ACCEPT
# mcprun の私的アドレス宛て(社内網)を遮断(優先度1)
for NET in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do
sudo firewall-cmd --permanent --direct --add-rule ipv4 filter \
OUTPUT 1 -m owner --uid-owner $UID_MCP -d $NET -j DROP
done
sudo firewall-cmd --reload
必ず自分の環境を確認してから適用
デフォルトGWやDNSが私的アドレス帯だと、このルールで通信が壊れます。
確認コマンド:ip route(GW)/ /etc/resolv.conf(DNS)/ podman network inspect podman(サブネット)
私の環境ではGW・DNSが公開IP、コンテナのみ 10.88.0.0/16 だったため、そこだけ例外許可しました。
検証:MCPは http=200 のまま、メタデータ宛ては rc=28(タイムアウト=遮断成功)
STEP 5. SSHトンネル + クライアント接続
MCPポートは外部非公開のまま、SSHの暗号路だけで localhost:8000 に到達させます。
# 手元PC:このタブは繋ぎっぱなし(閉じるとトンネルも切れる)
ssh -N -L 8000:127.0.0.1:8000 -i ~/.ssh/your-key.pem mcprun@<VPS_IP>
MCPクライアント設定(claude_desktop_config.json)
{
"mcpServers": {
"podman-vps": {
"command": "npx",
"args": ["-y", "mcp-remote",
"http://127.0.0.1:8000/sse",
"--transport", "sse-only"]
}
}
}
ハマりどころ②:sessionid must be provided(400)
mcp-remote は既定で Streamable HTTP を先に試すため、SSE専用入口 /sse に POST して弾かれます。
解決: --transport sse-only を明示して SSE の作法で接続させます。
デモ:自然言語でコンテナを立てる
ここまでできたら、あとは手元PCで日本語を打つだけです。
試しに nginxdemos/hello を立てて、一覧を出してもらいました。
起動してるコンテナ一覧見せて
nginxdemos/hello(カラフルなデモページを返すWebサーバ)が running 状態、ポートは 8080 → 80 で公開——と、自然言語の問いかけにツール連携で答えてくれているのが分かります。
digest固定で Rug Pull を防ぐ
デモでは :latest を使っていますが、本番では nginxdemos/hello@sha256:… のようにダイジェスト固定するのがおすすめです。
タグは中身をすり替えられる(Rug Pull)ため、@sha256:… で「この中身」を釘付けにします(②逆流の対策)。
ブラウザでこのページを見たいときは、MCP用(8000)とは別にもう1本トンネルを張ります。
ssh -N -L 8080:127.0.0.1:8080 -i ~/.ssh/your-key.pem mcprun@<VPS_IP>
その状態で http://localhost:8080 を開けばOK!
nginxdemos/hello のページが表示されます。
ファイアウォールはSSHしか開けていないので、外部から直接 VPS_IP:8080 には繋がりません。
SSHの暗号路の中だけで届く二重の守りになっています。
ハマりどころ一覧
| 症状 | 原因 | 解決 |
|---|---|---|
| サービスが起動直後に即終了(status=0) | stdin が /dev/null で EOF を検知し正常終了 |
< <(tail -f /dev/null) で stdin を保持 |
接続失敗 sessionid must be provided(400) |
mcp-remote が /sse に Streamable HTTP で POST |
--transport sse-only を明示 |
curl /sse が返ってこない |
/sse はストリーミングで開きっぱなしが正常 |
-m 2 でタイムアウト健診に切替 |
運用ルール
- 使うときだけトンネルを開く — 終わったらSSHタブを閉じる。閉じれば外部経路はゼロ
-
読み取り系は常時許可 / 変更系は毎回目視 —
list・logs・inspectは許可しっぱなしでも被害ゼロ。run・remove・pullは毎回ダイアログを確認 -
イメージは digest 固定 —
:latestではなく@sha256:…を使い、Rug Pull(中身すり替え)を防ぐ - 承認ダイアログは引数まで読む — ②逆流(ツール注入)の最後の砦。表示文ではなくダイアログの引数が唯一の根拠
セキュリティ注記
本記事中の <VPS_IP> および ~/.ssh/your-key.pem はプレースホルダです。
実IP・実鍵名・ホスト名は記事に載せず、別途管理してください。
まとめ
- ①縦 = Rootless Podman
- ②逆流 = 人間承認 + digest固定
- ③横 = egress遮断
この三重の対策で、自然言語によるコンテナ操作環境を組みました。
同じことをやってみたい人の参考になればうれしいです。

