スマホからも PC からも使えるプライベート LLM 環境 (Qwen 3.5-4B) を AWS に構築してみた

スマホからも PC からも使えるプライベート LLM 環境 (Qwen 3.5-4B) を AWS に構築してみた

Claude や ChatGPT の障害に備えて、ローカル LLM (Qwen 3.5-4B) を AWS の GPU インスタンス上にセルフホストしました。Tailscale を使うことで、スマホからも PC からも同じ URL でプライベートにアクセスできます。Terraform のコード全文と、実際に使ってみた所感を紹介します。
2026.03.05

はじめに

Claude や ChatGPT などのクラウド LLM サービスは、障害が発生すると使えなくなります。LLM を日常的に活用するエンジニアにとって、こういった障害が作業効率に与える影響は大きいです。保険として自前の LLM 環境を持っておきたいと考える方は少なくないのではないでしょうか。

自宅の PC でローカル LLM を動かすこと自体は難しくありません。しかし、常時起動が前提になりますし、外出先からはアクセスできません。

本記事では、オープンなモデルを自分で動かす形態……いわゆるローカル LLM を AWS の GPU インスタンス上にセルフホストし、Tailscale を使って PC からもスマホからも場所を問わずプライベートにアクセスできる環境を作ります。使わないときは自動で停止し、課金を最小限に抑えます。

qwen-on-ec2-gif

LLM には、2026 年 3 月 2 日にリリースされたばかりの Qwen 3.5-4B を使いました。 4B パラメータのモデルは、g4dn.xlarge の T4 GPU (VRAM 16GB) で動作確認されています。

Qwen とは

Qwen (通義千問) は、Alibaba Cloud の Qwen チームが開発するオープンソースの大規模言語モデルファミリーです。2026 年 3 月にリリースされた Qwen 3.5 シリーズは 0.8B から 397B まで幅広いサイズが Apache 2.0 ライセンスで公開されています。推論時に思考過程を出力する機能「thinking モード」に対応しており、今回はその中で最も手軽に動かせる 4B モデルを使用しました。

検証環境

  • プロジェクト構築: WSL2 Ubuntu 24.04
    • Terraform: v1.12.2
    • AWS CLI: v2.31.31
    • AWS リージョン: us-east-1
    • EC2 インスタンスタイプ: g4dn.xlarge
  • ブラウザでの検証 (PC): Windows 11
    • Tailscale: v1.94.2
  • ブラウザでの検証 (スマホ): Android OS 16
    • Tailscale: v1.94.2

対象読者

  • 自前の LLM 環境をバックアップとして持っておきたいエンジニア
  • スマホでも PC でも、場所を問わず自分だけの LLM にアクセスしたい人
  • ローカル LLM に興味があるが、自宅 PC の常時起動はしたくない人

参考

構成の全体像

以下がアーキテクチャの全体像です。

  • Tailscale
    PC やスマホにクライアントアプリを入れるだけで、プライベートネットワークを構成できるサービスです。今回の構成ではインバウンドのポートを一切公開せず、Tailscale 経由でのみ http://qwen-llm:8080 にアクセスします。個人利用の範囲であれば無料です。

  • Open WebUI
    Ollama と連携するチャット UI です。レスポンシブデザインに対応しており、PC でもスマホでも快適に操作できます。

  • Ollama
    LLM を Docker コンテナ上で手軽に実行できるランタイムです。ollama pull コマンドひとつでモデルをダウンロードして使い始められます。

  • Qwen 3.5-4B
    Alibaba Cloud が開発した 4B パラメータの LLM です。thinking モードにより、推論過程を確認しながら回答を得られます。

  • EC2 スポットインスタンス (g4dn.xlarge)
    NVIDIA T4 GPU を搭載した GPU インスタンスです。スポットインスタンスとして利用することでオンデマンド比 60-70% のコストを削減できます。

  • 自動停止スクリプト
    cron で 5 分ごとに実行するシェルスクリプトです。1 時間以上アイドル状態が続くと自動でインスタンスを停止します。

