Djangoでユーザ毎に言語設定できるようにしてみた

やったこと

  1. ブラウザの言語設定に応じた言語を表示する。
    • 日本語設定のブラウザなら日本語で、英語設定のブラウザなら英語でWebサイトを表示する。
    • 技術的にはDjangoの国際化(i18n)を利用してAccept-Languageの優先順位が高い言語でページを生成する。
  2.  Djangoのアカウント機能と合わせて、各ユーザが指定した言語に合わせてページを表示させる。
    • 技術的にはDjangoのセッション情報(session['_language'])を任意のタイミングで書き換える。

ちなみにソースコードはGitHubにもあります。

目次

  • 環境の準備
  • ブラウザの言語設定に応じたページ表示
  • アカウント認証機能の追加
  • ユーザ毎に言語設定機能の追加

環境の準備

本記事は以下の環境で実行した。

  • OS: CentOS7.6
  • Python 3.6.7
  • Django 2.1

以下のコマンドで諸々インストール

$ sudo yum install -y https://centos7.iuscommunity.org/ius-release.rpm
$ sudo yum install -y python36u python36u-libs python36u-devel python36u-pip
$ sudo pip3.6 install django==2.1

ブラウザの言語設定に応じたページ表示

Djangoプロジェクトを作成する。

$ cd ~
$ django-admin startproject i18n
$ cd i18n

i18n/settings.pyを編集する。(ファイル名は~/i18n/からの相対パス。以後同様)

# どのホストからのアクセスも受け付ける
ALLOWED_HOSTS = ['*']

# 国際化のミドルウェアを追加
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', # 追加
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(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',
],
},
},
]

# デフォルトの言語を日本語にする
LANGUAGE_CODE = 'ja'

# localeフォルダの位置を指定する
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale'),
)

# 選択できる言語を設定する
from django.utils.translation import ugettext_lazy as _
LANGUAGES = [
('en', _('English')),
('ja', _('Japanese')),
]

i18n/urls.pyを編集する。

from django.contrib import admin
from django.urls import path
from . import views # 追加

urlpatterns = [
path('admin/', admin.site.urls),
path('', views.index, name='index'), # 追加
]

i18n/views.pyを作成する。

from django.shortcuts import render

def index(request):
return render(request, 'index.html')

templatesフォルダを作成する。

templates/index.htmlを作成する。

{% load i18n %}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{% trans "Djangoのテストページ" %}</title>
</head>
<body>
<h1 id="title">{% trans "Djangoのテストページ" %}</h1>
<button type="button" onclick='alert("{% trans "こんにちは" %}")'>{% trans "ここをクリック" %}</button>
</body>
</html>

localeフォルダを作成する。

以下のコマンドで翻訳用ファイルを作成する。

$ django-admin makemessages -l en

locale/en/LC_MESSAGES/django.poを編集する。

# : i18n/templates/index.html:6 i18n/templates/index.html:9
msgid "Djangoのテストページ"
msgstr "Django test page" # 変更

# : i18n/templates/index.html:10
msgid "こんにちは"
msgstr "Hello" # 変更

# : i18n/templates/index.html:10
msgid "ここをクリック"
msgstr "Click here" # 変更

以下のコマンドで翻訳ファイルをコンパイルする。

$ django-admin compilemessages

Webサーバ起動

$ python3.6 manage.py runserver 0.0.0.0:8000

ブラウザでhttp://(仮想マシンのIPアドレス):8000にアクセスし、ブラウザの言語設定に応じたページが表示されることを確認する。

ブラウザの言語設定に応じたページ(日本語)
ブラウザの言語設定に応じたページ(英語)

アカウント認証機能の追加

i18n/settings.pyを編集してログインURL設定を追加する。

LOGIN_URL = '/login/'

i18n/urls.pyにログインとログアウトのエンドポイントを追加する。
※:ログインにはdjango.contrib.auth.views.LoginViewを使うのが簡単なのだが、今回はアカウント毎に言語を設定する処理を追加するため低水準関数のdjango.contrib.authauthenticateloginを利用する。

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

urlpatterns = [
path('admin/', admin.site.urls),
path('', views.index, name='index'),

path('login/', views.login_view, name="login"), # ログイン用エンドポイント追加
path('logout/', views.logout_view, name='logout'), # ログアウト用エンドポイント追加
]

i18n/views.pyにログインとログアウトの処理を追加する。

from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib.auth import views as auth_views

@login_required # トップページもログインしないと入れないようにしてみる
def index(request):
return render(request, 'index.html')

