最低限セキュリティから始めるElasticsearch&Kibana デプロイ編 ~こんにちはGCP~

こんにちは。
今回は下の記事の続編的な立ち位置に当たります。

https://qiita.com/tamaloon/items/81fd34c4d5750910f99c

前回から変更した点なども踏まえて紹介していこうと思います。
文量が増えてみにくくなると思ったので、詳細なTerraformの内容は載せてないです。
もし気になる点あればご質問いただけると幸いです。

また、間違いなどあればご指摘ください。

## ⚠️注意
前回から引き続きSingle ノードでの構築になります。
Multiノードではケアするべきポイント(ノード間通信など)が増えますのでこちらの構成を参考にしないでください。

# 環境
インスタンス: `e2-medium`
ソースイメージ: `"debian-cloud/debian-11"`

# 起動フロー

![a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3627854/1466f239-2237-4d30-8ea1-bc77e5c925fd.png)


タイトルにもある通りGCPで完結しています。
GCEのVMインスタンスにElasticsearchとKibanaのコンテナを立てています。

リクエストが来たらIAP(Identity-Aware Proxy)が待ち構えており、Google認証を行います。
社内で使うことがメインなので、社内アカウント以外は完全にシャットアウトしてます。

あとはロードバランサでElasticsearchか、Kibanaに繋ぐ形です。

Dockerコンテナの起動時には、GCS(Google Cloud Storage)から必要なファイルを持ってきて使用します。


# 前回からの変更点
- パスワードはランダムに生成 -> 初期パスワードを設定することにした
    - IAP周りでコマンドが叩けなくなってしまったため
- 証明書はlet's encryptで作成
    - 証明書と秘密鍵が必要、また証明書が信頼されていて欲しいため
- `.env`をなくした
    - Terraformだけで管理できそうだったため

書くまえはもうちょいありそうな気がしましたが意外と少ないですね

# Terraformでよかったとこ！
何よりもテンプレートファイルが超便利です！
元々、環境変数を使って変数的に管理していたものたちをterraform側で吸収できるのはでかいです。

また、テンプレートファイルを元にterraformで作成した各種起動用ファイルを、GCSにアップロードするようにしていて、変更があるたびに自動更新されるところも運用面から楽です。

# 詰まったところ
## IAP
### ロードバランサ
検証をVMインスタンス単体でやっていたこととロードバランサに対する知識が無さすぎてどう接続していいかわかんなかったです。

結論、url_mapと呼ばれるものを作れば良いです。
指定したパスやサブドメインに対して、ロードバランサがバックエンドサービスを紐づけてくれます。

ざっくりとモジュール化したterraformで説明します。

今回はサブドメインを使った構成にしていて、指定したホストとpathmatcherで指定したバックエンドサービスを紐づけてます。

```terraform
resource "google_compute_url_map" "main" {
  name = var.name

  default_service = var.default_service

  dynamic "host_rule" {
    for_each = var.host_rules
    content {
      hosts        = host_rule.value.hosts
      path_matcher = host_rule.value.path_matcher
    }
  }

  dynamic "path_matcher" {
    for_each = var.path_matchers
    content {
      name            = path_matcher.value.name
      default_service = path_matcher.value.default_service
    }
  }
}
```

IAPを導入するにあたって、インスタンスグループとロードバランサは必須のようなので要チェックです。

### パスワード設定
鬼門でした。今まではdocker execでコンテナからelasticsearch標準コマンドを使ってパスワードのリセットなどを行なっていましたが、IAPを導入するとできなくなります。

理由は、elasticsearch標準コマンドを使う際にヘルスチェックが行われるのそうなのですが、このヘルスチェックにおいてcurlっぽい処理を行っているからです。

そのため外部接続扱いになり、IAPで弾かれてしまいます。
これを回避するにはAuthorizationヘッダにトークンを入れたいところですが、どうも設定でき無さそうだったのでcurlを使った手法に切り替えてます。

また、curlを使った手法にすることで、elasticsearch側でも認証が必要となるため、初期パスワードを設定することにしました。

