2025年7月31日木曜日

nikto を使ってペネトレーションを実行する方法

nikto を使ってペネトレーションを実行する方法

概要

前回 zaproxy を使ってペネトレーションテストを実行しました
今回は nikto というツールを使ってみます

環境

  • macOS 15.5
  • docker 28.3.2
  • Nikto v2.5.0

実行

docker run --rm -v $(pwd):/tmp ghcr.io/sullo/nikto:latest -h http://host.docker.internal:9292 -F htm -o /tmp/report.html

レポートは open report.html で開きます

最後に

nikto を docker で実行し自分のアプリにペネトレーションを実施する方法を紹介しました
zaproxy と仕組みも全然違し結果も全然違うので別のペネトレーションテストで脆弱性チェックすることで更に堅牢なアプリにすることができます

参考サイト

2025年7月29日火曜日

Python の fluent-logger でタイムアウトを設定する方法

Python の fluent-logger でタイムアウトを設定する方法

概要

過去に fluent-logger の使い方を紹介しました
今回は fluent-logger でタイムアウトを設定する方法を紹介します

環境

  • macOS 15.5
  • Python 3.12.11
  • fluent-logger 0.11.1

サンプルコード

from fluent import sender

# デフォルトのタイムアウトは3.0秒
logger = sender.FluentSender(
    "app",
    host="127.0.0.3",
    port=24224,
    timeout=10.0,
)
result = logger.emit("follow", {"from": "userA", "to": "userB"})
print(result)

最後に

FluentSender の場合もし host にアクセスできない宛先を指定しても Exception は発生しません
emit してその結果を True/Flase を確認することで初めて送れたかどうかを確認できます
なので host にアクセス出来なかった理由が詳細にわからないので注意しましょう

事前に host に接続できるかどうかチェックしたい場合は以下のようなメソッドを自前で準備する必要がありそうです

import socket

def check_fluentd_reachable(host, port, timeout=2):
    try:
        with socket.create_connection((host, port), timeout):
            return True
    except Exception as e:
        print(f"[FluentD Unreachable] {e}")
        return False

if check_fluentd_reachable('localhost', 24224):
    logger.emit('app.test', {'message': 'hi'})
else:
    print("Fluentd に接続できません")

参考サイト

2025年7月28日月曜日

paho-mqtt でタイムアウトを設定する方法

paho-mqtt でタイムアウトを設定する方法

概要

前回 paho-mqtt v2 の使い方を紹介しました
今回は connect と publish 時のタイムアウトを設定する方法を紹介します

環境

  • macOS 15.5
  • Python 3.12.11
  • paho-mqtt 2.1.0

サンプルコード

  • vim app.py
from paho.mqtt.client import Client
from paho.mqtt.enums import CallbackAPIVersion


class CustomClient(Client):
    def __init__(self):
        super().__init__(callback_api_version=CallbackAPIVersion.VERSION2)

    def custom_on_disconnect(self, client, userdata, flags, reason_code, properties):
        print("on_disconnect")
        print(client)
        print(userdata)
        print(flags)
        print(reason_code)
        print(properties)

    def custom_on_publish(self, client, userdata, mid, reason_code, properties):
        print("on_publish")
        print(client)
        print(userdata)
        print(mid)
        print(reason_code)
        print(properties)
        self.disconnect()


if __name__ == "__main__":
    print("start")
    client = CustomClient()
    client.on_disconnect = client.custom_on_disconnect
    client.on_publish = client.custom_on_publish
    # 接続タイムアウトを60秒に設定
    client.connect_timeout = 60
    # ここで60秒待つことを確認する
    client.connect("127.0.0.1", port=1883, keepalive=60, bind_address="")
    info = client.publish(
        topic="default", payload='{"payload": "hoge"}', qos=0, retain=False
    )
    # wait_for_publish は、publish の完了を待つためのメソッド、ACKを待ち来ない場合はタイムアウトする
    info.wait_for_publish(timeout=60)
    print("end")

解説

内部的には Client クラスの self._connect_timeout で接続時間のタイムアウトを管理しておりデフォルトでは 5.0 秒になっています

socket.create_connection(addr, timeout=self._connect_timeout, source_address=source)

もし上書きしたい場合は setter 用の connect_timeout があるのでこれを使います
もし connect 時に指定したブローカーに接続できない場合には timeout 秒待ってからエラーになります

publish 時のタイムアウトは接続時とは少し違い ACK を受け取るまでのタイムアウトを指定します
基本的に publish は非同期なので送ったら送りっぱなしになります
もし送信できたかの確認をしたい場合は ACK 信号を受け取るようにします
その ACK 信号を受け取るためのメソッドが wait_for_publish になっておりその信号を受け取るまでのタイムアウトを指定することができます

最後に

paho-mqtt でタイムアウトを設定する方法を紹介しました
基本的にブローカーに接続できれば publish はできるはずなので connect 側のタイムアウトを設定すれば OK かなと思います

Subscribe などでブローカーと常に接続しておく必要がある場合は timeout + keepalive で接続を確保するようにしておきましょう

参考サイト

2025年7月27日日曜日

paho-mqtt v2 を使う方法

paho-mqtt v2 を使う方法

概要

過去に paho-mqtt の使い方を紹介しました
その際は v1 でしたが v2 となりいろいろと変わっていたので使い方を紹介します

環境

  • macOS 15.5
  • Python 3.12.11
  • paho-mqtt 2.1.0

サンプルコード

前回同様 Client クラスを継承する形でクライアントを定義しています

  • vim app.py
from paho.mqtt.client import Client
from paho.mqtt.enums import CallbackAPIVersion


class CustomClient(Client):
    def __init__(self):
        super().__init__(callback_api_version=CallbackAPIVersion.VERSION2)

    def custom_on_disconnect(self, client, userdata, flags, reason_code, properties):
        print("on_disconnect")
        print(client)
        print(userdata)
        print(flags)
        print(reason_code)
        print(properties)

    def custom_on_publish(self, client, userdata, mid, reason_code, properties):
        print("on_publish")
        print(client)
        print(userdata)
        print(mid)
        print(reason_code)
        print(properties)
        self.disconnect()