# ログイン用view関数を追加
def login_view(request):
if request.method == 'GET':
next = request.GET.get('next', '/')
return render(request, 'login.html', {'next': next})
elif request.method == 'POST':
username = request.POST.get('username', '')
password = request.POST.get('password', '')
next = request.POST.get('next', '/')
user = authenticate(username=username, password=password)
if user is not None:
login(request, user)
return redirect(next)
else:
return render(request, 'login.html', {'next': next, 'error': True})

# ログアウト用view関数を追加
@login_required
def logout_view(request):
return auth_views.logout_then_login(request)

トップページtemplates/index.htmlにログアウトボタンを追加する。

{% load i18n %}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{% trans "Djangoのテストページ" %}</title>
</head>
<body>
<h1 id="title">{% trans "Djangoのテストページ" %}</h1>
<button type="button" onclick='alert("{% trans "こんにちは" %}")'>{% trans "ここをクリック" %}</button>
<!-- ここを追加 -->
<form action="{% url 'logout' %}">
<input type="submit" value="{% trans "ログアウト" %}" />
</form>
<!-- 追加ここまで -->
</body>
</html>

ログイン用テンプレートtemplates/login.htmlを作成

{% load i18n %}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{% trans "ログイン" %}</title>
</head>
<body>

<h1 id="title">{% trans "ログイン" %}</h1>
{% if error %}
<p>{% trans "ユーザ名もしくはパスワードが間違っています。もう一度試してください。" %}</p>
{% endif %}

<form method="post" action=".">
{% csrf_token %}
<table>
<tr><td>{% trans "ユーザ名" %}: </td><td><input type="text" name="username"></td></tr>
<tr><td>{% trans "パスワード" %}: </td><td><input type="password" name="password"></td></tr>
</table>
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" value="{% trans "ログイン" %}" />
</form>
</body>
</html>

翻訳用ファイルを更新する。

$ django-admin makemessages -l en

locale/en/LC_MESSAGES/django.poを編集する。

# : templates/index.html:14
msgid "ログアウト"
msgstr "Log out" # 変更

# : templates/login.html:6 templates/login.html:10 templates/login.html:23
msgid "ログイン"
msgstr "Log in" # 変更

# : templates/login.html:12
msgid "ユーザ名もしくはパスワードが間違っています。もう一度試してください。"
msgstr "Your username and password didn't match. Please try again." # 変更

# : templates/login.html:18
msgid "ユーザ名"
msgstr "Username" # 変更

# : templates/login.html:20
msgid "パスワード"
msgstr "Password" # 変更

翻訳ファイルをコンパイルする。

$ django-admin compilemessages

以下の2つのコマンドでDBマイグレーションを実施する。

$ python3.6 manage.py makemigrations
$ python3.6 manage.py migrate

adminユーザを作成する。

$ python3.6 manage.py createsuperuser
ユーザー名 (leave blank to use 'root'): admin
メールアドレス: (空欄でも可)
Password: (パスワード)
Password (again): (パスワード)
Superuser created successfully.

Webサーバ起動

$ python3.6 manage.py runserver 0.0.0.0:8000

ブラウザでhttp://(仮想マシンのIPアドレス):8000にアクセスし、以下を確認する。

  • ログインページにリダイレクトされる。
    ログインページ(日本語)

    ログインページ(英語)
  • ログインするとトップページに遷移する。
    ログイン後のトップページ(日本語)

    ログイン後のトップページ(英語)
  • 「ログアウト」をクリックするとログアウト処理が行われ、再度ログインページにリダイレクトされる。

ユーザ毎に言語設定機能の追加

i18n/models.pyを作成してDBモデルを追加する。
※:アカウント毎の言語を設定できるようにするため、Userテーブルと1対1関係にあるProfileテーブルを作成してProfileに言語を格納する。

from django.db import models
from django.contrib.auth.models import User

class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
language = models.CharField(max_length=128, default='', blank=True) # ユーザの設定した言語を格納する

def __str__(self):
return self.user.username

管理画面でDB設定できるようにi18n/admin.pyを作成する。

from django.contrib import admin
from .models import Profile

# Register your models here.
@admin.register(Profile)
class Profile(admin.ModelAdmin):
pass

i18n/settings.pyを編集して、Profileモデルを読み込めるようにする。

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'i18n', # 追加
]

i18n/urls.pyに言語設定用のエンドポイントを追加する。

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

urlpatterns = [
path('admin/', admin.site.urls),
path('', views.index, name='index'),

path('login/', views.login_view, name="login"),
path('logout/', views.logout_view, name='logout'),

path('language/', views.language, name='language'), # 追加
]

i18n/views.pyに言語設定の処理を追加する。
また、ログイン時に設定してある言語を適用する処理も追加する。

from django.shortcuts import render, redirect, get_object_or_404 # 便利な関数を追加
from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login
from django.contrib.auth import views as auth_views
from .models import Profile # 追加

@login_required
def index(request):
return render(request, 'index.html')

