OpenSearchとOpenSearch Dashboardsによるダッシュボード作成(ローカルDocker)
本記事では、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))
冒頭に出現したようなデータが出てくる。サンプルデータ
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.yaml
のopensearch-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の中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内
技術書コーナー
北海道の駅巡りコーナー