Azureの音声テキスト変換サービス(Speech Services – Speech to Text)をPythonで使ってみた

Azureの文字起こしサービスを使ってみたので、Azureのポータル画面での操作とPythonコードをまとめておく。

今回使うAzureのサービスは文字起こしの中でも「バッチ文字起こし」というもので、Blob Storageにアップロードした音声ファイルを文字起こししてくれるものである。文字起こしの結果もBlob Storageに保存される。

処理の流れをまとめると以下のようになる。この記事は以下の順番で書いている。

  • 準備
  • STEP1: ローカルにある音声ファイルをBlob Storageにアップロード (Python SDK)
  • STEP2: AzureのSpeech Servicesに文字起こし処理を依頼 (REST API)
  • STEP3: 定期的に処理結果を確認 (REST API)
  • STEP4: 処理が完了したら処理結果のURLを取得 (REST API)
  • STEP5: 処理結果をBlob Storageからダウンロードし結果を表示 (Pyhon SDK)

準備

Azureポータル画面でリソースの作成と認証情報の取得を行う。

Blob Storage(コンテナー)を作成

まずはストレージアカウントというコンテナ―の上位概念を作成。作成ページへの行き方はいろいろあると思うけど、ポータルのホーム画面から左上の三本線アイコンから「ストレージアカウント」をクリックするのが簡単。

ストレージアカウント名はAzure内でユニークでなければならない。今回は「tetsisstorageaccount」にしてみた。リソースグループは事前に作っておいた「SpeechRecognition」、場所は「東日本」にした。その他はデフォルト。

ストレージアカウントが作成できたらコンテナーを作成する。図の赤枠で囲った「コンテナー」をクリックする。

右側から「新しいコンテナー」モーダルが出てくる。今回は「speechcontainer」というコンテナー名にしてみた。

接続文字列を取得

ストレージアカウント画面の左メニューから「アクセスキー」をクリックすると下図のようなページが表示されるので、「接続文字列」をメモしておく。(このページはいつでも見にこれる。key1とkey2はどちらでもよい)

Speech Servicesを作成

音声認識サービスのリソースを作成する。作成ページに行くにはポータル画面の検索欄で「speech」と入力して表示される「Cognitive Services」をクリックするのが簡単。Cognitive ServicesはSpeech Servicesの上位概念。

下図の「追加」をクリックする。

Marketplaceの検索欄で「speech」と入力して表示される「音声」をクリックする。

「作成」ボタンをクリックする。

情報入力ページが表示される。名前は任意でよい。今回は「firstSpeech」にしてみた。場所は「東日本」。価格レベルは「SO」を選択する。今回利用するバッチ文字起こしはSOでないと利用できない。

サブスクリプションキーを取得

Cognitive Services画面の左メニューから「Keys and Endpoint」をクリックすると下図のようなページが表示されるので、サブスクリプションキーである「キー1」をメモしておく。 (このページはいつでも見にこれる。キー1とキー2はどちらでもよい)

Pythonコーディング

ブラウザ操作はこれで終わり。ようやくコーディングのお時間。今回は以下の環境で実行している。
– OS: Windows 10
– Python 3.8.1
– pip 20.0.2

以下のライブラリをインストールしておく。

pip install requests azure-storage-blob

今回は一つのPythonファイルでコマンドラインから実行する簡単な方法でやってみる(ファイル名はazure_speech.py)。

まずファイルの先頭に以下のimport文を記載する。

# -*- coding: utf-8 -*-
import os
import uuid
import argparse
from datetime import datetime, timedelta
import requests
import json
import time

from azure.storage.blob import BlobServiceClient
from azure.storage.blob import ResourceTypes, AccountSasPermissions, ContainerSasPermissions
from azure.storage.blob import generate_account_sas, generate_container_sas

STEP1: ローカルにある音声ファイルをBlob Storageにアップロード (Python SDK)

関数コード

def upload_blob(connect_str, container_name, local_path, local_file_name):
    root_ext_pair = os.path.splitext(local_file_name)
    blob_name = root_ext_pair[0] + str(uuid.uuid4()) + root_ext_pair[1] # Blob Storage(コンテナー)内で唯一のBlob名になるようにランダム文字列(UUID)を挿入しておく
    upload_file_path = os.path.join(local_path, local_file_name)

    blob_service_client = BlobServiceClient.from_connection_string(connect_str)
    blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name)

    print("\nUploading to Azure Storage as blob:\n\t" + blob_name)

    with open(upload_file_path, "rb") as data:
        blob_client.upload_blob(data)

    return blob_name

main関数コード

実行時に引数として①音声ファイルと②言語を指定するようにした。これでいろんな言語の音声ファイルで試しやすくなるはず。

if __name__ == "__main__":
    connect_str = os.getenv("AZURE_STORAGE_CONNECTION_STRING")
    container_name = os.getenv("AZURE_STORAGE_CONTAINER_NAME")
    subscription_key = os.getenv("AZURE_SPEECH_SERVICE_SUBSCRIPTION_KEY")

    parser = argparse.ArgumentParser()
    parser.add_argument("-f", "--file", type=argparse.FileType("r", encoding="UTF-8"), required=True)
    parser.add_argument("-l", "--locale", help="e.g. \"en-US\" or \"ja-JP\"", required=True)
    args = parser.parse_args()
    file_name = args.file.name
    locale = args.locale

    # STEP1: ローカルにある音声ファイルをBlob Storageにアップロード (Python SDK)
    base_dir_pair = os.path.split(file_name)
    local_path = base_dir_pair[0]
    local_file_name = base_dir_pair[1]
    blob_name = upload_blob(connect_str, container_name, local_path, local_file_name) # ここでアップロード

実行

最初に環境変数を設定してからPythonファイルを実行する。今回は環境変数の設定方法がWindows仕様。Mac、Linuxの場合は export コマンドを使えば同じことができる。

音声ファイルは攻殻機動隊の名言でテストしてみた。攻殻機動隊SAC5話のセリフでNetflixから視聴可能。Pythonファイル(azure_speech.py)と同じフォルダに音声ファイル(荒巻名セリフ.wav)を置いている。