# ログイン用view関数を編集
def login_view(request):
if request.method == 'GET':
next = request.GET.get('next', '/')
return render(request, 'login.html', {'next': next})
elif request.method == 'POST':
username = request.POST.get('username', '')
password = request.POST.get('password', '')
next = request.POST.get('next', '/')
print(next)
user = authenticate(username=username, password=password)
if user is not None:
login(request, user)
profile = get_object_or_404(Profile, user=request.user) # Profileテーブルから該当のユーザを取得
request.session['_language'] = profile.language # この通信のsessionに言語を設定する
return redirect(next)
else:
return render(request, 'login.html', {'next': next, 'error': True})

# ログアウト用view関数を編集
@login_required
def logout_view(request):
del request.session['_language'] # このsessionの言語設定を削除する
return auth_views.logout_then_login(request)

# 言語設定用view関数を追加
@login_required
def language(request):
if request.method == 'GET':
return render(request, 'language.html')
elif request.method == 'POST':
language = request.POST['language']
profile = get_object_or_404(Profile, user=request.user) # Profileテーブルから該当のユーザを取得
profile.language = language # Profileにユーザからのリクエスト言語を設定
profile.save() # DB更新
request.session['_language'] = language # この通信のsessionに言語を設定する

return redirect('language')

request.session['_language']でセッション情報の言語設定を上書きすることがミソ。

トップページtemplates/index.htmlに言語設定ページへのリンクを追加する。

{% load i18n %}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{% trans "Djangoのテストページ" %}</title>
</head>
<body>
<h1 id="title">{% trans "Djangoのテストページ" %}</h1>
<button type="button" onclick='alert("{% trans "こんにちは" %}")'>{% trans "ここをクリック" %}</button>
<!-- ここを追加 -->
<br/>
<a href="{% url 'language' %}">{% trans "言語設定" %}</a>
<!-- 追加ここまで -->
<form action="{% url 'logout' %}">
<input type="submit" value="{% trans "ログアウト" %}" />
</form>
</body>
</html>

言語設定用テンプレートtemplates/language.htmlを作成する。

{% load i18n %}
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>{% trans "言語設定ページ" %}</title>
</head>
<body>
<h1 id="title">{% trans "言語設定ページ" %}</h1>
<form action="." method="post">
{% csrf_token %}
<select name="language">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ language.code }})
</option>
{% endfor %}
</select>
<input type="submit" value="{% trans "変更" %}">
</form>

<a href="{% url 'index' %}">{% trans "トップページに戻る" %}</a>

</body>
</html>

あとは翻訳系の作業。翻訳用ファイルを更新する。

$ django-admin makemessages -l en

locale/en/LC_MESSAGES/django.poを編集する。

# : templates/index.html:12
msgid "言語設定"
msgstr "Language setting" # 変更

# : templates/language.html:6 templates/language.html:9
msgid "言語設定ページ"
msgstr "Language setting page" # 変更

# : templates/language.html:22
msgid "変更"
msgstr "Change" # 変更

# : templates/language.html:25
msgid "トップページに戻る"
msgstr "Back to top page" # 変更

翻訳ファイルをコンパイルする。

$ django-admin compilemessages

DBマイグレーション

$ python3.6 manage.py makemigrations i18n
$ python3.6 manage.py migrate

Webサーバ起動

$ python3.6 manage.py runserver 0.0.0.0:8000

ブラウザで管理者ページhttp://(仮想マシンのIPアドレス):8000/adminにアクセスし、adminユーザのProfileを作成する。

管理者ページにアクセス。adminユーザでログインする。
このページでDBデータを操作できる。
Profileテーブルにadminユーザのデータを登録。Languageは空でOK。

管理者ページからログアウトし、トップページにアクセスして以下を確認する。(ログインは前項と同じなので省略)

  • 「言語設定」リンクがあること

    トップページに「言語設定」リンクが表示される。(日本語設定のブラウザの場合)
  • 「言語設定」リンクを押すと言語設定ページが表示されること

    言語設定ページで言語選択できる。
  • プルダウンから英語を設定して「変更」をクリックすると英語のページが表示されること

    言語設定を英語に変更すると英語表記のページが表示される
  • トップページに戻ると自分が設定した言語のまま

    トップページに戻っても英語のまま。
  • ログアウトするとブラウザ本来の言語でログインページが表示されること

    ログアウトするとブラウザ本来の言語に戻る。

Packerで作成したWebサーバAMIをTerraformで構築する

やりたいこと

  1. PackerでAmazon AMIを作成する
    • 下図の左側に示すように、jsonファイルを用意し、Packerを実行してAMIを作成する
    • 今回作成するAMIは、HTTPアクセスすると任意のメッセージを表示するApacheプロセスが自動起動するという簡単なもの
  2. TerraformでEC2インスタンスを起動する
    • 下図の右側に示すように、Packerで作成したAMIを使ってEC2インスタンスを構築するtfファイルを作り、Terraformを実行する

