はじめに
ローカルのPCではqwen2.5の7Bを動かしていますが、これだとPCの前に座っていないと使えません。手元にしばらく遊ばせていたさくらのVPSがあったので、ここに軽量なLLMを載せて、外からAPIとして叩ける状態にしてみることにしました。
最終的にはブラウザから使えるチャットUIを作り、性能が足りなくなったらAWS EC2に移す想定です。ただ今回は欲張らず、**「外部からcurlでレスポンスが返るところまで」**をゴールに設定しました。
問題はスペックです。
| 項目 | スペック |
|---|---|
| CPU | 仮想2コア |
| メモリ | 1GB |
| ストレージ | HDD 100GB |
| GPU | なし(CPU推論のみ) |
| OS | Ubuntu 24.04.4 LTS (Noble Numbat) / x86_64 |
メモリ1GB、GPUなし。生成AIを動かすサーバーとしては最低ラインを下回っている感すらありますが、結論から言うと動きました。ただし、動かすまでにいくつか転びました。
執筆時点(2026年7月)、Ollama v0.32.4 / nginx 1.24.0 での記録です。
先にまとめ
急いでいる方向けに、結論から3点。
- メモリ1GB + swap 2GBで、qwen2.5:0.5b は問題なく動く。 実測で約14トークン/秒。CPU推論としては十分実用的な速度です。
- 最大の罠はファイアウォールの二重構造。 さくらのVPSにはOS側の
ufwとは別に、コントロールパネル側の「パケットフィルタ」があります。両方開けないと通りません。これに気づかず、Let's Encryptの証明書取得で一度失敗しました。 - 「生成に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/tcpnginxの設定ファイルは以下です。auth_basicをserverブロックではなく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.0GiOS起動直後の時点で、使えるメモリ(available)は664Mi。ここからOllama本体とモデルを載せることになります。
なお、/etc/fstabへの追記を忘れると再起動でswapが消えます。忘れがちなので、以下で確認しておくと安心です。
grep swap /etc/fstab
cat /proc/sys/vm/swappiness2. 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.5b | 398MB |
| qwen2.5:1.5b | 986MB |
| qwen2.5:7b | 4.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_reasonがloadになっていて、モデルがメモリに載ったことがわかります。このときのメモリ状況がこちら。
$ free -h
total used free shared buff/cache available
Mem: 961Mi 770Mi 80Mi 56Ki 253Mi 191Mi
Swap: 2.0Gi 113Mi 1.9Giavailableは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.531s1分。正直、この時点では「やはり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.490s11.5秒。メタデータ上は150トークンを10.8秒なので、約13.9トークン/秒です。1回目より若干速いくらいで、この環境の実力としてはこのあたりが標準的な数字のようです。
finish_reasonならぬdone_reasonはlengthになります。上限で切られただけなので、仕様通りの挙動です。
この数字をどう見るかですが、ストリーミングで使うなら十分実用的だと思います。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_basicをlocation /の中に移してから作業しました。
# 移動前(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の向き先を変えるだけで済みます。
軽いモデルを、小さいサーバーで。それでも夜中に「こんにちは」と話しかければ、多少ぎこちない日本語で何かを返してくれます。まずはそこまで来ました。