> $env:AZURE_STORAGE_CONNECTION_STRING = "メモしておいた接続文字列"
> $env:AZURE_STORAGE_CONTAINER_NAME = "作成したコンテナー名"
> $env:AZURE_SPEECH_SERVICE_SUBSCRIPTION_KEY = "メモしておいたサブスクリプションキー"
> python .\azure_speech.py -f .\荒巻名セリフ.wav -l ja-JP

Uploading to Azure Storage as blob:
        荒巻名セリフ43936629-807a-4d1a-b6d5-a5d0c67c50fe.wav

Azureのポータル画面にて、元のファイル名にUUIDが追加されたファイル(上の実行結果の場合は 荒巻名セリフ43936629-807a-4d1a-b6d5-a5d0c67c50fe.wav )がコンテナーにアップロードされているのが確認できる。

STEP2: AzureのSpeech Servicesに文字起こし処理を依頼 (REST API)

API仕様はSwaggerに載っていて実装の時は結構参考になった。「Custom Speech transcriptions:」セクションが今回使うAPI。

関数コード

最初の2つの関数はアカウントSASとサービスSASという、APIを使う際の認証情報を取得する関数である。SAS自体はAzureが提供している関数を利用して生成している。公式のドキュメントページでSAS生成関数が紹介されている。

TranscriptionResultsContainerUrl には処理結果のファイルを格納してもらうコンテナ―を認証情報つきで記載する。今回は最初に作ったコンテナ―を流用した。なので、音声ファイルと同じコンテナ―に処理結果のファイルも格納される。

def get_sas_token(connect_str):
    blob_service_client = BlobServiceClient.from_connection_string(connect_str)
    sas_token = generate_account_sas(
        blob_service_client.account_name,
        account_key=blob_service_client.credential.account_key,
        resource_types=ResourceTypes(object=True),
        permission=AccountSasPermissions(read=True),
        expiry=datetime.utcnow() + timedelta(hours=1)
    )
    return sas_token

def get_service_sas_token(connect_str, container_name):
    blob_service_client = BlobServiceClient.from_connection_string(connect_str)
    service_sas_token = generate_container_sas(
        blob_service_client.account_name,
        container_name,
        account_key=blob_service_client.credential.account_key,
        permission=ContainerSasPermissions(read=True, write=True),
        expiry=datetime.utcnow() + timedelta(hours=1),
    )
    return service_sas_token

def start_transcription(connect_str, container_name, blob_name, subscription_key, locale):
    blob_service_client = BlobServiceClient.from_connection_string(connect_str)

    account_name = blob_service_client.account_name
    container_url = "https://" + account_name + ".blob.core.windows.net/" + container_name

    sas_token = get_sas_token(connect_str)
    service_sas_token = get_service_sas_token(connect_str, container_name)

    url = "https://japaneast.cris.ai/api/speechtotext/v2.0/transcriptions"
    headers = {
        "content-type": "application/json",
        "accept": "application/json",
        "Ocp-Apim-Subscription-Key": subscription_key,
    }
    payload = {
        "recordingsUrl": container_url + "/" + blob_name + "?" + sas_token,
        "locale": locale,
        "name": blob_name,
        "properties": {
            "TranscriptionResultsContainerUrl" : container_url + "?" + service_sas_token
        }
    }
    r = requests.post(url, data=json.dumps(payload), headers=headers)

    print("\nTranscription start")

main関数コード

if __name__ == "__main__":
    # STEP1のコード(ここでは省略)

    # STEP2: AzureのSpeech serviceに文字起こし処理を依頼 (REST API)
    start_transcription(connect_str, container_name, blob_name, subscription_key, locale)

実行

> python .\azure_speech.py -f .\荒巻名セリフ.wav -l ja-JP

Uploading to Azure Storage as blob:
        荒巻名セリフcbbbe4e2-5507-4cae-8b54-a13003104be8.wav

Transcription start

最後に「Transcription start」と表示される。

STEP3: 定期的に処理結果を確認 (REST API)

STEP2で開始した処理を特定できる情報は名前 name なんだけど、実行状態を取得するためのGETのAPIはIDで指定する仕様になっている。そのため最初にget_transcription_id関数で名前からIDを取得してから、IDを使って定期的に状態を取得している。これ、STEP2でPOSTした時のレスポンスにIDを含めてくれたらいんだけどなぁ、、と思う。名前だけだと毎回リスト取得するしかなくて無駄に情報取ってくることになって効率悪いんだよなぁ。

関数コード

def get_transcription_id(subscription_key, transcription_name):
    url = "https://japaneast.cris.ai/api/speechtotext/v2.0/transcriptions"
    headers = {
        "content-type": "application/json",
        "accept": "application/json",
        "Ocp-Apim-Subscription-Key": subscription_key,
    }
    r = requests.get(url, headers=headers)
    response = json.loads(r.text)

    for res in response:
        if res["name"] == transcription_name:
            id = res["id"]
            print("\nTranscription ID:\n\t" + id)
            return id
    return None

def get_transcription_info_from_id(subscription_key, transcription_id):
    url = "https://japaneast.cris.ai/api/speechtotext/v2.0/transcriptions/" + transcription_id
    headers = {
        "content-type": "application/json",
        "accept": "application/json",
        "Ocp-Apim-Subscription-Key": subscription_key,
    }
    r = requests.get(url, headers=headers)
    response = json.loads(r.text)
    return response

def get_transcription_status(subscription_key, transcription_id):
    response = get_transcription_info_from_id(subscription_key, transcription_id)

    status = response["status"]
    print("Transcription status:\t" + status)
    return status

main関数コード

if __name__ == "__main__":
    # STEP1のコード(ここでは省略)
    # STEP2のコード(ここでは省略)

    # STEP3: 定期的に処理結果を確認 (REST API)
    transcription_id = get_transcription_id(subscription_key, blob_name) # Transcription nameからIDを取得
    status = ""
    while status != "Succeeded": # 処理が完了したら状態が"Succeeded"になるからそれまでループ
        status = get_transcription_status(subscription_key, transcription_id) # 処理状態を取得
        time.sleep(3)

実行

> python .\azure_speech.py -f .\荒巻名セリフ.wav -l ja-JP

Uploading to Azure Storage as blob:
        荒巻名セリフb822c506-843c-4f1d-af81-35799e9209dc.wav

Transcription start

Transcription ID:
        e5512338-1e66-44c0-bd5f-68b680d99ba6
Transcription status:   NotStarted
Transcription status:   NotStarted
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Succeeded