検証環境

  • Packer: v1.3.4
  • Terraform: v0.11.11
  • AWSリージョン: ap-northeast-1(東京)

前準備:IAMでAPI実行用のグループとユーザを作成

まずはIAMグループページでグループを作成。グループ名は任意でOK

今回の検証ではEC2のみの利用であるため、必要なポリシーは「AmazonEC2FullAccess」のみ

次にIAMユーザページでユーザを作成。ユーザ名は任意でOK。アクセスの種類は「プログラムによるアクセス」を選択

先程作成したグループをこのユーザに適用する

タグはなんでもよいが「Name」タグを作っておくのが一般的。

最後に「アクセスキー ID」と「シークレットアクセスキー」が表示される。この二つのキーを忘れずに控えておくこと

PackerでAMI作成

jsonファイル作成

以下のjsonファイルを作成する

今回の例ではファイル名は「simple-http-ami.json」としている

後半の”provisioners”句で、Apacheをインストールして、ドキュメントルートに任意のメッセージが書かれたテキストファイルを配置して、自動起動を有効化している

{
    "variables": {
        "aws_access_key": "{{env `AWS_ACCESS_KEY_ID`}}",
        "aws_secret_key": "{{env `AWS_SECRET_ACCESS_KEY`}}",
        "region":         "ap-northeast-1"
    },
    "builders": [
        {
            "access_key": "{{user `aws_access_key`}}",
            "ami_name": "packer-linux-aws-demo-{{timestamp}}",
            "instance_type": "t2.micro",
            "region": "{{user `region`}}",
            "secret_key": "{{user `aws_secret_key`}}",
            "source_ami_filter": {
              "filters": {
                  "name": "amzn2-ami-hvm-*-x86_64-gp2"
              },
              "owners": ["137112412989"],
              "most_recent": true
            },
            "ssh_username": "ec2-user",
            "type": "amazon-ebs"
        }
    ],
    "provisioners": [{
        "type": "shell",
        "inline": [
            "sleep 30",
            "sudo yum -y install httpd",
            "sudo bash -c \"echo 'This is a simple web page.<br/>The AMI is created by Packer and this server is built by Terraform.' >> /var/www/html/index.html\"",
            "sudo systemctl enable httpd"
        ]
    }]
}

jsonファイルの構文チェック

packer validate コマンドを使って構文チェックする

「Template validated successfully.」と表示されればOK

$ packer validate simple-http-ami.json
Template validated successfully.

AMI作成

まずはアクセスキー等を環境変数に設定する

$ export AWS_ACCESS_KEY_ID=[アクセスキーID]
$ export AWS_SECRET_ACCESS_KEY=[シークレットアクセスキー]

packer build コマンドでAMIを作成する

$ packer build simple-http-ami.json
(中略)
==> Builds finished. The artifacts of successful builds are:
--> amazon-ebs: AMIs were created:
ap-northeast-1: ami-0b8314be1d52e879e

成功すれば、最後の行にがAMI IDが表示される

TerraformでEC2インスタンス構築

tfファイル作成

「simple-http-instance.tf」ファイルを作成し、以下の内容を記述する

(この記事ではEC2インスタンスのみ構築する方法を記載している。一応これだけでもインスタンスは作成される。しかし本来はVPCやサブネット、セキュリティグループを作成する必要がある。VPC等を含めたterraformのコードについてはこちらを参考にしてほしい)

variable "access_key" {}
variable "secret_key" {}
variable "ami" {}
 
provider "aws" {
    access_key = "${var.access_key}"
    secret_key = "${var.secret_key}"
    region = "ap-northeast-1"
}
 
resource "aws_instance" "app_main" {
    ami           = "${var.ami}"
    associate_public_ip_address = "true"
    instance_type = "t2.micro"
 
    tags = {
        Name = "simple http instance"
    }
}
 
output "public_ip" {
  value = "${aws_instance.app_main.public_ip}"
}

Terraform実行確認

まずは環境変数を設定する

$ export TF_VAR_access_key=[アクセスキーID]
$ export TF_VAR_secret_key=[シークレットアクセスキー]
$ export TF_VAR_ami=ami-0b8314be1d52e879e (先程Packerで作成したAMI)

terraform plan コマンドで実行内容を確認する

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
(中略)
Plan: 1 to add, 0 to change, 0 to destroy.

Terraform実行

teffaform apply コマンドでEC2インスタンスを構築する

$ terraform apply
(中略)
Plan: 1 to add, 0 to change, 0 to destroy.
 
Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.
 
  Enter a value: yes