if __name__ == "__main__":
    print("start")
    client = CustomClient()
    client.on_disconnect = client.custom_on_disconnect
    client.on_publish = client.custom_on_publish
    client.connect("localhost", port=1883, keepalive=60, bind_address="")
    client.publish(topic="default", payload='{"payload": "hoge"}', qos=0, retain=False)
    print("end")

変わっていた点

各種コールバックメソッドの引数が変わっています
具体的には flags, reason_code, properties が追加になっています
また Client を初期化する際に callback_api_version という引数が指定できることになっておりここに CallbackAPIVersion.VERSION2 を指定することで引数が追加になったコールバックメソッドを使うことができるようになっています

v2 のコードから Client クラスで on_disconnect がコールバック関数のみを受け取るように変更になっています
なので on_disconnect をオーバライドするのではなくコールバック用のメソッド自体を定義してその後そのコールバック用メソッドを on_disconnect に設定することでコールバックメソッドを設定します

最後に

v1 用のコールバックメソッドは非推奨になっており今後は v2 を使うことになるので今のうちにマイグレーションしておきましょう

参考サイト

2025年7月26日土曜日

Sinatra で静的ファイルにレスポンスヘッダーを追加する方法

Sinatra で静的ファイルにレスポンスヘッダーを追加する方法

概要

Rackミドルウェアを使います

環境

  • Ruby 3.4.4
  • Sinatra 4.1.1

設定方法

# 静的ファイルに対するヘッダーを設定するミドルウェア
class StaticHeadersMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call(env)

    path = env['PATH_INFO']
    if path =~ %r{^/(robots\.txt|favicon\.ico|img/|js/|css/|ipa/)}
      headers['Content-Security-Policy'] ||= "default-src 'self';"
      headers['Cross-Origin-Embedder-Policy'] ||= 'require-corp'
      headers['Cross-Origin-Opener-Policy'] ||= 'same-origin'
      headers['Cross-Origin-Resource-Policy'] ||= 'same-origin'
    end

    [status, headers, body]
  end
end

use StaticHeadersMiddleware

最後に

これで zaproxy の Insufficient Site Isolation Against Spectre Vulnerability に静的ファイルも対処できます

2025年7月25日金曜日

Sinatra で Cookie に SameSite 属性を設定する方法

Sinatra で Cookie に SameSite 属性を設定する方法

概要

Sinatra::Base で設定します

環境

  • Ruby 3.4.4
  • Sinatra 4.1.1

設定方法

class BasePage < Sinatra::Base
  enable :sessions
  set :session_secret, Credential::APP_SESSION_SECRET
  set :sessions,
      httponly: true,
      secure: production?,
      same_site: :lax
end

最後に

これで zaproxy の Cookie without SameSite Attribute に対応できます

2025年7月24日木曜日

Sinatra に CORP, COEP, COOP ヘッダを設定する方法

Sinatra に CORP, COEP, COOP ヘッダを設定する方法

概要

Sinatra::Base で設定します

環境

  • Ruby 3.4.4
  • Sinatra 4.1.1

設定方法

class BasePage < Sinatra::Base
  before do
    setup_response_headers
  end

  def setup_response_headers
    # Content Security Policy (CSP) の設定
    unless request.path.start_with?('/podcast/feed')
      csp = CSP.new.header
      headers 'Content-Security-Policy' => csp
    end
    # Cross-Origin Resource Policy (CORP) の設定
    headers 'Cross-Origin-Resource-Policy' => 'same-origin'
    # Cross-Origin Embedder Policy (COEP) の設定
    headers 'Cross-Origin-Embedder-Policy' => 'unsafe-none'
    # Cross-Origin Opener Policy (COOP) の設定
    headers 'Cross-Origin-Opener-Policy' => 'same-origin'
  end
end

最後に

これで zaproxy の Insufficient Site Isolation Against Spectre Vulnerability に対応できます

2025年7月23日水曜日

Sinatra で CSP レスポンスヘッダーを設定して XSS 対策する方法

Sinatra で CSP レスポンスヘッダーを設定して XSS 対策する方法

概要

CSP ヘッダを明示しなくても大きな問題にはなりませんが基本的に何でも受け入れる設定なので可能な限り設定したほうがいいかなと思います
そもそも外部にアクセスせずに self のみで完結するのが理想ですがそういうわけにもいかないのでアクセスするべき外部サイトを明示化しておくことでセキュリティ対策になる感じです

今回は Sinatra アプリで CSP (Content Security Policy) レスポンスヘッダーを設定する方法を紹介します

環境

  • Ruby 3.4.4
  • Sinatra 4.1.1

サンプルコード

まずは CSP を管理するクラスを定義します

  • vim libs/csp.rb
# frozen_string_literal: true

# Content-Security-Policy (CSP) の設定を行うクラス
class CSP
  def header
    "#{script_src} #{img_src} #{style_src} #{frame_src} #{connect_src} #{media_src} #{other_src}"
  end

  def script_src # rubocop:disable Metrics/MethodLength
    script_src_url_list = [
      'https://cdn.jsdelivr.net',
      'https://code.jquery.com',
      'https://www.googletagmanager.com',
      'https://cdnjs.cloudflare.com',
      'https://d3js.org',
      'https://unpkg.com',
      'https://www.youtube.com'
    ]
    gtag_hash = "'sha256-3j3z4K5sw7JEbB9oTbDCJixRv+lWuUYVNr8dOyKK7C0='"
    "script-src 'self' #{gtag_hash} #{script_src_url_list.join(' ')};"
  end

  def img_src # rubocop:disable Metrics/MethodLength
    img_src_url_list = [
      'https://lh3.googleusercontent.com',
      'https://blogger.googleusercontent.com',
      'https://addons.mozilla.org',
      'https://pbs.twimg.com',
      'http://pbs.twimg.com',
      'https://avatars.githubusercontent.com',
      'https://c10.patreonusercontent.com'
    ]
    "img-src 'self' data: #{img_src_url_list.join(' ')};"
  end

  def style_src
    style_src_url_list = [
      'https://cdn.jsdelivr.net',
      'https://unpkg.com',
      'https://cdnjs.cloudflare.com',
      'https://cdn.plyr.io/'
    ]
    # public/js/fontawesome-all.min.js が書き換わったら以下のハッシュが変わる可能性もある
    font_awesome_hash = "'sha256-bviLPwiqrYk7TOtr5i2eb7I5exfGcGEvVuxmITyg//c='"
    # views/alchol.erb で使用しているd3.jsのハッシュ
    d3_hash = "'sha256-WrkFMt0yMbnytekpJNs62cGCUpYDzgmKFnWzVnKQ6YY='"
    "style-src 'self' #{font_awesome_hash} #{d3_hash} #{style_src_url_list.join(' ')};"
  end

  def frame_src
    frame_src_url_list = [
      'https://www.youtube.com'
    ]
    "frame-src 'self' #{frame_src_url_list.join(' ')};"
  end

  def connect_src
    connect_src_url_list = [
      'https://cdn.plyr.io'
    ]
    "connect-src 'self' #{connect_src_url_list.join(' ')};"
  end

  def media_src
    media_src_url_list = [
      'https://storage.googleapis.com'
    ]
    "media-src 'self' #{media_src_url_list.join(' ')};"
  end

  def other_src
    [
      "form-action 'self';",
      "frame-ancestors 'self';",
      "font-src 'self';",
      "object-src 'self';",
      "manifest-src 'self';"
    ].join(' ')
  end