Transcription statusNotStartedからRunningになり、最終的にSucceededになっている。

STEP4: 処理が完了したら処理結果のBlob URLを取得 (REST API)

関数コード

def get_transcription_result_url(subscription_key, transcription_id):
    response = get_transcription_info_from_id(subscription_key, transcription_id)

    if "resultsUrls" in response:
        result_url = response["resultsUrls"]["channel_0"]
        print("\nResult URL:\n\t" + result_url)
        return result_url
    return None

main関数コード

if __name__ == "__main__":
    # STEP1のコード(ここでは省略)
    # STEP2のコード(ここでは省略)
    # STEP3のコード(ここでは省略)

    # STEP4: 処理が完了したら処理結果のBlob URLを取得 (REST API)
    result_url = get_transcription_result_url(subscription_key, transcription_id)

実行

> python .\azure_speech.py -f .\荒巻名セリフ.wav -l ja-JP

Uploading to Azure Storage as blob:
        荒巻名セリフ9b3f55c6-4c3f-4656-a48e-a4546907a31e.wav

Transcription start

Transcription ID:
        e83a0767-d4ab-421b-8476-7cba3f5dde30
Transcription status:   NotStarted
Transcription status:   NotStarted
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Succeeded

Result URL:
        https://tetsisstorageaccount.blob.core.windows.net/speechcontainer/e83a0767-d4ab-421b-8476-7cba3f5dde30_transcription_channel_0.json

処理結果のURLが表示される。

STEP5: 処理結果をBlob Storageからダウンロードし結果を表示 (Pyhon SDK)

関数コード

ダウンロードのコードもアップロードと同様に公式ページに載っている方法を参考にした。

結果のjsonファイルのデータフォーマットについては公式ページSwaggerページを参考にした。テキスト化した結果は Lexical, ITN, MaskedITN, Display の4種類があるみたいだけど、今回は句読点とかつけてくれる Display を表示することにした。

def download_blob(connect_str, container_name, blob_name):
    blob_service_client = BlobServiceClient.from_connection_string(connect_str)
    blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name)

    print("\nUploading to Azure Storage as blob:\n\t" + blob_name)
    with open(blob_name, "wb") as download_file:
        download_file.write(blob_client.download_blob().readall())

def get_transcript_from_file(file_name):
    with open(file_name, "r", encoding="utf-8") as f:
        df = json.load(f)

    transcript = ""
    for audio_file_result in df["AudioFileResults"]:
        for combined_result in audio_file_result["CombinedResults"]:
            transcript += combined_result["Display"] + "\n"
    
    print("\nTranscription result:\n" + transcript)
    return transcript

main関数コード

if __name__ == "__main__":
    # STEP1のコード(ここでは省略)
    # STEP2のコード(ここでは省略)
    # STEP3のコード(ここでは省略)
    # STEP4のコード(ここでは省略)

    # STEP5: 処理結果をBlob Storageからダウンロードし結果を表示 (Pyhon SDK)
    blob_name = result_url[result_url.rfind("/") + 1:]
    download_blob(connect_str, container_name, blob_name) # ここでダウンロード
    get_transcript_from_file(blob_name) # ダウンロードしたファイルから音声をテキスト化した文字列を取得

実行

> python .\azure_speech.py -f .\荒巻名セリフ.wav -l ja-JP

Uploading to Azure Storage as blob:
        荒巻名セリフc2d034b7-3803-431d-bcf0-368310cd678b.wav

Transcription start

Transcription ID:
        9388030b-cc98-428d-ae0c-3c5cff02ed3c
Transcription status:   NotStarted
Transcription status:   NotStarted
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Running
Transcription status:   Succeeded

Result URL:
        https://tetsisstorageaccount.blob.core.windows.net/speechcontainer/9388030b-cc98-428d-ae0c-3c5cff02ed3c_transcription_channel_0.json

Uploading to Azure Storage as blob:
        9388030b-cc98-428d-ae0c-3c5cff02ed3c_transcription_channel_0.json

Transcription result:
よかろう我々の間にはチームプレーなどという都合の良い言い訳は存在せん。あるとすれば、、スタンドプレーから生じるチームワークだけだ。

どのセリフをテキスト変換させてたのか完璧に分かる。

全コード

最後に今回のコードの全体を載せておく。

# -*- coding: utf-8 -*-
import os
import uuid
import argparse
from datetime import datetime, timedelta
import requests
import json
import time

from azure.storage.blob import BlobServiceClient
from azure.storage.blob import generate_account_sas, generate_container_sas, ResourceTypes, AccountSasPermissions, ContainerSasPermissions

def upload_blob(connect_str, container_name, local_path, local_file_name):
    root_ext_pair = os.path.splitext(local_file_name)
    blob_name = root_ext_pair[0] + str(uuid.uuid4()) + root_ext_pair[1] # Blob Storage(コンテナー)内で唯一のBlob名になるようにランダム文字列(UUID)を挿入しておく
    upload_file_path = os.path.join(local_path, local_file_name)

    blob_service_client = BlobServiceClient.from_connection_string(connect_str)
    blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name)

    print("\nUploading to Azure Storage as blob:\n\t" + blob_name)

    with open(upload_file_path, "rb") as data:
        blob_client.upload_blob(data)

    return blob_name

def download_blob(connect_str, container_name, blob_name):
    blob_service_client = BlobServiceClient.from_connection_string(connect_str)
    blob_client = blob_service_client.get_blob_client(container=container_name, blob=blob_name)

    print("\nUploading to Azure Storage as blob:\n\t" + blob_name)
    with open(blob_name, "wb") as download_file:
        download_file.write(blob_client.download_blob().readall())

def get_sas_token(connect_str):
    blob_service_client = BlobServiceClient.from_connection_string(connect_str)
    sas_token = generate_account_sas(
        blob_service_client.account_name,
        account_key=blob_service_client.credential.account_key,
        resource_types=ResourceTypes(object=True),
        permission=AccountSasPermissions(read=True),
        expiry=datetime.utcnow() + timedelta(hours=1)
    )
    return sas_token

def get_service_sas_token(connect_str, container_name):
    blob_service_client = BlobServiceClient.from_connection_string(connect_str)
    service_sas_token = generate_container_sas(
        blob_service_client.account_name,
        container_name,
        account_key=blob_service_client.credential.account_key,
        permission=ContainerSasPermissions(read=True, write=True),
        expiry=datetime.utcnow() + timedelta(hours=1),
    )
    return service_sas_token

