0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWSでQAシステム作成(Ubuntu、PostreSQL、Drango、Gunicorn 、 Nginxの組あわせ)

Last updated at Posted at 2025-08-29

目的

AWSのUbuntu上に、PostgreSQL + Django + Gunicorn + Nginxの構成で「QA(クイズ)システム」を構築します。

DB仕様:Primaryキー(通番)、大分類、小分類、質問、回答、解説、正解回数、間違え回数

使い方:

  1. 大分類/小分類/全体から出題範囲を選択
  2. 「正答回数が低い順」or「ランダム」を選択
  3. 回答→正誤表示+解説表示
  4. 正解なら正答回数+1、不正解なら間違え回数+1
  5. 正答回数・間違え回数をリセット可能
  6. 全問題をExcel形式でエクスポート/Excelで修正してインポート
  7. Django 管理画面あり

1) サーバ環境セットアップ(Ubuntu)

# 基本
sudo apt update && sudo apt -y upgrade
sudo apt -y install python3-venv python3-dev build-essential \
                    libpq-dev postgresql postgresql-contrib \
                    nginx git

# PostgreSQL
sudo -u postgres psql

psql内で:

CREATE DATABASE quizdb;
CREATE USER quizuser WITH PASSWORD '強いパスワード';
ALTER ROLE quizuser SET client_encoding TO 'utf8';
ALTER ROLE quizuser SET default_transaction_isolation TO 'read committed';
ALTER ROLE quizuser SET timezone TO 'Asia/Tokyo';
GRANT ALL PRIVILEGES ON DATABASE quizdb TO quizuser;
\c quizdb;
GRANT ALL ON SCHEMA public TO quizuser;
GRANT ALL ON ALL TABLES IN SCHEMA public TO quizuser;
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO quizuser;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO quizuser;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO quizuser;

\q

プロジェクト配置:

mkdir -p ~/quizproject && cd ~/quizproject
python3 -m venv .venv
source .venv/bin/activate

参考:毎回、AWSログイン/起動時に情報をしなくてよいように
/home/ubuntu/.bashrcに以下を追加
source /home/ubuntu/.venv/bin/activate


# 必要パッケージ
pip install --upgrade pip
pip install django psycopg2-binary python-dotenv openpyxl pandas gunicorn

requirements.txt(再現用):必須ではない。

Django>=5.0,<6.0
psycopg2-binary>=2.9
python-dotenv>=1.0
openpyxl>=3.1
pandas>=2.2
gunicorn>=21.2

2) Djangoプロジェクト作成

django-admin startproject quizproject .
python manage.py startapp quiz
参考
/home/ubuntuで以下を実施したときのディレクトリ構成
python -m venv .venv
django-admin startproject quizproject .
python manage.py startapp quiz

補足
django-admin startproject quizproject . の . により、quizproject 配下にもう1階層 quizproject/ ができず、
ルート (/home/ubuntu/) に manage.py と quizproject/ が並びます。

python manage.py startapp quiz で /home/ubuntu/quiz/ が追加されます。
★は今回修正ないし、作成されたファイル

/home/ubuntu/
├── quizproject/                  # Djangoプロジェクト本体
│   ├── manage.py
│   ├── .env★                      # 環境変数ファイル(SECRET_KEY, DB接続情報など)
│   ├── quizproject/              # プロジェクト設定ディレクトリ
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── settings.py ★設定読み込み
│   │   ├── urls.py ★HPのパス構成
│   │   └── wsgi.py
│   ├── quiz/                     # アプリ
│   │   ├── __init__.py
│   │   ├── admin.py ★管理者用画面
│   │   ├── apps.py
│   │   ├── migrations/
│   │   │   └── __init__.py
│   │   ├── models.py ★DB設計
│   │   ├── tests.py
│   │   └── views.py
│   │   └── views.py ★HPの出力設計
│   │   └── forms.py  ★HPの入力設計
│   ├── templates/                # HTMLテンプレート
│   │   └── quiz/
│   │       └── index.html ★
│   │       └── question.html ★
│   │       └── result.html ★
│   ├── statistics/                   # adminの静的ファイル
│       └── admin/
│           └── css
│           └── img
│
├── .venv/                        # Python仮想環境 (python3 -m venv .venv)