end

基本的には各 CSP ヘッダごとに「許可するサイト」「許可するハッシュ」を定義します
許可するサイトは見慣れた CDN サイトが並ぶ感じになります

許可するハッシュはどうしても style タグを使って直接 CSS を指定している場合にだけ使うので CSS ファイルを外部ファイルで管理している場合には不要です

使う側

CSP レスポンスヘッダーを指定するのは before が一番簡単です
特定のパスだけ適用したくない場合などは unless いましょう

before do
  # Content Security Policy (CSP) の設定
  unless request.path.start_with?('/podcast/feed')
    csp = CSP.new.header
    headers 'Content-Security-Policy' => csp
  end
end

動作確認

アプリを起動して問題なくすべてのコンテンツ (js or css or img etc) が取得できることを確認しましょう
Chrome などの開発者ツールでコンソールにエラーがないことを書くにしましょう
CSP は基本的にホワイトリスト形式なので設定が足りていないとコンテンツが取得できずエラーになりうまくサイトが表示できなくなります

基本的には CSP を設定した場合には style タグや onclick タグは使えないと思ったほうがいいです
css ファイルや js ファイルにすべて処理を移す感じになります

最後に

CSP レスポンスヘッダーを正しく設定することで XSS 対策しセキュアコードしましょう

参考サイト

2025年7月22日火曜日

zaproxy を使って自分のローカルアプリにペネトレーションをかける方法

zaproxy を使って自分のローカルアプリにペネトレーションをかける方法

概要

ローカルで動作しているアプリにペネトレーションテストを簡単にかける方法を紹介します
簡単なベースラインスキャンと時間のかかるフルスキャンを実行する方法を紹介します
また zaproxy は基本 GUI で操作しますが今回は CLI (docker) を使った方法を紹介します

環境

  • macOS 15.5
  • docker 28.3.2
  • zaproxy 2.16.1

アプリ起動

何でも OK です
今回は docker からスキャンをかけるのでホスト側では 0.0.0.0 などでバインドするようにしましょう

  • bundle exec rackup config.ru -o 0.0.0.0

ベースラインスキャン

  • mkdir zaproxy_test
  • cd zaproxy_test
  • docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py -t http://host.docker.internal:9292 -g gen.conf -r testreport.html

実行は 1 分程度で完了します
レポートは testreport.html をブラウザで開けば OK です

High は対策必須としてできれば Medium/Low も対策しましょう

フルスキャン

完了までに1時間ほどかかります
またローカルマシンの負荷もかかるので注意しましょう

  • docker run -v $(pwd):/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable zap-full-scan.py -t http://host.docker.internal:9292 -g gen.conf -r testreport.html

完了後は testreport.html を開けば OK です
ベースラインスキャンに比べてレポートも詳細になっています

最後に

zaproxy を使って自分のサービスにペネトレーションテストを実行する方法を紹介しました
かなり簡単に実施できるので CI などに組み込んでもいいかなと思います
Github Actions にはフルスキャンをかけるアクションもあります

参考サイト

2025年7月16日水曜日

slack-ruby-client でファイルを添付してメッセージを送信する方法

slack-ruby-client でファイルを添付してメッセージを送信する方法

概要

かなり昔に使い方を紹介したのですがその際はリアルタイムメッセージAPIの使い方を紹介しました
今回はファイルを添付したメッセージの送信方法を紹介します

環境

  • macOS 15.5
  • Ruby 3.4.4
  • slack-ruby-client 2.6.0

サンプルコード

# frozen_string_literal: true

require 'slack-ruby-client'

# MySlack クラスは Slack へのメッセージ送信とファイルアップロードを行います。
class MySlack
  USER_ID = 'U1234567890' # ユーザーIDを定義
  TOKEN = 'xoxb-xxx-xxx-xxx' # Slack API トークンを定義

  def initialize
    # Slack クライアントの初期化
    Slack.configure do |config|
      config.token = TOKEN
    end
    @client = Slack::Web::Client.new
  end

  def channel_id_for_sending_file
    # ボットと指定のユーザーとの会話を開始し、チャンネルIDを取得
    response = @client.conversations_open(users: USER_ID)
    response.channel.id
  end

  def send_message(message_text, channel_id)
    # メッセージを送信
    @client.chat_postMessage(
      channel: channel_id,
      text: message_text
    )
  end

  def upload_file(file_path, message_text, channel_id)
    # ファイルをアップロード
    @client.files_upload_v2(
      filename: File.basename(file_path),
      content: File.read(file_path),
      channels: [channel_id],
      initial_comment: message_text,
      snippet_type: 'text',
      title: '添付されたテキストファイル'
    )
  end
end

ポイント

Slack のファイルアップロード API は v1 から v2 になり大きく変更されています
チャネル名が指定不可になった他ボットからファイルを送信するためにはチャネルにボットが参加しているなどの条件があります
一番確実な方法として特定のユーザとの会話チャネルに送信する方法があるので今回はその方法を採用しています (参考)

トラブルシューティング

invalid_arguments (Slack::Web::Api::Errors::InvalidArguments)

ファイルが空の場合に発生します
どうやら空ファイルは送信できないようです