def start_transcription(connect_str, container_name, blob_name, subscription_key, locale):
    blob_service_client = BlobServiceClient.from_connection_string(connect_str)

    account_name = blob_service_client.account_name
    container_url = "https://" + account_name + ".blob.core.windows.net/" + container_name

    sas_token = get_sas_token(connect_str)
    service_sas_token = get_service_sas_token(connect_str, container_name)

    url = "https://japaneast.cris.ai/api/speechtotext/v2.0/transcriptions"
    headers = {
        "content-type": "application/json",
        "accept": "application/json",
        "Ocp-Apim-Subscription-Key": subscription_key,
    }
    payload = {
        "recordingsUrl": container_url + "/" + blob_name + "?" + sas_token,
        "locale": locale,
        "name": blob_name,
        "properties": {
            "TranscriptionResultsContainerUrl" : container_url + "?" + service_sas_token
        }
    }
    r = requests.post(url, data=json.dumps(payload), headers=headers)

    print("\nTranscription start")

def get_transcription_id(subscription_key, transcription_name):
    url = "https://japaneast.cris.ai/api/speechtotext/v2.0/transcriptions"
    headers = {
        "content-type": "application/json",
        "accept": "application/json",
        "Ocp-Apim-Subscription-Key": subscription_key,
    }
    r = requests.get(url, headers=headers)
    response = json.loads(r.text)

    for res in response:
        if res["name"] == transcription_name:
            id = res["id"]
            print("\nTranscription ID:\n\t" + id)
            return id
    return None

def get_transcription_info_from_id(subscription_key, transcription_id):
    url = "https://japaneast.cris.ai/api/speechtotext/v2.0/transcriptions/" + transcription_id
    headers = {
        "content-type": "application/json",
        "accept": "application/json",
        "Ocp-Apim-Subscription-Key": subscription_key,
    }
    r = requests.get(url, headers=headers)
    response = json.loads(r.text)
    return response

def get_transcription_status(subscription_key, transcription_id):
    response = get_transcription_info_from_id(subscription_key, transcription_id)

    status = response["status"]
    print("Transcription status:\t" + status)
    return status

def get_transcription_result_url(subscription_key, transcription_id):
    response = get_transcription_info_from_id(subscription_key, transcription_id)

    if "resultsUrls" in response:
        result_url = response["resultsUrls"]["channel_0"]
        print("\nResult URL:\n\t" + result_url)
        return result_url
    return None

def get_transcript_from_file(file_name):
    with open(file_name, "r", encoding="utf-8") as f:
        df = json.load(f)

    transcript = ""
    for audio_file_result in df["AudioFileResults"]:
        for combined_result in audio_file_result["CombinedResults"]:
            transcript += combined_result["Display"] + "\n"
    
    print("\nTranscription result:\n" + transcript)
    return transcript

if __name__ == "__main__":
    connect_str = os.getenv("AZURE_STORAGE_CONNECTION_STRING")
    container_name = os.getenv("AZURE_STORAGE_CONTAINER_NAME")
    subscription_key = os.getenv("AZURE_SPEECH_SERVICE_SUBSCRIPTION_KEY")

    parser = argparse.ArgumentParser()
    parser.add_argument("-f", "--file", type=argparse.FileType("r", encoding="UTF-8"), required=True)
    parser.add_argument("-l", "--locale", help="e.g. \"en-US\" or \"ja-JP\"", required=True)
    args = parser.parse_args()
    file_name = args.file.name
    locale = args.locale

    # STEP1: ローカルにある音声ファイルをBlob Storageにアップロード (Python SDK)
    base_dir_pair = os.path.split(file_name)
    local_path = base_dir_pair[0]
    local_file_name = base_dir_pair[1]
    blob_name = upload_blob(connect_str, container_name, local_path, local_file_name) # ここでアップロード

    # STEP2: AzureのSpeech serviceに文字起こし処理を依頼 (REST API)
    start_transcription(connect_str, container_name, blob_name, subscription_key, locale)

    # STEP3: 定期的に処理結果を確認 (REST API)
    transcription_id = get_transcription_id(subscription_key, blob_name) # Transcription nameからIDを取得
    status = ""
    while status != "Succeeded": # 処理が完了したら状態が"Succeeded"になるからそれまでループ
        status = get_transcription_status(subscription_key, transcription_id) # 処理状態を取得
        time.sleep(3)

    # STEP4: 処理が完了したら処理結果のBlob URLを取得 (REST API)
    result_url = get_transcription_result_url(subscription_key, transcription_id)

    # STEP5: 処理結果をBlob Storageからダウンロードし結果を表示 (Pyhon SDK)
    blob_name = result_url[result_url.rfind("/") + 1:]
    download_blob(connect_str, container_name, blob_name) # ここでダウンロード
    get_transcript_from_file(blob_name) # ダウンロードしたファイルから音声をテキスト化した文字列を取得

PythonでURLから末尾のファイル名を抜き出す

最近S3やBlob StorageのオブジェクトURLからファイル名だけ抜き出すことが何度かあって 毎回方法をググってたので備忘録にまとめておく。

object_url = 'https://account_name.blob.core.windows.net/container_name/object.json'
object_name = object_url[object_url.rfind('/') + 1:]
print(object_name)
# object.json と出力される

説明。URLの末尾から「/(スラッシュ)」を検索して、スラッシュより後ろの文字列を取得する。

サブドメイン分割で複数サイトをEKSクラスタに構築してみた(ALB Ingress Controller, ExternalDNS, 独自ドメイン, HTTPS対応)

EKSクラスタ内で動作しているpodを外部公開するためのエンドポイントとしてALBを使う場合の構築手順をまとめておく。
ALB Ingress Controllerを使うことで、ingressをapplyしたときに自動でALBを構築してくれるのですごく便利だった。
また、ingressで任意のドメインを指定するとALBのhost-based routingに反映してくれるので、サブドメインを変えて複数のサイト公開も楽にできた。
加えて、ExternalDNSを使うと自分が管理しているドメインのレコード追加を自動でやってくれる。
SSL証明書をACMに用意しておくと、簡単にHTTPS化できたので、HTTPS対応についても最後にまとめておく。

