CATHODE COASTaccess to tools
15 min read

VPSに軽めの生成AIを導入して、まずはcurlで叩いてみるまでの手順

メモリ1GBのさくらVPSにOllamaとqwen2.5:0.5bを入れ、外部からcurlで叩けるまでの記録。約14トークン/秒で動きましたが、最大の罠はファイアウォールの二重構造でした。

shareB!

はじめに

ローカルのPCではqwen2.5の7Bを動かしていますが、これだとPCの前に座っていないと使えません。手元にしばらく遊ばせていたさくらのVPSがあったので、ここに軽量なLLMを載せて、外からAPIとして叩ける状態にしてみることにしました。

最終的にはブラウザから使えるチャットUIを作り、性能が足りなくなったらAWS EC2に移す想定です。ただ今回は欲張らず、**「外部からcurlでレスポンスが返るところまで」**をゴールに設定しました。

問題はスペックです。

項目スペック
CPU仮想2コア
メモリ1GB
ストレージHDD 100GB
GPUなし(CPU推論のみ)
OSUbuntu 24.04.4 LTS (Noble Numbat) / x86_64

メモリ1GB、GPUなし。生成AIを動かすサーバーとしては最低ラインを下回っている感すらありますが、結論から言うと動きました。ただし、動かすまでにいくつか転びました。

執筆時点(2026年7月)、Ollama v0.32.4 / nginx 1.24.0 での記録です。


先にまとめ

急いでいる方向けに、結論から3点。

  1. メモリ1GB + swap 2GBで、qwen2.5:0.5b は問題なく動く。 実測で約14トークン/秒。CPU推論としては十分実用的な速度です。
  2. 最大の罠はファイアウォールの二重構造。 さくらのVPSにはOS側のufwとは別に、コントロールパネル側の「パケットフィルタ」があります。両方開けないと通りません。これに気づかず、Let's Encryptの証明書取得で一度失敗しました。
  3. 「生成に1分かかった」は誤読だった。 遅かったのではなく、モデルが指示を無視して長文を書き続けていただけでした。出力トークン数に上限をかけたら11秒に縮みました。

作業時間は、詰まった箇所を含めて1〜2時間程度です。

最終的にこうなった、という最短手順

以下は実際に動作を確認できた構成です。上から順に実行すれば再現できます。

# 1. swapを2GB確保(メモリ1GBなら必須)
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
 
# 2. Ollamaとモデル
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:0.5b
 
# 3. nginxとBasic認証
sudo apt update
sudo apt install -y nginx apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd ollama-user
 
# 4. ファイアウォール(※コントロールパネル側の設定も別途必要)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp

nginxの設定ファイルは以下です。auth_basicserverブロックではなくlocationの中に書いているのがポイントで、理由は後述します。

# /etc/nginx/sites-available/ollama
server {
    listen 80;
    server_name api.example.com;
 
    location / {
        auth_basic "Ollama API";
        auth_basic_user_file /etc/nginx/.htpasswd;
 
        proxy_pass http://127.0.0.1:11434;
        proxy_set_header Host localhost:11434;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
 
        # ストリーミング応答のために必須
        proxy_buffering off;
        proxy_cache off;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
 
        # CPU推論は時間がかかるので長めに
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
    }
}

あとは有効化して、certbotでHTTPS化すれば完成です。

sudo ln -sf /etc/nginx/sites-available/ollama /etc/nginx/sites-enabled/ollama
sudo nginx -t
sudo systemctl reload nginx
 
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com

実際にやったこと

ここからは時系列で、詰まった箇所も含めて書いていきます。

1. swapを先に確保する

メモリ1GBでLLMを動かす以上、swapなしで挑むのは無謀です。モデルのロード中にOOM Killerにプロセスを殺される可能性が高いので、何をするより先に2GB確保しました。

vm.swappinessを10に下げているのは、メモリに余裕があるうちからswapに書き出させないためです。今回のVPSはストレージがHDDなので、swapに落ちた瞬間の速度低下はSSD環境より深刻になります。「swapは保険であって常用するものではない」という位置づけにしておきたいところです。

設定後の状態がこちら。

$ free -h
               total        used        free      shared  buff/cache   available
Mem:           961Mi       296Mi       104Mi       368Ki       714Mi       664Mi
Swap:          2.0Gi        19Mi       2.0Gi