(中略)
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
 
Outputs:
 
public_ip = x.x.x.x

起動したEC2インスタンスはデフォルトのセキュリティグループが適用されている。デフォルトのセキュリティグループにて、検証環境から80番ポートのアクセスを許可し、ブラウザでアクセスすると以下の文言が記載されたWebページが表示される。

This is a simple web page.
The AMI is created by Packer and this server is built by Terraform. 

Accept-Languageを優先順位でソートするpython関数

Accept-Languageに応じてユーザに提供する言語を変えることがあったので、Accept-Languageの重みづけを見て優先順位でソートされた言語リストを返す関数を作った。
汎用性がありそうなので備忘録がてらブログに書いておく。

こちらのサイトを参考にしました。

def sort_accept_language(accept_language):
languages = accept_language.split(",")
locale_q_pairs = []

for language in languages:
if language.split(";")[0] == language:
# no q =&gt; q = 1
locale_q_pairs.append({"locale": language.strip(), "q": 1})
else:
locale = language.split(";")[0].strip()
q = float(language.split(";")[1].split("=")[1])
locale_q_pairs.append({"locale": locale, "q": q})

sorted_locale_q_pairs = sorted(locale_q_pairs, key=lambda x: (x['q'],), reverse=True)
print(sorted_locale_q_pairs)

sorted_locale = [l["locale"] for l in sorted_locale_q_pairs]
print(sorted_locale)

return sorted_locale

実行結果

関数の引数を”ja,en-US;q=0.7,en;q=0.3″にした場合

[{'locale': 'ja', 'q': 1}, {'locale': 'en-US', 'q': 0.7}, {'locale': 'en', 'q': 0.3}]
['ja', 'en-US', 'en']

GCPの負荷分散を使ってWebアプリの冗長構成を作ってみた

タイトル通り。
この記事で作成する環境の全体図は以下の通り。

GCE (Google Compute Engine)を2つ立てる

VMを作成する。設定は以下の図の通り。

このVMをもう一つ立てる。「VMインスタンス」ページで確認するとこんな感じ。

簡単なWebアプリケーションをデプロイ

以前作ったシンプルなWebアプリケーションを使う。
GitHubにソースコード載せてる。

参考程度に必要なコマンドを載せとく。

# yum -y install docker git
# systemctl start docker
# systemctl enable docker
# curl -L "https://github.com/docker/compose/releases/download/1.22.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
# chmod +x /usr/local/bin/docker-compose
# git clone https://github.com/tetsis/nginx-gunicorn-django-postgres.git
# cd nginx-gunicorn-django-postgres/
# sed -i -e "s/443:443/80:80/" docker-compose.yml (外部公開するポートをHTTPの80に変更する)
# docker-compose build
# docker-compose up -d
# docker-compose ps

インスタンスグループを作る

インスタンスグループを作成する。

  • ロケーション:「シングルゾーン」
  • グループタイプ:「非マネージドインスタンスグループ」
  • VMインスタンス:さっき作った2つのVMを追加する

(本当は障害耐性を上げるためマルチゾーンでやりたかったけど、マルチゾーンかつ非マネージドでやる方法が見つからなかった)

作成後の「インスタンスグループ」ページ。

HTTP(S)負荷分散(ロードバランサ)を作る

「負荷分散」からロードバランサを作成する。
HTTP(S)負荷分散の「設定を開始」をクリック。

バックエンドの設定

「バックエンドの設定」->「バックエンドサービスとバックエンドバケットの作成または選択」->「バックエンドサービス」->「バックエンドサービスを作成」
バックエンドサービスの設定項目はこんな感じ。

途中ヘルスチェックを作成する。

作成完了するとこんな感じ。

ホストとパスのルール

ここは特に設定しなくてOK。URLパスに応じてバックエンドを変えたい場合に設定するんだろう。

フロントエンドの設定

「フロントエンドの設定」はこんな感じ。

途中、証明書を設定する。
「証明書を選択してください」->「新しい証明書の作成」で公開鍵証明書、証明書チェーン(あれば)、秘密鍵を入力する。

作成完了

詳細ページ。

ロードバランサ経由でWebアプリケーションにアクセスする

ロードバランサのフロントエンドにつけられたグローバルIPアドレスにブラウザでアクセスする。

いい感じ。

GCP Cloud SQLでフェイルオーバーを試してみた

GCPの無料トライアル期間が終わりそうなので、クーポンが残ってるうちにいろいろ試してみようと思い立ち、ずっと気になっていたクラウドサービスのRDB (Relational Database)を使ってみた。
特にサーバで障害が発生した際のサーバ切替(フェイルオーバー)がどんな感じで使えるか試してみた。
ちなみにデータベースはPosgtreSQLを使用。