アーキテクチャ

今回使うアプリケーションはDjangoとその前段にリバースプロキシとしてnginxを配置したシンプルなもの。EKSクラスタ内にはその他にALBにルール反映してくれるALB Inress ControllerとRoute 53にレコード反映してくれるExternalDNSのpodが起動している。SSL証明書発行にはACMを利用。

アーキテクチャ

EKSクラスタ構築

公式サイトの方法を参考にクラスタ構築。
eksctl で構築するのが便利。

eksctl create cluster \
--name test-cluster \
--version 1.14 \
--region ap-northeast-1 \
--nodegroup-name standard-workers \
--node-type t3.medium \
--nodes 2 \
--nodes-min 1 \
--nodes-max 4 \
--managed

(参考)EC2インスタンスのタイプによって付与可能なIPアドレス数が異なるため、podをたくさん起動する場合はworker nodeのインスタンスタイプに注意すること。
IPアドレス数の一覧はこちら

ALB Ingress Controllerのデプロイ

基本は公式サイトの手順通りに実施する。

EKSクラスタのサブネットにタグが付いていることを確認

  • VPC 内のすべてのサブネット
    • kubernetes.io/cluster/<cluster-name>shared
  • VPC のパブリックサブネット
    • kubernetes.io/role/elb1
  • VPC 内のプライベートサブネット
    • kubernetes.io/role/internal-elb1

IAM ポリシーを作成

IAMポリシーファイルをダウンロード

curl -O https://raw.githubusercontent.com/kubernetes-sigs/aws-alb-ingress-controller/v1.1.3/docs/examples/iam-policy.json

IAMポリシーを作成

aws iam create-policy \
--policy-name ALBIngressControllerIAMPolicy \
--policy-document file://iam-policy.json

EKSクラスタのワーカーノードの IAM ロール名を取得

kubectl -n kube-system describe configmap aws-auth

出力結果の<アカウントID>と<ロール名>を控えておく。

Name:         aws-auth
Namespace:    kube-system
Labels:       <none>
Annotations:  <none>

Data
====
mapRoles:
----
- groups:
  - system:bootstrappers
  - system:nodes
  rolearn: arn:aws:iam::<アカウントID>:role/<ロール名>
  username: system:node:{{EC2PrivateDNSName}}

mapUsers:
----
[]

Events:  <none>

EKSクラスタのワーカーノードにIAMポリシーをアタッチ

aws iam attach-role-policy \
--policy-arn arn:aws:iam::<アカウントID>:policy/ALBIngressControllerIAMPolicy \
--role-name <ロール名>

ALB Ingress Controller で使用するサービスアカウント、クラスターロール、クラスターロールバインディングを作成

kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/aws-alb-ingress-controller/v1.1.3/docs/examples/rbac-role.yaml

ALB Ingress Controller をデプロイ

ここは公式サイトとは違う方法でデプロイする。
公式サイトではmanifestをデプロイした後に環境に合わせて編集しているが、今回は最初にデプロイ用manifestをダウンロードし、ファイルを編集した後にデプロイする。

ALB-Ingress-Controllerのmanifestをダウンロード

curl -O https://raw.githubusercontent.com/kubernetes-sigs/aws-alb-ingress-controller/v1.1.3/docs/examples/alb-ingress-controller.yaml

Manifestを編集する。以下のパラメータのコメントアウトを外し、書き換える。

    spec:
      containers:
      - args:
        - --cluster-name=test-cluster
        - --aws-vpc-id=<VPC ID>
        - --aws-region=ap-northeast-1

(参考)VPC IDを確認するには以下のコマンドを実行する。

aws ec2 describe-vpcs

デプロイ

kubectl apply -f alb-ingress-controller.yaml

以下のコマンドを実行してエラーが表示されなければデプロイ成功

kubectl logs -n kube-system -f $(kubectl get po -n kube-system | egrep -o 'alb-ingress-controller[A-Za-z0-9-]+')

ExternalDNSのデプロイ

ALBのエンドポイントを自分が管理しているRoute53にailiasとして反映させてくれる ExternalDNS というOSSがあるので使ってみる。

IAM ポリシーを作成

IAMポリシーファイルを作成。公式サイトのチュートリアルから流用

{
 "Version": "2012-10-17",
 "Statement": [
   {
     "Effect": "Allow",
     "Action": [
       "route53:ChangeResourceRecordSets"
     ],
     "Resource": [
       "arn:aws:route53:::hostedzone/*"
     ]
   },
   {
     "Effect": "Allow",
     "Action": [
       "route53:ListHostedZones",
       "route53:ListResourceRecordSets"
     ],
     "Resource": [
       "*"
     ]
   }
 ]
}

IAMポリシーを作成

aws iam create-policy \
--policy-name ExternalDNSIAMPolicy \
--policy-document file://external-dns-iam-policy.json

EKSクラスタのワーカーノードにIAMポリシーをアタッチ

aws iam attach-role-policy \
--policy-arn arn:aws:iam::<アカウントID>:policy/ExternalDNSIAMPolicy \
--role-name <ロール名>

ExternalDNS をデプロイ

ExternalDNSのmanifestをダウンロード

curl -O https://raw.githubusercontent.com/kubernetes-sigs/aws-alb-ingress-controller/v1.1.3/docs/examples/external-dns.yaml

Manifestを編集。example.comには自分で管理しているFQDNに変更すること。ダウンロードしたファイルではpolicyオプションがupsert-onlyになっているが、これはレコード追加だけすることを表す。今回はingressの内容に合わせて適宜レコード削除もしてほしいのでpolicyオプションは使わない。

args:
- --domain-filter=example.com
#- --policy=upsert-only # コメントアウトまたは削除

デプロイ

kubectl apply -f external-dns.yaml

確認

kubectl logs -f $(kubectl get po | egrep -o 'external-dns[A-Za-z0-9-]+')

以下のように表示されればOK

time="2019-12-13T14:48:31Z" level=info msg="Created Kubernetes client https://10.100.0.1:443"

app1のmanifestを作成

ここからようやくアプリケーションをデプロイすることができる。
まずはdeploymentとserviceとingressのmanifestを用意する。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: app1-deployment
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: app1
    spec:
      containers:
        - image: tetsis/simple-nginx-django-app
          name: app1
          ports:
            - containerPort: 8080
          env:
            - name: APPLICATION_NAME
              value: app1
        - image: tetsis/simple-nginx-django-proxy
          name: app1-proxy
          ports:
            - name: app1-port
              containerPort: 80
