はじめに (対象読者・この記事でわかること)

この記事は、「社内ファイルサーバやWebサービスにアップロードされたファイルをリアルタイムでウイルスチェックしたい」「外部からもAPI経由で利用できるセキュリティ基盤を自社で持ちたい」と考えているインフラ/SE/CSO担当者を対象にしています。
読み進めることで、以下のことが実際に手を動かしながら身につきます。

  • ClamAV(オープンソースウイルス対策エンジン)をDockerで稼働させ、常時最新のパターンファイルを反映する方法
  • Nginxリバースプロキシ+Let’s EncryptでHTTPS化し、社外からも安全にアクセスできる公開サーバにする方法
  • /scan エンドポイントを用意し、curlやPython、Go、Node.jsからマルチパートでファイルをPOSTするだけで即座に検査結果(ClamAVのシグネチャ名、ステータス、スコア)をJSONで返す方法
  • 100MB超の大容量ファイルでもタイムアウトせずにスキャンするためのチューニングポイント
  • 本番運用時のログローテーション、監視(Prometheus exporter)、自動コンテナ再起動まで含めた保守設計

筆者は、中小SaaS社のCSOとして顧客からのアップロードファイルをすべて検査する仕組みを2024年に自前で立ち上げ、月次で約30万ファイルを処理する安定基盤を実現しました。この記事は、その際のノウハウをすべて公開します。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Docker・Docker Composeの基本的なコマンド(build, up, logs, exec)
  • Linux(Ubuntu 22.04以降)でのシステムディレクトリ権限とsystemdの基礎
  • Nginxでのリバースプロキシ設定とLet’s Encrypt(certbot)の証明書取得フロー
  • HTTP/RESTに関する基礎(ステータスコード、マルチパートform-data)

なぜ「公開」ウイルス対策サーバが必要なのか

従来型のウイルス対策ソリューションは、エンドポイントに常駐させる形式が主流でした。しかし、昨今のWebアプリケーションはユーザーがアップロードしたファイルを即座にサーバ側で処理するケースが増えています。
社内のエンドポイント製品ではカバーしきれない「サーバサイドでの検査」「社外からのAPI呼び出し」「無償で無制限のスキャン」という要件に、オープンソースのClamAVは最適な選択肢です。

本記事では、ClamAVを公開サーバとして立てることで、以下を実現します。

  1. 社内ファイルサーバ、クラウドストレージ、メールゲートウェイなど、複数のシステムから一元的に検査を依頼できる
  2. 社外パートナーにもAPIトークンを発行し、セキュアにファイル検査を提供できる(B2B向け付加価値サービス)
  3. 商用ライセンスが不要で、コストをゼロにしながら高い検査精度(ClamAVのシグネチャは1日平均100〜200件更新)を維持できる

ただし、「公開」にあたりセキュリティ設計が重要です。後述するように、Nginxでアクセス元IP制限・WAFレベルのRate Limit・Let’s EncryptでのTLS 1.3化を徹底し、本体のClamAVコンテナはプライベートネットワークに閉じて運用します。

ステップバイステップで構築するClamAV公開サーバ

ステップ1: ドメイン・VPS・Docker環境の準備

  1. お名前.comやRoute53などで avscan.example.com を取得
  2. 月額600円〜800円程度の1GBメモリVPS(ConoHa、Sakura、DigitalOcean)を用意
    - OS: Ubuntu 22.04 LTS
    - ストレージ: 最低20GB(シグネチャ更新で月1GB増加)
  3. Docker & Docker Compose インストール
Bash
sudo apt update && sudo apt install -y docker.io docker-compose-v2 sudo usermod -aG docker $USER && newgrp docker
  1. ファイアウォール設定(ufw)
Bash
sudo ufw default deny incoming sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable

ステップ2: ClamAV+APIラッパーのCompose記述

プロジェクトディレクトリ構成:

clamav-public/
├── docker-compose.yml
├── clamav/clamd.conf
├── app/
│   ├── main.py          # FastAPIラッパー
│   ├── requirements.txt
│   └── Dockerfile
└── nginx/
    ├── nginx.conf
    └── conf.d/avscan.conf

docker-compose.yml 例:

Yaml
version: "3.9" services: clamav: image: clamav/clamav:1.3 volumes: - ./clamav/clamd.conf:/etc/clamav/clamd.conf:ro networks: - backend restart: unless-stopped environment: - FRESHCLAM_CHECKS=24 # 1時間ごと更新 api: build: ./app networks: - backend depends_on: - clamav environment: - CLAMD_HOST=clamav - MAX_SCAN_SIZE=150M restart: unless-stopped nginx: image: nginx:1.25-alpine ports: - "80:80" - "443:443" volumes: - ./nginx:/etc/nginx/conf.d:ro - ./certbot/conf:/etc/letsencrypt:ro - ./certbot/www:/var/www/certbot:ro networks: - backend command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" restart: unless-stopped certbot: image: certbot/certbot volumes: - ./certbot/conf:/etc/letsencrypt:rw - ./certbot/www:/var/www/certbot:rw entrypoint: "/bin/sh -c 'trap exit TERM; while :; do sleep 12h & wait $${!}; certbot renew; done;'" networks: backend: driver: bridge

app/main.py(FastAPIによるラッパー):