OS起動直後の時点で、使えるメモリ(available)は664Mi。ここからOllama本体とモデルを載せることになります。

なお、/etc/fstabへの追記を忘れると再起動でswapが消えます。忘れがちなので、以下で確認しておくと安心です。

grep swap /etc/fstab
cat /proc/sys/vm/swappiness

2. Ollamaを入れる。常駐コストは思ったより小さい

公式のインストールスクリプトを流すだけです。1〜2分で終わります。

curl -fsSL https://ollama.com/install.sh | sh

途中でこう言われますが、GPUなし構成なので正常です。

WARNING: No NVIDIA/AMD GPU detected. Ollama will run in CPU-only mode.

systemdへのサービス登録と自動起動まで、スクリプトが済ませてくれます。

$ ollama --version
ollama version is 0.32.4
$ systemctl is-active ollama
active
$ curl http://localhost:11434/
Ollama is running

ここで意外だったのが、Ollama本体の常駐コストです。インストール前後でfree -hを比べると、availableが664Mi → 628Mi差分は約36MBしかありませんでした。

事前に見積もっていたときは「本体だけで100MBくらい持っていかれるだろう」と踏んでいたので、これは嬉しい誤算です。モデルに600MB近く割ける計算になります。

3. モデル選定:1GB環境で選べるのは実質0.5b

Ollamaのモデル一覧を見ながら候補を絞りました。qwen2.5系のサイズはこうなっています。

モデルサイズ
qwen2.5:0.5b398MB
qwen2.5:1.5b986MB
qwen2.5:7b4.7GB

availableが628Miなので、1.5b(986MB)を選ぶと300MB以上が確実にswapに落ちます。HDD環境でこれをやると速度的に厳しい。素直に0.5bを選びました。

Qwen2.5を選んだのは、公式のモデルカードで29言語以上の多言語対応が謳われており、日本語が含まれているためです。ライセンスもApache 2.0(3Bと72Bを除く)で扱いやすい。ローカルで7Bを使っていて感触を知っている、という理由もあります。

ollama pull qwen2.5:0.5b

ここで一つ小技を。動作確認でいきなりollama runの対話モードに入ると画面が占有されてfree -hが打てません。APIでモデルをロードだけさせると、メモリの動きを別途確認できます。

# プロンプトなしでリクエスト → ロードだけ実行される
curl http://localhost:11434/api/generate -d '{"model":"qwen2.5:0.5b"}'
{
  "model": "qwen2.5:0.5b",
  "created_at": "...",
  "response": "",
  "done": true,
  "done_reason": "load"
}

done_reasonloadになっていて、モデルがメモリに載ったことがわかります。このときのメモリ状況がこちら。

$ free -h
               total        used        free      shared  buff/cache   available
Mem:           961Mi       770Mi        80Mi        56Ki       253Mi       191Mi
Swap:          2.0Gi       113Mi       1.9Gi

availableは191Mi、swapの使用は113Mi。ほぼ物理メモリに収まっています。swapへの落ち方も軽微で、この時点で「0.5bなら問題なく運用できる」と判断できました。

ちなみにOllamaは、最後に使ってから5分でモデルをメモリから降ろします。しばらく放置した後の初回リクエストだけ妙に遅いことがありますが、これは再ロードが走っているだけです。

4. 最初の生成に61秒。でも数字を分解したら話が違った

さて本命の生成テストです。桜をテーマにした恋愛小説の冒頭を、100文字程度で書いてもらうことにしました。

time curl http://localhost:11434/api/chat -d '{
  "model": "qwen2.5:0.5b",
  "messages": [{"role":"user","content":"桜の季節を舞台にした恋愛小説の冒頭を100文字程度で書いてください。"}],
  "stream": false
}'

結果。

real    1m1.531s

1分。正直、この時点では「やはり1GBのVPSでは無理があったか」と諦めかけました。

ところが、Ollamaのレスポンスに含まれるメタデータを見て考えが変わります。

"total_duration":61501698294,
"load_duration":441966798,
"prompt_eval_count":56,
"prompt_eval_duration":1639025000,
"eval_count":652,
"eval_duration":59324031000

単位はナノ秒です。整理するとこうなります。

  • モデルのロード:0.44秒
  • プロンプトの処理:1.64秒
  • 生成:652トークンを59.3秒 → 約11.0トークン/秒