以下の流れで実験してみた

  1. Cloud SQLインスタンスを作成
  2. SQLインスタンスのPostgreSQLユーザとデータベースを作成
  3. Compute Engineインスタンスを作成
  4. Compute Engineにデータベースを使った簡単なWebアプリケーションをデプロイ
  5. フェイルオーバーを実施

Cloud SQLインスタンスを作成

GCPのコンソールから「SQL」->「インスタンスを作成」でDBサーバのインスタンスを作成する。
今回はPostgreSQLを選択。

設定内容は以下の図を参照。
特筆する点は、1. 「接続の設定」で「プライベートIP」を追加、2. 「自動バックアップと高可用性の有効化」で「高可用性」をチェックすること。

プライベートIPにチェックを入れる

高可用性(リージョン)にチェックを入れる

作成が完了するとSQLインスタンス一覧に表示される。

VMと繋ぐためのIPアドレス情報はインスタンス名をクリックし、「インスタンスの詳細」ページで確認できる。

パブリックIPアドレスもついているが、今回はプライベートIPアドレスを利用する。(パブリックのほうは無効にしてインスタンス作ったほうが良かったかも)

SQLインスタンスのPostgreSQLユーザとデータベースを作成

ユーザ作成

「インスタンスの詳細」->「ユーザ」->「ユーザアカウントを作成」でPostgreSQLユーザを新たに作成する。

「ユーザ名」と「パスワード」を設定する。
(今回はユーザ名:django、パスワード:djangoでやってます)

今作成したユーザが表示される。

データベース作成

「インスタンスの詳細」->「データベース」->「データベースを作成」でデータベースを新たに作成する。

「データベース名」を設定する。

今作成したデータベースが表示される。

Compute Engineインスタンスを作成

GCPのコンソールから「Compute Engine」->「VMインスタンス」->「VMインスタンスを作成」でインスタンスを作成する。
設定内容は以下の図を参照。

作成が完了するとVMインスタンス一覧に表示される。

Compute Engineにデータベースを使った簡単なWebアプリケーションをデプロイ

VMインスタンスのほうにログインして、必要な設定をする。最低限Git, Docker, Docker Composeはインストールすること。

Webアプリケーションをインストール

今回はテスト用に自作したシンプルアプリケーションを利用する。

# git clone https://github.com/tetsis/nginx-gunicorn-django-postgres.git
# cd nginx-gunicorn-django-postgres/

docker-compose-without-postgres.ymlを編集し、接続先データベースのIPアドレスをさっき作ったSQLインスタンスのものに変更する。

version: '3'
services:
nginx:
image: nginx:1.12.2-alpine
ports:
- "80:80"
- "443:443"
depends_on:
- django
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/certs:/etc/pki/nginx/certs
- ./src/static:/var/www/static
django:
build: ./django
env_file: ./django/.django_env
environment:
DATABASE_HOST: "10.122.0.2" # ここをSQLインスタンスのプライベートIPアドレスに変更する
volumes:
- ./src:/usr/src/django
command: gunicorn app.wsgi -b 0.0.0.0:8080

Webアプリケーションの実行と動作確認

Docker Composeでアプリケーション実行する。

# docker-compose -f docker-compose-without-postgres.yml up -d

docker-compose psコマンドで正常起動しているか確認

# docker-compose ps
Name                                Command               State                    Ports
---------------------------------------------------------------------------------------------------------------------------
nginx-gunicorn-django-postgres_django_1   gunicorn app.wsgi -b 0.0.0 ...   Up
nginx-gunicorn-django-postgres_nginx_1    nginx -g daemon off;             Up      0.0.0.0:443-&gt;443/tcp, 0.0.0.0:80-&gt;80/tcp

データベース初期化

以下のコマンドでデータベースを初期化する。
自作スクリプトだけど中身はdjangoのmigrateしてるだけ。

# docker exec nginx-gunicorn-django-postgres_django_1 ./init.sh

アプリケーションにアクセス

ブラウザで「https://(Compute Engineの外部IPアドレス)/」にアクセスする。
すると以下のような画面が表示される。

「Save」をクリックすると、その時間をDBに保存して画面に表示するだけのシンプルなもの。

フェイルオーバーを実施

今回メインテーマのフェイルオーバー。
本来はメインのサーバ(マスター)が障害等でシャットダウンした際に発生するものだが、Cloud SQLには手動でフェイルオーバーする機能がついているので、今回はその機能を使ってサービス断時間を計ってみる。
「インスタンスの詳細」画面の右上にある「フェイルオーバー」から手動でフェイルオーバーを実行する。

Webアプリケーションの時間記録の機能を使って、サービス断時間を計ってみた。

