こしあん
2024-12-11

OpenSearchとOpenSearch Dashboardsによるダッシュボード作成(ローカルDocker)


31{icon} {views}

本記事では、OpenSearchとOpenSearch Dashboardsを使用して、商品別の売上データを可視化する方法を紹介します。ダミーデータの作成から環境構築、データの登録、ダッシュボードの作成までの全体の流れを詳細に解説します。

やりたいこと

こんなデータを商品別の売上として、ダッシュボードで可視化したい。横軸が日付で、縦軸が売上数で、商品別に出てくるイメージ。

{
  "salesData": [
    {
      "date": "2024-11-01",
      "products": [
        {
          "productId": "P001",
          "productName": "コーヒーメーカー",
          "unitsSold": 75
        },
        {
          "productId": "P002",
          "productName": "ノートパソコン",
          "unitsSold": 42
        },
        {
          "productId": "P004",
          "productName": "スマートウォッチ",
          "unitsSold": 65
        },
        // ... 他の商品データ(unitsSold > 0 のみ)
      ]
    },
    {
      "date": "2024-11-02",
      "products": [
        {
          "productId": "P001",
          "productName": "コーヒーメーカー",
          "unitsSold": 58
        },
        {
          "productId": "P003",
          "productName": "Bluetoothスピーカー",
          "unitsSold": 77
        },
        // ... 他の商品データ(unitsSold > 0 のみ)
      ]
    },
    // ... 11月30日までのデータ
  ]
}

Elastic SearchのApache 2.0ライセンス版であるOpenSearchにダッシュボード機能がついているので、それを活用してみる。

AWSでもマネージドのOpenSearchがあるが、コストがややかかるので一旦ローカルのDockerで動かしてみる。

全体の流れ

以下の流れで進める

  • ダミーデータのJSONを作成
  • Docker(docker compose)ベースで、OpenSearchのサーバーと、OpenSearch Dashboardsの起動
  • ローカルからJSONデータをパースして、OpenSearchに登録
  • OpenSearch Dashboardsでダッシュボードの作成

ダミーデータのJSONを作成

10個商品があり、日次にランダムの売上が発生する例を想定する。PythonでJSONを作成する。

import json
import os
import random
from datetime import datetime, timedelta

# 商品リストの定義
products = [
    {"productId": "P001", "productName": "コーヒーメーカー"},
    {"productId": "P002", "productName": "ノートパソコン"},
    {"productId": "P003", "productName": "Bluetoothスピーカー"},
    {"productId": "P004", "productName": "スマートウォッチ"},
    {"productId": "P005", "productName": "エアフライヤー"},
    {"productId": "P006", "productName": "タブレット端末"},
    {"productId": "P007", "productName": "LEDデスクランプ"},
    {"productId": "P008", "productName": "ワイヤレスイヤホン"},
    {"productId": "P009", "productName": "デジタルカメラ"},
    {"productId": "P010", "productName": "電動歯ブラシ"}
]

# 売上データを格納するリスト
sales_data = []

# 開始日と終了日の設定
start_date = datetime(2024, 11, 1)
end_date = datetime(2024, 11, 30)

# 各日についてデータを生成
current_date = start_date
while current_date <= end_date:
    date_str = current_date.strftime("%Y-%m-%d")
    daily_sales = {
        "date": date_str,
        "products": []
    }
    for product in products:
        # 20%の確率で売上数が0になる
        if random.random() < 0.2:
            units_sold = 0
        else:
            # 売上数にランダム性を持たせる(例: 20〜100の間でランダム)
            units_sold = random.randint(20, 100)

        if units_sold > 0:
            product_sales = {
                "productId": product["productId"],
                "productName": product["productName"],
                "unitsSold": units_sold
            }
            daily_sales["products"].append(product_sales)
    sales_data.append(daily_sales)
    current_date += timedelta(days=1)

# JSONデータの構造
output_data = {
    "salesData": sales_data
}

# JSONファイルに出力する場合
os.makedirs("sample_data", exist_ok=True)
with open("sample_data/sales_data_november_2024.json", "w", encoding="utf-8") as f:
    json.dump(output_data, f, ensure_ascii=False, indent=2)

# コンソールに出力する場合
print(json.dumps(output_data, ensure_ascii=False, indent=2))

冒頭に出現したようなデータが出てくる。サンプルデータ

sales_data_november_2024

OpenSearchのサーバーと、OpenSearch DashboardをDockerから起動

カレントディレクトリにdocker-compose.yamlを作成する。OpenSearchのパスワードは結構面倒くさいので、今説明のためにyamlに初期パスワードを直書きしているが、本番用では環境変数を使うなど、もっとセキュアなやり方でやるように

最新のOpenSearchのバージョンはDocker Hubから確認できる。これはOpenSearchのオフィシャルなリポジトリ。opensearchproject/opensearch:latestが最新のバージョンのエイリアス。

