Y_Yamashitaのブログ

勉強したことのアウトプット・メモが中心。記事の内容は個人の見解であり、所属組織を代表するものではありません。

(小ネタ) プロキシ経由で送付されたPOSTリクエストをLambdaで確認してみた

このブログは前回のブログの続きとなります。

前回のブログでは、Node.jsで作ったプロキシで、POSTリクエストのデータを取り出し、カスタムヘッダにセットしてみました。
その際、カスタムヘッダが想定通りセットされていることは確認しましたが、ボディのデータがターゲットまで届いていることを確認していませんでした。
そこで今回は、POSTリクエストのターゲットにLambdaをセットし、ヘッダとボディの内容を表示させてみます。

検証方法

検証構成

前回のブログでは、Proxyのターゲットに設定したALBで、カスタムヘッダの値に応じた固定レスポンスを返していました。

前回の検証構成

今回はALBのリスナールールを更新し、ターゲットにLambdaを設定します。Lambdaでは受け取ったイベントデータにメッセージを付与して返します。クライアントPCでLambdaからのレスポンスを確認します。カスタムヘッダの値に応じてメッセージを変えるために、Lambdaは2つ用意します。

今回の検証構成

Lambda

今回はPythonのLambdaを2つ用意します。といっても、内容はほとんど同じです。 一つは「こんにちは!」というメッセージとともに、受け取ったイベントデータをそのまま返します。もう一つはメッセージを「Hello!」にして、同様に受け取ったイベントデータをそのまま返します。

Lambda関数

Return_Json_Japanese

import json

def lambda_handler(event, context):    
    # 「こんにちは!」という文字列とイベントデータを組み合わせて返す
    response_body = {
        "message": "こんにちは!",
        "event": event
    }

    return {
        'statusCode': 200,
        'body': json.dumps(response_body, ensure_ascii=False)
    }

json.dumps(response_body, ensure_ascii=False) としているのは、日本語が \uXXXXエンコードされるのを防止するためです。


Return_Json_English

import json

def lambda_handler(event, context):    
    # 「Hello!」という文字列とイベントデータを組み合わせて返す
    response_body = {
        "message": "Hello!",
        "event": event
    }
    
    return {
        'statusCode': 200,
        'body': json.dumps(response_body, ensure_ascii=False)
    }

ALB

前回のブログで使ったALBを再利用します。まず、ターゲットグループに先ほど作成したLambdaを設定します。

ターゲットグループ

次に、ALBのリスナールールを更新します。カスタムヘッダ x-language の値が Japanese の場合は ターゲットグループ Return_Json_Japaneseトラフィックを送信し、 English の場合は Return_Json_Englishトラフィックを送信します。

リスナールール

クライアントとプロキシ

クライアントとプロキシは、前回のブログのものをそのまま使いまわします。詳細は以下をご確認ください。

クライアント
プロキシ

実際に検証してみる

それでは、準備が整ったので実際に検証してみます。POSTリクエストのボディの language の値を変えながら、レスポンスを確認していきます。

language=Japaneseの場合

まずはプロキシを起動し、 languageJapanese をセットしてPOSTリクエストを送ります。

languageがJapaneseの場合

200OKとともに、「こんにちは!」というメッセージと、イベントデータが返ってきました。

イベントデータの中のヘッダ部分を見ていきます。つまり、リクエストにセットされていたヘッダです。

イベントデータのヘッダ部分

hostlocalhost:3000user-agentvscode-restclient となっており、REST Clientからプロキシ経由でリクエストが送信されていることが見て取れます。また、カスタムヘッダ x-language もセットされていることが確認できました。

次は、肝心のボディ部分を見てみます。

イベントデータのボディ部分

どうやら全てターゲットのLambdaに届けられたようです。安心しました。

language=Englishの場合

続いて、 languageEnglish をセットしてPOSTリクエストを送ります。

languageがEnglishの場合

想定通り、こちらはメッセージが「Hello!」となっています。

続いて、イベントデータのヘッダ部分とボディ部分も見てみます。

イベントデータのヘッダ部分

イベントデータのボディ部分

こちらも想定通り、 x-languageEnglish が設定されており、ボディも全て取れていました。

おわりに

というわけで、プロキシを経由した後も、もともとのボディが想定通り送られていることが確認できました。前回ヘッダに気を取られてボディを見ておらず、消化不良になっていたので、それが解消されて良かったです。

今回のブログは以上です。何か少しでも参考になることがあれば幸いです。

【Node.js】プロキシでPOSTリクエストのボディからデータを取り出し、カスタムヘッダにセットしてみた

久々のブログ投稿になってしまいました。

今回はタイトル通り、プロキシでPOSTリクエストのボディからデータを取り出し、カスタムヘッダーに利用してみたいと思います。具体的な流れは以下です。

  • Node.jsで作ったプロキシを稼働させる
  • クライアントからプロキシ向けにPOSTリクエストでJSONを送る
  • プロキシは、JSONの特定のキーの値を取り出し、カスタムヘッダの値にセットして、ターゲットにPOSTリクエストを送付する

では早速やってみましょう。

検証方法

検証構成

今回は、検証を簡単に行うために、クライアントはローカルPC、プロキシはローカルPC上のコンテナにします。また、プロキシのターゲットにはALBを設定し、ALBではカスタムヘッダの値に応じて固定レスポンスを返すようにします。

クライアントからプロキシに送るJSONlanguage というキーを含めます。プロキシはこの language キーの値を取り出し、 x-language というカスタムヘッダにセットして、ALBにリクエストを転送します。ALBは x-language の値に応じて固定レスポンスを返し、それがプロキシ経由でクライアントに送り返されます。

検証構成イメージ

ターゲット用のALBを用意

まずはターゲット用のALBを用意します。
x-language というカスタムHTTPヘッダーの値に応じて固定レスポンスを返すリスナールールを設定します。

x-languageの値 レスポンスコード 固定レスポンスのテキスト
Japanese 200 こんにちは!
English 200 Hello!
no-language 503 Please tell me your language!


ALBリスナールール

POSTリクエストの準備

次に、クライアントからPOSTリクエストを送信するための準備をします。今回は、VS Code拡張機能であるREST Clientを使ってPOSTリクエストを送信します。最近知ったのですが、便利ですねこれ。
REST Clientについては、以下の記事が分かりやすかったので、興味のある方はご参照ください。

qiita.com

REST Clientの中身は以下です。非常にシンプルなJSONデータをPOSTで送信します。宛先はローカルの3000ポートで、パスは /greeting とします。

test.http

POST http://localhost:3000/greeting
content-type: application/json

{
    "name": "Yamashita",
    "language": "Japanese"
}

プロキシの用意

最後にNode.jsのプロキシを用意します。 /greeting パス宛てにPOSTメソッドを受け取ったら、リクエストボディのJSON中の language キーの値を取得し、 x-language カスタムヘッダーにセットします。ポートは3000でリッスンします。

アプリケーションコード

Node.jsの具体的なコードは以下です。

app.js(クリック・タップすると展開)

require('dotenv').config()
const express = require('express');
const httpProxy = require('http-proxy');
const bodyParser = require('body-parser');

const targetUrl = process.env.TARGET_URL;
const app = express();
const proxy = httpProxy.createProxyServer({});

// JSONボディを解析
app.use(bodyParser.json());

// プロキシするルートの定義
app.post('/greeting', (req, res) => {
  // クライアントのリクエストボディから'language'を取得
  // 存在しない場合は 'no-language' を変数にセット
  const language = req.body.language || 'no-language';
  
  // 'x-language'ヘッダーに'language'の値を設定
  req.headers['x-language'] = language;

  // content-lengthを後で作り直すので一旦削除
  delete req.headers['content-length'];

  proxy.web(req, res, { target: targetUrl });
});

// プロキシリクエストにJSONボディを手動で書き込む
proxy.on('proxyReq', (proxyReq, req, res, options) => {
  if (req.body) {
    const bodyData = JSON.stringify(req.body);
    // Content-Lengthに新しい値を設定
    proxyReq.setHeader('Content-Length', Buffer.byteLength(bodyData));
    // ボディデータをプロキシリクエストに書き込む
    proxyReq.write(bodyData);
  }
});

// エラーハンドリング
proxy.on('error', (err, req, res) => {
  console.error('Proxy error:', err);
  res.status(500).send('Proxy Error');
});