正確なDBサービス断時間ではないがアプリケーションからみた断時間は38秒間だった。
結構いい感じ。

HAProxyとKeepalivedを使ってオンプレ環境で冗長構成を実現

検証環境

  • ホストOS: CentOS7.5
  • 仮想環境: Vagrant (VirtualBox)
    • ゲストOS: CentOS7.5
    • haproxy1, haproxy2, app1, app2, postgres
  • HAProxy: 1.5.18
  • Keepalived: v1.3.5
  • PostgreSQL: 9.2.23

要件

第一の要件を可用性、次に運用容易性に設定して、自前のサーバでアプリケーション基盤を実現する方法を考えてみた。

構成イメージ図

前段のロードバランサー(HAProxy)はKeepalivedで冗長化し、LBの可用性を確保。

LBのバックにはアプリケーション本体を2台にデプロイするようにして、片方が落ちてもサービス継続できるようにした。(今時で言うとKubernetesやDocker Swarmなんだけど、運用容易性の観点から単純にアプリのプロセス立ち上げるだけで良いようにした)

その後段にはデータベースを配置。ここの可用性については今回の記事では省略。いつかpgpool的なのを使った場合の記事書きたい。

構成イメージ図

ネットワーク図

構成イメージでは階層構造になっているが、実際には全サーバ同一サブネット(192.168.33.0/24)に繋いで検証した。(すべて同じprivate networkに接続)
性能を求めているわけではないので、今回はこれで手打ち。

ネットワーク図

サーバ設定

データベース(PostgreSQL)

PostgreSQLをインストール

yum -y install postgresql-server
postgresql-setup initdb

/var/lib/pgsql/data/postgresql.confを編集

listen_addresses = '*'
timezone = 'Asia/Tokyo'
log_timezone = 'Asia/Tokyo'

/var/lib/pgsql/data/pg_hba.confを編集

host    all             all             192.168.33.0/24            md5

サービス起動

systemctl start postgresql
systemctl enable postgresql

データベースを作成

# su - postgres
-bash-4.2$ psql -c "CREATE ROLE django WITH LOGIN PASSWORD 'django';"
CREATE ROLE
-bash-4.2$ psql -c "CREATE DATABASE django OWNER django ENCODING 'utf8';"
CREATE DATABASE

アプリケーションサーバ

今回はDjangoで作ったシンプルWebアプリケーションを使う。
まずはDockerとdocker-composeをインストール

yum -y install git docker
systemctl start docker
systemctl enable docker
curl -L https://github.com/docker/compose/releases/download/1.21.2/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

アプリケーション本体をGitHubから持ってきてデプロイ

git clone https://github.com/tetsis/nginx-gunicorn-django-postgres.git
cd nginx-gunicorn-django-postgres/
docker-compose -f docker-compose-without-postgres.yml up -d
docker exec nginx-gunicorn-django-postgres_django_1 ./init.sh

上記の手順をアプリケーションサーバ2台(app1, app2)両方で実施する

ロードバランサー

HAProxyとKeepalivedをインストール
psmiscはプロセスチェックコマンドkillallのために必要

yum -y install haproxy keepalived psmisc

/etc/haproxy/haproxy.cfgを編集

# デフォルトのfrontend句を以下のように変更
frontend  main *:80
    default_backend             app

# デフォルトのbackend句を以下のように変更
backend app
    balance     roundrobin
    server  app1 192.168.33.103:80 check
    server  app2 192.168.33.104:80 check

/etc/keepalived/keepalived.confを以下のように修正

! Configuration File for keepalived

global_defs {
    router_id LVS_DEVEL
}

vrrp_script chk_haproxy {
    script "/usr/bin/killall -0 haproxy" # check the haproxy process
    interval 2 # every 2 seconds
    weight 2 # add 2 points if OK
}

vrrp_instance VI_1 {
    interface eth1 # interface to monitor
    state MASTER # MASTER on haproxy1, BACKUP on haproxy2
    virtual_router_id 51
    priority 101 # 101 on haproxy1, 100 on haproxy2
    virtual_ipaddress {
        192.168.33.100 # virtual ip address
    }
    track_script {
        chk_haproxy
    }
}

サービス起動

systemctl start haproxy
systemctl enable haproxy
systemctl start keepalived
systemctl enable keepalived

上記の手順をロードバランサーサーバ2台(haproxy1, haproxy2)両方で実施する
※ keepalived.confについては、stateとpriorityはhaproxy1とhaproxy2で設定値が異なる

WebSocketを使ったtornadoサーバの前段にnginxリバースプロキシを置いてみた

nginxをリバースプロキシにして、バックエンドにtornadoで作成したWebSocketアプリケーションを動作させた時にハマりポイントがあったので備忘録として記録。