つまり遅かったのではなく、書きすぎていたのです。「100文字程度で」と指示したのに、652トークン(日本語で1000文字以上)を出力していました。生成された文章を読むと、同じような内容を何度も繰り返しながら延々と続いています。

0.5Bクラスのモデルは指示追従能力が弱く、放っておくと止まりません。これは設定で殴るべき問題でした。Ollamaのオプションで出力トークン数に上限をかけます。

time curl http://localhost:11434/api/chat -d '{
  "model": "qwen2.5:0.5b",
  "messages": [{"role":"user","content":"桜の季節を舞台にした恋愛小説の冒頭を書いてください。"}],
  "stream": false,
  "options": {"num_predict": 150}
}'
real    0m11.490s

11.5秒。メタデータ上は150トークンを10.8秒なので、約13.9トークン/秒です。1回目より若干速いくらいで、この環境の実力としてはこのあたりが標準的な数字のようです。

finish_reasonならぬdone_reasonlengthになります。上限で切られただけなので、仕様通りの挙動です。

この数字をどう見るかですが、ストリーミングで使うなら十分実用的だと思います。14トークン/秒あれば、文字が流れ始めるまで1〜2秒。総生成時間が30秒かかったとしても、読みながら待てるので体感の待ち時間はずっと短く感じられます。

なお、OpenAI互換エンドポイント(/v1/chat/completions)を使う場合は、num_predictではなくmax_tokensで指定します。

5. nginxで前段に蓋をする

Ollamaには認証機能がありません。 ポート11434をそのまま外に公開すると、誰でも叩ける状態になります。nginxをリバースプロキシとして前に置き、Basic認証で保護しました。

sudo apt install -y nginx apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd ollama-user

設定ファイルの中身は「先にまとめ」に載せたとおりですが、LLM用途で特に効いてくるのは以下の2行です。

proxy_buffering off;      # これがないとストリーミングが機能しない
proxy_read_timeout 600s;  # デフォルト60秒だとCPU推論の途中で切れる

proxy_bufferingをoffにしないと、応答がnginxのバッファに溜まってから一括で届きます。ストリーミングのつもりが「生成完了まで沈黙して、最後に全部まとめて出る」という挙動になるので、UIを作る予定があるなら必須です。

サーバー内で動作確認します。認証なしで401が返ることを先に確かめるのが重要です。

$ curl -i http://localhost:8080/
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="Ollama API"
 
$ curl -u ollama-user:PASSWORD http://localhost:8080/
Ollama is running

ここで200が返ってしまう場合、Basic認証が効いていません。

6. 外から繋がらない:まずはufw

ローカルPCからVPSのIPに向けて叩いたところ、案の定つながりません。サーバー側のufwを確認します。

$ sudo ufw status
Status: active
 
To                         Action      From
--                         ------      ----
OpenSSH                    ALLOW       Anywhere
OpenSSH (v6)               ALLOW       Anywhere (v6)

SSHしか開いていませんでした。開放します。

sudo ufw allow 8080/tcp

注意点として、ufwを新規に有効化する場合は必ず先にsudo ufw allow 22/tcp(またはOpenSSH)を実行してください。 順番を間違えるとSSHが切断され、コントロールパネルのコンソールからしか復旧できなくなります。今回は既にOpenSSHが許可済みだったので問題ありませんでした。

これと合わせて、さくらのVPSコントロールパネル側でも8080を許可し、外部からのアクセスが通るようになりました。

$ curl -u ollama-user:PASSWORD http://203.0.113.10:8080/
Ollama is running

ここまでで当初のゴールは達成です。ただ、この時点ではHTTP通信なので、Basic認証のIDとパスワードはBase64エンコードされただけの、ほぼ平文の状態で流れています。使っていないドメインが手元にあったので、続けてHTTPS化することにしました。

7. certbotが Timeout で落ちる

サブドメイン(api.example.com)のAレコードをVPSのIPに向け、nginxの設定を80番+ドメイン指定に書き換えて、certbotを実行しました。

sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d api.example.com

メールアドレスの登録と規約への同意まで進んだところで、これが出ました。

Certbot failed to authenticate some domains (authenticator: nginx).
The Certificate Authority reported these problems:
  Domain: api.example.com
  Type:   connection
  Detail: 203.0.113.10: Fetching
  http://api.example.com/.well-known/acme-challenge/0A1Z1BBFU2eaz7lVZ0IySTE0qgtaRcjjRAhNkkeTIvM:
  Timeout during connect (likely firewall problem)
 