具体的な処理については下の方で紹介してます。

### 2段階認証
セキュアな状態を目指すと、IAPを導入し、elasticsearchでも認証を入れることになると思います。
しかし、curlコマンドを使うとAuthorizationの情報が一つまでしか入れられないので、どちらかの認証情報が認識されなくなるのです。

でも大丈夫。`Proxy-Authorization`を使えば、IAPの認証情報を分離できるので、2段階の認証も怖くありません。

同様にしたで紹介します。

### 証明書が作れない！
私の短いエンジニア人生では、`certbot`のHTTP-01チャレンジで事足りていました。
これはインスタンス側で受信用サーバーを立てて、あるパスにトークンを設定できるかチェックすることでドメインの所有者であることを確認する方法です。

しかし、IAPを導入することによりAuthorization Tokenがなければサーバーにアクセスができなくなってしまうのです。

でも大丈夫。DNS-01チャレンジを使えば問題ありません。
DNS-01チャレンジは、指定されたサブドメインのTXTレコードにトークンを設定できるかを見ることでドメインの所有者であることを確認する方法です。

# 起動用ファイルの一例（テンプレート）
## `start.sh`
補足事項を挙げると、container側とhost側でファイルに対する権限が変わってくるのでelasticsearchのユーザーに対して、権限を与えています。`1000`がelasticsearchのユーザーidにあたります。

```bash
#!/bin/bash

set -euo pipefail

DOMAIN="${es_host}"
EMAIL="${email}"
WORKDIR="/opt/serviceplace-search"
CERTSDIR="$WORKDIR/certs"
SECRET_NAME="certbot-dns-credentials"
GOOGLE_CREDENTIALS_PATH="/root/gcloud-dns-creds.json"

echo "🛠️ Installing dependencies..."
apt-get update
apt-get install -y --only-upgrade google-cloud-sdk
apt-get install -y docker.io curl jq docker-compose
apt-get install -y certbot python3-certbot-dns-google  # ← GCP用プラグイン

echo "📁 Creating working directories..."
mkdir -p "$CERTSDIR"
cd "$WORKDIR"

echo "📦 Fetching docker-compose file from GCS..."
gsutil -m cp -r gs://${bucket_name}/serviceplace-search/* .

sudo chown 1000:1000 "$WORKDIR/healthcheck_es.sh"
sudo chown 1000:1000 "$WORKDIR/healthcheck_kib.sh"
sudo chmod +x "$WORKDIR/healthcheck_es.sh"
sudo chmod +x "$WORKDIR/healthcheck_kib.sh"

echo "🌐 Creating docker network..."
docker network create elastic || true

echo "🔐 Fetching service account key from Secret Manager..."
gcloud secrets versions access latest --secret="$SECRET_NAME" > "$GOOGLE_CREDENTIALS_PATH"
chmod 600 "$GOOGLE_CREDENTIALS_PATH"

echo "🌍 Getting Let's Encrypt certificate using Cloud DNS..."
certbot certonly --dns-google \
  --dns-google-credentials "$GOOGLE_CREDENTIALS_PATH" \
  --dns-google-propagation-seconds 60 \
  --non-interactive --agree-tos \
  --email "$EMAIL" -d "$DOMAIN" -d "es.$DOMAIN" -d "kib.$DOMAIN"

# Copy certs to Docker-readable path
cp /etc/letsencrypt/live/"$DOMAIN"/fullchain.pem "$CERTSDIR/elasticsearch.pem"
cp /etc/letsencrypt/live/"$DOMAIN"/privkey.pem "$CERTSDIR/elasticsearch-key.pem"

chown 1000:1000 "$CERTSDIR/elasticsearch.pem"
chown 1000:1000 "$CERTSDIR/elasticsearch-key.pem"
chmod 644 "$CERTSDIR/elasticsearch.pem"
chmod 644 "$CERTSDIR/elasticsearch-key.pem"

echo "🚀 Starting Elasticsearch container..."
docker-compose -f compose.prod.yml up -d es01

echo "⏳ Waiting for Elasticsearch to be ready..."
sleep 15

echo "🔧 (Optional) Running password setup script..."
if [[ -f "$WORKDIR/set_password.sh" ]]; then
  chmod +x ./set_password.sh
  source ./set_password.sh
fi

echo "🚀 Starting Kibana container..."
docker-compose -f compose.prod.yml up -d kib01

echo "📝 Writing cert auto-renewal script..."
cat <<EOF > /opt/serviceplace-search/renew_certs.sh
#!/bin/bash
set -euo pipefail

DOMAIN="${es_host}"
CERTSDIR="/opt/serviceplace-search/certs"
GOOGLE_CREDENTIALS_PATH="/root/gcloud-dns-creds.json"

echo "🔐 Refreshing service account key from Secret Manager..."
gcloud secrets versions access latest --secret="$SECRET_NAME" > "\$GOOGLE_CREDENTIALS_PATH"
chmod 600 "\$GOOGLE_CREDENTIALS_PATH"

echo "🔄 Renewing Let's Encrypt certificate..."
certbot renew --quiet \
  --dns-google --dns-google-credentials "\$GOOGLE_CREDENTIALS_PATH" \
  --dns-google-propagation-seconds 60 \
  --deploy-hook "
    echo '✅ Certificate renewed. Copying to certs dir...';
    cp /etc/letsencrypt/live/\$DOMAIN/fullchain.pem \$CERTSDIR/elasticsearch.pem;
    cp /etc/letsencrypt/live/\$DOMAIN/privkey.pem \$CERTSDIR/elasticsearch-key.pem;
    echo '🔁 Restarting containers...';
    docker-compose -f /opt/serviceplace-search/compose.prod.yml restart es01 kib01
  "
EOF

chmod +x /opt/serviceplace-search/renew_certs.sh

echo "📆 Setting up cron job for cert renewal..."
cat <<EOF > /etc/cron.d/cert_renew
TZ=Asia/Tokyo
0 3 * * * root /opt/serviceplace-search/renew_certs.sh >> /var/log/renew_certs.log 2>&1
EOF

chmod 644 /etc/cron.d/cert_renew
touch /var/log/renew_certs.log

echo "✅ Deployment Complete"
```