apiVersion: v1
kind: Service
metadata:
  name: app1-service
spec:
  ports:
    - port: 80
      targetPort: app1-port
      protocol: TCP
  type: NodePort
  selector:
    app: app1
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
  labels:
    app: ingress
spec:
  rules:
    - host: app1.example.com
      http:
        paths:
          - path: /*
            backend:
              serviceName: app1-service
              servicePort: 80

example.comには自分が所有するドメインを指定する。

app1のデプロイ

kubectl apply -f app1-deployment.yaml 
kubectl apply -f app1-service.yaml 
kubectl apply -f ingress.yaml

ALB Ingress Controllerで作られたALBを確認

kubectl get ingress
NAME      HOSTS              ADDRESS                                                                     PORTS   AGE
ingress   app1.example.com   d7f12bb1-default-ingress-e8c7-1478980459.ap-northeast-1.elb.amazonaws.com   80      38s

Route 53にレコードが作られたことを確認

コンソール画面もしくは以下のコマンドでAliasレコードが作成されていることを確認する。

aws route53 list-resource-record-sets --hosted-zone-id /hostedzone/<ホストゾーンID>

ページにアクセスしてみる

ALBの起動とRoute 53のDNSレコード情報が反映されるまで少し待って、ブラウザで「http://app1.example.com」にアクセスし、以下のように表示されればOK。
(example.comは自分のドメインを指定)

This application is "app1".

サブドメイン分割でapp2を追加

さっきはapp1.example.comというドメインでアプリケーションをデプロイしたが、次はapp2.example.comドメインでアプリケーションを追加デプロする。
deploymentとserviceはapp1と同様にmanifestを作成する。
ingressについては 、app1とapp2で同じALBを使うので先程作成したmanifestに追記する。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: app2-deployment
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: app2
    spec:
      containers:
        - image: tetsis/simple-nginx-django-app
          name: app2
          ports:
            - containerPort: 8080
          env:
            - name: APPLICATION_NAME
              value: app2
        - image: tetsis/simple-nginx-django-proxy
          name: app2-proxy
          ports:
            - name: app2-port
              containerPort: 80
apiVersion: v1
kind: Service
metadata:
  name: app2-service
spec:
  ports:
    - port: 80
      targetPort: app2-port
      protocol: TCP
  type: NodePort
  selector:
    app: app2
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
  labels:
    app: ingress
spec:
  rules:
    - host: app1.example.com
      http:
        paths:
          - path: /*
            backend:
              serviceName: app1-service
              servicePort: 80
    - host: app2.example.com
      http:
        paths:
          - path: /*
            backend:
              serviceName: app2-service
              servicePort: 80

app2のデプロイ

kubectl apply -f app2-deployment.yaml 
kubectl apply -f app2-service.yaml 
kubectl apply -f ingress.yaml

ページにアクセスしてみる

ブラウザで「http://app2.example.com」にアクセスして以下の文字が表示されればOK。(もちろんドメインは環境に合わせること)

This application is "app2".

HTTPS化

公式ページを参考に、ingress.yamlの annotations 句に一行追加する。
尚、使用するドメインのSSL証明書は事前にACMで発行しておく。

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' # 追加
  labels:
    app: ingress
spec:
  rules:
    - host: app1.example.com
      http:
        paths:
          - path: /*
            backend:
              serviceName: app1-service
              servicePort: 80
    - host: app2.example.com
      http:
        paths:
          - path: /*
            backend:
              serviceName: app2-service
              servicePort: 80

忘れずにapplyする。

kubectl apply -f ingress.yaml

ブラウザで「https://app1.example.com」、 「https://app2.example.com」 にアクセスして、ページが表示されることを確認する。

リソースの削除

EKSクラスタは起動しておくだけでお金がかかるので、検証利用の場合は忘れずにクラスタを削除を実施する。


K8sリソースを削除

kubectl delete -f ingress.yaml
kubectl delete -f app1-service.yaml
kubectl delete -f app2-service.yaml
kubectl delete -f app1-deployment.yaml
kubectl delete -f app2-deployment.yaml

IAMポリシーをデタッチ

aws iam detach-role-policy --policy-arn arn:aws:iam::<アカウントID>:policy/ExternalDNSIAMPolicy --role-name <ロール名>
aws iam detach-role-policy --policy-arn arn:aws:iam::<アカウントID>:policy/ALBIngressControllerIAMPolicy --role-name <ロール名>

IAMポリシーを削除

 aws iam delete-policy --policy-arn arn:aws:iam::<アカウントID>:policy/ExternalDNSIAMPolicy
 aws iam delete-policy --policy-arn arn:aws:iam::<アカウントID>:policy/ALBIngressControllerIAMPolicy

EKSクラスタを削除

eksctl delete cluster --region=ap-northeast-1 --name=test-cluster

Pub/Subを使ってWebSocketサーバをスケールアウトさせる(クラウド版)

前回の記事の続き。前回はオンプレ(自分が管理するVM)でWebSocketサーバをスケールアウトする方法を書いたが、本稿はクラウド(AWS)のマネージドサービスを使った方法について書く。

記事の内容

  • WebSocketを使ったWebアプリケーションをAWS上に構築する。
  • AMI作成にPackerを利用し、AWS構築にはTerraformを使う。

前提

  • AWSのルートアカウント持っている。
  • インターネットで名前解決できるドメインを持っていて、Route53で管理している。
    • ドメインはトップレベルドメイン(.comや.netとったドメインの末尾)にこだわらなければ、お名前.comムームードメインで安価に購入できる。

システムアーキテクチャ

ロードバランサーにはApplication Load Balancer (ALB) を利用する。
ALBの後段にはアプリケーションサーバのEC2を二つ配置する。ALBでSSL終端するため、ALB-EC2間はHTTP/WebSocketで通信する。
EC2間の情報連携用のPub/SubにはAmazon ElastiCache for Redisを利用する。
認証局により証明されたSSL証明書を発行するためRoute53を使ってDNS認証する。

システムアーキテクチャ

Packer用IAMユーザ作成

IAMグループ作成

  • グループ名に「packer」と記入し、「次のステップ」をクリック
  • 「AmazonEC2FullAccess」ポリシーを選択し、「次のステップ」をクリック
  • 「グループの作成」をクリック

IAMユーザ作成

  • ユーザー名に「packer」と記入し、アクセスの種類「プログラムによるアクセス」を選択し、「次のステップ:アクセス権限」をクリック
  • グループ「packer」を選択し、「次のステップ:タグ」をクリック
  • キー「Name」、値「packer」を記入し、「次のステップ:確認」をクリック
  • 「ユーザーの作成」をクリック
  • 「アクセスキーID」と「シークレットアクセスキー」をメモしておく。

PackerでEC2のマシンイメージ(AMI)作成

Packerインストール

以下は執筆時点のコマンド。最新のインストール手順についてはこちらを参照。

# curl -O https://releases.hashicorp.com/packer/1.4.2/packer_1.4.2_linux_amd64.zip
# unzip packer_1.4.2_linux_amd64.zip
# mv packer /usr/local/sbin/
# packer version
Packer v1.4.2

認証情報

認証情報を環境変数に書き込む。
IAMユーザ作成時にメモしたpackerユーザのアクセスキーIDとシークレットアクセスキーを環境変数に書き込む。

export AWS_ACCESS_KEY_ID=xxxx
export AWS_SECRET_ACCESS_KEY=xxxx

AMI構成情報を作成

以下のjsonファイルを作成する。
DockerとDocker Composeのインストール、ソースコードのダウンロード、Dockerイメージの作成をAMIの中で済ませておく。

{
    "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 docker git",
            "sudo systemctl start docker",
            "sudo systemctl enable docker",
            "sudo curl -L \"https://github.com/docker/compose/releases/download/1.24.0/docker-compose-$(uname -s)-$(uname -m)\" -o /usr/local/bin/docker-compose",
            "sudo chmod +x /usr/local/bin/docker-compose",
            "git clone https://github.com/tetsis/simple-chat.git",
            "sudo cp -r simple-chat /root/",
            "sudo /usr/local/bin/docker-compose -f /root/simple-chat/docker-compose.yml build"
        ]
    }]
}

packer validate コマンドでjsonファイルの構文チェックをする。

# packer validate app-server.json
Template validated successfully.

Template validated successfully. が表示されればOK。

packer build コマンドでAMIを作成する。
最終行の ami-... をメモしておく。(ここでは xxxx としているが、実際は英数字の文字列)

# packer build app-server.json
...
==> Builds finished. The artifacts of successful builds are:
--> amazon-ebs: AMIs were created:
ap-northeast-1: ami-xxxx

EC2インスタンス用ロールを作成

EC2インスタンス内でawsコマンドを実行するため、ロールを作成する。

  • 「このロールを使用するサービスを選択」で「EC2」を選択し、「次のステップ:アクセス権限」をクリック
  • 「AmazonEC2ReadOnlyAccess」ポリシーを選択し、「次のステップ:タグ」をクリック
  • キー「Name」、値「ec2-role」を記入し、「次のステップ:確認」をクリック
  • ロール名「ec2-role」を記入し、「ロールの作成」をクリック

TerraformでAWSリソース作成

Terraform用IAMグループとIAMユーザ作成

Packer用にIAMグループとIAMユーザを作ったのと同様にTerraform用にも作成する。
作り方はPackerの時と同じなので詳細については省略する。
Packerとの違いはIAMグループにアタッチするポリシーで、今回の例では以下の5つのポリシーをアタッチする。

  • AmazonEC2FullAccess
  • AmazonElastiCacheFullAccess
  • AmazonRoute53FullAccess
  • AWSCertificateManagerFullAccess
  • IAMFullAccess

Terraformインストール

以下は執筆時点のコマンド。最新のインストール手順についてはこちらを参照。

# curl -O https://releases.hashicorp.com/terraform/0.12.5/terraform_0.12.3_linux_amd64.zip
# unzip terraform_0.12.3_linux_amd64.zip
# mv terraform /usr/local/sbin/
# terraform version
Terraform v0.12.3

AWSリソース作成

Terraformの.tfファイルはこちらを参照。
公開したくない情報は環境変数で設定してから terraform plan で正常性を確認して、 terraform apply でリソース作成を実行する。
(環境変数は ~/.bashrc 等に書いておいても良い)

# export TF_VAR_access_key=xxxx
# export TF_VAR_secret_key=xxxx
# export TF_VAR_allow_ip=x.x.x.x/32
# export TF_VAR_ami=ami-xxxx (Packerで作成したAMI)
# export TF_VAR_app_fqdn=simple-chat.xx.xx
# export TF_VAR_zone_id=xxxx (自分がホストしているドメインのZONE ID)
# cd /dir/to/terraform
# ls
aws_acm.tf  aws_alb.tf  aws_ec2.tf  aws_elasticache.tf  aws_r53.tf  aws_sg.tf  aws.tf  aws_tg.tf  aws_variables.tf  aws_vpc.tf terraform.tfvars
# terraform plan
...
Plan: 25 to add, 0 to change, 0 to destroy.
...
# terrafrom apply
...
  Enter a value: yes
...
(リソース作成。しばらく待つ)
Apply complete! Resources: 25 added, 0 changed, 0 destroyed. (「Apply complete!」が表示されたらリソース作成は正常に完了している。)

(参考)EC2初期処理用スクリプト

TerraformのEC2インスタンス作成ファイルで以下のような記述があるが、このuser_dataの作成方法について書いておく。

    user_data = <<EOF
IyE...
EOF

user_dataはEC2インスタンス起動時の処理をシェルスクリプト形式で書いたものをbase64エンコードしたものである。
シェルスクリプトの中身はEC2インスタンスを起動してからでないと決まらないパラメータをタグから取得してアプリケーションの設定ファイルに書き込む、という操作を行っている。
参考のためbase64変換前のシェルスクリプトを記載する。

# !/bin/sh

aws="/usr/bin/aws --region ap-northeast-1"
logger="logger -t $0"

get_instance_id()
{
    instance_id=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
}

get_redis_endpoint()
{
    redis_endpoint=$(${aws} ec2 describe-instances \
                  --instance-id ${instance_id} \
                  --query 'Reservations[].Instances[].Tags[?Key==`Redis_endpoint`].Value' \
                  --output text)
}

set_redis_endpoint()
{
    sed -i -e "s/REDIS_HOST.*$/REDIS_HOST: '${redis_endpoint}'/g" docker-compose.yml
}

${logger} "start $0"
sleep 60

${logger} "get info"
get_instance_id
get_redis_endpoint

${logger} "set environment"
cd /root/simple-chat
set_redis_endpoint

${logger} "run app"
docker-compose up -d

${logger} "finished $0"

exit 0

確認

Webブラウザで https://(環境変数TF_VAR_app_fqdnに設定したFQDN) にアクセスする。
simple-chatの画面が表示され、テキストボックスに入力した結果がテキストエリアに表示されたら正常に動作している。

後始末

検証が終わったら忘れずにリソースを削除する。

# terraform destroy
...
  Enter a value: yes
...

Pub/Subを使ってWebSocketサーバをスケールアウトさせる(オンプレ版)

スケーラブルなWebサービスのよくある設計はWebサーバの前段にロードバランサーを設置し後段にWebサーバを複数台配置するというものである。通常のWebサービスはステートレスなHTTP(もしくはHTTPS)通信のためこの方式で問題なく稼働する。
しかし、ステートフルなWebSocket通信においては以下の課題が存在する。

  1. ロードバランサーがWebSocket通信を判別しセッションを保持しているバックエンドサーバに転送できなければならない。
  2. 自分以外のWebSocketサーバが管理しているクライアントにはWebSocket通信を行うことができない。

そこで本稿では以下の方法でこの2つの課題の解決を試みる。

  1. WebSocket対応のロードバランサーを使う。
  2. Pub/Subを使う。全バックエンドサーバはsubscribeしておき、情報を送信したいサーバはpublishする。

この方法をオンプレミスで実現する方法を作ってみたので備忘録で残しておく。ソースコードはこちら
別記事でクラウド(AWS)で実装したときの記事も書く予定。

アプリの概要

今回は簡単なチャットアプリを作る。アプリの動作イメージは以下の通り。

  1. クライアントはWebブラウザを使ってアプリページを表示する。
  2. WebSocketでアプリと接続する。
  3. チャット入力欄に文章を入力し「Submit」ボタンを押すと、WebSocketを使ってアプリにメッセージを送信する。
  4. アプリはチャット画面を開いている(WebSocket接続を確立している)クライアントに向けて一斉にWebSocketでメッセージを送信。
  5. メッセージを受信したクライアントはメッセージ表示エリアにメッセージを表示する。
チャットアプリの動作イメージ

システムアーキテクチャ

ロードバランサーにはHAProxyを利用する。特別な設定をしなくてもWebSocketのセッションを認識してルーティングしてくれるみたい。(ちゃんと設定しようとするとこちらの記事にあるようにタイムアウトを個別に設定する必要がありそう。本稿ではデフォルト設定) また、SSLオフロードによりロードバランサーへのアクセスにはHTTPS/WebSocket over TLSを使用し、後段のサーバとはHTTP/WebSocketで通信する。
Pub/SubにはRedisを利用する。
アプリサーバにはWebSocketにも対応したTornadoを採用した。Webサーバプロセスはクライアントからメッセージを受信するとPub/Subにpublishする。また、Pub/Subをsubscribeするプロセスも動かしておいてsubscribeの結果をHTTP経由で自サーバのWebサーバプロセスに伝える。

システムアーキテクチャ図

インフラ設計

同一サブネットにロードバランサー、アプリケーションサーバ2台、Pub/Subサーバの合計4台のサーバを配置する。今回の検証ではサーバはVirtualBoxのVMを利用し、vagrantで管理している。ゲストOSはCentOS。

インフラ設計

実装

ロードバランサー (load-balancer)

HAProxyをインストール

yum -y install haproxy

/etc/haproxy/haproxy.cfg を編集

frontend ssl_proxy
    mode http
    bind *:443 ssl crt /etc/haproxy/server.pem
    server  app1 192.168.33.201:80 check
    server  app2 192.168.33.202:80 check

サーバ証明書 /etc/haproxy/server.pem を作成

普通は秘密鍵と証明書は別ファイルにすることが多いが、HAProxyでは一つのファイルにまとめる必要がある。
以下の例では秘密鍵(server.key)と自己証明書(server.crt)を作り、この2つのファイルをくっつけたserver.pemを作っている。

openssl genrsa 2048 > server.key
openssl req -new -key server.key > server.csr
openssl x509 -days 3650 -req -signkey server.key < server.csr > server.crt
cat server.key > /etc/haproxy/server.pem
cat server.crt >> /etc/haproxy/server.pem

サーバプロセス起動

systemctl start haproxy
systemctl enable haproxy

Pub/Subサーバ (pub-sub)

Redisをインストール

yum -y install epel-release
yum -y install redis

/etc/redis.conf を編集

bind 192.168.33.203

サーバプロセス起動

systemctl start redis
systemctl enable redis

アプリケーションサーバ (app1, app2)

まずはソースコードをダウンロード

git pull https://github.com/tetsis/simple-chat.git
cd simple-chat

Pythonを直接実行する場合

諸々インストール

yum install -y https://centos7.iuscommunity.org/ius-release.rpm
yum install python36u python36u-libs python36u-devel python36u-pip
pip3.6 install -r requirements.txt

サーバプロセス起動

python3.6 src/app.py | python3.6 src/subscribe.py

コンテナで動かす場合

諸々インストール

yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
yum -y install docker-ce docker-ce-cli containerd.io
systemctl start docker
systemctl enable docker
curl -L "https://github.com/docker/compose/releases/download/1.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose

サーバプロセス起動

docker-compose build --no-cache
docker-compose up -d

動作確認

2つのブラウザでhttps://192.168.33.200にアクセスするとチャット画面が表示される。(192.168.33.200はロードバランサーのIPアドレス)

片方のブラウザからメッセージを送信すると、両方のボックスにメッセージが表示される。(下図では左のブラウザで「Hello」と入力して「送信」ボタンを押すと、もう一つのブラウザにも「Hello」と表示される)

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

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

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

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

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

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

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

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

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

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

&lt;a href="{% url 'index' %}"&gt;{% trans "トップページに戻る" %}&lt;/a&gt;

&lt;/body&gt;
&lt;/html&gt;

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

$ 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秒間だった。
結構いい感じ。