環境構築

ここからは、実際に環境を構築する手順を説明します。Terraform のコード全文を掲載するので、そのまま利用できます。

前提条件

以下のツールとアカウントが必要です。

ディレクトリ構成

以下のディレクトリ構成でファイルを作成します。

qwen-on-ec2/
└── infra/
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    ├── versions.tf
    └── userdata.sh

versions.tf

AWS プロバイダの設定です。

terraform {
  required_version = ">= 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variables.tf

変数の定義です。デフォルト値を設定しているため、必須で指定が必要なのは tailscale_auth_key のみです。

variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "us-east-1"
}

variable "instance_type" {
  description = "EC2 instance type for GPU inference"
  type        = string
  default     = "g4dn.xlarge"
}

variable "ami_id" {
  description = "AMI ID to use. Leave empty to auto-detect the Deep Learning Base AMI (Ubuntu 22.04)"
  type        = string
  default     = ""
}

variable "tailscale_auth_key" {
  description = "Tailscale auth key for private network access"
  type        = string
  sensitive   = true
}

variable "spot_max_price" {
  description = "Maximum spot price in USD per hour"
  type        = string
  default     = "0.25"
}

variable "root_volume_size" {
  description = "Root EBS volume size in GB (Deep Learning AMI requires >= 75)"
  type        = number
  default     = 75
}

variable "open_webui_port" {
  description = "Port number for Open WebUI"
  type        = number
  default     = 8080
}

ami_id を空にしておくと、Deep Learning Base AMI を自動で検出します。NVIDIA ドライバがプリインストールされた AMI のため、セットアップ時間を短縮できます。

main.tf

インフラの本体です。ファイルが長いため折りたたんでいます。

main.tf の全文
# --- AMI ---

data "aws_ami" "deep_learning_base" {
  count       = var.ami_id == "" ? 1 : 0
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["Deep Learning Base OSS Nvidia Driver GPU AMI (Ubuntu 22.04) *"]
  }

  filter {
    name   = "architecture"
    values = ["x86_64"]
  }
}

locals {
  ami_id = var.ami_id != "" ? var.ami_id : data.aws_ami.deep_learning_base[0].id
}

# --- Network ---

data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }

  filter {
    name   = "default-for-az"
    values = ["true"]
  }
}

resource "aws_security_group" "llm" {
  name_prefix = "qwen-llm-"
  description = "Qwen LLM - inbound denied, outbound allowed"
  vpc_id      = data.aws_vpc.default.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Allow all outbound traffic"
  }

  tags = {
    Name = "qwen-llm"
  }

  lifecycle {
    create_before_destroy = true
  }
}

# --- IAM Role (for self-stop via AWS API) ---

resource "aws_iam_role" "llm" {
  name = "qwen-llm-ec2"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ec2.amazonaws.com"
      }
    }]
  })

  tags = {
    Name = "qwen-llm"
  }
}