## `docker-compose.yml`
GCE内のaptでdockerのインストールを完結しようとすると、いわゆるv1系のdockerしかインストールできないっぽいです。（調査不足かもしれない）

なのでところどころ古めの記述が見られると思います。(versionあたりとか)

```yml
version: "3.3"
services:
  es01:
    image: docker.elastic.co/elasticsearch/elasticsearch:${es_version}
    container_name: es01
    environment:
      - network.host=0.0.0.0
      - node.name=es01
      - discovery.type=single-node
      - bootstrap.memory_lock=true
      - ELASTIC_PASSWORD=${es_password}
      - ES_JAVA_OPTS=-Xms${heap_min} -Xmx${heap_max}
      - xpack.security.enabled=true
      - xpack.security.http.ssl.enabled=true
      - xpack.security.http.ssl.key=${es_path_conf}/${certs_dir}/${certs_file_key}
      - xpack.security.http.ssl.certificate=${es_path_conf}/${certs_dir}/${certs_file}
    ulimits:
      memlock:
        soft: -1
        hard: -1
    volumes:
      - data01:/usr/share/elasticsearch/data
      - ./${certs_dir}:${es_path_conf}/${certs_dir}:ro
      - /opt/serviceplace-search:/opt/serviceplace-search
    ports:
      - 9200:9200
    networks:
      - elastic
    healthcheck:
      test: ["CMD", "bash", "/opt/serviceplace-search/healthcheck_es.sh"]
      interval: 30s
      timeout: 30s
      retries: 5

  kib01:
    image: docker.elastic.co/kibana/kibana:${es_version}
    container_name: kib01
    depends_on:
      - es01
    environment:
      - ELASTICSEARCH_USERNAME=kibana_system
      - ELASTICSEARCH_PASSWORD=${kib_password}
      - ELASTICSEARCH_HOSTS=https://es01:9200
      - ELASTICSEARCH_SSL_VERIFICATIONMODE=certificate
      - csp.strict=true
      - SERVER_HOST=0.0.0.0
      - SERVER_SSL_ENABLED=true
      - SERVER_SSL_CERTIFICATE=${kib_path_conf}/${certs_dir}/${certs_file}
      - SERVER_SSL_KEY=${kib_path_conf}/${certs_dir}/${certs_file_key}
      - server.securityResponseHeaders.disableEmbedding=true
      - server.securityResponseHeaders.strictTransportSecurity=max-age=31536000
    volumes:
      - kibanadata:/usr/share/kibana/data
      - ./${certs_dir}:${kib_path_conf}/${certs_dir}:ro
      - /opt/serviceplace-search:/opt/serviceplace-search
    ports:
      - 5601:5601
    networks:
      - elastic
    healthcheck:
      test: ["CMD", "bash", "/opt/serviceplace-search/healthcheck_kib.sh"]
      interval: 30s
      timeout: 30s
      retries: 5

volumes:
  data01:
    driver: local
  kibanadata:
    driver: local

networks:
  elastic:
    driver: bridge
```