最後に

slack-ruby-client で file uplaod API v2 を使ってファイルのアップロードを行ってみました
v2 からかなり厳格なチェックが入っておりかつ v1 との互換性はないので注意が必要です

参考サイト

2025年7月15日火曜日

monero を kucoin に入金する方法

monero を kucoin に入金する方法

概要

マイニングで溜まった XRM をウォレットから KuCoin に送金する方法を紹介します
KuCoin に送金することで monero を USTD などに変換することができます
2025/07/15 現在 KuCoin での日本国内からの取引は可能です

環境

  • KuCoin (2025/07/14 時点)
  • monero-wallet 0.18.4.0
  • M2pro mac mini (macOS 15.5)

KuCoin の口座の作成

  • メールアドレス

があれば作成できます

KuCoin で本人確認

  • 運転免許証 (表裏)

の電子データがあれば簡単に確認できます
確認時に顔スキャンをして本人であることを確認するのでカメラが使えるマシンが必要になります

入金準備、KuCoin側のアドレスの発行

KuCoin にログインし右上の「資金追加」をクリックします

次の「仮想通貨」の入金をクリックします

送金する仮想通貨の種類を選択する画面になるので「XMR」を選択します
ネットワークは「XMR」しかないので XMR を選択します
すると入金用の KuCoin のアドレスが表示されるのでメモします

ウォレットから送金

あとはウォレットがある端末から先程発行した KuCoin 用の monero アドレスを使って送金します
金額を入力し「Send」しましょう

確認画面になるので「Confirm」をクリックします

spendable funds エラー

ウォレットに金額が同期されてもすぐには使えないので少し待ちましょう

not enough money to transfer, overall blance only エラー

送金には手数料がかかるのでウォレットにある全額を送金することはできません
monero-wallet の場合自動で手数料を含めた全額を送金するための all ボタンがあるのでそれを使って送金するのが簡単です

また KuCoin 側のウォレットは左メニューの「Address book」に登録しておくのをおすすめします

動作確認

送金できたら KuCoin 側の「総資産」を確認しましょう
これも Payout 同様少し時間 (30分から1時間程度) がかかります
送金が完了すると KuCoin から通知が来るので通知を待つのが無難です

ウォレット側でも Confirmations が 10/10 になっていることが確認できます

最後に

monero でマイニングしたコインを取引所の KuCoin に送金する方法を紹介しました
アドレス同士の送金なので仕組みさえ把握すれば簡単に送金できます

Payout やノード同期に時間がかかるのでプールにコインがある状態だと送金までに時間がかかるので定期的に Payout や同期はしたほうがいいのかもしれません

2025年7月14日月曜日

Ubuntuで仮想NICを作成しIPを設定する方法

Ubuntuで仮想NICを作成しIPを設定する方法

概要

systemd に登録しないと毎回仮想NICを作成することになるので systemd を使うのが簡単です

環境

  • Ubuntu 24.04

dummy0.service ファイルの作成

  • sudo vim /etc/systemd/system/dummy0.service
Unit]
Description=Setup dummy0 interface
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/sbin/modprobe dummy
ExecStart=/sbin/ip link add dummy0 type dummy
ExecStart=/sbin/ip link set dummy0 up
ExecStart=/sbin/ip addr add 192.168.1.100/16 dev dummy0
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

sudo systemctl enable dummy0.service

dummy というモジュールを使うので最初に modprobe で読み込みます
あとは dummy0 という名前の仮想デバイスを作成しそこに IP を追加します

動作確認

再起動して仮想NICが作成されていることを確認しましょう

  • ip -br a show dummy0
dummy0           UNKNOWN        192.168.1.100/16 fe80::58e6:72ff:fe62:cd5f/64

最後に

dummy インタフェースなのでアクセスできるのはマシンのみからとなります
外部からアクセスしたい場合にはNICに複数のIPを持たせる必要があります

2025年7月11日金曜日

This deployment job does not run automatically and must be started manually, but it's older than the latest deployment, and therefore can't run.

This deployment job does not run automatically and must be started manually, but it's older than the latest deployment, and therefore can't run.

概要

Gitlab は古いデプロイメントは自動で無効化する機能があります
場合によっては切り戻しなどで古いデプロイメントを使いたい場合があるかなと思います
その対処方法を紹介します

環境

  • Gitlab 17.10.7

対処方法

  • Settings
  • CI/CD
  • General pilelines
  • Prevenet outdated deployment jobs

のチェックをオフにします
再度 Pipeline を見るとデプロイできるようになっています

最後に

基本はオンにしておいて緊急時にオフにするのがいいと思います

参考サイト

2025年7月10日木曜日

ip コマンドでIPアドレス情報だけを表示する方法

ip コマンドでIPアドレス情報だけを表示する方法

概要

ip a show だと不要な情報も表示されるので NIC に紐づいてる IP アドレス情報だけど表示する方法を紹介します

環境

  • Ubuntu 24.04
  • ip (iproute2-6.1.0, libbpf 1.3.0)

IPアドレスだけを確認するコマンド

  • ip -br a show

おまけ: NIC のタイプを調べる方法

  • ip -d link show

veth or macvlan or dummy or tun など仮想 NIC のタイプを確認するときに使えます

最後に

ip コマンド使いこなしましょう

2025年7月9日水曜日

Linux カーネルを docker を使ってビルドしテストする

Linux カーネルを docker を使ってビルドしテストする

概要

docker 上で Linux カーネルのソースコードをビルドしビルドしてできたカーネルイメージファイルを使って docker 上で動作確認します
ビルド用のイメージとテスト用のイメージを作成しています
またテスト時には docker 上で QEMU + busybox でカスタムカーネルイメージを動かします

今回は M2pro mac mini 上でビルドしたのでカーなるは aarch64 向けのカーネルになります

環境

  • M2pro mac mini
    • macOS 15.5
  • docker 28.3.0
    • Ubuntu 24.04
  • Linux kernel 6.15

Linux カーネルビルド用のイメージ作成

  • mkdir kernel_build
  • cd kernel_build
  • vim Dockerfile
FROM ubuntu:24.04

# 必要なツールをインストール
RUN apt update && DEBIAN_FRONTEND=noninteractive apt install -y \
    git build-essential libncurses-dev bison flex libssl-dev libelf-dev bc \
    && apt clean