環境

  • CentOS 7.4
  • nginx 1.12.2
  • Python 3.6.5
  • tornado 5.0.2

check_origin関数が必要

結論から書くと、WebSocketHandlerクラスを継承したクラス内にcheck_origin関数を定義しないと動作しない。

class WebSocketHandler(tornado.websocket.WebSocketHandler):
def check_origin(self, origin):
return True

def open(self):
...

バージョン4.0から、この関数が必要になったみたい。

WebSocket connections from other origin sites are now rejected by default. To accept cross-origin websocket connections, override the new method WebSocketHandler.check_origin.(出典)

(和訳)Webページを取得したドメイン以外のサイトからのWebSocket接続は、デフォルトで拒否させるようになりました。異なるドメインのWebページからWebSocket接続を受け付けるにはcheck_origin関数をオーバーライドします。

check_origin関数がないと403エラーになる。

WARNING:tornado.access:403 GET /ws (::1) 0.51ms

セキュリティに注意

check_originをオーバーライドすることで、セキュリティ上のリスクを抱えることになる。そのため、オーバーライドする際には自身で対策を実装する必要がある。

This is an important security measure; don’t disable it without understanding the security implications. In particular, if your authentication is cookie-based, you must either restrict the origins allowed by check_origin() or implement your own XSRF-like protection for websocket connections. See these articles for more.(出典)

(和訳)これは重要なセキュリティ対策です。 セキュリティの意味を理解することなく無効にしないでください。 特に、認証がCookieベースの場合は、check_origin関数によって許可されたドメインを制限するか、websocket接続に対して独自のXSRFのような保護を実装する必要があります。 詳細については、これらの記事を参照してください。

実装例1: サブドメインは許可(公式サイトより

def check_origin(self, origin):
parsed_origin = urllib.parse.urlparse(origin)
return parsed_origin.netloc.endswith(".mydomain.com")

実装例2: 指定したドメインのみ許可

def check_origin(self, origin):
parsed_origin = urllib.parse.urlparse(origin)
return parsed_origin.netloc == "mydomain.com"

(おまけ)nginxの設定ファイル

とくになんの変哲もない普通のWebSocketプロキシ設定。

server {

(中略)

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_max_temp_file_size 0;

location /ws {
proxy_pass http://localhost:8080/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}

location / {
proxy_pass http://localhost:8080;
}
}

CentOS7にmod_dumpostをインストールする

POSTメソッドのデータ部分もログ出力してくれるApacheのモジュールmod_dumpost。

テスト環境

OS: CentOS7.4
Apache: 2.4.6

準備

# yum install httpd-devel
# yum install gcc

mod_dumpostインストール

# git clone https://github.com/danghvu/mod_dumpost
# cd mod_dumpost
# make
# make install

Apacheの設定ファイル編集と再起動

/etc/httpd/conf/httpd.conf

LogLevel info
# systemctl restart httpd

確認

下の例はローカルホストから実行しているが他ホストから確認しても同様の結果が得られる。
(その場合はもちろんTCP/80ポートを開放すること)

# curl http://127.0.0.1/test -X POST -d "data1" -d "data2"

/var/log/httpd/error_log

[Tue Jan 09 14:09:02.886789 2018] [:info] [pid 4042] [client 127.0.0.1:34154] "POST /test HTTP/1.1" data1&amp;data2

ちゃんとデータ「data1&data2」がログに出力されている。

SSLの場合

SSL化した場合も同じようにPOSTのデータをログ出力できる。

# yum install mod_ssl

/etc/httpd/conf.d/ssl.conf

LogLevel info
# systemctl restart httpd

確認

# curl --insecure https://127.0.0.1/test -X POST -d "data1" -d "data2"

/var/log/httpd/ssl_error_log

[Tue Jan 09 14:19:00.910862 2018] [:info] [pid 7994] [client 127.0.0.1:55982] "POST /test HTTP/1.1" data1&amp;data2

捕捉

画像アップロード等の大容量データを全部ログに書き出すとログファイルがすぐいっぱいになってしまうので、それを防ぐ設定があるみたい(まだ確かめてないけど)。

/etc/httpd/conf/httpd.conf

DumpPostMaxSize 1024

参考URL

https://www.shadan-kun.com/blog/tool/entry-184.html

https://hacknote.jp/archives/25059/

CentOS7.4でポート開放する

いつまでも覚えられないので備忘録に追加

ゾーンの確認

下のコマンドを実行して「(active)」がついているところが現在有効になっているゾーン
通常は「public」

firewall-cmd --list-all

ポートの追加

8888番TCPポートを開放したい場合の例
zoneオプションには現在有効になっているゾーンを指定する

firewall-cmd --add-port=8888/tcp --zone=public --permanent

設定の反映

忘れずに

firewall-cmd --reload