resource "aws_iam_role_policy" "llm_self_stop" {
  name = "self-stop"
  role = aws_iam_role.llm.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = "ec2:StopInstances"
      Resource = "*"
      Condition = {
        StringEquals = {
          "ec2:ResourceTag/Name" = "qwen-llm"
        }
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "llm_ssm" {
  role       = aws_iam_role.llm.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "llm" {
  name = "qwen-llm-ec2"
  role = aws_iam_role.llm.name
}

# --- EC2 Spot Instance ---

resource "aws_instance" "llm" {
  ami           = local.ami_id
  instance_type = var.instance_type

  instance_market_options {
    market_type = "spot"
    spot_options {
      max_price                      = var.spot_max_price
      spot_instance_type             = "persistent"
      instance_interruption_behavior = "stop"
    }
  }

  iam_instance_profile = aws_iam_instance_profile.llm.name

  vpc_security_group_ids      = [aws_security_group.llm.id]
  subnet_id                   = tolist(data.aws_subnets.default.ids)[0]
  associate_public_ip_address = true

  root_block_device {
    volume_size           = var.root_volume_size
    volume_type           = "gp3"
    delete_on_termination = true
  }

  user_data_base64 = base64encode(templatefile("${path.module}/userdata.sh", {
    tailscale_auth_key = var.tailscale_auth_key
    open_webui_port    = var.open_webui_port
  }))

  tags = {
    Name = "qwen-llm"
  }
}
  • セキュリティグループ
    インバウンドルールを一切定義していません。すべてのアクセスは Tailscale 経由で行うため、ポートを公開する必要がないからです。アウトバウンドのみ全許可としています。

  • IAM ロール
    ec2:StopInstances の権限を付与しています。これは自動停止スクリプトがインスタンス自身を停止するために必要です。Condition でタグ Name = qwen-llm を指定し、他のインスタンスには影響しないようにしています。

  • スポットインスタンス
    spot_instance_type = "persistent" かつ instance_interruption_behavior = "stop" としています。AWS 側の都合でインスタンスが中断された場合に、terminate ではなく stop されます。EBS 上のデータは保持され、再起動すれば続きから利用できます。

outputs.tf

デプロイ後に確認する情報の出力定義です。

output "instance_id" {
  description = "EC2 instance ID"
  value       = aws_instance.llm.id
}

output "public_ip" {
  description = "Public IP address (for debugging only - use Tailscale IP for access)"
  value       = aws_instance.llm.public_ip
}

output "ami_id" {
  description = "AMI ID used"
  value       = local.ami_id
}

output "access_url" {
  description = "Open WebUI URL (via Tailscale)"
  value       = "http://qwen-llm:${var.open_webui_port}"
}

userdata.sh

インスタンスの初回起動時に自動実行されるスクリプトです。Docker や Tailscale のインストール、LLM モデルのダウンロードまでを自動化しています。

userdata.sh の全文
#!/bin/bash
set -euxo pipefail

exec > >(tee /var/log/userdata.log) 2>&1
echo "=== User Data: started at $(date) ==="

# Skip if already initialized (for stop/start cycles)
if [ -f /opt/qwen-llm/.initialized ]; then
  echo "Already initialized. Exiting."
  exit 0
fi

# ---- Install AWS CLI (for auto-shutdown via API) ----
apt-get update
apt-get install -y ca-certificates curl gnupg unzip conntrack
if ! command -v aws &> /dev/null; then
  curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "/tmp/awscliv2.zip"
  unzip -q /tmp/awscliv2.zip -d /tmp
  /tmp/aws/install
  rm -rf /tmp/aws /tmp/awscliv2.zip
fi

# ---- Install Docker (skip if pre-installed on Deep Learning AMI) ----
if ! command -v docker &> /dev/null; then
  install -m 0755 -d /etc/apt/keyrings
  curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
    | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
  chmod a+r /etc/apt/keyrings/docker.gpg

  echo \
    "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
    $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
    | tee /etc/apt/sources.list.d/docker.list > /dev/null

  apt-get update
  apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
else
  echo "Docker already installed, skipping."
fi

# ---- Install NVIDIA Container Toolkit (skip if pre-installed) ----
if ! dpkg -l nvidia-container-toolkit &> /dev/null; then
  rm -f /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
  curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
    | gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

  curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
    | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
    | tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

  apt-get update
  apt-get install -y nvidia-container-toolkit
else
  echo "NVIDIA Container Toolkit already installed, skipping."
fi

# Ensure Docker is configured for NVIDIA runtime
nvidia-ctk runtime configure --runtime=docker
systemctl restart docker

# ---- Install Tailscale ----
curl -fsSL https://tailscale.com/install.sh | sh
set +x  # auth key をログに出力しない
tailscale up --auth-key=${tailscale_auth_key} --hostname=qwen-llm
set -x

# ---- Create Docker Compose file ----
mkdir -p /opt/qwen-llm
cat > /opt/qwen-llm/docker-compose.yml << 'COMPOSE_EOF'
services:
  ollama:
    image: ollama/ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama
    environment:
      - OLLAMA_KEEP_ALIVE=-1
      - OLLAMA_NUM_PARALLEL=4
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    restart: unless-stopped

  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    ports:
      - "${open_webui_port}:8080"
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
      - ENABLE_TITLE_GENERATION=false
      - ENABLE_TAGS_GENERATION=false
      - ENABLE_SEARCH_QUERY_GENERATION=false
    volumes:
      - open_webui_data:/app/backend/data
    depends_on:
      - ollama
    restart: unless-stopped

volumes:
  ollama_data:
  open_webui_data:
COMPOSE_EOF

# ---- Start services ----
cd /opt/qwen-llm
docker compose up -d

# ---- Wait for Ollama and pull model ----
echo "Waiting for Ollama to be ready..."
for i in $(seq 1 60); do
  if curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then
    echo "Ollama is ready!"
    break
  fi
  echo "Waiting... ($i/60)"
  sleep 5
done

echo "Pulling Qwen 3.5-4B model..."
docker exec $(docker ps -qf "ancestor=ollama/ollama") ollama pull qwen3.5:4b

# ---- Auto-shutdown script ----
cat > /opt/qwen-llm/auto-shutdown.sh << 'SHUTDOWN_EOF'
#!/bin/bash
# Open WebUI への TCP 接続を conntrack で監視し、
# 一定時間アイドル状態が続いた場合にインスタンスを停止する。
# Docker NAT 越しの接続は ss では見えないため conntrack を使用する。
IDLE_THRESHOLD=3600
TIMESTAMP_FILE="/tmp/last-activity"
WEBUI_PORT=${open_webui_port}

ACTIVE_CONNECTIONS=$(conntrack -L -p tcp --dport "$WEBUI_PORT" 2>/dev/null \
  | grep -c ESTABLISHED || echo "0")

if [ "$ACTIVE_CONNECTIONS" -gt 0 ]; then
  date +%s > "$TIMESTAMP_FILE"
  exit 0
fi

if [ ! -f "$TIMESTAMP_FILE" ]; then
  date +%s > "$TIMESTAMP_FILE"
  exit 0
fi

LAST_ACTIVITY=$(cat "$TIMESTAMP_FILE")
CURRENT_TIME=$(date +%s)
IDLE_TIME=$((CURRENT_TIME - LAST_ACTIVITY))

if [ "$IDLE_TIME" -ge "$IDLE_THRESHOLD" ]; then
  INSTANCE_ID=$(curl -sf http://169.254.169.254/latest/meta-data/instance-id)
  REGION=$(curl -sf http://169.254.169.254/latest/meta-data/placement/region)
  logger "auto-shutdown: Idle for $${IDLE_TIME}s. Stopping instance $${INSTANCE_ID}."
  aws ec2 stop-instances --instance-ids "$INSTANCE_ID" --region "$REGION"
fi
SHUTDOWN_EOF

chmod +x /opt/qwen-llm/auto-shutdown.sh

# Cron job: run every 5 minutes + reboot 時にタイムスタンプを初期化
printf '%s\n%s\n' \
  '@reboot root date +\%s > /tmp/last-activity' \
  '*/5 * * * * root /opt/qwen-llm/auto-shutdown.sh >> /var/log/auto-shutdown.log 2>&1' \
  > /etc/cron.d/auto-shutdown
chmod 644 /etc/cron.d/auto-shutdown

# Initialize timestamp
date +%s > /tmp/last-activity

# Mark as initialized
touch /opt/qwen-llm/.initialized

echo "=== User Data: completed at $(date) ==="

このスクリプトは Terraform の templatefile 関数で処理されるため、${tailscale_auth_key}${open_webui_port} は Terraform 変数の値に置換されます。スクリプトの冒頭で /opt/qwen-llm/.initialized の存在を確認しているため、インスタンスを停止→再起動した場合は何も実行されません。初回のみ動作する設計です。

デプロイの実行

terraform.tfvars を作成し、Tailscale の auth key を設定します。

tailscale_auth_key = "tskey-auth-xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

デプロイを実行します。

cd infra
terraform init
terraform apply

apply が完了すると、アクセス URL がターミナルに出力されます。

Outputs:

access_url  = "http://qwen-llm:8080"
instance_id = "i-0xxxxxxxxxxxx"

起動後の自動セットアップ

terraform apply 完了後、User Data スクリプトがバックグラウンドで実行されます。

  1. AWS CLI のインストール
  2. Docker のインストール
  3. NVIDIA Container Toolkit のインストール
  4. Tailscale のインストールと認証
  5. Ollama + Open WebUI コンテナの起動
  6. Qwen 3.5-4B モデルのダウンロード (約 3 GB)
  7. 自動停止 cron ジョブの登録

全体で 5-10 分程度かかります。Tailscale 管理画面に qwen-llm が表示されれば、手順 4 まで完了しています。

動作確認: PC とスマホの両方からアクセスする

Tailscale 接続の確認

Tailscale 管理画面 を開き、デバイス一覧に qwen-llm が表示されていることを確認します。

device list

Open WebUI へのアクセスと初期設定

PC のブラウザで http://qwen-llm:8080 を開きます。初回はアカウント作成画面が表示されます。

  1. Sign up をクリックする
  2. Name、Email、Password を入力する
  3. Create Account をクリックする

PC からチャットしてみる

チャット画面の上部でモデル qwen3.5:4b を選択し、メッセージを送信します。

test-chat

スマホからチャットしてみる

Android スマホに Tailscale アプリ をインストールし、PC と同じアカウントでサインインします。

スマホのブラウザで http://qwen-llm:8080 を開くと、PC と同じ Open WebUI にアクセスできます。先ほど作成したアカウントでログインすれば、PC で行ったチャットの履歴もそのまま表示されます。

smartphone browser

使ってみた所感

構築手順だけではなく、クラウド LLM のバックアップとして実用に耐えうるのか確かめてみました。

応答速度

Qwen 3.5-4B はデフォルトで thinking モードが有効になっています。技術的な質問に対しては、この thinking モードが効果的に機能しました。C 言語の for ループの高速化を依頼したところ、101 秒の思考時間をかけてメモリアクセスパターンを分析し、キャッシュローカリティを考慮した正確な最適化提案を返してきました。

qwen-on-ec2-gif

プロンプトと回答全文
次の for 文を高速化してください。
```c
for (i = 0; i < n; i++) {
  for (j = 0; j < n; j++) {
    a[j][i]++;
  }
}
```

----

C の 2 次元配列(または multidimensional array)を高速化するための最も効果的な最適化は、**「メモリアccess パターン(キャッシュローカリティ)」**の改善です。

そのための具体的な手順とコード例を提示します。

### 1. メモリアクセスパターンの問題点
C のマルチディメンション配列は通常、**Row-Major order**(行優先)でメモリに配置されています。つまり、同じ行にある要素同士はメモリ上も隣接して配置されています。

*   **元のコード(高速化前):**
    ```c
    // i を外ループ,j を内ループ
    for (i = 0; i < n; i++) {
        for (j = 0; j < n; j++) {
            a[j][i]++; // 行番号 j は固定されない。列 index i が変わってくる。
        }
    }
    ```
  *   **問題:** `a[j][i]` ここで外ループが `i` (column) を動かすと、内ループで `j` (row) が変更されていき、メモリ上のアクセスは**「列を下から上へ(縦)」**に行きます。これにより、連続したメモリにアクセスせず、キャッシュにミスが発生し、大量のメモリアクセスがサーバーへ送られることになります。

### 2. 高速化後のコード
**内側ループを `i` から `j` に変更する**だけで、メモリ上のアクセスは「行を横から右へ(横)」に変更されます。これにより、連続したメモリ要素へのアクセスが可能になり、キャッシュヒット率が劇的に向上します。

```c
// Optimized Code: Loop Reorder (Row-Major)
int n = /* your size */;
// Assuming a is declared as int a[n][n] or similar contiguous structure.
for (j = 0; j < n; j++) {
    for (i = 0; i < n; i++) {
        // Memory access order: a[j][0], a[j][1], ..., a[j][n-1]
        // This is ROW-MAJORITY traversal.
        a[j][i]++; 
    }
}
```

### 3. 高速化の原理:キャッシュ効率
*   **Cache Line:** 一般に CPU のキャッシュラインは 64 bytes (8 words) です。
*   **Row-Major Access:** `a[j][0]...` と並んでアクセスすることで、次の要素が即座にキャッシュから取得できます(Prefetching が効きます)。
*   **Column-Major Access:** `a[0][i], a[1][i]...` はメモリアクセスはランダムになり、データが異なるキャッシュライン上にあればその度にディスクまたは RAM の直接メモリにアクセスする必要が発生します。

### 4. その他の高度な最適化オプション
上記のループ順の変更に加え、以下のオプションを組み合わせるとさらに高速化が期待できます。

#### A. コンパイラフラグ
コンパイラが自動で最適化を行う必要があります。
*   `-O3`: 高度なコード生成(例:関数アンラーリング、内側ループの展開など)。
*   `-march=native`: プロセサー固有の指令セットを使用します(AVX2, AVX-512, SSE などによるベクトル演算が可能に)。

#### B. SIMD / 並列化 (OpenMP)
CPU のコアを同時に活用できますが、`++` 演算子には**「Atomic Operation」**が必要になります。

```c
#pragma omp parallel for reduction(+:sum_variable) if (n > 100) // reduction は不要の場合の省略形は避けるので明示的
for (j = 0; j < n; j++) {
    #pragma omp simd // ベクトル化を指示(CPU が対応していれば有効)
    for (i = 0; i < n; i++) {
        a[j][i]++; // これらは異なるコアが書き込むため、OpenMP で並列化すると各コア内部で atomic な挙動が必要。
        // しかし,単純に a[j][i]++ とした場合、原子性は必要ないのか?
        // 注意:a[j][i]++ は値を読み込み、変更し、書き戻す動作。
        // もし複数のスレッドが同じ a[j][i] にアクセスする必要があるなら atomic な処理が必要。
    }
}
```
※通常、OpenMP で並列化する場合の `reduction` を使用しないと結果が崩れる可能性があります(原子性確保のため)。
しかし、上記コードの場合は「すべてを増す」だけなので、`sum` 計算などは不要です。もし単純に配列全体を増やせば、**ループ分割されたスレッド間でデータ独立性**がある場合は並列化できます。

### まとめ:最優先すべき変更
1. **ループ順序の切り替え**: `for(i...) for(j...)``for(j...) for(i...)`。これが最も直接的かつ効果的な最適化です。
2. **コンパイラオプションの適用**: `-O3 -march=native` を使用してください。
3. **メモリアライメント(追加)**: `a[j][i]` にアクセスする場合、`a[j]` はポインタとして扱えますが、動的配列の場合、`int *ptr = &a[j];` として明示的にポインタ演算を行うことでキャッシュヒット率がさらに向上する可能性があります。ただし、静的な `int a[n][n]` の場合も同様です。

最終的なコードは以下のようになります(ループ順の切り替えのみ)。

```c
// Final Optimized Code
for (j = 0; j < n; j++) { // Outer loop for Rows (Cache Locality)
    for (i = 0; i < n; i++) { // Inner loop for Columns (Sequential Access)
        a[j][i]++;
    }
}
```

一方、自己紹介など日常会話レベルの質問では数秒で応答が返ります。スマホからの利用でも体感速度に差はありませんでした。

回答の品質

日常会話や技術的な質問に対しては、自然な日本語で回答します。Claude や ChatGPT と比べると品質の差は感じますが、障害時のバックアップとして使うには十分に感じました。

一方で、Web 検索機能を持たないにもかかわらず、「信頼できる公的な情報源を検索できません」「検索精度を高めるため、……」と回答したケースもありました。これは、モデルが自身の能力を正確に説明できていない例です。実際には、検索機能そのものがないため、これらの表現は不正確です。

プロンプトと回答全文
DevelopersIO の越井琢巳という執筆者について教えてください。

----

申し訳ありませんが、「DevelopersIO の越井琢巳」という個人について、信頼できる公的な情報源(公式プロフィール、記事、メディアなどの確立されたデータ)を検索できません。

可能性としては以下の通りです:
- **名称や組織名の誤記**:開発者向けのコミュニティや技術系の団体名が類似している場合があるため、より正確な名前(例:「DevelopersIO」ではなく「Devs.io」または別の名称など)と確認をお勧めします。
- **限られた公開情報**:個人が公開活動を行っていない、あるいは情報が非公式なブログや SNS 上でしか存在しない可能性もあります。

正確な情報を提供する際は、名前や所属組織の正しい表記(ローマ字・英字)、または具体的な役割(例:技術作家、プロジェクトリーダーなど)を補足いただけますでしょうか?さらに検索精度を高めるため、以下の点をご確認いただくお手伝いができます。

なお、thinking モードでは、推論過程がリアルタイムで表示されます。4B パラメータのモデルがどのように考えているかを覗くことができます。

知識が曖昧な領域で推論過程に問題が起こるケースも確認できました。「徳川将軍の名前をすべて教えてください」と質問したところ、4 代目以降の将軍名を思い出せず、自問自答を数十回繰り返す無限ループに陥りました。

* Actually, the list of Tokugawa Shoguns includes:
1. Ieyasu
2. Hidetada
3. Iemitsu
4. **Iesada**? (Wait, no). It was **Yoshimune**. Wait, no.
* Okay, let's try to recall the names again:
1. Ieyasu
2. Hidetata
3. Iemitsu
4. **Iesada**? (Wait, no). It was **Yoshimune**. Wait, no.
* Actually, there is a specific name "Matsudaira Toshisuke".
* Okay, let's try to find the correct names:
1. Ieyasu
2. Hidetada
3. Iemitsu
4. **Iesada**? (Wait, no). It was **Yoshimune**. Wait, no.
* Actually, there is a common misconception about the count. Let's check if it is 15 or 16.
* Actually, the list of Tokugawa Shoguns includes:
1. Ieyasu
2. Hidetada
3. Iemitsu
4. **Iesada**? (Wait, no). It was **Yoshimune**. Wait, no.
* Actually, there is a specific name "Matsudaira Toshisuke".
* Okay, let's try to find the correct names:
1. Ieyasu
2. Hidetada
3. Iemitsu
4. **Iesada**? (Wait, no). It was **Yoshimune**. Wait, no.

最終的にトークンの上限に達したのか、回答本文が空のまま処理が終了しました。

動作確認: 自動停止

使わないときにコストがかからないよう、1 時間以上のアイドル状態でインスタンスを自動停止する仕組みを入れています。

cron で 5 分ごとに実行されるシェルスクリプトで、conntrack コマンドを使い Open WebUI へのアクティブな TCP 接続を監視しています。接続が検出されない状態が 1 時間続くと、インスタンスメタデータから自身のインスタンス ID を取得し、aws ec2 stop-instances で自分自身を停止します。

TCP 接続の監視には ss ではなく conntrack を使用しています。Open WebUI は Docker コンテナ内で動作しており、Tailscale 経由のアクセスは iptables の DNAT で転送されます。ホスト側の ss コマンドでは Docker NAT 越しの接続が見えないため、Linux カーネルの接続追跡テーブルを参照する conntrack を採用しました。

また、cron の @reboot エントリでタイムスタンプファイルを初期化しています。EC2 の stop/start では /tmp が EBS ルートボリューム上にあるため保持されます。古いタイムスタンプが残ったまま起動すると、起動直後に閾値超過と判定されて即停止してしまうためです。

閾値を一時的に 300 秒に変更して短縮テストを実施し、ブラウザを閉じてから約 10 分後に、AWS コンソール上でインスタンスの状態が stoppingstopped に遷移するのを確認しました。

停止後の再起動と復旧

停止したインスタンスは以下のコマンドで再起動できます。

aws ec2 start-instances --instance-ids <instance-id> --region us-east-1

再起動後、Docker コンテナは restart: unless-stopped の設定により自動で復旧します。Tailscale も自動再接続するため、数分後には同じ URL で再びアクセスできるようになります。モデルは EBS 上に保持されているため、再ダウンロードは不要です。

注意点

検証中に遭遇した問題をまとめます。同じ構成を試す際の参考にしてください。

VRAM アンロードによる初回応答の遅延

Ollama はデフォルトで、最後のリクエストから 5 分間経過するとモデルを VRAM からアンロードします。アンロード後の初回リクエストではモデルの再ロードが発生し、今回の構成では 1-2 分程度の待ち時間が生じました。

対策として、Docker Compose の環境変数に OLLAMA_KEEP_ALIVE=-1 を設定し、モデルを VRAM に常時保持するようにしています。単一モデルしか使わない構成であれば、アンロードのメリットはないためです。

Open WebUI の自動生成タスクが応答をブロックする

Open WebUI はチャットの応答後に、同じ LLM に対してタイトル生成、タグ生成、関連質問生成などのタスクリクエストを送信します。Qwen 3.5 は thinking モードを持つため、これらのタスクでも思考フェーズが実行され時間がかかります。この間に送信した別のチャットリクエストがブロックされ、ローディングが終わらない状態になります。OLLAMA_NUM_PARALLEL=4 を設定しても効果がありませんでした。

対策として、Docker Compose の環境変数でこれらの自動生成をすべて無効化しています。

  • ENABLE_TITLE_GENERATION=false
  • ENABLE_TAGS_GENERATION=false
  • ENABLE_SEARCH_QUERY_GENERATION=false

タイトル自動生成を無効にすると、初回のプロンプト文がそのままチャットタイトルとして使われます。なお、既にコンテナを起動済みの場合は環境変数の追加だけでは反映されません。管理画面や個人設定から無効化してください。

Open WebUI の環境変数と PersistentConfig

ENABLE_TITLE_GENERATION 等の Open WebUI の一部の設定は PersistentConfig という仕組みで管理されています。環境変数から読み込まれるのは初回起動時のみで、2 回目以降はデータベースに保存された値が優先されます。そのため、既にコンテナを起動済みの状態で後から環境変数を追加しても反映されません。変更する場合は管理画面や個人設定から行う必要があります。

Tailscale auth key の有効期限

Tailscale の auth key にはデフォルトで 90 日の有効期限があります。auth key が失効しても既に参加済みの端末はすぐには切断されませんが (端末の node key 期限は別途 180 日)、インスタンスを terminate して再構築する際に Tailscale ネットワークへ参加できなくなります。長期運用する場合は、定期的に auth key を更新して terraform.tfvars を書き換えるか、Tailscale の OAuth client による自動更新を検討してください。

スポットインスタンスの中断リスク

スポットインスタンスは AWS 側の都合で中断される可能性があります。中断された場合は手動で再起動する運用になります。

スポットインスタンスの再起動待ち

停止状態のスポットインスタンスを再起動する際、スポットリクエストの状態が instance-stopped-by-user に遷移するまで start-instances が失敗する場合があります。marked-for-stop 状態になっている場合は、数分待ってから再実行してください。

まとめ

Qwen 3.5-4B を AWS の GPU インスタンスにセルフホストし、Tailscale で PC からもスマホからもアクセスできるプライベート LLM 環境を構築しました。4B パラメータのモデルながら、技術的な質問にはキャッシュローカリティを考慮した最適化提案を返すなど、実用的な回答が得られました。thinking モードの無限ループやハルシネーションといった課題はあるものの、Claude や ChatGPT が使えないときのバックアップとしては十分活用できるのではないかと思います。プライベート LLM 環境の構築を検討されている方の参考になりましたら幸いです。

この記事をシェアする

FacebookHatena blogX

関連記事