この記事について
本記事はAIで情報をまとめている。
複数台のUbuntuサーバーが出力するログを、1台のOpenSearchに集約するログ基盤の構築手順をまとめる。
ログの送信にはFluentdを使い、journaldに流れているアプリケーションログとシステムログ、さらにsysstatのリソース使用状況まで取り込む。
実際に構築した際に何度もつまずいた箇所があったため、手順の中にその理由と確認コマンドを埋め込んである。
上から順に実行すれば、初めての人でも同じ構成を再現できることを目標にしている。
なお、OpenSearch Dashboards上でグラフやダッシュボードを作る手順は扱わない。
本記事のゴールは、ログがOpenSearchに届き、DashboardsのDiscover画面で検索できる状態までである。
記事中のIPアドレス、ホスト名、ユーザー名、パスワードはすべてダミーまたはプレースホルダーに置き換えている。
<LOG_SERVER_IP> のように < > で囲まれた部分は、自分の環境の値に読み替えること。
構成と前提
全体構成
[app01] Fluentd ─┐
[app02] Fluentd ─┼── HTTPS(9200) + Basic認証 ──> [log-server] OpenSearch
[app03] Fluentd ─┘ 送信元IPで制限 │
│ コンテナ間通信
v
[手元PC] ── SSHトンネル(5601) ─────────────────> [log-server] OpenSearch Dashboards
| 役割 | ホスト名(本記事での呼称) | 備考 |
|---|---|---|
| ログ集約サーバー | log-server |
OpenSearchとDashboardsをDockerで動かす |
| ログ送信元サーバー |
app01 app02 app03
|
Fluentdを導入する |
| 閲覧者の手元PC | - | SSHトンネル経由でDashboardsを見る |
使用するソフトウェアとバージョン
| ソフトウェア | バージョン |
|---|---|
| OS | Ubuntu 22.04 LTS(jammy)。送信元の一部が20.04(focal)でも可 |
| OpenSearch / OpenSearch Dashboards | 3.8.0(公式Dockerイメージ) |
| Fluentd | fluent-package 6 LTS(focalでは5 LTS) |
| Fluentdプラグイン | fluent-plugin-systemd、fluent-plugin-opensearch |
設計上の判断
OpenSearchの9200番ポートは、ログ集約サーバーのグローバルIP上で待ち受ける。
そのうえで、送信元サーバーのIPアドレス以外からの接続をファイアウォールで遮断し、通信自体はTLSとBasic認証で保護する。
ドメインを割り当てていないため、証明書は自己署名で作成する。
ドメインを取得できる環境であれば、Let's Encrypt等の正式な証明書に切り替えることを推奨する。
Dashboardsはループバックアドレスでのみ待ち受け、外部には公開しない。
閲覧する際はSSHのポートフォワードを使う。
ログは送信元サーバーごとにインデックスを分けず、共有のインデックスに格納する。
どのサーバーのログかは、Fluentdが付与する hostname フィールドで区別する。
この方式にすると、Fluentdの設定ファイルを全サーバーで同一の内容にできる。
作成するインデックス
| インデックス名 | 内容 | 保持期間(初期値) |
|---|---|---|
logs-app-YYYY.MM.DD |
アプリケーション(myapp@*.service)のログ |
31日 |
logs-system-YYYY.MM.DD |
journald全体のシステムログ(任意) | 7日 |
logs-sysstat-YYYY.MM.DD |
CPU、メモリ、ディスクI/Oの使用状況(任意) | 14日 |
前提: アプリケーションのログがjournaldに流れていること
本記事では、送信元サーバーで docker compose up の標準出力がjournaldに流れている構成を前提とする。
すでに同様の仕組みがある場合は、この節は読み飛ばしてよい。
まだ仕組みがない場合は、以下のようなsystemdのテンプレートユニットを用意すると、/opt/apps/<アプリ名>/ に置いた docker-compose.yml を myapp@<アプリ名>.service として起動でき、その出力がjournaldに記録される。
【app01 / app02 / app03】
sudo tee /etc/systemd/system/myapp@.service > /dev/null <<'EOF'
[Unit]
Description=myapp %i (docker compose)
After=docker.service
Requires=docker.service
[Service]
WorkingDirectory=/opt/apps/%i
ExecStart=/usr/bin/docker compose up --no-color
ExecStop=/usr/bin/docker compose down
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now myapp@testapp.service
journalctl -u myapp@testapp.service -n 5 --no-pager
docker compose up の出力は testapp-web-1 | GET / 200 のように、コンテナ名と本文が | で区切られた形式になる。
後の手順で、Fluentdはこの | を境に container_name と log_message の2つのフィールドに分割する。
1. 送信元サーバーのグローバルIPを確認する
ログ集約サーバーのファイアウォールに登録するため、各送信元サーバーのグローバルIPを確認しておく。
【app01 / app02 / app03(それぞれで実行)】
curl -s https://ifconfig.me; echo
以降、それぞれ <APP01_IP> <APP02_IP> <APP03_IP> と表記する。
送信元のグローバルIPが変動する契約の場合、IPアドレスで制限するこの方式は成り立たない。
固定IPであることを事前に確認しておくこと。
2. ログ集約サーバーにDockerを導入する
Docker公式の「Install using the apt repository」の手順に従う。
すでにDockerとDocker Compose v2が導入済みであれば、この章は不要である。
参考: Install Docker Engine on Ubuntu | Docker Docs
【log-server】
# 競合する旧パッケージの削除(入っていなければエラーは無視してよい)
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt remove -y $pkg 2>/dev/null || true
done
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
一度ログアウトして再ログインし、バージョンが表示されることを確認する。
【log-server】
docker --version
docker compose version
3. ログ集約サーバーのカーネルパラメータを設定する
OpenSearchは vm.max_map_count が262144以上であることを必須要件としている。
これを満たさないと、コンテナが起動直後に停止する。
参考: Important system settings - OpenSearch Documentation
【log-server】
sudo sysctl -w vm.max_map_count=262144
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
4. ログ集約サーバーのファイアウォールを設定する
Dockerの公開ポートがufwを経由しない問題
この章は構築全体の中で最も見落としやすい。
Dockerで ports: "9200:9200" のようにポートを公開すると、DockerはiptablesのNATテーブルとFORWARDチェーンに直接ルールを追加する。
一方、ufwのルールはINPUTチェーンに書き込まれる。
そのため、公開ポート宛てのパケットはufwのルールを通らずにコンテナへ届いてしまう。
つまり ufw allow from <IP> to any port 9200 と設定しても、実際には全世界から9200番に接続できる状態のままになる。
これはDocker社自身も認識している既知の挙動である。
対策として、Dockerが自身のルールより先に評価する DOCKER-USER チェーンにルールを入れる。
このチェーンはDockerの起動時に上書きされないため、ufwの起動時に読み込まれる /etc/ufw/after.rules に書いて永続化する。
参考:
- Best Practices for Docker and UFW · Issue #777 · docker/for-linux
- chaifeng/ufw-docker
- Docker Container Published Port Ignoring UFW Rules | Baeldung on Linux
SSHなど通常のポートはufwで制御する
Dockerで公開していないポートは、通常通りufwで制御できる。
SSHの接続元を限定できる場合は、ufw allow OpenSSH で全開放するより、送信元IPを絞るほうが安全である。
【log-server】
sudo ufw status verbose
sudo ufw allow from <YOUR_ADMIN_IP> to any port <SSH_PORT> proto tcp
sudo ufw enable
ufw enable の前に、今使っているSSH接続が許可ルールに含まれていることを必ず確認すること。
誤ると自分自身を締め出してしまう。
9200番はDOCKER-USERチェーンで制御する
【log-server】
sudo vi /etc/ufw/after.rules
ファイルの末尾に以下を追記する。
送信元サーバーの台数に合わせて ACCEPT の行を増減させる。
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -s <APP01_IP>/32 -p tcp --dport 9200 -j ACCEPT
-A DOCKER-USER -s <APP02_IP>/32 -p tcp --dport 9200 -j ACCEPT
-A DOCKER-USER -s <APP03_IP>/32 -p tcp --dport 9200 -j ACCEPT
-A DOCKER-USER -p tcp --dport 9200 -j DROP
-A DOCKER-USER -j RETURN
COMMIT
反映し、DOCKER-USERチェーンに意図したルールが入っているか確認する。
【log-server】
sudo ufw reload
sudo iptables -L DOCKER-USER -n -v
期待される結果: 送信元IPごとの ACCEPT の行と、それ以外を落とす DROP の行が表示されること。
Dockerのバージョンアップやサーバーの再起動のあとは、sudo iptables -L DOCKER-USER -n -v でルールが残っているか再確認する習慣をつけておくとよい。
ufw status の表示だけでは、このチェーンの状態は分からない。
5. TLS証明書を作成する(自己署名)
参考: Generating self-signed certificates - OpenSearch Documentation
作業用のディレクトリを作成する。
以降、OpenSearch関連のファイルはすべて /opt/log-stack に置く。
【log-server】
sudo mkdir -p /opt/log-stack/certs
sudo chown -R $USER:$USER /opt/log-stack
cd /opt/log-stack/certs
ルートCA
【log-server】
openssl genrsa -out root-ca-key.pem 2048
openssl req -new -x509 -sha256 -key root-ca-key.pem -out root-ca.pem -days 3650 \
-subj "/C=JP/ST=Tokyo/O=Example/CN=example-log-root-ca"
ノード証明書
FluentdはTLS接続時に、接続先のアドレスと証明書の内容が一致するかを検証する。
本記事ではIPアドレスで接続するため、CNとSAN(subjectAltName)の両方にログ集約サーバーのグローバルIPを入れる。
【log-server】
openssl genrsa -out node-key-temp.pem 2048
openssl pkcs8 -inform PEM -outform PEM -in node-key-temp.pem -topk8 -nocrypt -v1 PBE-SHA1-3DES -out node-key.pem
openssl req -new -key node-key.pem -out node.csr \
-subj "/C=JP/ST=Tokyo/O=Example/CN=<LOG_SERVER_IP>"
echo "subjectAltName=IP:<LOG_SERVER_IP>" > node-san.ext
openssl x509 -req -in node.csr -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \
-sha256 -out node.pem -days 3650 -extfile node-san.ext
管理者証明書
管理者証明書は、パスワードを使わずにOpenSearchのセキュリティ設定を確認、変更するために使う。
パスワード認証でつまずいた際の切り分けにも役立つ。
【log-server】
openssl genrsa -out admin-key-temp.pem 2048
openssl pkcs8 -inform PEM -outform PEM -in admin-key-temp.pem -topk8 -nocrypt -v1 PBE-SHA1-3DES -out admin-key.pem
openssl req -new -key admin-key.pem -out admin.csr \
-subj "/C=JP/ST=Tokyo/O=Example/CN=admin"
openssl x509 -req -in admin.csr -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \
-sha256 -out admin.pem -days 3650
証明書のDN文字列を取得する
-subj で指定した文字列と、証明書に実際に埋め込まれるDN(識別名)は、順序も区切り文字も一致しない。
後で opensearch.yml に書く admin_dn と nodes_dn には、以下のコマンドで取得した文字列を使う必要がある。
【log-server】
openssl x509 -subject -nameopt RFC2253 -noout -in node.pem
openssl x509 -subject -nameopt RFC2253 -noout -in admin.pem
出力は以下のように subject= という接頭辞付きで表示される。
subject=CN=<LOG_SERVER_IP>,O=Example,ST=Tokyo,C=JP
subject=CN=admin,O=Example,ST=Tokyo,C=JP
opensearch.yml に書くのは subject= を取り除いた CN=...,O=...,ST=...,C=JP の部分だけである。
subject= を含めたまま書くと、証明書による権限チェックに失敗する。
不要ファイルの削除とパーミッション設定
【log-server】
rm -f node-key-temp.pem admin-key-temp.pem *.csr node-san.ext
chmod 644 *.pem
cd /opt/log-stack
パーミッションを 600 ではなく 644 にしているのには理由がある。
OpenSearchの公式Dockerイメージはコンテナ内部で非rootユーザーとして動作するため、600 のままだと Unable to read the file root-ca.pem のようなエラーで起動に失敗する。
6. 管理者パスワードのハッシュ値を生成する
なぜ自分でハッシュを作るのか
OpenSearchの公式イメージには、OPENSEARCH_INITIAL_ADMIN_PASSWORD という環境変数で管理者パスワードを設定する仕組みがある。
しかしこれを担っているのは、イメージ内蔵の「デモ設定インストーラー」である。
本記事のように独自の opensearch.yml を用意すると、このインストーラーは opensearch.yml seems to be already configured for Security. Quit. と出力して処理をスキップする。
その結果、環境変数にパスワードを書いても一切反映されない。
そのため、パスワードのハッシュ値は自分で生成し、internal_users.yml に直接書き込む。
参考:
- Seems to be already configured for Security. Quit - OpenSearch Forum
- Modifying the YAML files | OpenSearch Documentation
ハッシュの生成
イメージに同梱されている hash.sh を、使い捨てコンテナで実行する。
admin 用と、Dashboardsが内部通信に使う kibanaserver 用の2つを生成する。
【log-server】
# admin用(大文字、小文字、数字、記号を含む十分に長いパスワードにする)
docker run --rm opensearchproject/opensearch:3.8.0 \
/usr/share/opensearch/plugins/opensearch-security/tools/hash.sh -p '<ADMIN_PASSWORD>'
# kibanaserver用
docker run --rm opensearchproject/opensearch:3.8.0 \
/usr/share/opensearch/plugins/opensearch-security/tools/hash.sh -p '<KIBANASERVER_PASSWORD>'
期待される結果: それぞれ $2y$12$... で始まるbcryptハッシュ文字列が1行出力される。
次の章で使うので、2つともコピーしておく。
7. 設定ファイルを作成する
internal_users.yml
admin と kibanaserver の2ユーザーを定義する。
kibanaserver が無いと、Dashboardsが OpenSearch Dashboards server is not ready yet と表示したまま起動しない。
<ADMIN_HASH> と <KIBANASERVER_HASH> は、前章で生成した $2y$12$... の文字列に必ず置き換えてから実行すること。
ハッシュ値には $ が含まれるため、シェル展開を防ぐ <<'EOF'(クォート付き)のヒアドキュメントで書き込んでいる。
【log-server】
tee /opt/log-stack/internal_users.yml > /dev/null <<'EOF'
---
_meta:
type: "internalusers"
config_version: 2
admin:
hash: "<ADMIN_HASH>"
reserved: true
backend_roles:
- "admin"
description: "Admin user"
kibanaserver:
hash: "<KIBANASERVER_HASH>"
reserved: true
description: "OpenSearch Dashboards internal user"
EOF
chmod 644 /opt/log-stack/internal_users.yml
プレースホルダーや不可視文字が残っていないか確認する。
【log-server】
cat -A /opt/log-stack/internal_users.yml | grep -A1 "^admin:\|^kibanaserver:"
期待される結果: hash: "$2y$12$..."$ のように、$2y$12$ から始まる文字列がそのまま表示されること。
M-c のような文字化けや、<ADMIN_HASH> の文字が残っていないこと。
opensearch.yml
参考: Security configuration | OpenSearch Documentation
admin_dn と nodes_dn には、5章で取得したDN文字列(subject= を除いた部分)を書く。
【log-server】
tee /opt/log-stack/opensearch.yml > /dev/null <<'EOF'
cluster.name: example-log-cluster
network.host: 0.0.0.0
discovery.type: single-node
plugins.security.disabled: false
plugins.security.restapi.roles_enabled: ["all_access"]
plugins.security.ssl.transport.pemcert_filepath: node.pem
plugins.security.ssl.transport.pemkey_filepath: node-key.pem
plugins.security.ssl.transport.pemtrustedcas_filepath: root-ca.pem
plugins.security.ssl.transport.enforce_hostname_verification: false
plugins.security.ssl.http.enabled: true
plugins.security.ssl.http.pemcert_filepath: node.pem
plugins.security.ssl.http.pemkey_filepath: node-key.pem
plugins.security.ssl.http.pemtrustedcas_filepath: root-ca.pem
plugins.security.authcz.admin_dn:
- CN=admin,O=Example,ST=Tokyo,C=JP
plugins.security.nodes_dn:
- CN=<LOG_SERVER_IP>,O=Example,ST=Tokyo,C=JP
plugins.security.allow_default_init_securityindex: true
EOF
plugins.security.restapi.roles_enabled は省略しやすいが必須である。
これが無いと、admin が all_access ロールを持っていても、セキュリティ管理用のREST API(ロールやユーザーの作成)が Role based access not enabled で拒否される。
次の章以降でFluentd用のユーザーをAPIで作成するため、この設定が欠かせない。
参考: Cannot see Security Dashboard tab in Opensearch Dashboards under plugins - OpenSearch Forum
opensearch_dashboards.yml
独自の opensearch_dashboards.yml をマウントすると、イメージ内蔵のデフォルト設定は丸ごと置き換わる。
そのため、Dashboardsが内部通信に使う kibanaserver の認証情報も自分で書く必要がある。
csp.rules は、Dashboardsのセキュリティ管理画面がインラインスクリプトをブロックされて Application Not Found と表示される問題への対策である。
【log-server】
tee /opt/log-stack/opensearch_dashboards.yml > /dev/null <<'EOF'
server.host: "0.0.0.0"
opensearch.hosts: ["https://opensearch:9200"]
opensearch.ssl.verificationMode: none
opensearch.username: "kibanaserver"
opensearch.password: "<KIBANASERVER_PASSWORD>"
csp.rules:
- "script-src 'unsafe-eval' 'self' 'unsafe-inline'"
opensearch.requestHeadersAllowlist: ["authorization", "securitytenant"]
opensearch_security.multitenancy.enabled: true
opensearch_security.multitenancy.tenants.preferred: ["Global", "Private"]
EOF
末尾の3行は、マルチテナント(テナント機能)を有効にする設定である。
テナントは、Index Patternやダッシュボードなどの保存オブジェクトを共有する範囲のことで、16章で作る閲覧専用ユーザーの権限制御に使う。
イメージ内蔵のデフォルトファイルにはこの設定が含まれているが、独自のファイルで置き換えると一緒に消え、マルチテナントは無効になる。
無効のままだと、Dashboardsの画面に Switch tenants が表示されず、16章で設定するテナントの読み取り専用権限も効かない。
| 設定 | 役割 |
|---|---|
opensearch.requestHeadersAllowlist |
どのテナントを見ているかを示す securitytenant ヘッダーを、DashboardsからOpenSearchへ渡す。これが無いとテナントを切り替えても反映されない |
opensearch_security.multitenancy.enabled |
テナント機能を有効にする |
opensearch_security.multitenancy.tenants.preferred |
ログイン時に表示されるテナントの並び順 |
opensearch.ssl.verificationMode: none は、DashboardsからOpenSearchへの内部通信で証明書を検証しない設定である。
両者は同じDockerネットワーク内で通信するため実害は小さいが、より厳密にしたい場合は opensearch.ssl.certificateAuthorities にCA証明書を指定し、verificationMode: full にする。
docker-compose.yml
OpenSearchのデータは、Dockerの名前付きボリュームではなく、容量に余裕のあるディスク上のディレクトリにbind mountする。
名前付きボリュームは既定で /var/lib/docker/ 配下、つまりルートファイルシステム上に作られるため、ログの蓄積とともに容量不足になりやすい。
本記事では、大容量ディスクを /data にマウントしている想定とする。
OpenSearchのコンテナはUID/GID 1000で動作するため、データディレクトリの所有者を合わせておく。
【log-server】
sudo mkdir -p /data/opensearch
sudo chown -R 1000:1000 /data/opensearch
【log-server】
tee /opt/log-stack/docker-compose.yml > /dev/null <<'EOF'
services:
opensearch:
image: opensearchproject/opensearch:3.8.0
container_name: opensearch
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- /data/opensearch:/usr/share/opensearch/data
- ./certs/root-ca.pem:/usr/share/opensearch/config/root-ca.pem
- ./certs/node.pem:/usr/share/opensearch/config/node.pem
- ./certs/node-key.pem:/usr/share/opensearch/config/node-key.pem
- ./certs/admin.pem:/usr/share/opensearch/config/admin.pem
- ./certs/admin-key.pem:/usr/share/opensearch/config/admin-key.pem
- ./opensearch.yml:/usr/share/opensearch/config/opensearch.yml
- ./internal_users.yml:/usr/share/opensearch/config/opensearch-security/internal_users.yml
ports:
- "9200:9200"
networks:
- log-net
restart: unless-stopped
dashboards:
image: opensearchproject/opensearch-dashboards:3.8.0
container_name: dashboards
environment:
- 'OPENSEARCH_HOSTS=["https://opensearch:9200"]'
volumes:
- ./certs/root-ca.pem:/usr/share/opensearch-dashboards/config/root-ca.pem
- ./opensearch_dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml
ports:
- "127.0.0.1:5601:5601"
networks:
- log-net
depends_on:
- opensearch
restart: unless-stopped
networks:
log-net:
driver: bridge
EOF
-Xms2g -Xmx2g はJVMのヒープサイズである。
サーバーのメモリ量の半分程度を上限の目安に調整する。
書き込んだ直後に、internal_users.yml と opensearch_dashboards.yml のマウント行が含まれているか確認する。
この確認を怠ると、ファイルを正しく編集してもコンテナに渡らず、「何度直しても認証が通らない」という分かりにくい不具合の原因になる。
【log-server】
grep -E "internal_users.yml|opensearch_dashboards.yml" /opt/log-stack/docker-compose.yml
期待される結果: 2行が表示されること。
8. OpenSearchを起動して疎通を確認する
【log-server】
cd /opt/log-stack
docker compose up -d
docker compose logs -f opensearch
"message":"started" のようなログが出たら Ctrl+C で抜ける。
管理者でのログイン確認
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' https://127.0.0.1:9200
期待される結果: "cluster_name" : "example-log-cluster" を含むJSONが返ること。
-k は証明書の検証を省略するオプションである。
証明書には 127.0.0.1 ではなくグローバルIPが入っているため、ログ集約サーバー上で 127.0.0.1 宛てに確認する場合は -k を付ける。
セキュリティ管理APIの確認
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' https://127.0.0.1:9200/_plugins/_security/api/securityconfig
期待される結果: FORBIDDEN ではなく、authc などの設定を含むJSONが返ること。
Role based access not enabled が返る場合は、opensearch.yml の plugins.security.restapi.roles_enabled が反映されていない。
Unauthorizedが返る場合の切り分け
ハッシュの貼り間違いやマウント漏れなど、原因の候補が複数あるため、管理者証明書を使ってパスワードに頼らずに中身を直接確認するのが確実である。
【log-server】
docker exec -it opensearch curl -s -k \
--cacert /usr/share/opensearch/config/root-ca.pem \
--cert /usr/share/opensearch/config/admin.pem \
--key /usr/share/opensearch/config/admin-key.pem \
https://localhost:9200/_plugins/_security/api/internalusers/admin
"description" が "Admin user" になっていれば、自分で用意した internal_users.yml が読み込まれている。
"Demo admin user" のような覚えのない値が返る場合は、internal_users.yml がコンテナに渡っていない。
OpenSearchのセキュリティ設定(.opendistro_security インデックス)は、初回起動時に一度だけ internal_users.yml から初期化される。
初回起動後に internal_users.yml を修正しても、コンテナの再起動だけでは反映されない。
構築中にやり直す場合は、docker compose down のあと /data/opensearch の中身を削除してから起動し直す。
9. Fluentd専用の書き込みユーザーを作成する
Fluentdには admin を使わせず、ログの書き込みだけができる専用ユーザー fluentd を作成する。
DashboardsのGUIからも作成できるが、本記事では手順の再現性を優先してREST APIで作成する。
参考: Getting started with OpenSearch security | OpenSearch Documentation
ロールの作成
後で任意で追加する logs-system-* と logs-sysstat-* の分も、最初からまとめて許可しておく。
使わないインデックスパターンが含まれていても害はない。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/roles/fluentd_writer" \
-H 'Content-Type: application/json' \
-d '{
"cluster_permissions": ["cluster_monitor", "indices:data/write/bulk"],
"index_permissions": [
{
"index_patterns": ["logs-app-*", "logs-system-*", "logs-sysstat-*"],
"allowed_actions": ["create_index", "write", "indices:admin/mapping/put"]
}
]
}'
期待される結果: "status":"CREATED" を含むJSONが返ること。
Cluster permissionsの2つは、どちらも欠けると書き込みが失敗する。
| 権限 | 必要な理由 |
|---|---|
cluster_monitor |
fluent-plugin-opensearchは接続時にOpenSearchのバージョンを確認する。この確認に必要な cluster:monitor/main が無いと 403 security_exception でリトライし続け、書き込みまで到達しない |
indices:data/write/bulk |
ログの書き込みは内部的にこのアクションとして処理され、まずクラスタレベルで許可チェックが行われる。これが無いと、Index permissions側で write を付けていても 403 になる |
2つ目が欠けている場合、docker logs opensearch に No cluster-level perm match for User ... [Action [indices:data/write/bulk]] という形で記録される。
ユーザーの作成
パスワードは、大文字、小文字、数字、記号を含む十分に長いものにする。
弱いパスワードはAPI側で拒否される。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/internalusers/fluentd" \
-H 'Content-Type: application/json' \
-d '{"password": "<FLUENTD_PASSWORD>"}'
ロールとユーザーの紐付け
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/rolesmapping/fluentd_writer" \
-H 'Content-Type: application/json' \
-d '{"users": ["fluentd"]}'
動作確認
認証とロールの紐付けを確認する。
【log-server】
curl -k -u 'fluentd:<FLUENTD_PASSWORD>' https://127.0.0.1:9200/_plugins/_security/api/account
期待される結果: "roles":["own_index","fluentd_writer"] を含むJSONが返ること。
own_index は全ユーザーに自動付与される既定のロールなので気にしなくてよい。
https://127.0.0.1:9200(ルートパス)に fluentd で接続すると、パスワードが合っていても 403 が返ることがある。
これは認証失敗ではなく、権限が書き込みに絞られているための正常な挙動である。
401(認証失敗)と403(権限不足)を混同しないこと。
続けて、テスト用ドキュメントを1件書き込み、書き込み権限を確認する。
【log-server】
curl -k -u 'fluentd:<FLUENTD_PASSWORD>' -X POST \
"https://127.0.0.1:9200/logs-app-test/_doc" \
-H 'Content-Type: application/json' \
-d '{"message": "connectivity test"}'
期待される結果: "result":"created" を含むJSONが返ること。
確認できたら、テスト用のインデックスを削除する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X DELETE "https://127.0.0.1:9200/logs-app-test"
10. 送信元サーバーにFluentdを導入する
参考: Install by .deb Package (Debian/Ubuntu) | Fluentd
インストールスクリプトはOSのバージョンごとに異なる。
誤ったスクリプトを実行すると、依存パッケージのバージョンが合わず Unable to correct problems, you have held broken packages で失敗する。
最初にOSのコードネームを確認する。
【app01 / app02 / app03(それぞれで実行)】
lsb_release -cs
jammy(22.04)の場合は以下を実行する。
【jammyのサーバー】
curl -fsSL https://fluentd.cdn.cncf.io/sh/install-ubuntu-jammy-fluent-package6-lts.sh | sh
sudo /opt/fluent/bin/fluent-gem install fluent-plugin-systemd fluent-plugin-opensearch
focal(20.04)の場合は以下を実行する。
fluent-package 6はfocal向けに提供されていないため、5 LTSを使う。
インストール先(/opt/fluent/bin)は共通なので、以降の手順に違いはない。
【focalのサーバー】
curl -fsSL https://fluentd.cdn.cncf.io/sh/install-ubuntu-focal-fluent-package5-lts.sh | sh
sudo /opt/fluent/bin/fluent-gem install fluent-plugin-systemd fluent-plugin-opensearch
インストール中に Pending kernel upgrade のようなダイアログが出ることがある。
カーネル更新に関する別件の通知なので、<Ok> を選んで進めてよい。
実行ユーザーをsystemd-journalグループに追加する
fluent-packageは _fluentd という非rootユーザーでサービスを実行する。
journaldを読み取るには systemd-journal グループへの所属が必要だが、インストール直後は所属していない。
【app01 / app02 / app03(それぞれで実行)】
sudo usermod -aG systemd-journal _fluentd
id _fluentd
期待される結果: groups= の中に systemd-journal が含まれること。
この設定を忘れると、Fluentdはエラーを一切出さずに、journaldから何も読めないまま動き続ける。
気づきにくい不具合なので、必ず先に設定しておくこと。
11. 送信元サーバーにCA証明書を配布する
Fluentdがログ集約サーバーの証明書を検証できるよう、ルートCA証明書(root-ca.pem)を各送信元サーバーに配置する。
配布するのは公開鍵側の root-ca.pem だけであり、root-ca-key.pem などの秘密鍵は絶対にコピーしないこと。
【app01 / app02 / app03(それぞれで実行)】
sudo mkdir -p /etc/fluent/certs
scp -P <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP>:/opt/log-stack/certs/root-ca.pem /tmp/root-ca.pem
sudo mv /tmp/root-ca.pem /etc/fluent/certs/root-ca.pem
sudo chmod 644 /etc/fluent/certs/root-ca.pem
ログ集約サーバーへのSSHが踏み台サーバー経由に限られている場合は、-o ProxyJump=<YOUR_USER>@<BASTION_IP>:<BASTION_SSH_PORT> を付けて多段接続する。
証明書が使えるか、送信元サーバーから確認しておく。
ここでは -k を付けず、配布したCA証明書で検証する。
【app01 / app02 / app03(それぞれで実行)】
curl --cacert /etc/fluent/certs/root-ca.pem -u 'fluentd:<FLUENTD_PASSWORD>' \
https://<LOG_SERVER_IP>:9200/_plugins/_security/api/account
期待される結果: fluentd_writer を含むJSONが返ること。
SSL: no alternative certificate subject name matches のようなエラーが出る場合は、5章でノード証明書に入れたIPアドレスと、接続先のIPアドレスが一致していない。
12. Fluentdの設定ファイルを作成する
設定ファイル全体を示す前に、つまずいた箇所とその理由を説明しておく。
matchesはワイルドカードに対応していない
fluent-plugin-systemdには、取り込むログを絞る matches というパラメータがある。
matches [{ "_SYSTEMD_UNIT": "myapp@*.service" }]
しかし matches はjournald本体のフィルタ機能をそのまま使っており、完全一致でしか比較しない。
myapp@*.service という文字列は * を含む文字列そのものとして扱われ、実際のユニット名には一致しない。
そのため本記事では matches を使わず、全ジャーナルを読み込んだうえで、後段の grep フィルタで正規表現による絞り込みを行う。
journaldの挙動は以下で確認できる。
journalctl _SYSTEMD_UNIT="myapp@*.service" -n 5 --no-pager # 0件
journalctl _SYSTEMD_UNIT="myapp@testapp.service" -n 5 --no-pager # 表示される
read_from_headはfalseにする
read_from_head true は、journaldに保存されている最も古いログから順に読む指定である。
長期間稼働しているサーバーには数か月から数年分の履歴が残っていることがあり、Fluentdはそれを最初から読み進める。
その間は新しいログに到達できず、エラーも出ないまま何も送信されていないように見える。
導入時点から先のログを集めれば十分なので、false にする。
日付入りのインデックス名には <buffer time> が必要
index_name logs-app-%Y.%m.%d のように日付のプレースホルダーを使う場合、バッファのチャンクキーに time を指定し、timekey を設定する必要がある。
これを忘れると、プレースホルダーが展開されず logs-app-%Y.%m.%d という名前のインデックスがそのまま作られる。
また <buffer time> にすると、既定の flush_mode が lazy(timekeyの区切りまで送信しない)に変わり、flush_interval が無視される。
数秒おきに送信させたいので、flush_mode interval を明示する。
運用開始後に <buffer> を <buffer time> に変更するなど、チャンクキーの構造を変えた場合は、古いバッファが残っていると unexpected error while checking flushed chunks で詰まることがある。
その場合は sudo systemctl stop fluentd のあと、/var/log/fluent/buffer/ 配下の該当ディレクトリを空にしてから起動し直す。
@timestampは自分で付与する
fluent-plugin-opensearchが @timestamp を自動で付けるのは、logstash_format true のときだけである。
本記事ではインデックス名を自分で指定するため logstash_format false にしており、@timestamp も付かなくなる。
@timestamp が無いとDashboardsで時刻による絞り込みができないため、record_transformer で付与する。
hostnameフィールドでサーバーを区別する
record_transformer で hostname ${Socket.gethostname} を付与する。
実行中のホスト名が自動で入るため、設定ファイルは全サーバーで同一の内容のまま使える。
パスワードはダブルクォートで囲む
Fluentdの設定ファイルでは、クォート無しの値に # が含まれると、それ以降がコメントとして切り捨てられることがある。
パスワードは必ずダブルクォートで囲む。
設定ファイル全体
<LOG_SERVER_IP> と <FLUENTD_PASSWORD> を自分の値に置き換えて実行する。
host には、5章でノード証明書に入れたIPアドレスと同じ値を書く。
【app01 / app02 / app03(それぞれで実行)】
sudo tee /etc/fluent/fluentd.conf > /dev/null <<'EOF'
<source>
@type systemd
path /var/log/journal
read_from_head false
tag app.journal
<storage>
@type local
persistent true
path /var/log/fluent/systemd-app.pos
</storage>
<entry>
fields_strip_underscores true
</entry>
</source>
<filter app.journal>
@type grep
<regexp>
key SYSTEMD_UNIT
pattern /^myapp@.*\.service$/
</regexp>
</filter>
<filter app.journal>
@type record_transformer
enable_ruby true
<record>
container_name ${record["MESSAGE"].to_s.split("|", 2)[0].to_s.strip}
log_message ${record["MESSAGE"].to_s.split("|", 2)[1].to_s.strip}
"@timestamp" ${Time.at(time.to_i).strftime('%Y-%m-%dT%H:%M:%S%:z')}
hostname ${Socket.gethostname}
</record>
</filter>
<match app.journal>
@type opensearch
host <LOG_SERVER_IP>
port 9200
scheme https
ssl_verify true
ca_file /etc/fluent/certs/root-ca.pem
user fluentd
password "<FLUENTD_PASSWORD>"
index_name logs-app-%Y.%m.%d
logstash_format false
reconnect_on_error true
reload_on_failure true
<buffer time>
@type file
timekey 1d
flush_mode interval
path /var/log/fluent/buffer/opensearch-app
flush_interval 5s
retry_max_times 10
retry_wait 5s
chunk_limit_size 8MB
</buffer>
</match>
EOF
fields_strip_underscores true により、journaldのフィールド名 _SYSTEMD_UNIT は先頭のアンダースコアが外れて SYSTEMD_UNIT になる。
grep フィルタの key は外れた後の名前に合わせてある。
参考:
13. Fluentdを起動して到達を確認する
設定ファイルの文法を確認してから起動する。
【app01 / app02 / app03(それぞれで実行)】
sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf
sudo systemctl restart fluentd
sudo systemctl status fluentd
対象サービスのログを出すため、アプリケーションに何度かアクセスするか、サービスを再起動する。
数十秒待ってから、ログ集約サーバーでインデックスを確認する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/_cat/indices/logs-*?v"
期待される結果: logs-app-YYYY.MM.DD が表示されること。
1件取り出して、フィールドの中身も確認する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/logs-app-*/_search?pretty&size=1"
期待される結果: _source の中に @timestamp、hostname、container_name、log_message が含まれていること。
届かない場合の切り分け
エラーが出ないのに届いていない、という状態になりやすいため、以下の順に確認する。
1. バッファディレクトリの中身
ls -la /var/log/fluent/buffer/opensearch-app/
空のままであれば、<source> か <filter> の段階でデータが流れていない。
ファイルがあるのに送信されていなければ、<match>(OpenSearchへの送信)側の問題である。
2. position(読み取り位置)ファイルの中身
sudo cat /var/log/fluent/systemd-app.pos/worker0/storage.json
{} のままであれば、journaldから1件も読めていない。
{"journal":"s=..."} のようなカーソル情報が入っていれば、読み取りは進んでいる。
3. 実行ユーザーでjournaldを読めるか
sudo runuser -u _fluentd -- journalctl -u myapp@testapp.service -n 5 --no-pager
権限エラーになる場合は、10章の systemd-journal グループへの追加が反映されていない。
グループ追加後にFluentdを再起動していない場合によく起きる。
4. Fluentdのログ
sudo tail -60 /var/log/fluent/fluentd.log
| ログに出る内容 | 疑うべき箇所 |
|---|---|
security_exception、403
|
9章のロールのCluster permissions |
401、Unauthorized
|
fluentd.conf のパスワードと9章で設定したパスワードの不一致 |
SSLError、hostname does not match
|
証明書のIPと host の不一致(5章、11章) |
unexpected error while checking flushed chunks |
バッファ構造の変更後に残った古いチャンク(12章) |
has timestamp placeholders, but chunk key 'time' is not configured |
<buffer time> の指定漏れ(12章) |
14. 古いインデックスを自動削除する(ISM)
ログは日ごとにインデックスが増え続けるため、Index State Management(ISM)で一定期間後に自動削除する。
logs-app-* は、7日経過で読み取り専用(warm)にし、31日経過で削除する。
参考: ISM API | OpenSearch Documentation
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-app-policy" \
-H "Content-Type: application/json" -d '{
"policy": {
"description": "app log lifecycle",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [],
"transitions": [{ "state_name": "warm", "conditions": { "min_index_age": "7d" } }]
},
{
"name": "warm",
"actions": [{ "read_only": {} }],
"transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "31d" } }]
},
{
"name": "delete",
"actions": [{ "delete": {} }]
}
],
"ism_template": {
"index_patterns": ["logs-app-*"],
"priority": 100
}
}
}'
ism_template を含めておくと、パターンに一致するインデックスが新しく作られたときに自動でポリシーが適用される。
既存のインデックスには自動適用されない
ism_template が効くのは、ポリシー作成後に新しく作られたインデックスだけである。
13章ですでに作られた当日分のインデックスには適用されていないため、確認して手動で適用する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/_plugins/_ism/explain/logs-app-*?pretty"
"policy_id" : null であれば未適用なので、以下で適用する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X POST \
"https://127.0.0.1:9200/_plugins/_ism/add/logs-app-*" \
-H 'Content-Type: application/json' \
-d '{"policy_id": "logs-app-policy"}'
再度 _ism/explain を実行し、"policy_id" : "logs-app-policy" になっていることを確認する。
15. Dashboardsでログを確認する
ダッシュボードの作成は本記事の範囲外とし、ここではDiscover画面でログが検索できることだけを確認する。
SSHトンネルで接続する
Dashboardsは 127.0.0.1:5601 でのみ待ち受けているため、手元PCからSSHのポートフォワードで接続する。
【手元PC】
ssh -L 5601:localhost:5601 -p <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP>
踏み台サーバーを経由する場合は -J を使う。
【手元PC】
ssh -L 5601:localhost:5601 -J <YOUR_USER>@<BASTION_IP>:<BASTION_SSH_PORT> -p <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP>
接続したまま、手元PCのブラウザで http://localhost:5601 を開く。
https ではなく http である点に注意する。
ブラウザとDashboards間の通信はSSHトンネル自体で暗号化されているため、この構成では http のままで問題ない。
ログインには admin と、6章で設定したパスワードを使う。
SSHのセッションを閉じるとトンネルも切れるので、閲覧中はターミナルを開いたままにしておく。
初回ログイン時に Select your tenant という画面が表示された場合は、Global を選ぶ。
テナントは、Index Patternなどの保存オブジェクトを共有する範囲のことである。
Private を選んだまま次のIndex Patternを作成すると、作成したユーザー本人にしか見えず、16章で作る閲覧専用ユーザーからは見えなくなる。
現在のテナントは、右上のユーザーアイコンから確認、切り替えできる。
Index Patternを作成する
- 左上のメニューから
Dashboards Managementを開く -
Index Patternsを選び、Create index patternをクリックする - パターンに
logs-app-*と入力してNext stepに進む - 時刻フィールドに
@timestampを選び、Create index patternで完了する
Discoverで検索する
- 左メニューから
Discoverを開き、logs-app-*を選ぶ - 画面右上で表示期間を指定する
- 左のフィールド一覧から
hostname、container_name、log_messageを追加すると、その列だけを表示できる - 検索バーに
hostname:app01 and container_name:testapp-web-1のように入力すると絞り込める
OpenSearch Dashboards 3.x系では、Discover画面が真っ白のまま何も表示されないことがある。
その場合は、Global テナントを選んだ状態で Dashboards Management の Advanced Settings を開き、query:enhancements:enabled を false に変更する。
Advanced Settingsはテナントごとに別管理であり、16章の閲覧専用ユーザーは自分で変更できないため、必ず Global 側で設定しておく。
参考: Discover page is blank due to query enhancements · Issue #10925
16. 閲覧専用ユーザーを作成する
ログを見るだけの利用者に admin を渡すと、ユーザーやロールの変更、インデックスの削除まで行えてしまう。
そこで、DashboardsとDiscoverでログを閲覧できるが、ログやダッシュボードの書き込み、削除はできない閲覧専用ユーザー viewer を作成する。
閲覧専用ユーザーには、次の2つのロールを付与する。
| ロール | 種類 | 役割 |
|---|---|---|
logs_reader |
新規作成 |
logs-* 系インデックスの読み取りと、Globalテナントの読み取りだけを許可する |
kibana_user |
組み込み | Dashboardsへのログインと基本操作に必要。単体ではログデータへのアクセス権を持たない |
書き込みと削除を実際に防いでいるのは、logs_reader の権限である。
事前確認: マルチテナントが有効になっていること
7章の opensearch_dashboards.yml にマルチテナントの設定が入っていることを確認する。
【log-server】
grep -i "multitenancy\|requestHeaders" /opt/log-stack/opensearch_dashboards.yml
期待される結果: 7章で追加した3行が表示されること。
何も表示されない場合は7章の3行を追記し、docker compose restart dashboards で再起動する。
あわせて、admin でDashboardsにログインし、右上のユーザーアイコンに Switch tenants が表示されること、現在のテナントが Global であることを確認する。
15章で作成したIndex Patternと、query:enhancements:enabled の変更が Global テナント側にあることもここで確認しておく。
Private テナントで作成していた場合は、Dashboards Management の Saved objects でエクスポートし、Global に切り替えてからインポートする。
組み込みロールの名称の確認
Dashboards用の組み込みロールは、新名称(opensearch_dashboards_user)ではなく旧名称(kibana_user)で登録されていることがある。
実際の名称を確認してから進める。
【log-server】
curl -s -k -u 'admin:<ADMIN_PASSWORD>' \
"https://127.0.0.1:9200/_plugins/_security/api/roles" \
| python3 -c 'import json,sys; [print(k) for k in sorted(json.load(sys.stdin))]'
一覧に kibana_user があればそのまま進める。
kibana_user が無く opensearch_dashboards_user がある環境では、以降の kibana_user をそちらに読み替える。
読み取り用ロールの作成
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/roles/logs_reader" \
-H 'Content-Type: application/json' \
-d '{
"cluster_permissions": ["cluster_composite_ops_ro"],
"index_permissions": [
{
"index_patterns": ["logs-app-*", "logs-system-*", "logs-sysstat-*"],
"allowed_actions": ["read"]
}
],
"tenant_permissions": [
{
"tenant_patterns": ["global_tenant"],
"allowed_actions": ["kibana_all_read"]
}
]
}'
期待される結果: "status":"CREATED" を含むJSONが返ること。
| 区分 | 対象 | 権限 |
|---|---|---|
| Cluster permissions | - |
cluster_composite_ops_ro(検索系の読み取り操作のみ) |
| Index permissions |
logs-app-*、logs-system-*、logs-sysstat-*
|
read(検索と取得のみ。書き込み、削除、インデックス作成は不可) |
| Tenant permissions | global_tenant |
kibana_all_read(Globalテナントのダッシュボード等の閲覧のみ。保存と削除は不可) |
17章、18章を導入しない場合は、logs-system-* と logs-sysstat-* を外してもよい。
残しておいても害はない。
ユーザーの作成とロールの付与
ユーザー作成時に opendistro_security_roles を指定し、ユーザー側から直接ロールを付与する。
組み込みロールの rolesmapping を PUT すると、既存の紐付けを丸ごと置き換えてしまうためである。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/internalusers/viewer" \
-H 'Content-Type: application/json' \
-d '{
"password": "<VIEWER_PASSWORD>",
"opendistro_security_roles": ["logs_reader", "kibana_user"],
"description": "Read-only viewer"
}'
curl -k -u 'admin:<ADMIN_PASSWORD>' -X DELETE \
https://127.0.0.1:9200/_plugins/_security/api/cache
期待される結果: 1つ目で "status":"CREATED"、2つ目で "status":"OK" が返ること。
キー名は opendistro_security_roles である。
opensearch_security_roles のように新しい名称に見える綴りにすると、ロールが付与されない。
この方式では、DashboardsのSecurity画面にある Roles の Mapped users タブに viewer は表示されない。
付与状況は次の動作確認のAPIで確認する。
動作確認
ロールが付与されていることを確認する。
【log-server】
curl -k -u 'viewer:<VIEWER_PASSWORD>' \
https://127.0.0.1:9200/_plugins/_security/api/account
期待される結果: "roles" に logs_reader と kibana_user が含まれること。
ログインデックスへの書き込みと削除が拒否されることを確認する。
【log-server】
# 書き込み(拒否されるべき)
curl -k -u 'viewer:<VIEWER_PASSWORD>' -X POST \
"https://127.0.0.1:9200/logs-app-test/_doc" \
-H 'Content-Type: application/json' -d '{"message": "should fail"}'
# インデックスの削除(拒否されるべき)
curl -k -u 'viewer:<VIEWER_PASSWORD>' -X DELETE \
"https://127.0.0.1:9200/logs-app-$(date +%Y.%m.%d)"
期待される結果: どちらも 403 と security_exception が返ること。
Globalテナントへの保存が拒否されることを、DashboardsのAPIで確認する。
ここではテスト用のIndex Patternを作成しようとして、拒否されることを見る。
【log-server】
curl -s -u 'viewer:<VIEWER_PASSWORD>' -X POST \
-H 'securitytenant: global' \
-H 'osd-xsrf: true' \
-H 'Content-Type: application/json' \
"http://127.0.0.1:5601/api/saved_objects/index-pattern/viewer-write-test" \
-d '{"attributes": {"title": "viewer-write-test-*"}}'
期待される結果: 403 のエラーが返ること。
"id":"viewer-write-test" を含むJSONが返った場合は保存できてしまっているので、logs_reader のTenant permissionsとマルチテナントの設定を見直し、作成されたオブジェクトを admin で削除する。
最後にDashboardsの画面で確認する。
- 15章と同じ手順でSSHトンネルを張り、一度ログアウトしてから
viewerでログインする - テナントの選択画面が表示されたら
Globalを選ぶ - 左メニューに
Discoverが表示され、logs-app-*のログが閲覧できること(真っ白にならないこと) - 左メニューに
Securityが表示されないこと
設計上の注意点
- 画面上には
EditやSaveのボタンが表示されるが、押してもGlobalテナントへの保存は403で拒否され、反映されない。見た目は編集できそうに見えても、実際には何も変更できない状態である。 - Dashboardsの読み取り専用モード(
opensearch_security.readonly_mode.roles)は使わない。このモードは編集ボタンを隠すだけでなく、Discoverなどダッシュボード以外のアプリもメニューから隠してしまうため、Discoverでログを見るという目的を満たせない。 -
viewerが閲覧できるのはGlobalテナントの保存オブジェクトだけである。今後adminでIndex Patternやダッシュボードを作る際は、必ずGlobalテナントを選んだ状態で作成する。 -
viewer自身のPrivateテナントへの保存はできるが、他のユーザーやGlobalテナント、ログデータには影響しない。 -
adminはall_accessのままなので、閲覧や操作の範囲は変わらない。マルチテナントの有効化により、ログイン時にテナントを選ぶ手順が加わる点だけが変わる。
ここまでで、ログの収集と閲覧の基盤は完成である。
以降の2章は、必要に応じて追加する任意の手順である。
17. (任意)システムログ全体を収集する
12章の設定は、myapp@*.service のログだけを収集する。
カーネルログや他のサービスのログなど、OS全体のジャーナルも収集したい場合は、別のパイプラインとして追加する。
既存のパイプラインに混ぜないのは、ログ量と保持期間の要件が異なるためである。
アプリケーションログは障害調査で頻繁に参照するが、OS全体のログは量が多く、参照頻度は低い。
そこで別インデックス logs-system-* に格納し、保持期間も短くする。
fluentd.confへの追記
既存のブロックは変更せず、末尾に追記する。
grep フィルタは入れず、全件を対象にする。
バッファの格納先も別ディレクトリにする。
【app01 / app02 / app03(それぞれで実行)】
sudo tee -a /etc/fluent/fluentd.conf > /dev/null <<'EOF'
<source>
@type systemd
path /var/log/journal
read_from_head false
tag system.journal
<storage>
@type local
persistent true
path /var/log/fluent/systemd-system.pos
</storage>
<entry>
fields_strip_underscores true
</entry>
</source>
<filter system.journal>
@type record_transformer
enable_ruby true
<record>
"@timestamp" ${Time.at(time.to_i).strftime('%Y-%m-%dT%H:%M:%S%:z')}
hostname ${Socket.gethostname}
</record>
</filter>
<match system.journal>
@type opensearch
host <LOG_SERVER_IP>
port 9200
scheme https
ssl_verify true
ca_file /etc/fluent/certs/root-ca.pem
user fluentd
password "<FLUENTD_PASSWORD>"
index_name logs-system-%Y.%m.%d
logstash_format false
reconnect_on_error true
reload_on_failure true
<buffer time>
@type file
timekey 1d
flush_mode interval
path /var/log/fluent/buffer/opensearch-system
flush_interval 5s
retry_max_times 10
retry_wait 5s
chunk_limit_size 8MB
</buffer>
</match>
EOF
sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf
sudo systemctl restart fluentd
書き込み権限は9章のロールですでに許可済みである。
ISMポリシー
warmを挟まず、7日で削除する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-system-policy" \
-H "Content-Type: application/json" -d '{
"policy": {
"description": "system journal log lifecycle",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [],
"transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "7d" } }]
},
{
"name": "delete",
"actions": [{ "delete": {} }]
}
],
"ism_template": {
"index_patterns": ["logs-system-*"],
"priority": 100
}
}
}'
既存インデックスへの適用確認は14章と同じ手順で行う。
Dashboardsで閲覧する場合は、15章と同じ要領で logs-system-* のIndex Patternを作成する。
全ジャーナルを対象にするとログ量が大きく増える。
いきなり全台に入れるのではなく、1台で1〜2週間ほど試験運用し、ディスク使用量の増え方を見てから広げると安全である。
18. (任意)sysstatのリソース使用状況を取り込む
CPU、メモリ、ディスクI/Oの使用状況を、ログと同じOpenSearch上で確認できるようにする。
sysstatは常時ログを出力するサービスではなく、sar で問い合わせるとその時点の値を返すツールである。
そこで、Fluentdの exec 入力プラグインで1分おきにスクリプトを実行し、結果を取り込む。
sysstatの導入
【app01 / app02 / app03(それぞれで実行)】
sudo apt update
sudo apt install -y sysstat
sudo sed -i 's/ENABLED="false"/ENABLED="true"/' /etc/default/sysstat
sudo systemctl enable --now sysstat
sar -u 1 1
期待される結果: CPU使用率のレポートが表示されること。
出力形式の確認
sadf -j(JSON出力)のキー名は、sysstatのバージョンによって異なることがある。
スクリプトを作る前に、自分の環境での出力を確認しておく。
LC_ALL=C sadf -j -- -u -r -d 1 1
LC_ALL=C は必ず付ける。
ロケールによっては小数点が , で出力され、JSONとして不正な形式になる既知の不具合があるためである。
参考: sadf produces invalid JSON output: Floats with comma instead of dot · Issue #128
出力の中に cpu-load、memory、disk のキーがあることを確認する。
Ubuntu 20.04と22.04では、cpu-load の user system idle と、memory の memused-percent memused は共通だった。
一方、disk のデバイス名は、22.04では vda のような名前、20.04では dev252-0 のような番号形式になることがある。
次のスクリプトは disk の配列をそのまま格納するため影響はないが、デバイス名で絞り込む際は注意する。
変換スクリプトの作成
sadf -j の出力は複数行にまたがる整形済みJSONであり、Fluentdの行単位の読み取りと相性が悪い。
そこで、必要な値だけを抜き出して1行のJSONにまとめるスクリプトを用意する。
また、sar は2回サンプリングして初めてその間の差分を計算できる。
1回だけのサンプリングでは値がほぼ空になるため、sar -o で2回分を一時ファイルに記録してから sadf で読む。
【app01 / app02 / app03(それぞれで実行)】
sudo mkdir -p /opt/fluent-scripts
sudo tee /opt/fluent-scripts/sysstat_collect.py > /dev/null <<'EOF'
#!/usr/bin/env python3
import subprocess
import json
import socket
import datetime
import os
import tempfile
env = os.environ.copy()
env["LC_ALL"] = "C"
with tempfile.NamedTemporaryFile(prefix="sysstat_", delete=True) as tmp:
# 1秒間隔で2回、一時ファイルにライブ測定を記録する
subprocess.check_call(
["sar", "-o", tmp.name, "1", "2"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
raw = subprocess.check_output(
["sadf", "-j", "--", "-u", "-r", "-d", tmp.name],
text=True,
env=env,
)
data = json.loads(raw)
# 最後の要素が実際の差分データ
stat = data["sysstat"]["hosts"][0]["statistics"][-1]
# キー名は「出力形式の確認」の結果に合わせて調整する
cpu = stat.get("cpu-load", [{}])[0]
mem = stat.get("memory", {})
disks = stat.get("disk", [])
record = {
"@timestamp": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
"hostname": socket.gethostname(),
"cpu_user": cpu.get("user"),
"cpu_system": cpu.get("system"),
"cpu_idle": cpu.get("idle"),
"mem_used_percent": mem.get("memused-percent"),
"mem_used_kb": mem.get("memused"),
"disks": disks,
}
print(json.dumps(record, ensure_ascii=False))
EOF
sudo chmod 755 /opt/fluent-scripts/sysstat_collect.py
Fluentdの実行ユーザー _fluentd で正しく動くか確認する。
sudo runuser -u _fluentd -- /opt/fluent-scripts/sysstat_collect.py
期待される結果: {"@timestamp": "...", "hostname": "...", "cpu_user": ..., ...} のような改行を含まない1行のJSONが出力されること。
何度か実行して cpu_user などの値が変動していれば、ライブ測定が正しく行われている。
スクリプトをホームディレクトリ配下(例: /home/<YOUR_USER>/scripts/)に置く場合は注意が必要である。
Ubuntuのホームディレクトリは 750 になっていることが多く、_fluentd はスクリプト自体の権限が正しくても手前のディレクトリを通過できず Permission denied になる。
本記事で /opt/fluent-scripts に置いているのはこのためである。
ホームディレクトリに置く必要がある場合は、sudo chmod o+x /home/<YOUR_USER> で通過権限だけを追加する。
fluentd.confへの追記
【app01 / app02 / app03(それぞれで実行)】
sudo tee -a /etc/fluent/fluentd.conf > /dev/null <<'EOF'
<source>
@type exec
tag sysstat.metrics
command /opt/fluent-scripts/sysstat_collect.py
run_interval 60s
<parse>
@type json
</parse>
</source>
<match sysstat.metrics>
@type opensearch
host <LOG_SERVER_IP>
port 9200
scheme https
ssl_verify true
ca_file /etc/fluent/certs/root-ca.pem
user fluentd
password "<FLUENTD_PASSWORD>"
index_name logs-sysstat-%Y.%m.%d
logstash_format false
reconnect_on_error true
reload_on_failure true
<buffer time>
@type file
timekey 1d
flush_mode interval
path /var/log/fluent/buffer/opensearch-sysstat
flush_interval 5s
retry_max_times 10
retry_wait 5s
chunk_limit_size 8MB
</buffer>
</match>
EOF
sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf
sudo systemctl restart fluentd
run_interval 60s により、1分おきにスクリプトが実行される。
書き込み権限は9章のロールですでに許可済みである。
ISMポリシー
1分おきに継続してデータが増えるため、14日で削除する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-sysstat-policy" \
-H "Content-Type: application/json" -d '{
"policy": {
"description": "sysstat metrics lifecycle",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [],
"transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "14d" } }]
},
{
"name": "delete",
"actions": [{ "delete": {} }]
}
],
"ism_template": {
"index_patterns": ["logs-sysstat-*"],
"priority": 100
}
}
}'
既存インデックスへの適用確認は14章と同じ手順で行う。
19. 構築後の確認項目
| 確認内容 | 確認方法 |
|---|---|
| OpenSearchとDashboardsが起動している | 【log-server】docker compose ps で両方が Up
|
| 9200番が送信元IP以外から遮断されている | 【log-server】sudo iptables -L DOCKER-USER -n -v に DROP の行がある |
| 第三者のIPから9200番に接続できない | 送信元以外の回線から curl -k https://<LOG_SERVER_IP>:9200 がタイムアウトする |
| 各送信元でFluentdが稼働している | 【各送信元】systemctl status fluentd がエラーなく active
|
| 全サーバー分のログが届いている | Discoverで hostname ごとに件数がある |
| 閲覧専用ユーザーで検索だけができる | 【log-server】viewer で _search が成功し、_doc への書き込みが 403 になる |
| ISMポリシーが適用されている | 【log-server】_plugins/_ism/explain/logs-* で policy_id が入っている |
運用上の注意点
- Docker公開ポートのアクセス制御は、ufwではなくDOCKER-USERチェーンで行う。Dockerの更新やサーバー再起動のあとは
iptables -L DOCKER-USER -n -vで再確認する。 - 送信元サーバーのグローバルIPが変わると、ログが届かなくなる。サーバーを追加、移設した際は
/etc/ufw/after.rulesの更新を忘れないようにする。 - 証明書の有効期限(本記事では3650日)を管理台帳などに記録しておく。
-
root-ca-key.pemを持っていれば任意の証明書を発行できてしまう。ログ集約サーバーの外には出さず、バックアップも厳重に扱う。 -
kibanaserverのパスワードはopensearch_dashboards.ymlに平文で書かれる。/opt/log-stackのディレクトリの権限は、管理者以外が読めないようにしておく。 - ログを閲覧するだけの利用者には
adminを渡さず、16章のviewerのような閲覧専用ユーザーを使う。利用者ごとにユーザーを分けておくと、退職や異動の際にそのユーザーだけを削除できる。 - セキュリティ設定は初回起動時にしか
internal_users.ymlから初期化されない。運用開始後のユーザー追加やパスワード変更は、REST APIかDashboardsのSecurity画面から行う。 - DashboardsのGUIでロールのIndex permissionsを編集する場合、Document level securityの欄を空文字列のまま確定しないこと。空文字列が保存されると、そのインデックスパターンへの権限判定全体が無効になる現象を3.8.0で確認している。誤って保存した場合は、API(
GET _plugins/_security/api/roles/<ロール名>)で定義を確認し、dlsキーを含まない形でPUTし直す。