# ビルド作業ディレクトリ
WORKDIR /kernel

# 実行シェル
CMD ["bash"]
  • docker build -t kernel-builder .

Linux カーネルのソースコードを取得する

テストする際にちゃんと自分でビルドした Linux カーネルイメージかどうか確認しやすいように Linux カーネル名を変更しておきます

  • git clone --depth=1 --branch v6.15 https://github.com/torvalds/linux.git
  • cd linux
  • vim Makefile
EXTRAVERSION = -custom123

ビルドする

  • docker run --rm -it -v $(pwd):/kernel -v ./kernel-build-out:/kernel_build kernel-builder

以下は起動したコンテナ上で実行します

  • cd /kernel/linux
  • make defconfig
  • make -j$(nproc)
  • make modules -j$(nproc)

ビルドが完了したら成果物である Linux カーネルイメージをコピーしておきます

  • cp ./arch/arm64/boot/Image /kernel_build/

エラーが出る場合は make -j$(nproc) 2>&1 | tee build.log で再度実行しエラーログを確認しましょう

Linux カーネルテスト用のイメージ作成

生成された Linux カーネルイメージは QEMU を使ってテストします
なので qemu と busybox が使えるイメージを準備します

  • vim test.dockerfile
FROM ubuntu:24.04

RUN apt update && apt install -y cpio qemu-system-aarch64 busybox-static && apt clean

WORKDIR /test

CMD ["bash"]
  • docker build -t kernel-tester -f test.dockerfile .

テストする

  • docker run --rm -it -v ./kernel-build-out:/kernel_build kernel-tester

以下は起動したコンテナ上で実行します

起動のための簡易的なファイルシステムを準備します
busybox の使い方などはこの記事内では説明しないので興味があれば調べてみてください

  • mkdir -p /kernel_build/initramfs_root
  • cd /kernel_build/initramfs_root
cat > init << 'EOF'
#!/bin/sh
mount -t proc proc /proc
exec /bin/sh
EOF
  • chmod +x init
  • mkdir -p bin proc
  • cp /usr/bin/busybox bin/sh
  • cp /usr/bin/busybox bin/mount
  • find . | cpio -o --format=newc > ../initramfs.cpio

あとは QEMU を使って Linux カーネルイメージを指定し起動します

  • cd /kernel_build
qemu-system-aarch64 \
  -M virt \
  -cpu cortex-a53 \
  -nographic \
  -kernel ./Image \
  -append "console=ttyAMA0" \
  -initrd ./initramfs.cpio

動作確認

QEMU が起動したら以下のようなプロンプトが表示されれば OK です

[    0.529104] clk: Disabling unused clocks
[    0.529510] PM: genpd: Disabling unused power domains
[    0.530130] ALSA device list:
[    0.530334]   No soundcards found.
[    0.568895] Freeing unused kernel memory: 11200K
[    0.569684] Run /init as init process


BusyBox v1.36.1 (Ubuntu 1:1.36.1-6ubuntu3.1) built-in shell (ash)
Enter 'help' for a list of built-in commands.

/bin/sh: can't access tty; job control turned off
~ #

試しに uname -an を実行してみると自分で設定した Linux カーネル名になっていることが確認できると思います

~ # uname -an
Linux (none) 6.15.0-custom123-g0ff41df1cb26-dirty #1 SMP PREEMPT Tue Jul  8 03:03:15 UTC 2025 aarch64 GNU/Linux

最後に

Linux カーネルのソースコードを M2pro mac mini 上でビルドしてみました
初回のビルドは30分から1時間ほどかかります
2回目以降はエラーや修正部分のみ再度ビルドされるので早くビルドできます

一応これで Linux カーネルをトライ&エラーできる環境はできたのであとは C のコードを修正すれば Linux カーネルにコミットできるかもしれません

2025年7月8日火曜日

unattended-upgrade に docker の公式リポジトリを追加する方法

unattended-upgrade に docker の公式リポジトリを追加する方法

概要

過去に ESM パッケージを更新する定義を追加しました
今回は docker 公式と terraform/ansible も追加してみたのでその手順を紹介します

環境

  • Ubuntu 24.04
  • unattended-upgrade 2.9.1

docker

  • grep -e 'Origin:' -e 'Suite:' /var/lib/apt/lists/download.docker.com_linux_ubuntu_dists_noble_*
/var/lib/apt/lists/download.docker.com_linux_ubuntu_dists_noble_InRelease:Origin: Docker
/var/lib/apt/lists/download.docker.com_linux_ubuntu_dists_noble_InRelease:Suite: noble
  • sudo vim /etc/apt/apt.conf.d/52unattended-upgrades-docker-packages
Unattended-Upgrade::Allowed-Origins {
        "Docker:noble";
};
  • sudo unattended-upgrade --dry-run
/usr/bin/dpkg --status-fd 10 --no-triggers --unpack --auto-deconfigure /var/cache/apt/archives/docker-ce-cli_5%3a28.3.1-1~ubuntu.24.04~noble_amd64.deb /var/cache/apt/archives/docker-ce_5%3a28.3.1-1~ubuntu.24.04~noble_amd64.deb /var/cache/apt/archives/docker-ce-rootless-extras_5%3a28.3.1-1~ubuntu.24.04~noble_amd64.deb /var/cache/apt/archives/docker-compose-plugin_2.38.1-1~ubuntu.24.04~noble_amd64.deb
/usr/bin/dpkg --status-fd 10 --configure --pending
/usr/bin/dpkg --status-fd 10 --no-triggers --unpack --auto-deconfigure /var/cache/apt/archives/docker-buildx-plugin_0.25.0-1~ubuntu.24.04~noble_amd64.deb
/usr/bin/dpkg --status-fd 10 --configure --pending

terraform

  • grep -e 'Origin:' -e 'Suite:' /var/lib/apt/lists/apt.releases.hashicorp.com_dists_noble_*
/var/lib/apt/lists/apt.releases.hashicorp.com_dists_noble_InRelease:Origin: Artifactory
/var/lib/apt/lists/apt.releases.hashicorp.com_dists_noble_InRelease:Suite: noble
  • sudo vim /etc/apt/apt.conf.d/53unattended-upgrades-hashicorp-packages