version: '3'
services:
  opensearch:
    image: opensearchproject/opensearch:2.18.0
    container_name: opensearch
    environment:
      discovery.type: "single-node"
      bootstrap.memory_lock: "true"
      OPENSEARCH_JAVA_OPTS: "-Xms2g -Xmx2g"
      # 初期パスワードを一定強いのにしないとコンテナ起動に失敗する
      OPENSEARCH_INITIAL_ADMIN_PASSWORD: "StrongPass_1234!"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    ports:
      - 9200:9200
    volumes:
      - ./opensearch-data1:/usr/share/opensearch/data # Creates volume called opensearch-data1 and mounts it to the container
    networks:
      - opensearch-net

  opensearch-dashboards:
    image: opensearchproject/opensearch-dashboards:2.18.0
    container_name: opensearch-dashboards
    environment:
      OPENSEARCH_HOSTS: '["https://opensearch:9200"]'
    ports:
      - 5601:5601
    depends_on:
      - opensearch
    networks:
      - opensearch-net

networks:
  opensearch-net:
    driver: bridge

バックグラウンドでコンテナを起動する。今回はWSL2で確認している。

docker compose up -d

後述の理由により、OpenSearchの起動に失敗していることがあるので、docker compose psで起動に成功しているか確認する。以下のように、OpenSearchとDashboardのコンテナの両方のStatusが「running」になっていればOK。起動に失敗した場合は、Dashboardだけがrunningになっている。

docker compose ps
# 以下のようになっていればOK
NAME                    COMMAND                  SERVICE                 STATUS              PORTS
opensearch              "./opensearch-docker…"   opensearch              running             9300/tcp, 9600/tcp, 0.0.0.0:9200->9200/tcp, :::9200->9200/tcp, 9650/tcp
opensearch-dashboards   "./opensearch-dashbo…"   opensearch-dashboards   running             0.0.0.0:5601->5601/tcp, :::5601->5601/tcp

ハマりどころ1:初期パスワードは強固にしなければならない

OpenSearchの起動に失敗する原因に、初期パスワードのセキュリティがある。OpenSearchの初期設定はかなりセキュリティが厳しめになっているので、「ユーザー名admin、パスワードadmin」のような、ふざけたセキュリティ設定だと弾かれて、OpenSearchが終了してしまう

強制終了の原因を特定したい場合は、docker compose logs opensearchで可能。

docker compose logs opensearch

強制終了されたときのログメッセージは、以下の通りであったため、大文字小文字を含み8文字以上で、特殊記号を含むパスワードでなければいけなかった。

Please re-try with a minimum 8 character password and must contain at least one uppercase letter, one lowercase letter, one digit, and one special character that is strong.

ハマりどころ2:localhostなのにHTTPSで通信しないといけない

OpenSearchのサーバーではデフォルトでSSLが有効化されていて、localhostでの通信であってもHTTPSで通信しないといけない。もちろんlocalhostだとSSLの自己署名のエラーが出てしまうのだが、それでもOKである。

影響があるのは、docker-compose.yamlopensearch-dashboardsの環境変数で、サーバーのURLをHTTPSで定義する。

    environment:
      OPENSEARCH_HOSTS: '["https://opensearch:9200"]'

難しいのは、サーバではSSLが有効化されているのに、Dashboards側ではSSLが有効化されていない点。Pythonからデータを送るときはHTTPSで送って、ブラウザからダッシュボードを見るときはHTTPという、なんとも不思議なことをする必要がある。

OpenSearchの起動確認(オプション)

ブラウザからOpenSearchが起動していることを確認してみる。https://127.0.0.1:9200/にアクセスする。SEC_ERROR_UNKNOWN_ISSUERのエラーが出るが、ローカルホストだと仕様なのでそのまま進める。

ログイン画面が出てくるので、ログイン。

以下のようなJSONが出てくれば起動確認は成功。

インデックスの登録

以下は、OpenSearchが正常に起動していることが前提とする。先ほど作成したダミーのJSONのファイル名をsample_data/sales_data_november_2024.jsonとして保存。その上で、インデックスの作成とデータの登録を以下のコードで行う。

import json
import requests

OPENSEARCH_URL = "https://127.0.0.1:9200" # HTTPSで接続
USERNAME = "admin"
PASSWORD = "StrongPass_1234!"
INDEX_NAME = "sales-data"

def create_index():
    # 必要に応じてマッピング定義(unitsSoldを数値、dateをdate型)
    mapping = {
        "settings": {
            "number_of_shards": 1
        },
        "mappings": {
            "properties": {
                "date": {"type": "date"},
                "productId": {"type": "keyword"},
                "productName": {"type": "text"},
                "unitsSold": {"type": "integer"}
            }
        }
    }

    # インデックス作成(存在しない場合)
    res = requests.put(f"{OPENSEARCH_URL}/{INDEX_NAME}", auth=(USERNAME, PASSWORD), json=mapping, verify=False)
    if res.status_code not in [200, 201]:
        print("Index creation response:", res.text)
    else:
        print("Index created successfully!")