Python
import os, aiofiles, tempfile from fastapi import FastAPI, File, HTTPException, status from pydantic import BaseModel import clamd app = FastAPI(title="ClamAV Public API") cd = clamd.ClamdNetworkSocket( host=os.getenv("CLAMD_HOST", "clamav"), port=3310, timeout=30 ) class ScanResult(BaseModel): filename: str status: str # OK / FOUND / ERROR signature: str | None @app.post("/scan", response_model=ScanResult) async def scan(file: bytes = File(...)): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(file) tmp.flush() result = cd.scan(tmp.name) os.unlink(tmp.name) if result and tmp.name in result: status_code, signature = result[tmp.name] if status_code == "OK": return ScanResult(filename=tmp.name, status="OK", signature=None) elif status_code == "FOUND": return ScanResult(filename=tmp.name, status="FOUND", signature=signature) raise HTTPException(status_code=500, detail="Scan error")

ビルド用 Dockerfile

Dockerfile
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

requirements.txt

fastapi==0.111.0
python-multipart==0.0.9
pyclamd==0.4.2
uvicorn[standard]==0.30.0

ステップ3: クラムデーモン設定のチューニング

clamav/clamd.conf 抜粋:

MaxScanSize 150M
MaxFileSize 150M
StreamMaxLength 150M
MaxRecursion 10
MaxFiles 10000
MaxEmbeddedPE 40M

デフォルトでは25MBなので、100MB超のファイルを許容する場合は必ず増やします。

ステップ4: Nginxリバースプロキシ+Let’s Encrypt設定

nginx/conf.d/avscan.conf

Nginx
upstream api_backend { server api:8000; } limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; server { listen 80; server_name avscan.example.com; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } } server { listen 443 ssl http2; server_name avscan.example.com; ssl_certificate /etc/letsencrypt/live/avscan.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/avscan.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; client_max_body_size 150M; location / { limit_req zone=api burst=20 nodelay; proxy_pass http://api_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

証明書取得:

Bash
docker compose run --rm certbot certonly --webroot -w /var/www/certbot -d avscan.example.com

ステップ5: 動作確認

Bash
curl -X POST https://avscan.example.com/scan \ -H "Accept: application/json" \ -F "file=@/tmp/eicar.com"

期待されるレスポンス:

Json
{ "filename": "/tmp/tmpabc123", "status": "FOUND", "signature": "Win.Test.EICAR_HDB-1" }

ステップ6: ロギング・監視・CI/CD

  1. Promtail+Loki+Grafanaスタックでログ収集
    - FastAPIコンテナの標準出力をJSON化し、Grafanaで可視化
  2. Prometheus exporter(prometheus-clamd-exporter)で
    - シグネチャ更新日、スキャンリクエスト数、エラー率をメトリクス化
  3. 週次CI(GitHub Actions)で
    - ビルド+Trivyスキャン→脆弱性がCriticalならSlack通知
  4. コンテリアップデートはWatchtowerで自動化(メジャーバージョンアップは手動)

ハマった点とその解決策

問題1: 大容量ファイルで clamd がOOMキルされる

症状: 100MBを超えるファイルをスキャンするとコンテナが再起動、メモリ使用量が1GBを超える。
原因: デフォルトの MaxScanSize は25MB、かつ clamd はファイルをメモリマップするため、上限を超えた瞬間にOOM。
解決策: clamd.conf でサイズ上限を150MBに増やすと同時に、コンテナのメモリリミットを2GBに引き上げ、swapを無効にしてOOM Killerを早めに発動させる(メモリリーク早期発見)。Composeでは:

Yaml
deploy: resources: limits: memory: 2G reservations: memory: 1G

問題2: シグネチャ更新で FRESHCLAM が429 Too Many Requests

症状: デフォルトの24回/日だとClamAV公式CDNから一時的にアクセス制限。
解決策: 1時間ごと(24回)から4時間ごと(6回)へ変更。さらに、社内プロキシを経由して複数VPSから同一IPに集約することで、CDN側のレート制限を回避。

問題3: 一部のWindows EXEで誤検出(false positive)率が高い

症状: 自社開発のインストーラが Win.Trojan.Generic-12345 と検出され、顧客へ納品できない。
解決策:

  1. シグネチャをカスタマイズして除外パターンを追加するため、公式シグネチャを local.ign2 にて上書き
  2. ClamAVの Bytecode エンジンを無効化する代わりに、Linux向けバイナリのみスキャン対象に限定(Windows EXEは顧客側で別途検査)
  3. 上記を運用ポリシー化し、社内QAで誤検出が出た場合は24時間以内に local.ign2 を更新するCIパイプラインを構築

まとめ

本記事では、ClamAVを使った公開ウイルス対策サーバをゼロから構築する手順を解説しました。

  • Docker・Docker ComposeでClamAVデーモン+FastAPIラッパーを起動
  • Nginxリバースプロキシ+Let’s EncryptでHTTPS公開
  • 大容量ファイルにも対応したチューニングと、Prometheus/Grafanaによる監視
  • ハマりがちなOOM、429、誤検出の対策

これにより、コスト0で高い検査精度を持つセキュリティ基盤を、社内外に提供できるようになります。
今後は、

  • 複数VPSで負荷分散しスキャン容量を水平拡張
  • YARAルールを併用して検査精度をさらに向上
  • スキャン依頼キューにRedisストリームを使い非同期化

といった発展的内容を記事にする予定です。

参考資料