Unattended-Upgrade::Allowed-Origins {
        "Artifactory:noble";
};
  • sudo unattended-upgrade --dry-run
/usr/bin/dpkg --status-fd 10 --no-triggers --unpack --auto-deconfigure /var/cache/apt/archives/terraform_1.12.2-1_amd64.deb
/usr/bin/dpkg --status-fd 10 --configure --pending

ansible

  • grep -e 'Origin:' -e 'Suite:' /var/lib/apt/lists/ppa.launchpadcontent.net_ansible_ansible_ubuntu_dists_noble_*
/var/lib/apt/lists/ppa.launchpadcontent.net_ansible_ansible_ubuntu_dists_noble_InRelease:Origin: LP-PPA-ansible-ansible
/var/lib/apt/lists/ppa.launchpadcontent.net_ansible_ansible_ubuntu_dists_noble_InRelease:Suite: noble
  • sudo vim /etc/apt/apt.conf.d/54unattended-upgrades-ansible-packages
Unattended-Upgrade::Allowed-Origins {
        "LP-PPA-ansible-ansible:noble";
};
  • sudo unattended-upgrade --dry-run
/usr/bin/dpkg --status-fd 10 --no-triggers --unpack --auto-deconfigure /var/cache/apt/archives/ansible_11.7.0-1ppa~noble_all.deb
/usr/bin/dpkg --status-fd 10 --configure --pending

最後に

他のリポジトリも全部同じ方法で unattended-upgrade に追加できます
この作業をリポジトリを追加したときに自動でやってくれるツールはないのだろうか

2025年7月7日月曜日

google.auth.exceptions.RefreshError の対処方法

google.auth.exceptions.RefreshError の対処方法

概要

エラー全文は以下です

google.auth.exceptions.RefreshError: ('invalid_grant: Token has been expired or revoked.', {'error': 'invalid_grant', 'error_description': 'Token has been expired or revoked.'})

環境

  • GCP (2025/07/05 時点)

アプリがテストの場合はリフレッシュトークンが7日になる

らしいです

解決方法の一番はアプリを公開すること

基本は自分で使うだけなら公開しても問題ないです
スコープやアプリの細かい設定が必要な場合は公開するアプリの審査が必要になるかもです
なので可能な限りアプリの設定はシンプルにしましょう (許可ドメインとコールバックURLくらい)

もしアプリを公開できない場合は OAuth クライアントのシークレットを更新しなければならない

しかもシークレットは無効 -> 削除 -> 新規作成が推奨されているので面倒です
しかもアプリがテストの場合はリフレッシュトークンが7日なので再度シークレットを作成し直す必要があります

認証後のトークンの期限は変更できない?

API ならできるのかもしれません
少なくともコンソールからトークンの期限を変更することはできませんでした

最後に

できればアプリは公開状態にしましょう

2025年7月5日土曜日

MySQL8.4.5でInnoDBClusterにMySQLRouterを適用し自動でPRIMARYにアクセスできるようにしてみた

MySQL8.4.5でInnoDBClusterにMySQLRouterを適用し自動でPRIMARYにアクセスできるようにしてみた

概要

前回 MySQL8.4.5 で InnoDBCluster を構築し FailOver 可能な MySQL 環境を構築してみました
今回は FailOver 後に PRIMARY が変わってしまうのに自動で対応するために MySQL Router を導入しバランシングしてみました

なお MySQL Router は Arm 環境には対応していないので注意しましょう

環境

  • macOS 15.5
    M1 mac 上の docker では動きません

環境

  • Ubuntu 24.04
  • docker 28.2.2
  • MySQL 8.4.5

compose.yaml

過去のやつに mysqlrouter のサービスを追加しています
次回起動時に bootstrap の設定を使うようにボリュームを使っています

services:
  mysqlrouter:
    image: container-registry.oracle.com/mysql/community-router:8.4
    depends_on:
      - mysql1
      - mysql2
      - mysql3
    environment:
      - MYSQL_HOST=mysql1
      - MYSQL_PORT=3306
      - MYSQL_USER=root
      - MYSQL_PASSWORD=rootpass
      - MYSQL_INNODB_CLUSTER_MEMBERS=3
      - MYSQL_ROUTER_BOOTSTRAP_EXTRA_OPTIONS=--conf-use-sockets --conf-use-gr-notifications
    ports:
      - "6446:6446"  # Read-write port (Primary)
      - "6447:6447"  # Read-only port (all members)
    networks:
      - mysqlnet
    restart: always

  mysql1:
    image: mysql:8.4.5
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
    ports:
      - "33061:3306"
    command: >
      --mysql-native-password=ON
      --server-id=1
      --gtid-mode=ON
      --enforce-gtid-consistency=ON
      --binlog-format=ROW
      --log-bin=mysql-bin
      --relay-log=relay-bin
      --log-replica-updates=ON
      --read-only=OFF
    volumes:
      - mysql1_data:/var/lib/mysql
    restart: always
    networks:
      - mysqlnet

  mysql2:
    image: mysql:8.4.5
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
    ports:
      - "33062:3306"
    command: >
      --mysql-native-password=ON
      --server-id=2
      --gtid-mode=ON
      --enforce-gtid-consistency=ON
      --binlog-format=ROW
      --log-bin=mysql-bin
      --relay-log=relay-bin
      --log-replica-updates=ON
      --read-only=OFF
    volumes:
      - mysql2_data:/var/lib/mysql
    restart: always
    networks:
      - mysqlnet

  mysql3:
    image: mysql:8.4.5
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
    ports:
      - "33063:3306"
    command: >
      --mysql-native-password=ON
      --server-id=3
      --gtid-mode=ON
      --enforce-gtid-consistency=ON
      --binlog-format=ROW
      --log-bin=mysql-bin
      --relay-log=relay-bin
      --log-replica-updates=ON
      --read-only=OFF
    volumes:
      - mysql3_data:/var/lib/mysql
    restart: always
    networks:
      - mysqlnet

  mysqlshell:
    image: mysql:8.4.5
    depends_on:
      - mysql1
      - mysql2
      - mysql3
    networks:
      - mysqlnet
    volumes:
      - ./scripts:/scripts
    command: >
      bash -c "
      echo 'Waiting for MySQL to be ready...' &&
      sleep 15 &&
      mysqlsh --host=mysql1 --port=3306 --user=root --password=rootpass --py < /scripts/setup_cluster.py
      "