def index_data(file_path):
    with open(file_path, 'r', encoding='utf-8') as f:
        data = json.load(f)

    bulk_lines = []
    for entry in data["salesData"]:
        date = entry["date"]
        for product in entry["products"]:
            # indexアクション行
            bulk_lines.append(json.dumps({"index": {"_index": INDEX_NAME}}))
            # ドキュメント行
            doc = {
                "date": date,
                "productId": product["productId"],
                "productName": product["productName"],
                "unitsSold": product["unitsSold"]
            }
            bulk_lines.append(json.dumps(doc))

    # 最後に改行が必要
    bulk_body = "\n".join(bulk_lines) + "\n"

    # bulk APIで投入
    bulk_res = requests.post(f"{OPENSEARCH_URL}/_bulk", 
                             auth=(USERNAME, PASSWORD), 
                             headers={"Content-Type": "application/json"}, 
                             data=bulk_body, 
                             verify=False)
    if bulk_res.status_code != 200:
        print("Bulk insert error:", bulk_res.text)
    else:
        response = bulk_res.json()
        print(response)
        print("Data indexed successfully!")

if __name__ == "__main__":
    # インデックス作成(既存なら削除してもよい)
    # requests.delete(f"{OPENSEARCH_URL}/{INDEX_NAME}", auth=(USERNAME, PASSWORD), verify=False)
    create_index()
    index_data("sample_data/sales_data_november_2024.json")

ダッシュボードの起動とインデックスの確認

OpenSearch Dashboardにアクセスする。以下のURLで、HTTPで接続をする。

http://127.0.0.1:5601

ログイン画面でログインを行う。その後、チュートリアルが出てくるがスキップする。「テナントどうしますか?」みたいな質問もとりあえずスキップ。ホーム画面が出てくる。

先ほど登録したデータが格納できているかどうか確認する。左上に「三」のトグルボタンを押す。「Management→Index Management」で「Indexes」を選択。先ほど登録した「sales-data」が入っている。

(思いっきり「Kibana」って名前のインデックスができてるから本当にKibanaのフォークなんですね。AWSの公式ページでも堂々とリネームしましたって書いてあるし)

Index Patternの作成

初回だけIndex Patternを作成する必要がある。ホーム画面から、「OpenSearch Dashboards」→「Dahsboards」(2回目以降は、「Management」→「Dashboards Management」)。「Index patterns」から「Create index pattern」。

「Define an index pattern」では「sales-data」を入力

Time fieldは「date」を選択。

以下のようにカラムが展開される。

ダッシュボードの作成

Visualizationの新規作成

ホーム画面に戻る。「OpenSearch Dashboards」から「Dashboards」。「Create new dashboard」。「Create new」を選択。

「Line」を選択。

「sales-data」をSourceとして選択。

X軸の作成

右下のBucketsを「Add」し、「X-axis」を選択

  • Aggregation: Date Histogram
  • Field : date
  • Minimum interval: Day
  • Custom label: 日付

Y軸の作成

右上の「Metrics」から「Y-axis」を選択

  • Aggregation: Sum
  • Field: unitsSold
  • Custom label: 売上合計

これだけだと直近15分でデータがおそらくないので、右上の時間軸を選択し、データのある期間(2024/11/1~2024/12/1)を選択。「Update」をクリック。

グラフが描画される。これは全アイテムの合計。

商品別の推移の表示

やりたいことは商品別の売上推移のプロットなので、「Split Series」を追加する。「Buckets」から。

  • Subaggregation: Terms
  • Field: productId
  • Order by: Metric 売上合計

売上ソートしなくても、商品のアルファベット順にすることも可能。Order byをAlphabetにし、OrderをAscendingにするとP001、P002…といった並び順になる。

以下のようなグラフが出てくる。これが欲しかった結果。右上の「Save」をクリック。

Visualizationの保存

Visualizationに名前をつけて保存。名前はここでは「Product sales by items」とした。

作成したグラフが追加された。この後別のグラフを追加することもでき、これらを複数追加してダッシュボードを作っていく。

ダッシュボードの画面の「Save」で同様に保存し、「My First Dashboard」とした。右上の「Share」からダッシュボードのリンクを取得でき、ダッシュボードを保存した場合は、以下のようなURLになっている。

http://127.0.0.1:5601/app/dashboards?security_tenant=private#/view/c58c6e10-b731-11ef-ad36-8b5132491b03?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:’2024-10-31T15:00:00.000Z’,to:’2024-11-30T15:00:00.000Z’))&_a=(description:”,filters:!(),fullScreenMode:!f,options:(hidePanelTitles:!f,useMargins:!t),query:(language:kuery,query:”),timeRestore:!f,title:’My%20First%20Dashboard’,viewMode:view)

今回はローカルホストからなのでそこまで問題ないが、デプロイした場合はこれを通じて共有するのだろう。

まとめ

  • この例を通じてOpenSearchとOpenSearch Dashboardをローカルから扱うことができた。
  • OpenSearchのとっつきにくさは非常にあるが、動的なダッシュボードが実質無料でできるのは強い。AWSのマネージドのダッシュボード(QuickSightやGrafana)などどうしてもID単位の課金が発生してしまうので、これを無視できるのはなかなか強い。
  • 閲覧のたびにログイン発生するのが少し面倒だけど、ダッシュボードをPublishできれば完璧


Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内

技術書コーナー

北海道の駅巡りコーナー


Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です