Hint: The Certificate Authority failed to verify the temporary nginx
configuration changes made by Certbot. Ensure the listed domains point
to this nginx server and that it is accessible from the internet.

まず読み取れることを整理します。

  • DNSは正常。 エラーメッセージ内でLet's Encrypt側がIPアドレスを正しく認識しています。名前解決は成功しています。
  • 問題はポート80。 証明書の発行にはHTTP-01チャレンジ(/.well-known/acme-challenge/への到達確認)が使われるため、80番が外部から見えていないと失敗します。

そして**Timeout during connectという文言が決定的**でした。ここは切り分けの分かれ道になります。

エラー意味
Connection refusedサーバーには届いているが、そのポートで何も待ち受けていない
Timeout during connectそもそもサーバーに届いていない。パケットが破棄されている

タイムアウトということは、ファイアウォールでDROPされているということです。certbot自身が(likely firewall problem)と言っているとおりでした。

原因:ファイアウォールが二重になっていた

ufwで80と443を開けても解決しませんでした。原因は**さくらのVPSのコントロールパネル側にある「パケットフィルタ」**でした。

公式マニュアルにも明記されていますが、この機能はOS内のファイアウォールとは完全に独立して動作します。マニュアル自体が「各OSに設定されたファイアウォールと動作が重複する可能性があります」と注意を促しています。

自分の判断が甘かったのは、8080番が外から通っていたことを根拠に「パケットフィルタは無効なのだろう」と考えてしまった点です。実際には、パケットフィルタは有効なままで、先の手順で8080だけをカスタムルールとして個別に許可していた状態でした。80と443は追加していないので、当然そこだけ塞がっていたわけです。

「一部のポートが通っている」ことは「フィルタが無効である」ことを意味しません。ポート単位で見なければいけませんでした。

コントロールパネルで80と443を許可したうえで再実行したところ、あっさり通りました。

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/api.example.com/fullchain.pem
This certificate expires on 2026-10-25.
Certbot has set up a scheduled task to automatically renew this certificate.

再実行の前に必ず疎通確認を

反省として、certbotを再実行する前にこれをやるべきでした。

# ローカルPCから
curl -I http://api.example.com/

401 Unauthorized(Basic認証をかけているため)が返れば、80番が外部から到達しているということです。ここが通らないうちにcertbotを回しても、同じエラーを繰り返すだけです。

Let's Encryptにはレート制限があり、認証失敗にも制限がかかります。闇雲な再実行は避けたほうが無難です。

補足:auth_basic の位置について

これは実際には失敗を経験していない、予防措置として書いておきます。

Basic認証をserverブロック直下に書くと、/.well-known/acme-challenge/を含むすべてのパスに認証がかかります。certbotがチャレンジ用に一時的な設定を追加しても、認証で弾かれて検証に失敗する可能性があります。

そこで今回は、HTTPS化にあたってauth_basiclocation /の中に移してから作業しました。

# 移動前(server直下 = すべてのパスに認証がかかる)
server {
    auth_basic "Ollama API";
    auth_basic_user_file /etc/nginx/.htpasswd;
    location / { ... }
}
 
# 移動後(location配下 = 必要な範囲だけに認証をかける)
server {
    location / {
        auth_basic "Ollama API";
        auth_basic_user_file /etc/nginx/.htpasswd;
        ...
    }
}

移動した状態で証明書取得に成功しているため、この配置で問題ないことは確認できています。ただし**「移動しなければ失敗した」という検証はしていない**ので、そこは未確認です。

8. 後始末

証明書が入ったので、自動更新の設定を確認します。

$ sudo systemctl status certbot.timer
● certbot.timer - Run certbot twice daily
     Active: active (waiting)
    Trigger: ... ; 13h left
 
$ sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded

証明書の有効期限は90日ですが、systemdのタイマーが1日2回チェックしてくれます。dry-runが通ればあとは放置で構いません。

更新時にもポート80が使われるため、HTTPS化後も80番は閉じないでください。

最後に、不要になった8080を閉じます。

sudo ufw delete allow 8080/tcp

コントロールパネルのパケットフィルタからも8080を削除しました。

最終確認です。