networks:
  mysqlnet:

volumes:
  mysql1_data:
  mysql2_data:
  mysql3_data:

MYSQL_ROUTER_BOOTSTRAP_EXTRA_OPTIONS--conf-use-sockets は最後に s が付くのが正しいようです (参考)

--conf-use-gr-notifications も同様に s が付くのが正しいです

動作確認

まずは起動します

  • docker compose up -d

そして router に対して mysql コマンドが使えるか確認します

書き込みができるポートと読み込みのみのポートがあるのでそれぞれで挙動を確認しましょう
今回であれば 6446 が書き込み可能なポートで 6447 が読み込み専用ポートです

  • mysql -u root -h 192.168.1.100 --port 6446 -p

ちなみに InnoDBCluster では PRIMARY_KEY がないテーブルに対する書き込みはエラーになるので必ず PRIMARY_KEY を各テーブルに設定するようにしましょう

mysql> create database test;
Query OK, 1 row affected (0.01 sec)

mysql> use test;
Database changed
mysql> create table test (`id` int, `name` varchar(50));
Query OK, 0 rows affected (0.02 sec)

mysql> insert into test values (1, "hoge");
ERROR 3098 (HY000): The table does not comply with the requirements by an external plugin.
mysql> drop table test;
Query OK, 0 rows affected (0.01 sec)

mysql> create table test (`id` int primary key, `name` varchar(50));
Query OK, 0 rows affected (0.02 sec)

mysql> insert into test values (1, "hoge");
Query OK, 1 row affected (0.00 sec)

読み込み専用では上記のテーブルに書き込みできないことが確認できます

  • mysql -u root -h 192.168.1.100 --port 6447 -p
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from test;
+----+------+
| id | name |
+----+------+
|  1 | hoge |
+----+------+
1 row in set (0.00 sec)

mysql> insert into test values (2, "fuga");
ERROR 1290 (HY000): The MySQL server is running with the --super-read-only option so it cannot execute this statement

耐障害テスト

PRIMARY なノードを停止し他に移動した上で書き込みが継続できるか試してみます

  • docker compose stop mysql1
  • docker compose start mysql1
  • docker compose exec -it mysql1 mysqlsh --py
dba.get_cluster().status()

クラスタの状態を確認し PRIMARY なノードが移動していることを確認します
このあとで 6446 ポートに接続し問題なくデータが登録できることを確認しましょう

mysql -u root -h 10.104.37.79 --port 6446 -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 0
Server version: 8.4.5-router MySQL Community Server - GPL

Copyright (c) 2000, 2025, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> use test;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> select * from test;
+----+------+
| id | name |
+----+------+
|  1 | hoge |
+----+------+
1 row in set (0.00 sec)

mysql> insert into test values (2,"fuga");
Query OK, 1 row affected (0.01 sec)

最後に

MySQL8.4.5 で InnoDB Cluster に MySQL Router を設定し自動的に PRIMARY にバランシングしてくれる仕組みを試しました
確かに便利ですが PRIMARY_KEY の制約などは注意が必要かもしれません

参考サイト

2025年7月4日金曜日

MySQL8.4.5でInnoDBClusterを構築してみた

MySQL8.4.5でInnoDBClusterを構築してみた

概要

前回 MySQL8.4.5 で簡単なレプリケーション構成を組んでみました
今回は少し複雑にして Group Replication を使った InnoDB Cluster を構築してみます

なお検証なので docker compose で一発で InnoDB Cluster が構築できるようしています

環境

  • macOS 15.5
  • docker 28.2.2
  • MySQL 8.4.5

compose.yaml

まずは3台の MySQL を Group Replication に必要なオプションを付与して起動します
InnoDB Cluster を構築するには mysqlshell コンテナがクラスタを構築するための Python スクリプトを実行することで構築されます

MySQL の起動オプションのみで InnoDB Cluster を構築できるわけではないので注意しましょう

services:
  mysql1:
    image: mysql:8.4.5
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
    ports:
      - "33061:3306"
    command: >
      --mysql-native-password=ON
      --server-id=1
      --gtid-mode=ON
      --enforce-gtid-consistency=ON
      --binlog-format=ROW
      --log-bin=mysql-bin
      --relay-log=relay-bin
      --log-replica-updates=ON
      --read-only=OFF
    volumes:
      - mysql1_data:/var/lib/mysql
    restart: always
    networks:
      - mysqlnet

  mysql2:
    image: mysql:8.4.5
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
    ports:
      - "33062:3306"
    command: >
      --mysql-native-password=ON
      --server-id=2
      --gtid-mode=ON
      --enforce-gtid-consistency=ON
      --binlog-format=ROW
      --log-bin=mysql-bin
      --relay-log=relay-bin
      --log-replica-updates=ON
      --read-only=OFF
    volumes:
      - mysql2_data:/var/lib/mysql
    restart: always
    networks:
      - mysqlnet

  mysql3:
    image: mysql:8.4.5
    environment:
      - MYSQL_ROOT_PASSWORD=rootpass
    ports:
      - "33063:3306"
    command: >
      --mysql-native-password=ON
      --server-id=3
      --gtid-mode=ON
      --enforce-gtid-consistency=ON
      --binlog-format=ROW
      --log-bin=mysql-bin
      --relay-log=relay-bin
      --log-replica-updates=ON
      --read-only=OFF
    volumes:
      - mysql3_data:/var/lib/mysql
    restart: always
    networks:
      - mysqlnet

  mysqlshell:
    image: mysql:8.4.5
    depends_on:
      - mysql1
      - mysql2
      - mysql3
    networks:
      - mysqlnet
    volumes:
      - ./scripts:/scripts
    command: >
      bash -c "
      echo 'Waiting for MySQL to be ready...' &&
      sleep 15 &&
      mysqlsh --host=mysql1 --port=3306 --user=root --password=rootpass --py < /scripts/setup_cluster.py
      "

networks:
  mysqlnet:

volumes:
  mysql1_data:
  mysql2_data:
  mysql3_data:

./scripts/setup_cluster.py

mysqlsh という機能を使って Python スクリプトを実行します
Python の他に JavaScript でも InnoDB Cluster を操作できます