2.1 .env(プロジェクト直下)

SECRET_KEY=ランダムで長い秘密鍵
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1,あなたのEC2パブリックIP,あなたのドメイン

DB_NAME=quizdb
DB_USER=quizuser
DB_PASS=StrongPassword!!
DB_HOST=localhost
DB_PORT=5432
TIME_ZONE=Asia/Tokyo

※あなたのEC2パブリックIPは* でもよい(AWSで固定IP使えないとき)

2.2 quizproject/settings.py

import os
from pathlib import Path
from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")

SECRET_KEY = os.getenv("SECRET_KEY")
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")

INSTALLED_APPS = [
    "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes",
    "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles",
    "quiz",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "quizproject.urls"
TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [BASE_DIR / "templates"],
    "APP_DIRS": True,
    "OPTIONS": {"context_processors": [
        "django.template.context_processors.debug",
        "django.template.context_processors.request",
        "django.contrib.auth.context_processors.auth",
        "django.contrib.messages.context_processors.messages",
    ]},
}]
WSGI_APPLICATION = "quizproject.wsgi.application"

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.getenv("DB_NAME", "quizdb"),
        "USER": os.getenv("DB_USER", "quizuser"),
        "PASSWORD": os.getenv("DB_PASS"),
        "HOST": os.getenv("DB_HOST", "localhost"),
        "PORT": os.getenv("DB_PORT", "5432"),
    }
}

LANGUAGE_CODE = "ja"
TIME_ZONE = os.getenv("TIME_ZONE", "Asia/Tokyo")
USE_I18N = True
USE_TZ = True

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

参考
SECRET_KEYは新規プロジェクト作成時 (django-admin startproject)にsettings.py に書きこまれるものを.envに転記する。

例:
SECRET_KEY = 'django-insecure-xxxxxxxxxxxxxxxxxxxxx'


3) モデル定義

/home/ubuntu/quizproject/quiz/models.py

from django.db import models

class Question(models.Model):
    category = models.CharField("大分類", max_length=100, db_index=True)
    subcategory = models.CharField("小分類", max_length=100, db_index=True)
    question_text = models.TextField("質問")
    answer = models.CharField("回答", max_length=50)  # Yes/No や短文を想定
    explanation = models.TextField("解説", blank=True)
    correct_count = models.IntegerField("正答回数", default=0)
    wrong_count = models.IntegerField("間違え回数", default=0)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f"{self.category}/{self.subcategory}: {self.question_text[:30]}"

マイグレーション:Django で モデル(models.py)で定義した Python クラス と、実際の データベースのテーブル構造 を同期させる