# 3000ポートでリッスン
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Proxy server is running on port ${PORT}`);
});

プロキシをコンテナで起動

今回、プロキシはコンテナで起動します。ちょっとした検証の時にローカル環境を汚さずに使い捨ての環境を作れるのが良いですね。

Dockerfileの中身はこんな感じです。非常にシンプルです。

#Node.js 22の公式イメージ(slim版)をベースとして使用
FROM node:22-slim

#コンテナ内の作業ディレクトリを設定
WORKDIR /app

#パッケージの依存関係をインストール
COPY package*.json ./
RUN npm install

#.envファイルとapp.jsをコンテナ内の作業ディレクトリにコピー
COPY .env app.js ./

#Node.jsでアプリケーションを起動
CMD ["node", "app.js"]


package.jsonの中身は以下です。さらに輪をかけてシンプルですね。

{
  "dependencies": {
    "dotenv": "^17.2.2",
    "express": "^5.1.0",
    "http-proxy": "^1.18.1"
  }
}


.envの中身は以下です。プロキシのターゲットのURLを設定します。ホスト名はALBのDNS名です。さらにさらに輪をかけて以下略。

TARGET_URL=http://proxy-test-lb-xxxxxxxxx.ap-northeast-1.elb.amazonaws.com


最終的なディレクトリ構造は以下です。test.httpはREST Client用なので、コンテナには不要です。

.
├── test
│   └── test.http
├── .env
├── app.js
├── Dockerfile
├── package-lock.json
└── package.json


ファイルが準備できたら以下のコマンドを実行し、コンテナをビルド、起動します。(使い捨てのコンテナなので、docker runには --rmをつけてもよかったかも。)

docker build -t http-proxy .
docker run -p 3000:3000 http-proxy

実際に検証してみる

それでは、準備が整ったので実際に検証してみます。POSTリクエストのボディの language の値を変えながら、レスポンスを確認していきます。

まずは、languageJapanese をセットしてPOSTリクエストを送ります。

languageがJapaneseの場合

想定通り、「こんにちは!」というレスポンスが返ってきました。

次に、languageEnglish をセットしてPOSTリクエストを送ります。

languageがEnglishの場合

こちらも想定通り、「Hello!」というレスポンスが返ってきました。

最後に、languageJSONから除外してPOSTします。この場合、プロキシは x-languageno-lunguage という値をセットする想定です。

リクエストにlanguageが存在しない場合

想定通り、「Please tell me your language!」というレスポンスが返ってきました。

(オマケ)POSTで送信するJSONをもう少し複雑にしてみる

JSONがあまりに簡素だったので、もう少し複雑なJSONにしてみます。自分でキーや値を考えるのが面倒だったので、生成AIに架空の人物のパラメータを適当に作ってもらいました。

languageがJapaneseの場合


languageがEnglishの場合


JSONが複雑になっても機能しているようで安心しました。


最後に、URLのパスを /greeting ではなくルートパス / にしてみます。

リクエストパスが / の場合

エラーが返ってきました。これはALBのデフォルトルールではなく、プロキシからのリプライのようです。今回プロキシには /greeting 以外のパスに対する動作を設定していないため、リクエストがALBまで送信されませんでした。

おわりに

というわけで、POSTリクエストのボディからカスタムヘッダを作る検証でした。POSTリクエストのボディの内容で処理を振り分けたい場合には使えるかもしれません。
なお、今回はヘッダにフォーカスしていたので、ボディのJSONがターゲットまで正しく送付されていることを確認しませんでした。今度プロキシの検証をする時は、ボディの確認もしっかり行いたいと思います。

今回のブログは以上です。何か少しでも参考になることがあれば幸いです。

極めて個人的な、AWS Summit 2025の感想と反省

2023年、2024年に引き続き、今年もAWS Summitに一般参加者として参加してきました。

昨年、Summit参加後に個人的な反省点をブログに残していたのですが、今年も個人的な振り返りを行い、昨年と比べて改善した部分や引き続き反省が残る部分などを、自分用の備忘として残したいと思います。
なお、昨年書いた個人的な反省ブログは以下です。

yuy-83.hatenablog.com

個人的な感想

まずは参加してみての個人的な感想を記載します。

AWS Builders' Fairがとても面白かった

AWS Builders' Fairは、AWSの中の人がAWSサービスを活用して開発したアプリ・ソリューションをデモ展示するエリアです。業務に活用できそうな実践的なものから、遊び心溢れるものまで色々とありました。詳細はAWS Summitの公式ページを参照ください。

aws.amazon.com

この展示で非常に良かった点は、実際に開発した人がその場にいて、色々と質問できる点です。アーキテクチャーの細かい事から、AWSサービスの基本的な事まで、何でも丁寧に解説してくださり、とてもありがたかったです。デモを見ているだけでも楽しいですが、中身について色々と質問すると、より楽しいと感じました。(昨年に比べると、自分の知見も少しだけ増えたので、昨年よりは色々と会話できたように思います。)
結局、今年はBuilders' Fairに一番長くいた気がします。

実を言うと、最初にBuilders' Fairを通った際には、あまりの人の多さに気圧されて、解説者の方と会話することを諦めそうになっていました。ただ、その後に偶然会った友人に、「Builders' Fairおすすめですよ!」と言われたので、それならと気合いを入れ直して突撃しました。結果的に非常に実りある時間となりました。

あの時背中を押してくれた友人に心から感謝しています。

Expoの展示はどこも人がスゴい。。

上述のBuilders' Fairだけでなく、Expoの展示やミニステージはどこも人が凄かったです。解説者の方に色々聞けるチャンスとあって、話し込んでいる人もたくさんいました。「行けばいつでも話が聞ける」と思ってはいけないのだな、、と学習しました。

生成AIに関するブース・セッションは去年に引き続き多かったが、、

昨年の感想でも「生成AIに関するブース・セッションが非常に多かった」と書いたのですが、今年もやっぱり多かったです。ただ、今年はAIエージェントに関する話が多かった印象でした。生成AI(とそれにまつわるツールやサービス)の進化が速いので、1年で展示の内容がガラッと変わった気がします。この調子だと、来年はまた全然違う展示になっているのかなあ、と何となく思いました。

リアルで友人知人に会えて嬉しかった

これは昨年も書いたことですが、今年も何人か友人知人に会うことが出来、色々と会話が出来て嬉しかったです。特に嬉しかったのが、前職時代、あまり一緒に仕事をする機会が無かった同僚が自分の事を覚えていてくれて、向こうから声をかけてくれたことです。(転職して1年経ったので、そろそろ皆の記憶から消えるころかな、と思っていたので。。。w)
私にとってAWS Summitは、定期的に友人知人と会える貴重な機会にもなっています。

2年振りに表彰してもらえて嬉しかった

昨年のブログで、「APNパートナーじゃなくなった」「表彰されている人たちが羨ましい」と書いたのですが、その後よくよく確認したら、自分の所属企業も応募できることが判明しました。そこで今年は改めて 2025 Japan All AWS Certifications Engineers に応募し、表彰いただくことが出来ました。現職の同僚複数名と一緒に受賞できたことも嬉しかったです。

一方で、2025 Japan AWS Top Engineers にも初めて応募してみたのですが、こちらは残念ながら受賞できませんでした。今年は活動の幅も深さも拡げられるように精進したいと思います。

個人的な反省点

ここからは、今年の反省点を綴っていきたいと思います。

Expoの展示内容を事前にもっと調べておくべきだった

今回、セッションについては事前に内容を確認して早めに予約してあったのですが、Expoの内容は全く確認していませんでした。「その場で気になったものを見れば良いだろう」くらいに思っていたためです。

ところが、いざ行ってみると、展示が非常にたくさんあり、内容を把握するだけでも一苦労です。結局全部は把握できず、後日、他の人の振り返りを見聞きして「そんな面白そうなブースがあったのか。。行けば良かった。。」と後悔することもありました。

また、上述通り、各ブースが人でごった返しているため、解説してくれる人を捕まえるのも大変で、一つの展示で想像以上に時間が取られました。

さらに、Expoにはミニステージがあり、ここでもセッションが行われていました。個人的には、通常のパートナーセッションや事例セッション以上に興味を惹かれる内容もありました。事前にタイムテーブルを把握していなかったことを後悔しました。

初日はもっと早く行くべきだった

今回、初日はかなりゆっくり行って、現地到着が午前11時半くらいでした。セッションよりもExpoメインで回るつもりだったため、それくらいでも大丈夫かなと思っていたのですが、実際には全然時間が足りませんでした。。上述通り、Expoは想像以上に時間がかかりましたし、せっかく友人知人に会えたら会話もしたい所です。その辺が考慮できていなかったです。

企業ブースも見ておけばよかった

今回、企業ブースはほとんど見ませんでした。自分は調達担当ではないので、後で営業メールがたくさん来ても困るだけだしなあ、という思いがあったためです。
とはいえ、AWSのビジネス活用事例が色々見れますし、普段だったら聞けないような話を聞けるチャンスでもあるので、尻込みしないで足を運んだ方が良かったかな、と思いました。

体力や気合いがもう一つ足りなかった

参加前は、普段リモートワーク中心とはいえ、一応最低限の運動はしているし、体力的には大丈夫だろうと考えていました。ただ、1年振りに参加して改めて思い出しましたが、人混みに突撃して解説者の方をつかまえたり、人に見られながらアトラクション(?)を体験したり、企業ブースの呼び込みをかわしたりするのは、単に歩き回るのとはまた違った体力の消耗がありました。
結果、体力や気合いが足りず、体験型のブースからは少し足が遠のいてしまいました。

ただ、こういった体験やFace to Faceの会話こそ、リアルイベントの醍醐味かと思いますので、来年は心身のコンディションを整えて、100%イベントを楽しみ、学びたいと思います。

おわりに

以上、極めて個人的なAWS Summit 2025の感想と反省でした。
今回でSummitに参加するのは3回目になります。まだまだ一参加者としての反省点は多いものの、参加する度に充実度が増しています(※ここでいう充実度は、Summitの内容そのものの話ではなく、私が参加者としてSummitを上手く活用できているか、という話です)。
自分の知見が増えれば、ブースでの会話がより充実しますし、セッションの理解度も深まって、より多くのことを吸収できると感じています。
AWS Summitは、私自身がこの1年で多少なりとも成長したかどうかを感覚的に測る場にもなっています。

来年はもっと楽しみながら、色々と吸収できるようになりたいと思います。

はじめてのStrands AgentsでAzure OpenAIを利用してみた

本日、はじめてStrands Agentsをインストールし、触ってみました。

通常、とりあえず動かしてみる場合はデフォルトのエージェント(オレゴンリージョンのBedrockでClaude3.7 Sonnet)を使うケースが多いかと思いますが、今回はちょっとした事情でAzure OpenAIを使いたかったので、方法を調べてみました。

しかしピンポイントで「これ」という情報がなく、色々と試行錯誤した結果、最終的に何とか動かすことが出来ました。

せっかくなので、とりあえず上手くいった方法を備忘として書き記しておきます。

はじめにお断り

  1. 今回、Strands Agentsのモジュールの一部に手を加えました。とりあえず動くことだけ確認しましたが、もしかしたら予期せぬ影響があるかもしれないので、この方法を参考にされる場合は、自己責任でご利用いただければ幸いです。
  2. 今後、Strands Agentsのバージョンが上がった場合、想定通り動かなくなる可能性があります。とりあえず、後述の動作確認環境では動いたとご認識いただければ幸いです。

動作環境

pip listの結果だけ記載しておきます。

pip list(クリック/タップすると展開されます)

Package                            Version
---------------------------------- -----------
annotated-types                    0.7.0
anyio                              4.9.0
attrs                              25.3.0
aws-requests-auth                  0.4.3
boto3                              1.39.4
botocore                           1.39.4
certifi                            2025.7.9
charset-normalizer                 3.4.2
click                              8.2.1
colorama                           0.4.6
dill                               0.4.0
distro                             1.9.0
docstring_parser                   0.16
h11                                0.16.0
halo                               0.0.31
httpcore                           1.0.9
httpx                              0.28.1
httpx-sse                          0.4.1
idna                               3.10
importlib_metadata                 8.7.0
jiter                              0.10.0
jmespath                           1.0.1
jsonschema                         4.24.0
jsonschema-specifications          2025.4.1
log-symbols                        0.0.14
markdown-it-py                     3.0.0
mcp                                1.11.0
mdurl                              0.1.2
mpmath                             1.3.0
ollama                             0.5.1
openai                             1.95.0
opentelemetry-api                  1.34.1
opentelemetry-sdk                  1.34.1
opentelemetry-semantic-conventions 0.55b1
pillow                             11.3.0
pip                                25.1.1
prompt_toolkit                     3.0.51
pydantic                           2.11.7
pydantic_core                      2.33.2
pydantic-settings                  2.10.1
Pygments                           2.19.2
PyJWT                              2.10.1
python-dateutil                    2.9.0.post0
python-dotenv                      1.1.1
python-multipart                   0.0.20
pywin32                            310
referencing                        0.36.2
requests                           2.32.4
rich                               14.0.0
rpds-py                            0.26.0
s3transfer                         0.13.0
setuptools                         80.3.1
six                                1.17.0
slack_bolt                         1.23.0
slack_sdk                          3.36.0
sniffio                            1.3.1
spinners                           0.0.24
sse-starlette                      2.4.1
starlette                          0.47.1
strands-agents                     0.2.1
strands-agents-builder             0.1.5
strands-agents-tools               0.1.8
sympy                              1.14.0
tenacity                           9.1.2
termcolor                          3.1.0
tqdm                               4.67.1
typing_extensions                  4.14.1
typing-inspection                  0.4.1
tzdata                             2025.2
urllib3                            2.5.0
uvicorn                            0.35.0
watchdog                           6.0.0
wcwidth                            0.2.13
zipp                               3.23.0

結論

Strands Agentsのモジュールである openai.pyOpenAIConfigクラスの、以下の部分を変更します。

openai.py変更箇所

# 変更前
self.client = openai.OpenAI(**client_args)

# 変更後
self.client = openai.AzureOpenAI(**client_args)

変更はこの1箇所だけです。一応、変更後の openai.pyの全コードを記載します。

openai.py(クリック/タップすると展開されます)

"""OpenAI model provider.

- Docs: https://platform.openai.com/docs/overview
"""

import logging
from typing import Any, AsyncGenerator, Optional, Protocol, Type, TypedDict, TypeVar, Union, cast

import openai
from openai.types.chat.parsed_chat_completion import ParsedChatCompletion
from pydantic import BaseModel
from typing_extensions import Unpack, override

from ..types.content import Messages
from ..types.models import OpenAIModel as SAOpenAIModel

logger = logging.getLogger(__name__)

T = TypeVar("T", bound=BaseModel)


class Client(Protocol):
    """Protocol defining the OpenAI-compatible interface for the underlying provider client."""

    @property
    # pragma: no cover
    def chat(self) -> Any:
        """Chat completions interface."""
        ...


class OpenAIModel(SAOpenAIModel):
    """OpenAI model provider implementation."""

    client: Client

    class OpenAIConfig(TypedDict, total=False):
        """Configuration options for OpenAI models.

        Attributes:
            model_id: Model ID (e.g., "gpt-4o").
                For a complete list of supported models, see https://platform.openai.com/docs/models.
            params: Model parameters (e.g., max_tokens).
                For a complete list of supported parameters, see
                https://platform.openai.com/docs/api-reference/chat/create.
        """

        model_id: str
        params: Optional[dict[str, Any]]

    def __init__(self, client_args: Optional[dict[str, Any]] = None, **model_config: Unpack[OpenAIConfig]) -> None:
        """Initialize provider instance.

        Args:
            client_args: Arguments for the OpenAI client.
                For a complete list of supported arguments, see https://pypi.org/project/openai/.
            **model_config: Configuration options for the OpenAI model.
        """
        self.config = dict(model_config)

        logger.debug("config=<%s> | initializing", self.config)

        client_args = client_args or {}
        #self.client = openai.OpenAI(**client_args)     #変更前
        self.client = openai.AzureOpenAI(**client_args)  #変更後

    @override
    def update_config(self, **model_config: Unpack[OpenAIConfig]) -> None:  # type: ignore[override]
        """Update the OpenAI model configuration with the provided arguments.

        Args:
            **model_config: Configuration overrides.
        """
        self.config.update(model_config)

    @override
    def get_config(self) -> OpenAIConfig:
        """Get the OpenAI model configuration.

        Returns:
            The OpenAI model configuration.
        """
        return cast(OpenAIModel.OpenAIConfig, self.config)

    @override
    async def stream(self, request: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None]:
        """Send the request to the OpenAI model and get the streaming response.

        Args:
            request: The formatted request to send to the OpenAI model.

        Returns:
            An iterable of response events from the OpenAI model.
        """
        response = self.client.chat.completions.create(**request)

        yield {"chunk_type": "message_start"}
        yield {"chunk_type": "content_start", "data_type": "text"}

        tool_calls: dict[int, list[Any]] = {}

        for event in response:
            # Defensive: skip events with empty or missing choices
            if not getattr(event, "choices", None):
                continue
            choice = event.choices[0]

            if choice.delta.content:
                yield {"chunk_type": "content_delta", "data_type": "text", "data": choice.delta.content}

            if hasattr(choice.delta, "reasoning_content") and choice.delta.reasoning_content:
                yield {
                    "chunk_type": "content_delta",
                    "data_type": "reasoning_content",
                    "data": choice.delta.reasoning_content,
                }

            for tool_call in choice.delta.tool_calls or []:
                tool_calls.setdefault(tool_call.index, []).append(tool_call)

            if choice.finish_reason:
                break

        yield {"chunk_type": "content_stop", "data_type": "text"}

        for tool_deltas in tool_calls.values():
            yield {"chunk_type": "content_start", "data_type": "tool", "data": tool_deltas[0]}

            for tool_delta in tool_deltas:
                yield {"chunk_type": "content_delta", "data_type": "tool", "data": tool_delta}

            yield {"chunk_type": "content_stop", "data_type": "tool"}

        yield {"chunk_type": "message_stop", "data": choice.finish_reason}

        # Skip remaining events as we don't have use for anything except the final usage payload
        for event in response:
            _ = event

        yield {"chunk_type": "metadata", "data": event.usage}

    @override
    async def structured_output(
        self, output_model: Type[T], prompt: Messages
    ) -> AsyncGenerator[dict[str, Union[T, Any]], None]:
        """Get structured output from the model.

        Args:
            output_model: The output model to use for the agent.
            prompt: The prompt messages to use for the agent.

        Yields:
            Model events with the last being the structured output.
        """
        response: ParsedChatCompletion = self.client.beta.chat.completions.parse(  # type: ignore
            model=self.get_config()["model_id"],
            messages=super().format_request(prompt)["messages"],
            response_format=output_model,
        )

        parsed: T | None = None
        # Find the first choice with tool_calls
        if len(response.choices) > 1:
            raise ValueError("Multiple choices found in the OpenAI response.")

        for choice in response.choices:
            if isinstance(choice.message.parsed, output_model):
                parsed = choice.message.parsed
                break

        if parsed:
            yield {"output": parsed}
        else:
            raise ValueError("No valid tool use or tool use input was found in the OpenAI response.")


あとはStrands Agentの公式ドキュメントに記載のコードとほとんど同じですが、client_argsが少し異なります。具体的には、api_key に加えて、api_versionazure_endpointを加えます。コードの全体は以下です。

サンプルコード(クリック/タップすると展開されます)

from strands import Agent
from strands.models.openai import OpenAIModel
from strands_tools import calculator

model = OpenAIModel(
    client_args={
        "api_key": "<KEY>",
        "azure_endpoint": "https://xxxxxxxx.openai.azure.com/",
        "api_version": "2024-12-01-preview"
    },
    # **model_config
    model_id="gpt-4o",
    params={
        "max_tokens": 1000,
        "temperature": 0.7,
    }
)

agent = Agent(model=model, tools=[calculator])
message = """
Tell me about 1&2.
1. Calculate 23456 * 9876
2. Your LLM model. Name and Release date and feature.
回答は日本語で出力してください。
"""

agent(message)


以上です。

試行錯誤の経緯

ここからは、試行錯誤の経緯を書き残したいと思います。ご興味のある方はご笑覧ください。

まずはAzure OpenAIの準備

とりあえず、Azure OpenAI側でgpt-4oをデプロイしました。APIキーとエンドポイントURLが発行されました。

Azure全然使い慣れていないのでおっかなびっくり

次にStrands Agentsの公式ドキュメントを見てみる

Strands Agentsを触るのが初めてだったので、とりあえず公式ドキュメントを見てみたところ、OpenAIを利用する方法が載っていました。

strandsagents.com

「これはすんなりいけるか!?」と期待に胸を膨らませて内容を流し読みしてみました。
サンプルコードと、To connect to a custom OpenAI-compatible server, you will pass in its base_url into the client_args:の注意書きがあったため、base_urlに AzureのエンドポイントURLを指定してみました。

失敗コード(クリック/タップすると展開されます)

from strands import Agent
from strands.models.openai import OpenAIModel
from strands_tools import calculator

model = OpenAIModel(
    client_args={
        "api_key": "xxxxxxxx",
        "base_url": "https://xxxxxxxx.openai.azure.com/",
    },
    # **model_config
    model_id="gpt-4o",
    params={
        "max_tokens": 1000,
        "temperature": 0.7,
    }
)

agent = Agent(model=model, tools=[calculator])
message = """
Tell me about 1&2.
1. Calculate 23456 * 9876
2. Your LLM model. Name and Release date and feature.
回答は日本語で出力してください。
"""

agent(message)

とりあえずこれで試しに実行してみましたが、Resource not foundというエラーが出てしまいました。

client_argsの正解を求めてopenai.pyの中身を探る

base_urlが不発に終わったため、他のキーを指定する必要があるのかもしれないと考えました。ただ、そもそもclient_argsに指定可能なキーが分かりません。「困ったな」と思いながら改めてコードを見てみました。

失敗コード(一部抜粋)

from strands.models.openai import OpenAIModel

model = OpenAIModel(
    client_args={

strands/modelsディレクトリ配下のopenaiというモジュールをインポートして使っているようです。とりあえず、このモジュールの中身を見てみることにしました。
中身を見てみると、175行くらいのコードでした。ヒントを探していると、以下の記載を見つけました。

strands/models/openai.py(一部抜粋)

    def __init__(self, client_args: Optional[dict[str, Any]] = None, **model_config: Unpack[OpenAIConfig]) -> None:
        """Initialize provider instance.

        Args:
            client_args: Arguments for the OpenAI client.
                For a complete list of supported arguments, see https://pypi.org/project/openai/.
            **model_config: Configuration options for the OpenAI model.
        """
        self.config = dict(model_config)

        logger.debug("config=<%s> | initializing", self.config)

        client_args = client_args or {}
        self.client = openai.OpenAI(**client_args)

client_argsがサポートしている引数の一覧が載っているサイトがあるようなので、今度はそちらを見てみることにしました。

OpenAI Python API library

というわけで、以下のサイトを確認してみました。

pypi.org

こちらのサイトを眺めていると、OpenAIとAzure OpenAIで、clientインスタンスの作り方が違うことに気づきました。

OpenAI(一部抜粋)

from openai import OpenAI

client = OpenAI(
    # This is the default and can be omitted
    api_key=os.environ.get("OPENAI_API_KEY"),
)

Azure OpenAI(一部抜粋)

from openai import AzureOpenAI

# gets the API Key from environment variable AZURE_OPENAI_API_KEY
client = AzureOpenAI(
    # https://learn.microsoft.com/azure/ai-services/openai/reference#rest-api-versioning
    api_version="2023-07-01-preview",
    # https://learn.microsoft.com/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource
    azure_endpoint="https://example-endpoint.openai.azure.com",
)


そのうえで改めてopenai.pyを確認してみました。

strands/models/openai.py(一部抜粋)

import openai
(中略)
        self.client = openai.OpenAI(**client_args)

どうやら上述のOpenAIのサンプルとほぼ同じようです。ということは、ここの記載をAzure OpenAI用に変えてあげれば動きそうです。

修正と動作確認

そこでself.clientの記述を以下の通り変更してみました。

openai.py変更箇所(再掲)

# 変更前
self.client = openai.OpenAI(**client_args)

# 変更後
self.client = openai.AzureOpenAI(**client_args)


そのうえで、サンプルコードのclient_argsのキーは、Azure OpenAI用に修正しました。

サンプルコード再掲(クリック/タップすると展開されます)

from strands import Agent
from strands.models.openai import OpenAIModel
from strands_tools import calculator

model = OpenAIModel(
    client_args={
        "api_key": "xxxxxxxx",
        "azure_endpoint": "https://xxxxxxxx.openai.azure.com/",
        "api_version": "2024-12-01-preview"
    },
    # **model_config
    model_id="gpt-4o",
    params={
        "max_tokens": 1000,
        "temperature": 0.7,
    }
)

agent = Agent(model=model, tools=[calculator])
message = """
Tell me about 1&2.
1. Calculate 23456 * 9876
2. Your LLM model. Name and Release date and feature.
回答は日本語で出力してください。
"""

agent(message)


これでプログラムを実行してみました。

PS C:\Users\YukiYamashita\Documents\StrandsAgent\localtest> python .\strands_agent_sample.py

Tool #1: calculator

Tool #2: calculator
1. 計算結果は以下の通りです:
23456 * 9876 = **231651456**

2. 私の言語モデルは、OpenAIのGPTシリーズの1つです。最新のバージョンはGPT-4であり、2023年3月にリリースされました。このモデルは、より高い正確
性、コンテキスト理解、そして幅広い応用に対応する能力を持っています。
PS C:\Users\YukiYamashita\Documents\StrandsAgent\localtest> 

Toolが2回出てきているのが気になりますが、とりあえずAzure OpenAIにはアクセスできたようです。
Azure側のメトリクスにもリクエストが記録されていました。

確かにアクセスの痕跡が。。!!

終わりに

というわけで、何とかAzure OpenAIのモデルを利用することが出来ました。まだMCPも利用していないし、もう少し使ってみないと期待通りに動いてくれるか分かりませんが、とりあえず第一関門を突破できて良かったです。

私はプログラミング経験が浅いので、Strands Agentsが使えたことよりも、モジュールの中身を見たりしながらトラシュー出来たことの方が、個人的に満足度が高かったです。(たとえ非常に粗いやり方だったとしても)

生成AIのキャッチアップはずっと後手後手になっていた中で、ようやく最近のソリューションを触ったのに、いきなり変則的な内容になってしまいました。この後はMCPを使ったり、王道のやり方を試していけたらと思います。

今回のブログは以上です。何か少しでも参考になることがあれば幸いです。

Amazon Q Developer CLIで将棋ゲームを作ってみた

生成AIで次々と新しいツールやソリューションが出てきて、さて何から始めれば良いやら、、と途方に暮れていたところ、面白そうなキャンペーンが発表されました。

community.aws

Amazon Q Developer CLIとのチャットのみでゲームを作成すると、記念Tシャツが貰えるそうです。

実際にゲームを作成した方のブログ記事をいくつか拝見しましたが、1~2時間程度で作成されている方もいらっしゃいました。

zenn.dev

blog.kazzpapa3.com

zenn.dev

なんだか面白そうだし、「チャットのみで作る」という制限がある方が割り切ってやれるので、自分もやってみる事にしました。

事前準備

事前準備については、以下のクラスメソッドさんのブログでとても分かりやすく説明されていましたので、こちらを参考にさせていただきました。

dev.classmethod.jp

特に迷うこともなく、Amazon Q Developer CLIをローカル環境で立ち上げることが出来ました。

これが噂のAmazon Q Developer CLI。。。!!
Claude-3.7-Sonnetが実際のチャット相手のようです。

指示を出してみる

さて、いよいよゲームを作っていきます。(作るのはAmazon Qですが。。)タイトル通り、将棋ゲームを作ってみようと思います。ルールや動きがハッキリしており、「将棋のゲームを作って」と伝えるだけで、ある程度まで形にしてくれそうな期待があるためです。

プロトタイプの作成

まずはシンプルなプロンプトで、単純なプロトタイプを作るようにお願いしてみます。以下のプロンプトを投げました。

> pygameを利用してゲームを作成します。将棋ゲームのサンプルを作ってください。
細かいことはあとで指示するので、まずはプロトタイプをお願いします。全てローカルに保存してください。

すると早速、「将棋ゲームのプロトタイプをPygameで作成しましょう。まずは基本的な将棋盤と駒の表示、駒の移動ができるシンプルなバージョンを 作ります。」と言って、作業を開始してくれました。まずは作業用のディレクトリを作成するようです。

1つ1つのアクションに対して、許可するかどうか選択可能。

実行しようとしているコマンドと、その目的が表示されるので、許可する/しないを選択可能です。このように、Qがこれから何をしようとしているのか具体的に示してくれるので、1つ1つのアクションに対してチェックが可能です。いちいちチェックするのが面倒な場合は、「t」を入力するとチェックを省略して作業させることが可能なようです。
また、これは後で気付いたのですが、「y / n / t」だけでなく、ここで追加の指示を出すことも可能です。
今回はQの動きをじっくり見てみたいので、基本的に「t」は使わず、「y」で許可を出しながら進めてみます。

ディレクトリを作成した後、早速Pythonのコードを書き始めました。数分もしないうちに、250行程度のコードが出来上がりました。

みるみるうちにコードが書かれていく。。

あっという間に250行程度のコードが出来上がり。

この時点ではまだファイルは作成されておらず、「y」を入力して許可した段階で、実際にpyファイルが作成されます。

指示しなくてもREADMEを作成してくれている。
そして今度はREADMEを作成し始めました。これは特に指示したわけではないですが、自主的に作ってくれました。
実行方法や操作方法、注意点を記載してくれている。親切。。
実装済みの機能や、実行方法・操作方法などを記載してくれていますね。ただ、現時点では駒はどこにでも動かすことができ、王手や詰みの判定もなく、終了条件も無いようです。まずは最低限の動作のみ実装したという所でしょうか。

ひとまず「y」を押して許可すると、READMEファイルを作成したうえで、ここまでの作業のサマリを報告してくれました。
分かりやすく作業内容を報告してくれる。
初回なので、READMEに書いてあることと大体同じ内容になっていますね。おそらく今後は、前回から変更した箇所などを報告してくれるのではないかと思います。

とりあえず、どんなものが出来たのか、実際に見てみましょう。pyファイルを起動します。
駒の区別が無い状態
駒の区別が全く無いですね。なんとなく将棋っぽい形だけしている、という感じです。
とりあえず動かしてみましょう。
今はどこにでも動ける状態
敵の駒をすり抜けることも出来る状態
動かすことが出来ました。説明通りどこにでも動くことができます。敵の駒をすり抜けて移動することも可能な状態ですね。ただし、敵の駒がある位置に移動すると、ちゃんと駒を取ることが出来ました。

プロトタイプの時点でもう少し将棋っぽいものが出来ることを期待していたのですが、Qの考えるプロトタイプはここまでだったようです。ただし、将棋っぽくするために追加で必要な機能は認識しているようなので、追加の指示出しはさほど難しくなさそうです。続けていきましょう。

駒に文字を入れて、動きも正確にする

さきほど、Qはこのように言っていました。

## 制限事項

このプロトタイプでは、以下の機能はまだ実装されていません:

• 各駒の正確な動きのルール(現在はどこにでも移動可能)
• 成り(駒の昇格)機能
• 持ち駒を盤上に打つ機能
• 王手や詰みの判定
• ゲームの終了条件

これらの機能は、ご指示に応じて追加実装することができます。何か特定の機能を優先して実装したい場合や、デザインの変更などがありましたらお知らせください。

ということなので、書いてくれている通り、機能を追加実装するよう指示していきます。「一度に全て実装させようとすると上手くいかないかもしれない」と何となく不安になったため、まずは一つ目の「各駒の正確な動きのルール」を実装することにしました。また、駒に文字がないと味気ないので、合わせて入れてもらうことにしました。

> 各駒の正確な動きのルールを追加してください。また、駒の種別が分かるように、駒に「歩」や「金」などの文字を入れてください。

追加で出した指示はたったこれだけです。しかし特に追加の質問をすることもなく、すぐにコードを書き始めました。やはり、将棋の駒の動きは説明しなくても認識してくれているようです。
数分後、以下の報告が返ってきました。

将棋ゲームに各駒の正確な動きのルールを追加し、駒に漢字を表示するように修正しました。以下が主な変更点です:

## 追加・改善した機能

1. 駒の漢字表示
   • 各駒に「歩」「香」「桂」「銀」「金」「王/玉」「飛」「角」などの漢字を表示
   • 成り駒も「と」「杏」「圭」「全」「馬」「龍」と適切に表示

2. 各駒の正確な動きのルール
   • 歩兵:前に1マスのみ移動可能
   • 香車:前方向に何マスでも直進可能
   • 桂馬:L字型の動き(前に2マス、横に1マス)
   • 銀将:斜め前3方向と斜め後ろ2方向に移動可能
   • 金将:前後左右と斜め前に移動可能
   • 王将/玉将:周囲8マスに移動可能
   • 角行:斜め方向に何マスでも移動可能
   • 飛車:縦横方向に何マスでも移動可能

3. 成り駒の実装
   • 敵陣3段目に入ると自動的に成る機能を追加
   • 各成り駒の適切な動きを実装(例:成り角は馬として斜め+十字1マス)

4. 移動可能なマスのハイライト表示
   • 駒を選択すると、その駒が移動できるマスが緑色でハイライト表示

## 実行方法

cd shogi_game
python main.py


## 操作方法

1. 自分の手番の駒をクリックして選択
2. 緑色にハイライトされた移動可能なマスをクリックして駒を移動
3. 相手の駒を取ると持ち駒として画面の上下に表示
4. 敵陣3段目に入ると自動的に成ります

まだ実装されていない機能(持ち駒を打つ、王手判定など)は今後の指示に応じて追加できます。何か特定の機能を優先して実装したい場合はお知らせください。

成りの機能も入れてくれたようです。また、移動できる箇所が緑色にハイライトするとのことです。細かく指示しなくても、色々と良い感じに実装してくれるのでありがたいですね。
それでは再度起動してみましょう。

一気に将棋っぽい見た目に
おお、ちゃんと駒に文字が入っていますね!!やはり文字が入ると一気に将棋っぽくなります。駒を選択して動きがどのようになっているのかも見てみましょう。
ちゃんと将棋のルールに従った駒の動きになっている。。!!
良いですね!将棋のルールに従った駒の動きになっていそうです。それでは、先手と後手で交互に駒を動かしてみましょう。
…おや?
…あれ?なんかおかしいですね。。。先手番(上側)の歩は下に進んでくれないと困るのですが。。後手番はどうなっているんでしょうか。
上下の動きと、成り判定が逆
後手番は下に進みますね。。動きが逆になっているようです。また、成り判定も逆のようで、動いた瞬間に「と金」になってしまいました。
とはいえ、先手番と後手番の動き・成り判定を逆にすれば正しくなるわけですから、そこまで大がかりな修正にはならないでしょう。早速修正してもらいます。

駒の動きと成り判定の修正を依頼

修正の指示について、どの程度細かく指摘すれば理解してもらえるか分からなかったので、とりあえず具体例を交えつつ指示してみました。

> 先手番の歩が上のマスに進むようになっていました。先手番と後手番では進む方向が逆になるはずです。つまり、先手番の歩は下に向かって進み、後手番の歩は上に向かって進みます。香や桂も同様に、先手番と後手番では上下の動きが逆になります。修正をお願いします。

すると再びQがコードを修正し始めました。途中で何度か変更内容を通知してきましたが、逐一チェックはせず「y」で飛ばし、最後の結果報告だけ読んでみることにしました。

将棋ゲームのコードの修正が完了しました。先手と後手の駒の動きの方向を正しく修正しました。

主な変更点は以下の通りです:

1. 駒の動きの方向を修正
   • 先手(player=1)の歩は上に進む(row-1)
   • 後手(player=2)の歩は下に進む(row+1)
   • 香車、桂馬、銀将、金将も同様に方向を修正

2. 各駒の動きを正確に実装
   • 先手と後手で動きの方向が逆になるように修正
   • 金将の動きを先手と後手で正しく定義

3. READMEも更新して、各駒の動きの説明を先手と後手で分けて記載

これで先手の駒は上方向(盤面の上側)に進み、後手の駒は下方向(盤面の下側)に進むように修正されました。コードの変更は完了しています。

まだ「先手は上に、後手は下に」と言ってますね。。仕方ないので、もう少し具体的に指摘してみます。

> 逆です。先手の駒は下方向(盤面の下側)に進み、後手の駒は上方向(盤面の上側)に進むように修正してください。先手(player=1)の歩は下に進む(row+1)、後手(player=2)の歩は上に進む(row-1)とすれば直るはずです。

すると、話が通じたのか、再度修正を開始してくれました。

ご指摘ありがとうございます。おっしゃる通り、先手と後手の進行方向を逆に修正する必要があります。先手は下方向に進み、後手は上方向に進むように修正します。

(途中省略)

## 修正内容

1. 駒の進行方向を修正
   • 先手(player=1)の歩は下に進む(row+1)
   • 後手(player=2)の歩は上に進む(row-1)

2. 各駒の動きを正しく修正
   • 香車:先手は下方向、後手は上方向
   • 桂馬:先手は下に2マス横に1マス、後手は上に2マス横に1マス
   • 銀将:先手は下斜め3方向と上斜め2方向、後手は上斜め3方向と下斜め2方向
   • 金将:先手は上下左右と下斜め2方向、後手は上下左右と上斜め2方向

3. READMEも更新
   • 各駒の動きの説明を先手と後手で正しく記載

これで先手の駒は下方向(盤面の下側)に進み、後手の駒は上方向(盤面の上側)に進むように修正されました。コードの変更は完了しています。

どうやら直してくれたようなので、再度確認してみます。

正しい方向に進むようになった!

おお!正しい方向に進むようになりました。どうやら直ったようです!

おや??

と思ったのも束の間、1マス進んだ歩がいきなり「と」に成ってしまいました。。どうやら成り判定が直ってなかったようです。これも直してもらいましょう。先ほどの上下逆の件があったので、今回は最初からかなり具体的に指示してみます。

>駒の動きの修正はOKです。ただ、成りの判定が直っていないようですね。main.pyの408、409行目の部分です。先手の場合は上側3段、後手の場合は下側3段が敵陣となっていますが、これが逆です。先手の場合は下段3段、後手の場合は上段3段が敵陣です。修正をお願いします。

さて、これでどうでしょうか。先ほどの件があるので、今回は修正箇所をじっくり見てみます。

  400, 400:
  401, 401:         # 駒を移動
  402, 402:         self.grid[to_row][to_col] = self.grid[from_row][from_col]
  403, 403:         self.grid[from_row][from_col] = None
  404, 404:
  405, 405:         # 成りの判定(簡易版:敵陣3段目に入ったら成れる)
  406, 406:         piece = self.grid[to_row][to_col]
  407, 407:         if not piece.is_promoted and piece.name != "king" and piece.name != "gold":
- 408     :             # 先手の場合は上側3段、後手の場合は下側3段が敵陣
+      408:             # 先手の場合は上側3段(0,1,2)、後手の場合は下側3段(6,7,8)が敵陣
  409, 409:             promotion_zone = [0, 1, 2] if piece.player == 1 else [6, 7, 8]
  410, 410:             if to_row in promotion_zone or from_row in promotion_zone:
  411, 411:                 # 成るかどうかの選択は省略(自動的に成る)
  412, 412:                 if piece.name in ["pawn", "lance", "knight", "silver", "bishop", "rook"]:
  413, 413:                     piece.is_promoted = True


Allow this action? Use 't' to trust (always allow) this tool for the session. [y/n/t]:

直ってないですねこれは。。。もっと具体的に指摘しないとダメなのか。。。

> 違います。先手の場合は下段3段(6,7,8)、後手の場合は上段3段(0,1,2)が敵陣です。そのため、409行目も次のように変わる認識です。promotion_zone = [0, 1, 2] if piece.player == 2 else [6, 7, 8] もしくは promotion_zone = [6, 7, 8] if piece.player == 1 else [0, 1, 2]
ご指摘ありがとうございます。おっしゃる通りです。敵陣の設定が間違っていました。先手(player=1)の敵陣は上段3段(0,1,2)ではなく、下段3段(6,7,8)です。そして
後手(player=2)の敵陣は下段3段(6,7,8)ではなく、上段3段(0,1,2)です。

修正します。409行目を次のように変更します:
python
promotion_zone = [6, 7, 8] if piece.player == 1 else [0, 1, 2]


これで先手の駒は下段3段(6,7,8)に入ると成れるようになり、後手の駒は上段3段(0,1,2)に入ると成れるようになります。

> はい、それで問題ありませんので、その通りに修正をしてください。

🛠️  Using tool: fs_write
 ⋮
 ● Path: shogi_game/main.py

  400, 400:
  401, 401:         # 駒を移動
  402, 402:         self.grid[to_row][to_col] = self.grid[from_row][from_col]
  403, 403:         self.grid[from_row][from_col] = None
  404, 404:
  405, 405:         # 成りの判定(簡易版:敵陣3段目に入ったら成れる)
  406, 406:         piece = self.grid[to_row][to_col]
  407, 407:         if not piece.is_promoted and piece.name != "king" and piece.name != "gold":
- 408     :             # 先手の場合は上側3段、後手の場合は下側3段が敵陣
- 409     :             promotion_zone = [0, 1, 2] if piece.player == 1 else [6, 7, 8]
+      408:             # 先手の場合は下側3段(6,7,8)、後手の場合は上側3段(0,1,2)が敵陣
+      409:             promotion_zone = [6, 7, 8] if piece.player == 1 else [0, 1, 2]
  410, 410:             if to_row in promotion_zone or from_row in promotion_zone:
  411, 411:                 # 成るかどうかの選択は省略(自動的に成る)
  412, 412:                 if piece.name in ["pawn", "lance", "knight", "silver", "bishop", "rook"]:
  413, 413:                     piece.is_promoted = True


Allow this action? Use 't' to trust (always allow) this tool for the session. [y/n/t]:

>y

どうやら、今度は大丈夫そうです。

紆余曲折を経てそれっぽいものが出来上がり

上記の感じでミスが出始めたので、ここからは慎重になって、一度の指示では一つの機能だけ追加するようにして、少しずつ将棋ゲームを作っていきました。また、フリー素材の駒の画像を用意して、見た目を少しそれっぽくしたりしました。
そして、何とかそれっぽいものが作成できました。

とりあえずそれっぽくなった

途中でコードの中身を見て具体的な修正指示を出したりしたものの、プロンプトの指示だけでここまで出来ることは驚きでした。

この後について

とりあえず将棋の基本的な部分が出来たので、少しオリジナルの要素を追加してみたいと思います。駒にランダムな変化を与えるような特殊効果を実装したり、エフェクトや音をつけたりして、よりゲームっぽくすることにトライしてみます。



今回のブログは以上です。何か少しでも参考になることがあれば幸いです。

Private MarketplaceでBedrockのLLMモデルを制限する

最近(といっても1年以上前からでしょうが)、生成AIの進歩は目覚ましいものがありますね。IT業界では、常に話題の中心は生成AIという感じがします。
そこでこのビッグウェーブに乗って、私も一筆書きたいと思います。ただ、最新のソリューションの紹介や実際に使ってみた系の記事は既に出回っているし(そもそもキャッチアップが追い付いていないし)、すぐに古くなってしまいそうなので、世間であまり取り上げられていなさそうな、「Private MarketplaceでLLMモデルを制限する方法」について書きたいと思います。(私の認識不足なだけで、十分取り上げられていたらすみません。)

前提知識

AWS Marketplace

サードパーティの提供するソフトウェア、データ、サービスを検索、購入できるデジタルカタログです。ソフトウェアがインストールされたEC2のAMIであったり、機会学習用のデータであったりと、提供形態は様々です。詳細は下記公式ドキュメントを参照ください。

docs.aws.amazon.com

Private Marketplace

AWS Marketplaceの提供形態の一つで、ユーザーが調達できる製品を管理・制限するための、カスタマイズ可能なマーケットプレイスです。ユーザーは、管理者によってあらかじめ許可された製品のみ調達することが可能です。AWS Organizationsを使用することで、組織全体・OU単位・アカウント単位で製品の調達を制御することが可能です。詳細は下記公式ドキュメントを参照ください。

docs.aws.amazon.com

BedrockのLLMモデルとPrivate Marketplace

Bedrockで新しいLLMモデルを利用したい場合、モデルリクエストを行いますが、実はこのモデルについても、Private Marketplaceで制限することが可能です。ただ、ユーザーがリクエストを行う場所はMarketplaceではなくBedrockのページのため、意識しづらい点かもしれません。

モデルリクエストはBedrockのページから行う

実際にモデルリクエストを制限してみる

ここからは、実際にPrivate Marketplaceを設定して、モデルリクエストを制限してみます。

作業1:Private Marketplaceの有効化

まずはPrivate Marketplaceを有効化していきます。管理者となるアカウントで、Private Marketplaceの画面に遷移し、「Get started with Private Marketplace」ボタンを押して開始します。

管理者となるアカウントでPrivate Marketplaceの利用を開始


今回はOrganizationsを利用するので、「Enable trusted access for AWS Organizations」のチェックボックスにチェックを入れて、「Enable private marketplace」ボタンを押下します。(チェックを入れた後の画像がキャプチャできていなかったため、下図ではチェックが入っていません、すみません。)


これでPrivate Marketplaceが有効化できました。


Organizationsのデータも取得できています。下図中の「y2labo」が今回の管理者アカウントで、「y-yamashita」が利用者アカウントです。

作業2:experienceの作成

続いて、Private Marketplaceにexperienceを作成します。experienceというのは、「誰に」「何の製品を許可するか」をひとまとめにした設定とお考えください。
Experiencesの画面に遷移し、「Create experience」を押下します。


名前と説明を設定します。今回は「Test」という名前だけ設定します。


これでexperienceが出来ました。

ただし、作り立てのexperienceは空の箱のようなもので、「誰に」「何の製品を許可するか」が設定されていません。以降のステップで、「誰に」と「何の製品を許可するか」を追加していきます。

作業3:experienceにaudienceを追加

まずは「誰に」です。これはPrivate Marketplaceでは、audienceと呼ばれます。audienceは、Organizationsの組織全体・OU・アカウントの単位で追加できます。今回は「y-yamashita」アカウントのみ追加します。


experienceとaudienceの組み合わせを確認し、問題なければ「Associate with experience」ボタンを押下します。


これでaudienceが追加されました。

作業4:experienceにproductsを追加

続いて、「何の製品を許可するか」です。これはPrivate Marketplaceでは、productsと呼ばれます。初期状態では許可リストには何も登録されていません。なお、「All AWS Marketplace products」には、Marketplaceの全製品がリストされています。

それ以外の項目についても簡単に触れておきます。「Pending requests」は、ユーザーから製品利用許可のリクエストが来た内、まだ許可/拒否を決定していないもののリストです。なお、ユーザーがリクエストを送ることができるようにするかどうかは、後述のSettingsで選択可能です。
「Declined/blocked products」は、ユーザーから製品利用許可のリクエストが来たが拒否したもののリストです。そのため、Settingsでユーザーがリクエストを送れないようにしている場合、このリストが使われることはありません。

話が逸れましたが、ここからproductsを追加していきます。今回はClaudeをいくつか許可してみます。「All AWS Marketplace products」の検索窓で「Vendor name: Anthropic」で検索すると、Claudeのモデルが出てきます。


Claude 3.5 Sonnet、Claude 3 Sonnet、Claude 3.7 Sonnetを選択し、「Add」ボタンを押下します。確認画面が出てくるので、「Add Products」ボタンを押下します。


「Approved products」にClaude 3.5 Sonnet、Claude 3 Sonnet、Claude 3.7 Sonnetが追加されました。また、「All AWS Marketplace products」でも、3つのモデルのStatusが「Added」になりました。

作業5:experienceのSettingsを変更

さて、これで「誰に」「何の製品を許可するか」が設定できましたが、まだこれで終わりではありません。改めてexperienceを確認すると、Statusが「Not Live」になっています。

この状態では、まだexperienceは有効になっていません。最後にSettingsを設定する必要があります。

Settingsでは、「Experience mode」を「Live」にすることで、experienceを有効化できます。また、「Product requests」の項目で、ユーザーに製品利用リクエストを出すことを許可するかどうか選択できます。今回はoffにします。


Settingsでは他にも、Private Marketplaceの画面のロゴやメッセージなどをカスタマイズすることが可能です。今回はデフォルト設定のままにします。最後に右下の「Save」ボタンを押下します。


experienceのStatusが「Live」に変わりました。


これで管理者側の設定は完了です。

ユーザー側で動作確認を行う

ここからは、ユーザー側のアカウントで動作確認を行います。

許可されたモデルをリクエス

まずは許可されたモデル(Claude 3.7 Sonnet、Claude 3.5 Sonnet、Claude 3 Sonnet)をリクエストします。


問題なくアクセスが許可されました。

許可されていないモデルをリクエス

次に、許可されていないモデル(Claude 3.5 Haiku、Claude 3.5 Sonnet v2、Claude 3 Haiku)をリクエストしてみます。

リクエストが失敗しました。「Unauthorized to perform action due to private marketplace eligibility」というメッセージが表示され、Private Marketplaceで許可されていないことが分かります。

ここで一点ご注意いただきたい点があります。それは、許可されていないモデルも、「アクセスのステータス」が「リクエスト可能」と表示されていた点です。つまり、ユーザーはBedrockのモデルリクエストの画面を見ても、本当にモデルがリクエスト可能なのかどうか判断できず、実際にリクエストを送ってみなければ許可されているかどうか分からない、 ということです。これは、あまりユーザーフレンドリーではないような気がしました。

ユーザー側のPrivate Marketplaceの画面を見てみる

続いて、ユーザー側のPrivate Marketplaceの画面を見てみます。初回は「Explore your Private Marketplace」ボタンを押下して開始します。


サブスクリプションの管理画面で、先ほど利用登録したモデル(Claude 3.7 Sonnet、Claude 3.5 Sonnet、Claude 3 Sonnet)が確認できます。

注意点として、この画面に表示されるリストは「ユーザーが実際にサブスクリプションした製品」であって、許可された製品のリストが表示されているわけではありません。許可された製品の一覧は、ユーザー側のMarket placeのカタログの「Approved Products」に表示されます。
ただし更に注意点として、「Approved Products」にはBedrockでリクエストするモデルは表示されないようです。全てのモデルを試したわけではないですが、少なくとも今回試したClaudeは表示されませんでした。おそらく、ここに表示されるのは、あくまでMarket placeからサブスクリプションする製品のみのようです。

カタログを見ても、許可されているはずのClaudeのモデルが表示されていない

LLMモデル以外の製品を追加で許可して、ユーザー側の「Approved Products」を確認してみる

最後に、LLMモデル以外の製品を追加で許可して、ユーザー側のMarket placeの「Approved Products」に表示されることを確認してみます。

まずは管理者側のアカウントで作業します。Red Hatの製品を10個追加してみます。せっかくなので、「Bulk add」という方法で一気に追加します。これは複数の製品を複数のexperienceに一気に追加できる機能です。

Bulk addでRed Hat製品を10個追加(※管理者アカウントでの作業)

Approved ProductsにRed Hat製品が追加された(※管理者アカウントの画面)

管理者アカウントの画面では、Approved ProductsにRed Hat製品が追加されました。

それでは再度ユーザーアカウントのPrivate Marketplaceの画面を見てみます。

Red Hat製品は確認可能だが、Claudeは表示されない(※ユーザーアカウントの画面)

Red Hat製品は10個確認できましたが、Claudeは表示されませんでした。結論、ユーザー側では、Bedrockの画面でも、Private Marketplaceの画面でも、許可されたLLMモデルを確認することができませんでした。

終わりに

以上、Private MarketplaceでBedrockのLLMモデルを制限する方法でした。冒頭では「世間であまり取り上げられていない」などと書きましたが、「ガバナンスを利かせるために、利用可能なLLMモデルを管理したい」という企業の要望はあるでしょうし、有用な機能だと思います。
ただ、ユーザー側の見え方がもう少し分かりやすいと、より良いのではないかという気がしました。(もっとも、OrganizationsのSCPの設定もユーザー側からは見えないので、それと同じといえば同じなのですが。)

今回のブログは以上です。何か少しでも参考になることがあれば幸いです。

AWS Client VPNとDHCPオプションセットのDNS設定はどちらが優先されるのか?

最近、AWS Client VPNDNS設定について色々と調べていました。その際、「VPCに疑似クライアントを用意して、DNS設定を色々いじって検証してみようかな」と考えたのですが、そこでふと、「VPCではDHCPオプションセットでDNSの設定をしているから、その設定が影響して上手く検証できないのでは。。?」と思い至りました。そのため、まずはDHCPオプションセットと、AWS Client VPNDNS設定の力比べをしてみることにしました。

通常、VPCのリソースから、異なるVPCAWS ClientVPNエンドポイントにVPN接続することはあまり無いと思います。そのため、DHCPオプションセットとClient VPNDNS設定が戦う事も無いでしょう。生息地の違うトラとライオンが戦うことがないのと同様です。 今回の記事は、そんな夢のカード(?)を色々な条件で戦わせてみた記録となります。

ザックリ前提知識

AWS Client VPNにはDNSサーバーの設定があり、VPN接続したクライアントが名前解決に使用するDNSサーバーを指定することが出来ます。
一方で、VPCにはDHCPオプションセットという項目があり、VPC内のリソースが名前解決に使用するDNSサーバーを指定することが出来ます。
そのため、VPC内のリソースがAWS Client VPNVPN接続した場合、2つの異なる設定でDNSサーバーを指定されることになります。その際にどちらが優先されるのかを確認するのが、本記事の主旨です。

はじめに結論

夢のカードとか言いながらいきなり結論を書いてしまいますが、DNS設定については、以下の順で優先されました。

  1. OS上の設定
  2. DHCPオプションセット
  3. AWS Client VPN

残念ながら、AWS Client VPNDNS設定は最弱のようでした。DHCPオプションセットの方が優先されるため、VPCでClient VPNのクライアント側を疑似するのは少々やりにくかったです。

動作検証

ここからは、真面目に動作検証の内容をお届けします。

構成概要

構成概要は以下です。

  • クライアントVPN VPCと疑似オンプレVPCを用意します。
  • クライアントVPN VPCAWS Client VPNを用意します。
  • 疑似オンプレVPCにEC2 (Amazon Linux2023) を用意し、openvpnをインストールして、VPN接続を行います。
  • 双方のVPCにALBを設置して、固定レスポンスを返すように設定します。
  • プライベートホストゾーンを2つ用意し、それぞれのALBのCNAMEレコードを設定します。(今思えば、別にエイリアスレコードで良かった。。)
  • 疑似クライアントが、プライベートホストゾーンのDNS名を使って、双方のALBに同時にアクセスできるようにすることを目指します。

その他の前提条件

Client VPNではスプリットトンネルをオンにします。オフにすると、全ての通信がVPNに向いてしまい、疑似オンプレVPCのALBにアクセスできなくなってしまうためです。 また、どちらのVPCでも、enableDnsHostnames と enableDnsSupport の両方を trueにします。プライベートホストゾーンを使用するのに必要なためです。

DNS設定パターン毎の検証結果

ここからは、DHCPオプションセット、Client VPN、OSのDNS設定を変えたり、プライベートホストゾーンを関連付けるVPCを変えたりしながら、名前解決の挙動がどう変わるのか確認していきます。

パターン1(DHCPオプションセット:Default、Client VPNDNS設定なし、OS:Default)

パターン1は全てデフォルトの設定です。Client VPNのデフォルト設定では、DNS設定は無しになっています。その場合、クライアント端末は、VPN接続前に利用していたDNSサーバーをそのまま利用します。

つまり、疑似クライアントは、疑似オンプレVPCのRoute 53 Resolverを利用することが想定されます。 そのため、2つのプライベートホストゾーンを両方とも疑似オンプレVPCと関連付けます。

どちらのALBも、デフォルトのDNS名(internal-xxxx.ap-northeast-1.elb.amazonaws.com)に加えて、プライベートホストゾーンに設定したDNS名(alb.yamashita-test-20250429-xxxxx.com)でもアクセス出来ることが期待されます。

それでは、疑似クライアントでVPN接続を行います。接続後も操作が出来るように、末尾に&をつけてバックグラウンドで動作させます。

[ssm-user@ip-10-0-0-29 openvpn]$ sudo openvpn --config clientvpn-test.client.com.ovpn &
[1] 26902
[ssm-user@ip-10-0-0-29 openvpn]$ 2025-04-29 13:13:06 OpenVPN 2.6.12 x86_64-amazon-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [MH/PKTINFO] [AEAD]
2025-04-29 13:13:06 library versions: OpenSSL 3.2.2 4 Jun 2024, LZO 2.10
2025-04-29 13:13:06 TCP/UDP: Preserving recently used remote address: [AF_INET]54.168.48.30:443
2025-04-29 13:13:06 Socket Buffers: R=[212992->212992] S=[212992->212992]
2025-04-29 13:13:06 UDPv4 link local: (not bound)
2025-04-29 13:13:06 UDPv4 link remote: [AF_INET]54.168.48.30:443
2025-04-29 13:13:06 TLS: Initial packet from [AF_INET]54.168.48.30:443, sid=c3d1dcab bc14a0f7
2025-04-29 13:13:06 VERIFY OK: depth=1, CN=clientvpn-test
2025-04-29 13:13:06 VERIFY KU OK
2025-04-29 13:13:06 Validating certificate extended key usage
2025-04-29 13:13:06 ++ Certificate has EKU (str) TLS Web Server Authentication, expects TLS Web Server Authentication
2025-04-29 13:13:06 VERIFY EKU OK
2025-04-29 13:13:06 VERIFY X509NAME OK: CN=clientvpn-test.server.com
2025-04-29 13:13:06 VERIFY OK: depth=0, CN=clientvpn-test.server.com
2025-04-29 13:13:06 Control Channel: TLSv1.3, cipher TLSv1.3 TLS_AES_256_GCM_SHA384, peer certificate: 2048 bits RSA, signature: RSA-SHA256, peer temporary key: 253 bits X25519
2025-04-29 13:13:06 [clientvpn-test.server.com] Peer Connection Initiated with [AF_INET]54.168.48.30:443
2025-04-29 13:13:06 TLS: move_session: dest=TM_ACTIVE src=TM_INITIAL reinit_src=1
2025-04-29 13:13:06 TLS: tls_multi_process: initial untrusted session promoted to trusted
2025-04-29 13:13:07 SENT CONTROL [clientvpn-test.server.com]: 'PUSH_REQUEST' (status=1)
2025-04-29 13:13:07 PUSH: Received control message: 'PUSH_REPLY,route 172.16.0.0 255.255.255.0,route-gateway 192.168.0.1,topology subnet,ping 1,ping-restart 20,echo,echo,echo,ifconfig 192.168.0.2 255.255.255.224,peer-id 0,cipher AES-256-GCM,protocol-flags cc-exit tls-ekm dyn-tls-crypt,tun-mtu 1500'
2025-04-29 13:13:07 OPTIONS IMPORT: --ifconfig/up options modified
2025-04-29 13:13:07 OPTIONS IMPORT: route options modified
2025-04-29 13:13:07 OPTIONS IMPORT: route-related options modified
2025-04-29 13:13:07 OPTIONS IMPORT: tun-mtu set to 1500
2025-04-29 13:13:07 net_route_v4_best_gw query: dst 0.0.0.0
2025-04-29 13:13:07 net_route_v4_best_gw result: via 10.0.0.1 dev ens5
2025-04-29 13:13:07 ROUTE_GATEWAY 10.0.0.1/255.255.255.224 IFACE=ens5 HWADDR=06:ce:04:36:56:ff
2025-04-29 13:13:07 TUN/TAP device tun0 opened
2025-04-29 13:13:07 net_iface_mtu_set: mtu 1500 for tun0
2025-04-29 13:13:07 net_iface_up: set tun0 up
2025-04-29 13:13:07 net_addr_v4_add: 192.168.0.2/27 dev tun0
2025-04-29 13:13:07 net_route_v4_add: 172.16.0.0/24 via 192.168.0.1 dev [NULL] table 0 metric -1
2025-04-29 13:13:07 Initialization Sequence Completed
2025-04-29 13:13:07 Data Channel: cipher 'AES-256-GCM', peer-id: 0
2025-04-29 13:13:07 Timers: ping 1, ping-restart 20
2025-04-29 13:13:07 Protocol options: protocol-flags cc-exit tls-ekm dyn-tls-crypt

[ssm-user@ip-10-0-0-29 openvpn]$

問題なく接続できました。この状態で、名前解決とアクセス確認を行います。

[ssm-user@ip-10-0-0-29 openvpn]$ nslookup alb.yamashita-test-20250429-vpnvpc.com
Server:         10.0.0.2
Address:        10.0.0.2#53

Non-authoritative answer:
alb.yamashita-test-20250429-vpnvpc.com  canonical name = internal-clientvpnalb-1301670922.ap-northeast-1.elb.amazonaws.com.
Name:   internal-clientvpnalb-1301670922.ap-northeast-1.elb.amazonaws.com
Address: 172.16.0.75
Name:   internal-clientvpnalb-1301670922.ap-northeast-1.elb.amazonaws.com
Address: 172.16.0.38

[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ nslookup alb.yamashita-test-20250429-onpre.com
Server:         10.0.0.2
Address:        10.0.0.2#53

Non-authoritative answer:
alb.yamashita-test-20250429-onpre.com   canonical name = internal-onpremalb-1015627341.ap-northeast-1.elb.amazonaws.com.
Name:   internal-onpremalb-1015627341.ap-northeast-1.elb.amazonaws.com
Address: 10.0.0.77
Name:   internal-onpremalb-1015627341.ap-northeast-1.elb.amazonaws.com
Address: 10.0.0.61

[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ curl http://alb.yamashita-test-20250429-vpnvpc.com
This is Client VPN side ALB.
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ curl http://alb.yamashita-test-20250429-onpre.com
This is on-premises side ALB.
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$

名前解決、アクセスともに出来ました。

パターン2(DHCPオプションセット:Default、Client VPN:172.16.0.2、OS:Default)

続いて、DHCPオプションセットとOS設定はデフォルト設定、Client VPNは172.16.0.2(クライアントVPN VPCのRoute 53 Resolver)を設定しました。動作検証前は、「VPN接続前は10.0.0.2を参照して、VPN接続後は172.16.0.2を参照するようになるのでは?」と思っていました。そのため、プライベートホストゾーンをクライアントVPN VPCと関連付けました。

VPN接続後は、クライアントVPN側のRoute 53 Resolverを使ってくれそうなイメージ

それでは、VPN接続したうえで、名前解決とアクセス確認を行います。(ここからは、VPN接続のログは省略します)

[ssm-user@ip-10-0-0-29 openvpn]$ nslookup alb.yamashita-test-20250429-vpnvpc.com
Server:         10.0.0.2
Address:        10.0.0.2#53

** server can't find alb.yamashita-test-20250429-vpnvpc.com: NXDOMAIN

[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ nslookup alb.yamashita-test-20250429-onpre.com
Server:         10.0.0.2
Address:        10.0.0.2#53

** server can't find alb.yamashita-test-20250429-onpre.com: NXDOMAIN

[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ curl http://alb.yamashita-test-20250429-vpnvpc.com
curl: (6) Could not resolve host: alb.yamashita-test-20250429-vpnvpc.com
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ curl http://alb.yamashita-test-20250429-onpre.com
curl: (6) Could not resolve host: alb.yamashita-test-20250429-onpre.com
[ssm-user@ip-10-0-0-29 openvpn]$

VPN接続後も、引き続きDHCPオプションセットの設定が優先されて、疑似オンプレVPCのRoute 53 Resolverにクエリを投げています。そのため、名前解決に失敗し、アクセスも出来なくなっています。

なお、この状態でもALBのデフォルトのDNS名は名前解決できます(ALBのDNS名はパブリックに名前解決可能なため)。試しに名前解決とアクセスを試みます。

[ssm-user@ip-10-0-0-29 openvpn]$ nslookup internal-OnPremALB-1015627341.ap-northeast-1.elb.amazonaws.com
Server:         10.0.0.2
Address:        10.0.0.2#53

Non-authoritative answer:
Name:   internal-OnPremALB-1015627341.ap-northeast-1.elb.amazonaws.com
Address: 10.0.0.61
Name:   internal-OnPremALB-1015627341.ap-northeast-1.elb.amazonaws.com
Address: 10.0.0.77

[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ nslookup internal-ClientVPNALB-1301670922.ap-northeast-1.elb.amazonaws.com
Server:         10.0.0.2
Address:        10.0.0.2#53

Non-authoritative answer:
Name:   internal-ClientVPNALB-1301670922.ap-northeast-1.elb.amazonaws.com
Address: 172.16.0.38
Name:   internal-ClientVPNALB-1301670922.ap-northeast-1.elb.amazonaws.com
Address: 172.16.0.75

[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ curl http://internal-clientvpnalb-1301670922.ap-northeast-1.elb.amazonaws.com
This is Client VPN side ALB.
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ curl http://internal-onpremalb-1015627341.ap-northeast-1.elb.amazonaws.com
This is on-premises side ALB.
[ssm-user@ip-10-0-0-29 openvpn]$

問題なく名前解決とアクセスが出来ました。VPN接続自体は出来ており、ネットワークの疎通性はあることが確認できました。

というわけで、パターン2の実際の動作は下図のようになりました。どうやら、Client VPNDNS設定よりも、DHCPオプションセットのDNS設定の方が優先されるようです。

DNSサーバーが切り替わらず、名前解決に失敗

パターン3(DHCPオプションセット:172.16.0.2、10.0.0.2 Client VPN:172.16.0.2、OS:Default)

今度は、DHCPオプションセットでも172.16.0.2をDNSサーバーに設定しました。ただし、このままだとVPN接続前に全く名前解決が出来なくなりそうなので、セカンダリDNSサーバーとして10.0.0.2も設定しました。これならば、DNS接続後にクライアントVPN側のRoute 53 Resolverで名前解決できるのではないでしょうか?

これならいけるのでは?

期待に胸を膨らませつつ、名前解決とアクセス確認を行ってみます。

[ssm-user@ip-10-0-0-29 openvpn]$ nslookup alb.yamashita-test-20250429-vpnvpc.com
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
Server:         10.0.0.2
Address:        10.0.0.2#53

** server can't find alb.yamashita-test-20250429-vpnvpc.com: NXDOMAIN

[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ nslookup alb.yamashita-test-20250429-onpre.com
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
Server:         10.0.0.2
Address:        10.0.0.2#53

** server can't find alb.yamashita-test-20250429-onpre.com: NXDOMAIN

[ssm-user@ip-10-0-0-29 openvpn]$

あれ、DNSサーバーにアクセスできないですね。。VPN接続に失敗しているのでしょうか?試しにALBのデフォルトDNS名でも試してみます。

[ssm-user@ip-10-0-0-29 openvpn]$ nslookup internal-OnPremALB-1015627341.ap-northeast-1.elb.amazonaws.com
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
Server:         10.0.0.2
Address:        10.0.0.2#53

Non-authoritative answer:
Name:   internal-OnPremALB-1015627341.ap-northeast-1.elb.amazonaws.com
Address: 10.0.0.61
Name:   internal-OnPremALB-1015627341.ap-northeast-1.elb.amazonaws.com
Address: 10.0.0.77
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out

[ssm-user@ip-10-0-0-29 openvpn]$ nslookup internal-ClientVPNALB-1301670922.ap-northeast-1.elb.amazonaws.com
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
Server:         10.0.0.2
Address:        10.0.0.2#53

Non-authoritative answer:
Name:   internal-ClientVPNALB-1301670922.ap-northeast-1.elb.amazonaws.com
Address: 172.16.0.38
Name:   internal-ClientVPNALB-1301670922.ap-northeast-1.elb.amazonaws.com
Address: 172.16.0.75
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out
;; communications error to 172.16.0.2#53: timed out

[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ curl http://internal-clientvpnalb-1301670922.ap-northeast-1.elb.amazonaws.com
This is Client VPN side ALB.
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ curl http://internal-onpremalb-1015627341.ap-northeast-1.elb.amazonaws.com
This is on-premises side ALB.
[ssm-user@ip-10-0-0-29 openvpn]$

172.16.0.2のDNSサーバーにはアクセスできないので、代わりにセカンダリDNSサーバーが応答します。ただ、クライアントVPN VPCのALB自体にはアクセスできているので、VPN接続が出来ていないわけでは無さそうです。これはどうしたことでしょうか。

ここで、疑似クライアントのifconfigとルートテーブルを見てみます。

[ssm-user@ip-10-0-0-29 openvpn]$ ifconfig
ens5: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 9001
        inet 10.0.0.29  netmask 255.255.255.224  broadcast 10.0.0.31
        inet6 fe80::4ce:4ff:fe36:56ff  prefixlen 64  scopeid 0x20<link>
        ether 06:ce:04:36:56:ff  txqueuelen 1000  (Ethernet)
        RX packets 3200  bytes 1102944 (1.0 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 2876  bytes 358412 (350.0 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 12  bytes 1020 (1020.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 12  bytes 1020 (1020.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

tun0: flags=4305<UP,POINTOPOINT,RUNNING,NOARP,MULTICAST>  mtu 1500
        inet 192.168.0.130  netmask 255.255.255.224  destination 192.168.0.130
        inet6 fe80::fab2:db4b:7012:869a  prefixlen 64  scopeid 0x20<link>
        unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00  txqueuelen 500  (UNSPEC)
        RX packets 4  bytes 405 (405.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 14  bytes 832 (832.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

[ssm-user@ip-10-0-0-29 openvpn]$ ip route
default via 10.0.0.1 dev ens5 proto dhcp src 10.0.0.29 metric 512
10.0.0.0/27 dev ens5 proto kernel scope link src 10.0.0.29 metric 512
10.0.0.1 dev ens5 proto dhcp scope link src 10.0.0.29 metric 512
10.0.0.2 dev ens5 proto dhcp scope link src 10.0.0.29 metric 512
172.16.0.0/24 via 192.168.0.129 dev tun0
172.16.0.2 via 10.0.0.1 dev ens5 proto dhcp src 10.0.0.29 metric 512
192.168.0.128/27 dev tun0 proto kernel scope link src 192.168.0.130
[ssm-user@ip-10-0-0-29 openvpn]$
[ssm-user@ip-10-0-0-29 openvpn]$ route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         ip-10-0-0-1.ap- 0.0.0.0         UG    512    0        0 ens5
ip-10-0-0-0.ap- 0.0.0.0         255.255.255.224 U     512    0        0 ens5
ip-10-0-0-1.ap- 0.0.0.0         255.255.255.255 UH    512    0        0 ens5
ip-10-0-0-2.ap- 0.0.0.0         255.255.255.255 UH    512    0        0 ens5
ip-172-16-0-0.a ip-192-168-0-12 255.255.255.0   UG    0      0        0 tun0
ip-172-16-0-2.a ip-10-0-0-1.ap- 255.255.255.255 UGH   512    0        0 ens5
ip-192-168-0-12 0.0.0.0         255.255.255.224 U     0      0        0 tun0
[ssm-user@ip-10-0-0-29 openvpn]$

172.16.0.0向けのルートはVPNトンネルに向いていますが、172.16.0.2のネクストホップは10.0.0.1になっています。

10.0.0.1はVPCルーターのアドレスです。VPCでは、ネットワークアドレス+1のアドレスがVPCルーター用として予約されています。VPC内のリソースが他リソースと通信する場合は、VPCルーターを経由して通信を行います。(普段はあまり意識することはありませんが)

そして、DHCPオプションセットで設定したDNSサーバーと通信する際は、Client VPNのトンネルではなく、VPCルーターに通信が向くようになっていました。そのため、DNSサーバーにクエリが到達せず、名前解決が出来なくなっていました。

まとめると、下図のような挙動になりました。VPCルーターからは172.16.0.2に到達できず、セカンダリDNSの10.0.0.2はプライベートホストゾーンを名前解決できず、結局名前解決には失敗しました。

DHCPオプションセットを変えても、DNSクエリがVPNトンネルに入らず失敗

パターン4(DHCPオプションセット:設定なし Client VPN:172.16.0.2、OS:Default)

さて、どうにもDHCPオプションセットの壁を越えることができないため、いっそDHCPオプションセットを無しにしてみます。これならどうでしょうか。それ以前に、DHCPオプションセットを無しにした状態で、セッションマネージャーに接続できるのでしょうか。

今度こそいけるのでは?

とりあえず試してみます。

[ssm-user@ip-10-0-0-26 openvpn]$ nslookup alb.yamashita-test-20250429-vpnvpc.com
Server:         169.254.169.253
Address:        169.254.169.253#53

** server can't find alb.yamashita-test-20250429-vpnvpc.com: NXDOMAIN

[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$ nslookup alb.yamashita-test-20250429-onpre.com
Server:         169.254.169.253
Address:        169.254.169.253#53

** server can't find alb.yamashita-test-20250429-onpre.com: NXDOMAIN

[ssm-user@ip-10-0-0-26 openvpn]$

セッションマネージャーでの接続は出来ましたが、名前解決には失敗しました。そしてDNSサーバーのアドレスが「169.254.169.253」になっています。

実はこのアドレスもRoute 53 Resolverのアドレスです。VPCDHCP オプションセットが設定されていない場合、Nitro System上に構築されたEC2インスタンスでは、169.254.169.253 をデフォルトのドメインネームサーバーとして設定します。
詳細は以下公式ページを参照ください。

docs.aws.amazon.com

docs.aws.amazon.com

疑似オンプレVPCはプライベートホストゾーンと関連付けていないため、名前解決はできませんでした。

ここで、再び疑似クライアントのルートテーブルを見てみます。

[ssm-user@ip-10-0-0-26 openvpn]$ ip route
default via 10.0.0.1 dev ens5 proto dhcp src 10.0.0.26 metric 512
10.0.0.0/27 dev ens5 proto kernel scope link src 10.0.0.26 metric 512
10.0.0.1 dev ens5 proto dhcp scope link src 10.0.0.26 metric 512
169.254.169.253 via 10.0.0.1 dev ens5 proto dhcp src 10.0.0.26 metric 512
172.16.0.0/24 via 192.168.0.161 dev tun0
192.168.0.160/27 dev tun0 proto kernel scope link src 192.168.0.162
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$ route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         ip-10-0-0-1.ap- 0.0.0.0         UG    512    0        0 ens5
ip-10-0-0-0.ap- 0.0.0.0         255.255.255.224 U     512    0        0 ens5
ip-10-0-0-1.ap- 0.0.0.0         255.255.255.255 UH    512    0        0 ens5
169.254.169.253 ip-10-0-0-1.ap- 255.255.255.255 UGH   512    0        0 ens5
ip-172-16-0-0.a ip-192-168-0-16 255.255.255.0   UG    0      0        0 tun0
ip-192-168-0-16 0.0.0.0         255.255.255.224 U     0      0        0 tun0
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$

やはり、169.254.169.253のネクストホップはVPCルーター(10.0.0.1)になっていますね。VPNトンネルには通信は向いていませんでした。

まとめると、パターン4の実際の動作は下図のようになりました。パターン2とほとんど同じですね。

これでもダメなのか。。。

パターン5(DHCPオプションセット:設定なし Client VPN:172.16.0.2、OS:172.16.0.2)

というわけで、最終手段として、OSのDNS設定を直接編集します。

以下の記事の内容に従い、/etc/systemd/resolved.conf を編集します。

repost.aws

[ssm-user@ip-10-0-0-26 openvpn]$ sudo vim /etc/systemd/resolved.conf
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$ sudo cat /etc/systemd/resolved.conf
#  This file is part of systemd.
#
#  systemd is free software; you can redistribute it and/or modify it under the
#  terms of the GNU Lesser General Public License as published by the Free
#  Software Foundation; either version 2.1 of the License, or (at your option)
#  any later version.
#
# Entries in this file show the compile time defaults. Local configuration
# should be created by either modifying this file, or by creating "drop-ins" in
# the resolved.conf.d/ subdirectory. The latter is generally recommended.
# Defaults can be restored by simply deleting this file and all drop-ins.
#
# Use 'systemd-analyze cat-config systemd/resolved.conf' to display the full config.
#
# See resolved.conf(5) for details.

[Resolve]
# Some examples of DNS servers which may be used for DNS= and FallbackDNS=:
# Cloudflare: 1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com 2606:4700:4700::1111#cloudflare-dns.com 2606:4700:4700::1001#cloudflare-dns.com
# Google:     8.8.8.8#dns.google 8.8.4.4#dns.google 2001:4860:4860::8888#dns.google 2001:4860:4860::8844#dns.google
# Quad9:      9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net
DNS=172.16.0.2
#FallbackDNS=
#Domains=
#DNSSEC=no
#DNSOverTLS=no
#MulticastDNS=no
#LLMNR=no
#Cache=yes
#CacheFromLocalhost=no
#DNSStubListener=yes
#DNSStubListenerExtra=
#ReadEtcHosts=yes
#ResolveUnicastSingleLabel=no
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$ sudo systemctl restart systemd-resolved.service
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$ sudo tail /etc/resolv.conf
# Third party programs should typically not access this file directly, but only
# through the symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a
# different way, replace this symlink by a static file or a different symlink.
#
# See man:systemd-resolved.service(8) for details about the supported modes of
# operation for /etc/resolv.conf.

nameserver 172.16.0.2
nameserver 169.254.169.253
search .
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$


名前解決とアクセスをしてみます。

[ssm-user@ip-10-0-0-26 openvpn]$ nslookup alb.yamashita-test-20250429-vpnvpc.com
Server:         172.16.0.2
Address:        172.16.0.2#53

Non-authoritative answer:
alb.yamashita-test-20250429-vpnvpc.com  canonical name = internal-clientvpnalb-41888842.ap-northeast-1.elb.amazonaws.com.
Name:   internal-clientvpnalb-41888842.ap-northeast-1.elb.amazonaws.com
Address: 172.16.0.61
Name:   internal-clientvpnalb-41888842.ap-northeast-1.elb.amazonaws.com
Address: 172.16.0.69

[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$ nslookup alb.yamashita-test-20250429-onpre.com
Server:         172.16.0.2
Address:        172.16.0.2#53

Non-authoritative answer:
alb.yamashita-test-20250429-onpre.com   canonical name = internal-onpremalb-1107595459.ap-northeast-1.elb.amazonaws.com.
Name:   internal-onpremalb-1107595459.ap-northeast-1.elb.amazonaws.com
Address: 10.0.0.69
Name:   internal-onpremalb-1107595459.ap-northeast-1.elb.amazonaws.com
Address: 10.0.0.59

[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$ curl alb.yamashita-test-20250429-vpnvpc.com
This is Client VPN side ALB.
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$ curl alb.yamashita-test-20250429-onpre.com
This is on-premises side ALB.
[ssm-user@ip-10-0-0-26 openvpn]$
[ssm-user@ip-10-0-0-26 openvpn]$


問題なく出来ました。ルートテーブルも見てみます。

[ssm-user@ip-10-0-0-26 bin]$ ip route
default via 10.0.0.1 dev ens5 proto dhcp src 10.0.0.26 metric 512
10.0.0.0/27 dev ens5 proto kernel scope link src 10.0.0.26 metric 512
10.0.0.1 dev ens5 proto dhcp scope link src 10.0.0.26 metric 512
169.254.169.253 via 10.0.0.1 dev ens5 proto dhcp src 10.0.0.26 metric 512
172.16.0.0/24 via 192.168.0.1 dev tun0
192.168.0.0/27 dev tun0 proto kernel scope link src 192.168.0.2
[ssm-user@ip-10-0-0-26 bin]$
[ssm-user@ip-10-0-0-26 bin]$
[ssm-user@ip-10-0-0-26 bin]$ route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         ip-10-0-0-1.ap- 0.0.0.0         UG    512    0        0 ens5
ip-10-0-0-0.ap- 0.0.0.0         255.255.255.224 U     512    0        0 ens5
ip-10-0-0-1.ap- 0.0.0.0         255.255.255.255 UH    512    0        0 ens5
169.254.169.253 ip-10-0-0-1.ap- 255.255.255.255 UGH   512    0        0 ens5
ip-172-16-0-0.a ip-192-168-0-1. 255.255.255.0   UG    0      0        0 tun0
ip-192-168-0-0. 0.0.0.0         255.255.255.224 U     0      0        0 tun0
[ssm-user@ip-10-0-0-26 bin]$

172.16.0.2がVPCルーターに向くこともなく、172.16.0.0/24がすべてVPNトンネルに向いていますね。

というわけで、VPN接続先のDNSサーバーを参照させるのに大変苦労してしまいましたが、何とか最終的に上手くいきました。

はじめからこうすれば良かったんや。。。

終わりに

以上、夢の対戦の実況中継でした。何とかClient VPNDNS設定を勝たせようと、色々と策を講じましたが、どれも通用しませんでした。結局、第3勢力のOS設定が最強でしたし。
冗談はさておき、今回の検証は実務で使うような構成ではないので、正直あまり役立つ知見にはならなかったかもしれません。それでも、DHCPオプションセットやVPCルーターの挙動など、普段あまり意識しない細かい動きを理解する事が出来たので、個人的には良い勉強の機会になりました。

おまけ

今回の検証にあたり、色々と外部の記事を参考にさせていただきました。それらの記事を紹介します。たぶんこの項目が本ブログで一番役立つと思いますw

VPCルーターについては以下のNRIネットコムさんの記事で分かりやすく説明されていました。

tech.nri-net.com

Amazon Linux 2023のDNS設定の変更については、以下のクラメソさんの記事で大変詳しく懇切丁寧に説明されていました。

dev.classmethod.jp

AWS Client VPNDNS設定の仕様については、以下のクラメソさんの記事が非常に分かりやすかったです。

dev.classmethod.jp

dev.classmethod.jp

AWS Client VPN全般の仕様については、以下のクラメソさん、サバワさんの記事が非常に丁寧で分かりやすかったです。

dev.classmethod.jp

dev.classmethod.jp

blog.serverworks.co.jp

今回のブログは以上です。少しでも参考になることがあれば幸いです。