# setup_cluster.py

import time


# 簡単なリトライ(実際はもっと robust に作ったほうがよい)
# dba は import しなくても使えるようになっている
def wait_and_configure(uri):
    for i in range(10):
        try:
            # cluster管理用のユーザを各ノードに作成する
            dba.configure_instance(
                uri, {"clusterAdmin": "admin", "clusterAdminPassword": "adminpass"}
            )
            return
        except Exception as e:
            print(f"Retry configuring {uri} in 5 sec: {e}")
            time.sleep(5)
    raise RuntimeError(f"Failed to configure instance: {uri}")


wait_and_configure("root:rootpass@mysql1:3306")
wait_and_configure("root:rootpass@mysql2:3306")
wait_and_configure("root:rootpass@mysql3:3306")

# Cluster 作成
cluster = dba.create_cluster("testCluster")
cluster.add_instance("root:rootpass@mysql2:3306", {"recoveryMethod": "clone"})
cluster.add_instance("root:rootpass@mysql3:3306", {"recoveryMethod": "clone"})

print("Cluster status:")
print(cluster.status())

流れとしては

  1. 各ノードにクラスタ構築用のユーザを設定 (configure_instance)
  2. 各ノードの追加 (add_instance)
  3. クラスタの状態を確認 (status)

という感じになっています
今回は一発で構築するためスクリプトにしましたが各 MySQL を起動したあとに手動で mysqlsh を実行し InnoDB Cluster を構築しても OK です

動作確認

  • docker compose up -d

でまずは各種コンテンを起動します
今回は上記一発で InnoDB Cluster が構築できるのであとは構築できたかを mysqlsh を使って確認します

  • docker compose exec -it mysql1 mysqlsh --py

でますは mysqlsh を起動しましょう root ユーザのパスワードが必要になります
そして Python を使ってクラスタの状況を確認します

dba.get_cluster().status() 

以下のようにクラスタが ONLINE で構築されていれば OK です

{
  "clusterName": "testCluster",
  "defaultReplicaSet": {
    "name": "default",
    "primary": "65d9833167a3:3306",
    "ssl": "REQUIRED",
    "status": "OK",
    "statusText": "Cluster is ONLINE and can tolerate up to ONE failure.",
    "topology": {
      "65d9833167a3:3306": {
        "address": "65d9833167a3:3306",
        "memberRole": "PRIMARY",
        "mode": "R/W",
        "readReplicas": {},
        "replicationLag": "applier_queue_applied",
        "role": "HA",
        "status": "ONLINE",
        "version": "8.4.5"
      },
      "6892d7a7d916:3306": {
        "address": "6892d7a7d916:3306",
        "memberRole": "SECONDARY",
        "mode": "R/O",
        "readReplicas": {},
        "replicationLag": "applier_queue_applied",
        "role": "HA",
        "status": "ONLINE",
        "version": "8.4.5"
      },
      "bcbe386a9df7:3306": {
        "address": "bcbe386a9df7:3306",
        "memberRole": "SECONDARY",
        "mode": "R/O",
        "readReplicas": {},
        "replicationLag": "applier_queue_applied",
        "role": "HA",
        "status": "ONLINE",
        "version": "8.4.5"
      }
    },
    "topologyMode": "Single-Primary"
  },
  "groupInformationSourceMember": "65d9833167a3:3306"
}

どのノードがどのホスト名なのかわかない場合は add_instance 時に label というオプションが指定できるのでそれを使ってホスト名などを指定するといいかなと思います

FailOver の確認

PRIMARY なノードを停止して他のノードが PRIMARY になることを確認しましょう

  • docker compose stop mysql1

しばらくしたら再度起動します

  • docker compose start mysql1

これで再度クラスタの状態を確認すると PRIMARY が変わっていることが確認できると思います

{
    "clusterName": "testCluster", 
    "defaultReplicaSet": {
        "name": "default", 
        "primary": "bcbe386a9df7:3306", 
        "ssl": "REQUIRED", 
        "status": "OK", 
        "statusText": "Cluster is ONLINE and can tolerate up to ONE failure.", 
        "topology": {
            "65d9833167a3:3306": {
                "address": "65d9833167a3:3306", 
                "memberRole": "SECONDARY", 
                "mode": "R/O", 
                "readReplicas": {}, 
                "replicationLag": "applier_queue_applied", 
                "role": "HA", 
                "status": "ONLINE", 
                "version": "8.4.5"
            }, 
            "6892d7a7d916:3306": {
                "address": "6892d7a7d916:3306", 
                "memberRole": "SECONDARY", 
                "mode": "R/O", 
                "readReplicas": {}, 
                "replicationLag": "applier_queue_applied", 
                "role": "HA", 
                "status": "ONLINE", 
                "version": "8.4.5"
            }, 
            "bcbe386a9df7:3306": {
                "address": "bcbe386a9df7:3306", 
                "memberRole": "PRIMARY", 
                "mode": "R/W", 
                "readReplicas": {}, 
                "replicationLag": "applier_queue_applied", 
                "role": "HA", 
                "status": "ONLINE", 
                "version": "8.4.5"
            }
        }, 
        "topologyMode": "Single-Primary"
    }, 
    "groupInformationSourceMember": "bcbe386a9df7:3306"
}

mysql クライアントを使うときには

基本は普通に使えば OK ですが必ず PRIMARY に接続するようにしましょう

  • mysql -u root -h 192.168.1.100 -p --port=33063

InnoDB Cluster の場合マルチマスター構成なのでどのノードもマスタなので書き込みが出来そうなのですが実際はできずにエラーになります

mysql> create database test;
ERROR 1290 (HY000): The MySQL server is running with the --super-read-only option so it cannot execute this statement

なので Failover 時にはアプリ側で書き込み先を変更しなければいけないのですがそれを自動で行う MySQL Router という機能があるので次回はそれを組み合わせてみます

最後に

MySQL8.4.5 で InnoDB Cluster を構築してみました
構築自体は非常に簡単で MySQL を3台用意して mysqlsh でクラスタを構築するための API をコールするだけでした
これで MySQL の冗長構成 + 自動 FailOver な環境は構築できました
次回は MySQL Router を組み合わせてみます

参考サイト