## ヘルスチェック
IAP突破のために、最新トークンをとってきている点がポイントです。
Authorizationヘッダにぶち込みましょう。

```bash
#!/bin/bash
set -euo pipefail

# メタデータサーバーから最新トークン取得
TOKEN=$(curl -s -H 'Metadata-Flavor: Google' \
  "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=${iap_client_id}&format=full")

# IAP 越しにヘルスチェック
curl -s -H "Authorization: Bearer $${TOKEN}" \
  "https://${host}" > /dev/null
```

## kibana_systemのパスワード設定
最後にパスワード設定のところです。
IAPのトークン作成とその使い方に注目ですね。
パスワード設定のcurlではProxy-Authorizationを使っています！

```bash
#!/bin/bash

set -euo pipefail

WORKDIR="/opt/serviceplace-search"
CERTSDIR="$WORKDIR/certs"

echo "🔐 Fetching secrets from Secret Manager..."
ACCESS_TOKEN=$(curl -s -H 'Metadata-Flavor: Google' \
  "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=${iap_client_id}&format=full")

echo "Start Creating Containers...";

echo "Waiting for Elasticsearch availability";
until curl -s https://${es_host} -H "Authorization: Bearer $ACCESS_TOKEN" >/dev/null; do
sleep 5;
echo "Waiting for Elasticsearch...";
done;

# Elasticsearchのユーザー名と現在の管理者パス（ELASTIC_PASSWORD）を.envなどから取得
ES_USER="elastic"
ES_PASSWORD=${es_password}

# kibana_system のパスワードを変更
echo "🔐 Setting new password for kibana_system via Basic Auth..."

curl -s -o /dev/null \
  -u "$ES_USER:$ES_PASSWORD" \
  -X POST "https://${es_host}/_security/user/kibana_system/_password" \
  -H "Content-Type: application/json" \
  -H "Proxy-Authorization: Bearer $ACCESS_TOKEN" \
  -d "{\"password\": \"${kib_password}\"}"

echo "Finish Creating Containers...";
```

# 思いつく改善点と感想
terraformで初期パスワードを設定するわけですが、ファイルに変更がないとパスワードが更新されないっぽいので、どうにかしたいです。
あと、コンテナの更新が今の形だとサービスダウンに繋がるので、サービスを継続可能な状態でコンテナを切り替えられるようにしたいです。

マルチノード環境ではTCPの話も入ってきますし、何よりクラスタで管理しないとなのでより難しそうです。
無事に動くものができてよかったです。

ありがとうございました。

# 参考
https://qiita.com/ikea_shark_jk/items/9b0e8753648a6fd47e4d

https://discuss.elastic.co/t/how-to-set-elasticsearch-user-elastic-password-in-dockerfile/226206/2

https://letsencrypt.org/docs/challenge-types/