# HTTPS経由でOpenAI互換エンドポイントを叩く
$ curl -u ollama-user:PASSWORD https://api.example.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen2.5:0.5b","messages":[{"role":"user","content":"こんにちは"}],"max_tokens":200}'
{
  "id": "chatcmpl-7",
  "object": "chat.completion",
  "model": "qwen2.5:0.5b",
  "choices": [
    {
      "message": { "role": "assistant", "content": "..." },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 30,
    "completion_tokens": 125,
    "total_tokens": 155
  }
}
# HTTPからのリダイレクト
$ curl -I http://api.example.com/
HTTP/1.1 301 Moved Permanently
Location: https://api.example.com/
 
# 8080は閉じている
$ curl -m 5 http://203.0.113.10:8080/
curl: (28) Connection timed out after 5005 milliseconds

完了です。


ハマりどころまとめ

症状原因対処
certbotがTimeout during connectで失敗さくらのパケットフィルタで80/443が閉じていたコントロールパネルとufwの両方で開放する
一部ポートは通るのにcertbotだけ落ちるフィルタはポート単位。8080だけ許可済みだった「通るポートがある=フィルタ無効」と判断しない
生成に1分かかるモデルが指示を無視して長文を出力していたnum_predict / max_tokensで上限をかける
ストリーミングが最後にまとめて届くnginxがバッファリングしているproxy_buffering off;
長い生成が途中で切れるnginxのデフォルトタイムアウトが60秒proxy_read_timeout 600s;
モデルロード中にプロセスが落ちるメモリ不足(OOM Killer)swapを確保する。dmesg | grep -i oomで確認

やってみての所感

0.5Bの日本語は「動くが、読ませるものではない」

速度面は想定より良かったのですが、出力の品質は0.5Bなりです。生成された文章を読むと、助詞や活用があちこちで崩れています。「〜ととに漂い」「過ごすことがでました」「見えありません」といった具合で、パッと見は日本語なのに、よく読むと文になっていません。

もう一つ面白かったのが、「こんにちは」とだけ送ったときの反応です。挨拶を返すこともあれば、突然「この技術は個人の権利および倫理を尊重する基本原則に基づくもので」と説教を始めることもありました。入力が短すぎて文脈を掴めず、学習データにあった注意書き的な文章に引きずられているのだと思います。

実運用ではシステムプロンプトを固定で入れておくと、この手のブレはかなり抑えられます。

向く人・向かない人

向いている用途は、APIの疎通確認、UIの開発中のダミー応答、簡単な分類やタグ付けなど、出力品質より「常時動いていること」が重要なケースです。月1000円前後のVPSで生成AIのエンドポイントが手に入るのは、開発環境としてかなり便利です。

向いていない用途は、そのまま人に読ませる文章の生成です。0.5Bで日本語の品質を求めるのは無理があります。ここは設定でどうにかなる領域ではないので、素直にモデルサイズを上げるしかありません。

数字は分解して読む

今回いちばんの学びは、技術的なことよりも「real 1m1.5sだけ見て諦めかけた」という点でした。Ollamaはレスポンスにロード時間・プロンプト処理時間・生成時間を分けて返してくれます。分解してみれば、遅かったのは環境ではなく設定でした。

「遅い」と感じたときに、それがスループットの問題なのか、出力量の問題なのかを切り分ける。ローカルLLMを触るうえでは、この癖をつけておくと無駄な諦めが減りそうです。


まとめ

メモリ1GB・GPUなしのVPSでも、0.5Bクラスのモデルであれば実用的な速度で動きます。実測で約14トークン/秒。ストリーミングで使えば体感の待ち時間はさらに短くなります。

一方で、つまずいたのはLLMそのものではなく、ファイアウォールという枯れた領域でした。さくらのVPSにおけるパケットフィルタとufwの二重構造は、公式マニュアルにも注意書きがあるにもかかわらず、ポート単位で状態が違うぶん見落としやすい罠だと思います。「一部が通っているから全体が通っている」と思い込まないことが教訓でした。

次はこのAPIを叩くブラウザUIを作る予定です。0.5Bの品質を考えると、実用段階ではメモリ8GB以上の環境へ移すことになりそうですが、エンドポイントのURLを差し替えるだけで移行できる構成にはなりました。ドメインを当てておいたので、実際にはDNSの向き先を変えるだけで済みます。

軽いモデルを、小さいサーバーで。それでも夜中に「こんにちは」と話しかければ、多少ぎこちない日本語で何かを返してくれます。まずはそこまで来ました。


参考リンク