`/home/ubuntu/quizproject/

python manage.py makemigrations
python manage.py migrate

4) 管理画面(Django Admin)

/home/ubuntu/quizproject/quiz/admin.py

from django.contrib import admin
from .models import Question

@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
    list_display = ("id","category","subcategory","question_text","answer",
                    "correct_count","wrong_count","created_at")
    list_filter = ("category","subcategory")
    search_fields = ("question_text","explanation","category","subcategory")

参考:管理者画面イメージ
image.png

スーパーユーザー作成:

python manage.py createsuperuser

5) 画面(出題→回答→結果表示→記録)

5.1 URL

Django プロジェクト全体のルーティング(URLの振り分け)を管理するファイルの作成
quizproject/urls.py

from django.contrib import admin
from django.urls import path
from quiz import views

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", views.index, name="index"),
    path("question/", views.get_question, name="get_question"),
    path("answer/", views.submit_answer, name="submit_answer"),
    path("reset_counts/", views.reset_counts, name="reset_counts"),
    path("export_excel/", views.export_excel, name="export_excel"),
    path("import_excel/", views.import_excel, name="import_excel"),
]

5.2 フォーム

quiz/forms.py

from django import forms

SELECTION_CHOICES = (
    ("least_correct", "正答回数が低い順"),
    ("random", "ランダム"),
)

class SelectionForm(forms.Form):
    category = forms.CharField(label="大分類", max_length=100, required=False)
    subcategory = forms.CharField(label="小分類", max_length=100, required=False)
    mode = forms.ChoiceField(label="出題モード", choices=SELECTION_CHOICES)

class AnswerForm(forms.Form):
    question_id = forms.IntegerField(widget=forms.HiddenInput())
    user_answer = forms.CharField(label="回答", max_length=50)

5.3 ビュー

quiz/views.py

import io
import random
from django.db.models import F
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect, get_object_or_404
from django.views.decorators.http import require_http_methods
from django.contrib import messages
from .models import Question
from .forms import SelectionForm, AnswerForm
A
# トップ:出題条件選択
def index(request):
    form = SelectionForm(request.GET or None)
    # カテゴリ一覧の補助(プルダウンに使うならテンプレで出力)
    categories = Question.objects.values_list("category", flat=True).distinct().order_by("category")
    subcategories = Question.objects.values_list("subcategory", flat=True).distinct().order_by("subcategory")
    return render(request, "quiz/index.html", {"form": form, "categories": categories, "subcategories": subcategories})

# 出題
@require_http_methods(["GET"])
def get_question(request):
    form = SelectionForm(request.GET or None)
    if not form.is_valid():
        return HttpResponseBadRequest("条件が不正です。")

    qs = Question.objects.all()
    category = form.cleaned_data.get("category")
    subcategory = form.cleaned_data.get("subcategory")
    mode = form.cleaned_data.get("mode")

    if category:
        qs = qs.filter(category=category)
    if subcategory:
        qs = qs.filter(subcategory=subcategory)

    if not qs.exists():
        messages.warning(request, "該当する問題がありません。条件を変えてください。")
        return redirect("index")

    if mode == "least_correct":
        qs = qs.order_by("correct_count", "id")
        q = qs.first()
    else:  # random
        count = qs.count()
        q = qs[random.randrange(count)]

    answer_form = AnswerForm(initial={"question_id": q.id})
    return render(request, "quiz/question.html", {"question": q, "answer_form": answer_form})

# 回答送信
@require_http_methods(["POST"])
def submit_answer(request):
    form = AnswerForm(request.POST)
    if not form.is_valid():
        return HttpResponseBadRequest("回答が不正です。")

    q = get_object_or_404(Question, pk=form.cleaned_data["question_id"])
    user_answer = form.cleaned_data["user_answer"].strip()

    is_correct = (user_answer.lower() == q.answer.strip().lower())
    if is_correct:
        Question.objects.filter(pk=q.pk).update(correct_count=F("correct_count") + 1)
        result = "正解!"
    else:
        Question.objects.filter(pk=q.pk).update(wrong_count=F("wrong_count") + 1)
        result = "不正解"

    # 最新値を再読込
    q.refresh_from_db()
    return render(request, "quiz/result.html", {"question": q, "user_answer": user_answer, "is_correct": is_correct, "result": result})

# カウントリセット(全件 or 条件付きに拡張可)
@require_http_methods(["POST"])
def reset_counts(request):
    scope = request.POST.get("scope", "all")
    qs = Question.objects.all()
    if scope == "category":
        cat = request.POST.get("category")
        qs = qs.filter(category=cat)
    if scope == "subcategory":
        sub = request.POST.get("subcategory")
        qs = qs.filter(subcategory=sub)

    updated = qs.update(correct_count=0, wrong_count=0)
    messages.success(request, f"{updated}件のカウントをリセットしました。")
    return redirect("index")

# Excelエクスポート
@require_http_methods(["GET"])
def export_excel(request):
    import pandas as pd
    df = pd.DataFrame(list(Question.objects.values(
        "id","category","subcategory","question_text","answer","explanation","correct_count","wrong_count"
    )))
    output = io.BytesIO()
    with pd.ExcelWriter(output, engine="openpyxl") as writer:
        df.to_excel(writer, index=False, sheet_name="questions")
    output.seek(0)
    resp = HttpResponse(output.read(),
                        content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    resp["Content-Disposition"] = 'attachment; filename="questions.xlsx"'
    return resp

# Excelインポート(上書き編集・新規追加)
@require_http_methods(["POST"])
def import_excel(request):
    file = request.FILES.get("file")
    if not file:
        messages.error(request, "Excelファイルを選択してください。")
        return redirect("index")
    import pandas as pd
    try:
        df = pd.read_excel(file, sheet_name=0, dtype={"id":"Int64"}, engine="openpyxl")
    except Exception as e:
        messages.error(request, f"読み込みエラー: {e}")
        return redirect("index")

    required_cols = {"category","subcategory","question_text","answer","explanation","correct_count","wrong_count"}
    if not required_cols.issubset(set(df.columns)):
        messages.error(request, f"必要列が不足しています。必要: {sorted(required_cols)}")
        return redirect("index")

    # id があれば上書き、なければ新規
    upserted = 0
    for _, row in df.iterrows():
        data = dict(
            category=str(row["category"]),
            subcategory=str(row["subcategory"]),
            question_text=str(row["question_text"]),
            answer=str(row["answer"]),
            explanation="" if pd.isna(row["explanation"]) else str(row["explanation"]),
            correct_count=int(row["correct_count"]) if not pd.isna(row["correct_count"]) else 0,
            wrong_count=int(row["wrong_count"]) if not pd.isna(row["wrong_count"]) else 0,
        )
        if "id" in df.columns and not pd.isna(row.get("id", None)):
            obj, _created = Question.objects.update_or_create(id=int(row["id"]), defaults=data)
        else:
            obj = Question.objects.create(**data)
        upserted += 1

    messages.success(request, f"Excelインポート完了:{upserted}件反映しました。")
    return redirect("index")

5.4 テンプレート(最小)

Djangoの「テンプレート」とは、 Webページの見た目(HTML部分)を定義する仕組み のことです。
ビュー(views.py)から渡されたデータを埋め込み、ユーザーに返すために使います。

/home/ubuntu/quizproject/templates/quiz/index.html

<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>出題条件</title></head>
<body>
  <h1>出題条件</h1>
  <form action="{% url 'get_question' %}" method="get">
    <label>大分類:</label>
    <input type="text" name="category" list="category-list">
    <datalist id="category-list">
      {% for c in categories %}<option value="{{ c }}">{% endfor %}
    </datalist>
    <label>小分類:</label>
    <input type="text" name="subcategory" list="subcategory-list">
    <datalist id="subcategory-list">
      {% for s in subcategories %}<option value="{{ s }}">{% endfor %}
    </datalist>
    <label>出題モード:</label>
    <select name="mode">
      <option value="least_correct">正答回数が低い順</option>
      <option value="random">ランダム</option>
    </select>
    <button type="submit">出題</button>
  </form>

  <hr>
  <h2>カウント・Excel</h2>
  <form action="{% url 'reset_counts' %}" method="post">{% csrf_token %}
    <input type="hidden" name="scope" value="all">
    <button type="submit" onclick="return confirm('全件リセットします。よろしいですか?');">全件リセット</button>
  </form>

  <p><a href="{% url 'export_excel' %}">Excelエクスポート</a></p>
  <form action="{% url 'import_excel' %}" method="post" enctype="multipart/form-data">{% csrf_token %}
    <input type="file" name="file" accept=".xlsx">
    <button type="submit">Excelインポート</button>
  </form>

  <p><a href="/admin/">管理画面はこちら</a></p>
  {% for message in messages %}<p>{{ message }}</p>{% endfor %}
</body></html>

templates/quiz/question.html

<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>問題</title></head>
<body>
  <h1>問題</h1>
  <p><strong>{{ question.category }} / {{ question.subcategory }}</strong></p>
  <p>{{ question.question_text }}</p>
  <form action="{% url 'submit_answer' %}" method="post">
    {% csrf_token %}
    {{ answer_form.as_p }}
    <button type="submit">回答する</button>
  </form>
  <p><a href="{% url 'index' %}">条件に戻る</a></p>
</body></html>

templates/quiz/result.html

<!doctype html><html lang="ja"><head><meta charset="utf-8"><title>結果</title></head>
<body>
  <h1>{{ result }}</h1>
  <p>あなたの回答:{{ user_answer }}</p>
  <p>正解:{{ question.answer }}</p>
  <h3>解説</h3>
  <p>{{ question.explanation|linebreaksbr }}</p>
  <p>正答回数:{{ question.correct_count }} 間違え回数:{{ question.wrong_count }}</p>
  <p><a href="{% url 'get_question' %}?mode=random">もう一問(ランダム)</a></p>
  <p><a href="{% url 'index' %}">条件に戻る</a></p>
</body></html>

######################8/29はここから

6) 初期データ投入(例)

管理画面から直接入力してもOKですが、CSVやExcelがある場合はエクスポート形式に合わせて整備すると簡単です。Excelカラムは下記(行例はご提示のものに近い形):

id category subcategory question_text answer explanation correct_count wrong_count
1 NW VPN IPVPNで使われるトンネルモードの利点は○○である Yes トンネルモードはEnd2Endで実装されるもので 0 1

idは省略可(新規採番)。correct_count/wrong_countは数値で。


7) 本番運用(Gunicorn + Nginx)

7.1 静的ファイル収集

# settings.py の DEBUG=False に変更し、ALLOWED_HOSTS を適切に設定
python manage.py collectstatic --noinput

7.2 Gunicorn(systemd)

Gunicorn:Django などの Python Webアプリケーション を 外部からのHTTPリクエストで動かせるようにする仲介します。
具体的にはWSGI(PythonのWebアプリケーションとWebサーバーをつなぐための標準インターフェース )という仕組みを使ってDjangoを呼び出し、リクエストを処理します。

/etc/systemd/system/quiz.service

[Unit]
Description=Gunicorn for quizproject
After=network.target

[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/quizproject
##Environment="PATH=/home/ubuntu/quizproject/quiz/bin"
Environment="PATH=/home/ubuntu/quizproject/.venv/bin"
ExecStart=/home/ubuntu/quizproject/.venv/bin/gunicorn \
  --workers 3 --bind unix:/home/ubuntu/quizproject/quiz.sock \
  quizproject.wsgi:application
Restart=always

[Install]
WantedBy=multi-user.target

有効化:

sudo systemctl daemon-reload
sudo systemctl enable --now quiz.service
sudo systemctl status quiz.service

7.3 Nginx リバースプロキシ

NginxはWebサーとして動作し、HTTPリクエストをWSGI形式に変換してWSGIサーバーであるGunicornに渡します。

【重要】/etc/nginx/sites-availableにdefaultがあるとそちらを参照してしまうので削除する

rm /etc/nginx/sites-available/default

/etc/nginx/sites-available/quiz

server {
    listen 80;
    server_name ec2-xx-xx-xx-xx.compute-1.amazonaws.com;  # もしくは your.domain

    location = /favicon.ico { access_log off; log_not_found off; }

    location /static/ {
        alias /home/ubuntu/quizproject/staticfiles/;
    }

    location / {
        include proxy_params;
        proxy_pass http://unix:/home/ubuntu/quizproject/quiz.sock;
    }
}

有効化:

sudo ln -s /etc/nginx/sites-available/quiz /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx

7.4 セキュリティ・運用メモ

  • .env は権限600で保持:chmod 600 .env
  • DEBUG=FalseALLOWED_HOSTSを必ず設定
  • HTTPS化:Let’s Encrypt(snap install certbot --classicsudo certbot --nginx
  • DBバックアップ:pg_dump quizdb > backup.sql
  • 管理者ユーザーは強パス、二段階認証(Authy/OTP)を考慮

8) 動作確認フロー

# ローカル開発
source ~/quizproject/quiz/bin/activate
python manage.py runserver 0.0.0.0:8000

# 管理画面
# http://<EC2のIP>:8000/admin/ でログイン→Question追加

# フロント
# http://<EC2のIP>:8000/ にアクセス → 条件選択→出題→回答→結果

#実環境
http://<EC2のIP>/
http://<EC2のIP>/admin/

なお、adminは

python manage.py createsuperuser

実行すると以下の画面がでてくるのでそちらで設定する

Username (leave blank to use 'ubuntu'): admin
Email address: admin@example.com
Password:
Password (again):
Superuser created successfully.
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?