TECH PLAY

NTT西日本

NTT西日本 の技術ブログ

78

はじめに NTTフィールドテクノの荒川 瑞斗です。 本記事では、コンテナネットワーク検証ツール「Containerlab」と、高機能なOSSソフトウェアルータである「freeRouter」を組み合わせて、「Inter-AS OptionC」という高度なネットワーク構成を構築しました。 本記事の結論と得られた知見 商用NOSのライセンス費用をかけず、Containerlab x freeRouter で複雑なSR-MPLS環境であるInter-AS OptionCの再現が出来ました。 「グローバルルーティングテーブルを持たない」といったfreeRouter特有の設計思想への理解が、構築の鍵となりました。 高度なSR-MPLS機能をOSSであるfreeRouterがサポートしている点は、Cisco IOS XRに匹敵する検証能力を備えていることを示しています。 ※ 本記事は2025年6月の構築当時の情報に基づきつつ、一部2026年4月時点の最新状況を追記しています 対象読者 本記事が想定する対象読者は以下の通りです。 Containerlabを用いたネットワーク検証に興味がある人 仮想環境でネットワークを構築して検証したいが、予算を抑えたいと考えている人 freeRouter(RAREプロジェクト)の独自の設計思想や仕様について知りたい人 SR-MPLSの基礎知識がある人 目次 はじめに 対象読者 目次 1. 背景・目的 2. freeRouterの特徴・仕様 Cisco IOS XR と freeRouter の確認コマンドを比較 Cisco IOS XR と freeRouter のコンフィグを比較 比較した結果 ルーティングテーブルの仕様と独自の解釈 3. Inter-AS OptionC 概要 4. 実装・検証内容 前提条件・動作環境 システム構成 設計方針/設計ポイント ネットワーク設計 5. 工夫点と注意点 デプロイ(起動)時にコンフィグが反映されない事象 SR-MPLS網の隠蔽(ハイド) 6. 動作確認 疎通が安定しているか (VPC間のping) 網内(AS内)リンク障害 AS間(Inter-AS)直結リンク障害 結果 7.最後に 今後の課題 執筆者 参考資料・出典 商標 免責事項 1. 背景・目的 複雑なトポロジを持つネットワークの保守運用を想定し、その事前検証が可能な環境を構築する機会がありました。 検証環境を構築するにあたり、コンテナベースで動作し、ホストOS(依頼主の環境)に依存せず動作する「 Containerlab 」に着目しました。 一方で、商用NOSはライセンス費用が高額な上、Containerlab上で動作するコンテナイメージとして用意することは至難の業です。 そこでOSS NOSの活用を検討しました。 VyOSやFRRouting、SONiCなども候補に挙がりましたが、今回の要件である高度なSR-MPLS機能の柔軟性、そして何より「 Cisco IOS XRに近いCLI体系 」を重視した結果、 freeRouter が最適解であると判断しました。 本記事に登場する主なネットワーク用語 NOS (Network Operating System): ルーターやスイッチなどのネットワーク機器を動かす専用のOS。 PE (Provider Edge) / P (Provider): 網の端に位置するルータ(PE)と、中心部で中継を担うルータ(P)。 ASBR (Autonomous System Boundary Router): 異なるASを接続する境界ルータ。 RR (Route Reflector): BGPの経路情報を効率的に配布する役割を持つルータ。 SR-MPLS / SID / SRGB: セグメントルーティング技術。各ノードを識別するID(SID)や、そのラベル範囲(SRGB)を使用します。 AFI / SAFI: BGPで扱う「どのアドレス体系(IPv4等)」と「経路の用途(ユニキャスト、マルチキャスト、ラベル付きなど)」を指す定義。 ECMP / BFD: 複数経路を同時に使う技術(ECMP)。 2. freeRouterの特徴・仕様 Cisco IOS XR と freeRouter の確認コマンドを比較 以下に主要なコマンド比較の例を抜粋します。 項目 Cisco IOS XR (例) freeRouter (相当) ルーティング確認 show route vrf A show ipv4 route A Ping確認 ping vrf A 172.16.100.1 ping 172.16.100.1 vrf A OSPFネイバー確認 show ospf neighbor show ipv4 ospf 10 neighbor Cisco IOS XR と freeRouter のコンフィグを比較 Segment Routing Global Block (SRGB) Cisco IOS XRでは segment-routing 配下でグローバルブロックを定義しますが、freeRouterではOSPFプロセス内でベースと範囲を定義します。 ! Cisco IOS XR segment-routing global-block 16000 275999 ! router ospf 100 segment-routing mpls segment-routing sr-prefer area 0 mpls traffic-eng interface Loopback1 prefix-sid index 16001 ! ! ! ! freeRouter (相当) router ospf4 10 vrf DEF_SEGROUT router-id 172.16.100.100 traffeng-id 0.0.0.0 segrout 500 base 12000 ! SRGBの定義 area 0 enable area 0 spf-ecmp area 0 segrout ecmp exit ! interface loopback1 vrf forwarding DEF_SEGROUT ipv4 address 172.16.100.100 255.255.255.255 router ospf4 10 enable router ospf4 10 area 0 router ospf4 10 passive router ospf4 10 segrout index 100 ! Indexの指定 router ospf4 10 segrout node no shutdown log-link-change exit ! Route Policy (RPL) 複雑な優先度制御(MEDやLocal Preferenceの打ち分け)についても、freeRouterの route-policy 構文を用いることが可能です。 具体的には、Cisco IOS XRの prefix-set を prefix-list に変換し、 sequence 管理下の elsif 構文にマッピングすることで、同等の経路選択ロジックを実現しています。 ! Cisco IOS XR prefix-set L3vpnGW-1 172.16.200.100/32 end-set ! prefix-set L3vpnGW-2 172.16.200.200/32 end-set ! route-policy L3vpnGW-POLICY if destination in L3vpnGW-1 or destination in L3vpnGW-2 then pass elseif next-hop in L3vpnGW-1 then set med 100 elseif next-hop in L3vpnGW-2 then set med 300 else pass endif end-policy ! ! freeRouter (相当) prefix-list A-ROUTE sequence 10 permit 172.16.200.100/32 ge 32 le 32 sequence 20 permit 10.50.80.4/30 ge 30 le 30 sequence 30 permit 172.16.200.1/32 ge 32 le 32 sequence 40 permit 10.200.135.0/30 ge 30 le 30 sequence 50 permit 10.200.135.4/30 ge 30 le 30 exit ! prefix-list B-ROUTE sequence 10 permit 172.16.200.200/32 ge 32 le 32 sequence 20 permit 10.50.81.4/30 ge 30 le 30 sequence 30 permit 172.16.200.2/32 ge 32 le 32 sequence 40 permit 10.200.136.0/30 ge 30 le 30 sequence 50 permit 10.200.136.4/30 ge 30 le 30 exit ! route-policy PE-UL-ROUTE sequence 10 if prefix-list A-ROUTE sequence 20 set locpref 1400 sequence 30 pass sequence 40 elsif prefix-list B-ROUTE sequence 50 set locpref 1000 sequence 60 pass sequence 70 else sequence 80 pass sequence 90 enif exit ! 比較した結果 このように、Cisco IOS XRとfreeRouterとのコンフィグ差分をマッピングし、相当コマンドの存在を確認しました。 互換性維持の判断基準をクリアしたため、freeRouterにて設計を進めました。 freeRouterでは、VRFやプロセスを明示的に指定して情報を取得する体系となっています。 調査を続けていく中で、ルーティングテーブルに関して、とても興味深い知見が得られました。 ルーティングテーブルの仕様と独自の解釈 freeRouterはグローバルルーティングテーブルを持ちません。 この独特な仕様は非常に苦労させられました。 ここで、公式の説明(引用)と、 検証から得た独自の考察 を整理して解説します。 公式の設計思想(引用) no global routing table: every routed interface must be in a virtual routing table In FreeRouter everything is in a VRF (so there is no global VRF) This design choice has very positive consequences like: No VRF awareness questions,have multiple bgp processes for the same freeRouter instance (each bound to a different VRF) 検証から得た独自考察と所感 「グローバルルーティングテーブル」が存在しない 全てのインターフェースは何らかのVRFに属している必要があります。 VRF分離の課題 現状、VRF間をルーティングさせてもRT(Route Target)値が期待通りに機能しないケースが見受けられました。 BGPプロセスの多重化 1つのBGPプロセスが1つのVRFに固定されるため、ユーザ(テナント)を物理的に分けるには、BGPプロセスそのものを分ける必要があるという結論に達しました。 3. Inter-AS OptionC 概要 Cisco社が提示している内容を確認します。 ・Route Reflectors exchange VPNv4 routes ・ASBRs Exchange PE loopbacks (IPv4) with labels as these are BGP NH addresses ・Eliminates LFIB duplication at ASBRs. ASBRs don’t hold VPNv4 prefix/label info. ・Two Options for Label Distribution for BGP NH Addresses for PEs in each domain:  1. BGP IPv4 + Labels (RFC3107) – most preferred & recommended  2. IGP + LDP ・BGP exchange Label Advertisement Capability - Enables end-end LSP Paths ・Subsequent Address Family Identifier (SAFI value 4) field is used to indicate that the NLRI contains a label ・Disable Next-hop-self on eBGP RRs (peers) 私は、以下の解釈をしました。 VPNルートの伝送 RR (Route Reflector) が AS間のVPNv4 ルート交換を行います。 ASBRはVPNv4のパス情報やラベル(LFIB)を保持することはしません。 エンドツーエンドLSPの確立 異なるASにあるPE間で通信するため、PEのループバックアドレスをラベル付きで共有します。 RFC 3107 (SAFI 4) に基づき、IPv4プレフィックスにラベルを付与して広告するBGP-LUを推奨します。 BGPのCapabilityを利用してラベルをやり取りし、ASを跨いだ一気通貫のラベルスイッチパス(LSP)を形成します。 ルーティングの制御 eBGP RR間でルートを交換する際、ネクストホップを自分自身(RR)に書き換えないように設定します。 4. 実装・検証内容 前提条件・動作環境 本記事の検証環境は以下の通りです。 ホストOS: Linux環境(Containerlabが動作する環境) ネットワークOS: freeRouter(RAREプロジェクト) ※ 使用したバージョンは、v24.3.30-cur, done by sprscc13@mrn0b0dy. クライアントOS: Alpine Linux ツール: Containerlab, Docker 検証時点: 2025年6月 システム構成 本検証では、AS65000とAS64512の2つの自律システム(AS)を跨ぐ構成を構築しました。 合計14台のコンテナを使用し、各ASにはPE、P、ASBR( ASBR兼Route Reflector )を配置しています。 表記について freeRouterのコマンド体系に基づいて記載している個所があります。 以下の読み替えをお願いします。 「 labeled 」と「labeled-unicast」 : 一般的なBGP用語(BGP-LU)では、 labeled-unicast です。 設計方針/設計ポイント 前述のInter-AS OptionCを実現するため、以下のポイントに基づき構築を行いました。 ASBR間でのラベル交換: ASBRがlabeledルートを交換します。 Next-hopの管理: PEのIPv4 LoopbackアドレスをBGP next hopとしてラベル交換を行います。 ラベル配布: 各ドメインのPEに対して、OSPFによるSR-MPLS(Segment Routing)を使用します。 再配布の活用: Loopbackアドレスのリーチビリティ(到達性)は別プロセスのOSPFで広告し、BGPに再配布させます。 ネットワーク設計 内部リンク、およびAS間には特定のセグメントを割り当て、OSPFプロセスをAS内(プロセス10)とAS外(プロセス20)で分離しました 。 AS間にて、Loopback到達性を確保するため、各ASBRに別プロセスのOSPF(プロセス20)を動作させています。 一部、設定値を掲載します。 基本パラメータ 項目 設定内容 VRF定義 DEF_SEGROUT OSPFプロセス番号 AS内: 10 / AS外: 20 SRGB (segrout 500 base 12000) 12000 ~ 12499 AS番号 eなし装置: 65000 / eあり装置: 64512 OSPF コスト値一覧 該当箇所 コスト値 PE-P 2000 P-ASBR, ePE-eASBR 700 ASBR-ASBR, eASBR-eASBR (process 10) 1300 ASBR-ASBR, eASBR-eASBR (process 20) 900 ASBR-eASBR 3000 IPアドレス / サブネット範囲 区分 プレフィックス サブネットマスク 最大ノード数 範囲 OSPF 10 (AS 65000) /20 255.255.240.0 4096 10.100.128.0 ~ 10.100.143.255 OSPF 10 (AS 64512) /20 255.255.240.0 4096 10.200.128.0 ~ 10.200.143.255 OSPF 20 /28 255.255.255.240 16 10.10.186.0 ~ 10.10.186.15 CE (VPC1_1, VPC1_2) /29 255.255.255.248 8 10.50.80.0 ~ 10.50.80.7 CE (VPC2_1, VPC2_2) /29 255.255.255.248 8 10.50.81.0 ~ 10.50.81.7 Lo1 (AS 65000) /24 255.255.255.0 256 172.16.100.0 ~ 172.16.100.255 Lo2 (AS 64512) /24 255.255.255.0 256 172.16.200.0 ~ 172.16.200.255 実際のコンフィグ freeRouterでの物理インターフェースや論理インタフェースと、今回の肝であるOSPF, labeledのコンフィグを紹介します。 ! ASBR1のコンフィグ ! 物理インタフェース interface ethernet5 description [B1]eASBR1_eth5 cdp enable bundle-group 1 no shutdown log-link-change exit ! ! 論理インタフェース interface bundle1 description eASBR1 vrf forwarding DEF_SEGROUT ipv4 address 10.10.186.1 255.255.255.252 mpls enable router ospf4 20 enable router ospf4 20 area 0 router ospf4 20 cost 3000 no shutdown log-link-change exit ! ! OSPFとSegment Routing router ospf4 20 vrf DEF_SEGROUT router-id 192.18.1.1 traffeng-id 0.0.0.0 segrout 500 base 10000 area 0 enable area 0 segrout exit ! ! Loopbackインタフェースへの適用 interface loopback2 vrf forwarding DEF_SEGROUT ipv4 address 192.18.1.1 255.255.255.255 router ospf4 20 enable router ospf4 20 area 0 router ospf4 20 passive router ospf4 20 segrout index 20 router ospf4 20 segrout node no shutdown log-link-change exit ! ! BGP-LU router bgp4 65000 vrf DEF_SEGROUT local-as 65000 router-id 172.16.100.100 address-family labeled vpnuni ! template RRC-LU remote-as 65000 template RRC-LU local-as 65000 template RRC-LU address-family labeled vpnuni template RRC-LU distance 200 template RRC-LU additional-path-rx labeled vpnuni template RRC-LU additional-path-tx labeled vpnuni template RRC-LU update-source loopback1 template RRC-LU segrout template RRC-LU route-reflector-client template RRC-LU next-hop-self ! template eBGP remote-as 64512 template eBGP local-as 65000 template eBGP address-family labeled vpnuni template eBGP distance 20 template eBGP ttl-security 3 template eBGP additional-path-rx labeled vpnuni template eBGP additional-path-tx labeled vpnuni template eBGP update-source loopback2 template eBGP segrout template eBGP route-policy-in PE-UL-ROUTE ! template eBGP-LU remote-as 64512 template eBGP-LU local-as 65000 template eBGP-LU address-family labeled vpnuni template eBGP-LU distance 20 template eBGP-LU ttl-security 1 template eBGP-LU additional-path-rx labeled vpnuni template eBGP-LU additional-path-tx labeled vpnuni template eBGP-LU update-source loopback1 template eBGP-LU segrout template eBGP-LU next-hop-unchanged ! template iBGP-LU remote-as 65000 template iBGP-LU local-as 65000 template iBGP-LU address-family labeled vpnuni template iBGP-LU distance 200 template iBGP-LU additional-path-rx labeled vpnuni template iBGP-LU additional-path-tx labeled vpnuni template iBGP-LU update-source loopback1 template iBGP-LU segrout template iBGP-LU next-hop-self ! neighbor 192.18.5.5 template eBGP ! neighbor 172.16.100.1 template RRC-LU ! neighbor 172.16.100.2 template RRC-LU ! neighbor 172.16.100.200 template iBGP-LU ! neighbor 172.16.200.100 template eBGP-LU ! ! redistribute connected route-policy CON-TO-BGP exit ! 5. 工夫点と注意点 デプロイ(起動)時にコンフィグが反映されない事象 Containerlabの公式サイトにおけるfreeRouter(RARE)の解説では、以下のように説明されています。 参考: https://containerlab.dev/manual/kinds/rare-freertr/ User defined config It is possible to make RARE/freeRtr nodes to boot up with a user-defined config instead of a default one. In this case you'd have to create rtr-hw.txt and rtr-sw.txt files and bind mount them to the /rtr/run/conf dir: topology: nodes: rtr1: kind: rare image: ghcr.io/rare-freertr/freertr-containerlab:latest binds: - rtr-hw.txt:/rtr/run/conf/rtr-hw.txt - rtr-sw.txt:/rtr/run/conf/rtr-sw.txt しかしながら、これを記載してデプロイするとコンフィグが初期状態のままとなります。 freeRouterのDockerfileの仕様から全てを確認したところ、特定のファイルによって設定が上書きされる仕様であることが判明したため、該当ファイルを直接バインドマウントすることで解決しました。 topology: nodes: PE_A: kind: rare image: ghcr.io/rare-freertr/freertr-containerlab:main mgmt-ipv4: 172.31.0.2 binds: - startup_configuration/PE_A/rtr-sw.txt:/rtr/rtr-sw.txt ※ 2026年4月時点では、 /rtr/run/conf/ にバインドで動作するように修正されている ことを確認済みです。 SR-MPLS網の隠蔽(ハイド) Cisco IOS XRの mpls ip-ttl-propagate disable forwarded に相当するコマンドがfreeRouterには存在しません 。 そのため、PEルータのユーザ側インタフェースにアウトバウンドACLを適用し、SR-MPLS網内のホップが見えないよう工夫しました 。 mpls ip-ttl-propagate disable forwarded の機能概要 IPパケットのTTL(Time To Live)値をMPLSラベルのTTLへコピーする動作(伝搬)を無効化するために使用されます。 TTL伝搬の仕組み ルータやL3SWがIPパケットをラベルでカプセル化してSR-MPLS網に送り出す際、以下の動作が行われます。 1. IPヘッダのTTL値が、MPLSラベルのTTLフィールドにコピーされます。 2. SR-MPLS網内の各ルータ(Pルータ)を通るたびに、ラベルのTTLが「1」ずつ減ります。 3. SR-MPLS網を出る際、ラベルのTTL値がIPヘッダのTTLに戻されます。 最大の目的 外部ユーザから MPLSコアネットワークの内部構造を見えないようにする ことです。 実際にクライアントOS間のtracerouteをしたところ、以下の通り、網内の秘匿に成功しました。 / # traceroute 10.50.80.6 traceroute to 10.50.80.6 (10.50.80.6), 30 hops max, 46 byte packets 1 10.50.80.2 (10.50.80.2) 0.620 ms 0.868 ms 1.260 ms 2 * * * 3 * * * 4 * * * 5 10.50.80.6 (10.50.80.6) 2.772 ms 2.461 ms 1.841 ms 仕組みとしては、単純に 明示的な deny だけで隠蔽しています。 access-list custA_Filter sequence 10 permit all 10.50.80.0 255.255.255.248 all any all sequence 20 deny all any all any all exit ! tracerouteは、TTLが0になった時にルータが返す ICMP Time Exceeded (Type 11, Code 0) を受け取ることで経路を表示します。 上記のACLでは、最終的な宛先への通信は permit していますが、それ以外の通信(網内ルータからのICMPエラー通知など)を sequence 20 deny で叩き落としています。 まとめ 本構成では、SR-MPLS網内のPルータが返す ICMP Time Exceeded (Type 11, Code 0) を、PEルータの出口ACL(sequence 20 deny all any all any all)で意図的にドロップさせています。 通常の mpls ip-ttl-propagate disable が「TTLのコピーを止めることで、PルータでTTLを0にさせない(=ICMPを発生させない)手法」であるのに対し、今回の手法は 「発生したICMP通知を境界で検閲・遮断する手法」 です。 これにより、外部からは網内のIPアドレスはおろか、ICMPエラーメッセージすら到達しないため、結果として * * *(タイムアウト)となり、ユーザ・トラフィックと網内管理情報の境界の明確化を実現しています。 6. 動作確認 通常時のルート (上り、下り共に同一ルート) Aルート: VPC1_1 ~ ASBR1 ~ eASBR1 ~ VPC1_2 Bルート: VPC2_1 ~ ASBR2 ~ eASBR2 ~ VPC2_2 ※ パケットロス計測は、VPC(Alpine Linux)間で デフォルトのping(1秒間隔、タイムアウト1秒) を実行し、ICMP Sequenceの抜けをカウントしたものです。   したがって、33 packetsのロスは約33秒間の通信断を意味します。 疎通が安定しているか (VPC間のping) 1日目 10000 packets loss 0% 2日目 10000 packets loss 0% 3日目 10000 packets loss 0% 4日目 10000 packets loss 0% 5日目 10000 packets loss 0% 網内(AS内)リンク障害 記載のないインタフェースはパケットロス無し。 検証の結果、リンクパススルー機能が無いため、OSPFのダウン検知に依存する箇所では数十パケット単位のロスが避けられないことが分かりました。 仮想環境ゆえに物理リンクの断が即座に伝播せず、BFD非対応の影響をこの段階で身をもって実感することになりました。 障害箇所 パケットロス(目安) 挙動・ルート変化 ログ・検知の特徴 PE_A (P2向け論理インタフェース) 約34 packets ・Aルートのみロス有 ・AS間ルート変化なし P2とのOSPFダウン検知 (対向で約36秒後) PE_B (P1向け論理インタフェース) 約33 packets ・Bルートのみロス有 ・AS間ルート変化なし P1とのOSPFダウン検知 (対向で約40秒後) P1 (両ASBR向け論理インタフェース) 0 ~ 40 packets ・送信側ならロス無 ・受信側ならOSPF検知までロス有 ASBR1/2とのOSPFダウンを検知 P2 (両ASBR向け論理インタフェース) 0 ~ 35 packets ・送信側ならロス無 ・受信側ならOSPF検知までロス有 ASBR1/2とのOSPFダウンを検知 AS間(Inter-AS)直結リンク障害 ASBR間のeBGP/OSPFリンクの障害です。 ここはInter-AS OptionCの核心部であり、冗長ルートへの切り替わりが発生します。 障害箇所 パケットロス(目安) 挙動・ルート変化 ログ・検知の特徴 ASBR1 (eASBR1向け論理インタフェース) 約33 packets ・AルートがASBR2経由へ迂回 eASBR1とのOSPFダウン後、eBGPもダウン ASBR2 (eASBR2向け論理インタフェース) 約39 packets ・BルートがASBR1経由へ迂回 eASBR2とのOSPFダウン後、eBGPもダウン eASBR1 (ASBR1向け論理インタフェース) 約37 packets ・AルートがeASBR2経由へ迂回 ASBR1とのOSPFダウン後、eBGPもダウン eASBR2 (ASBR2向け論理インタフェース) 約36 packets ・BルートがeASBR1経由へ迂回 ASBR2とのOSPFダウン後、eBGPもダウン 結果 インタフェースをダウンさせると対向装置から受け取る方向のトラフィックであった場合、OSPFネイバーダウンを検知後に切り替わるため、それまではパケットロスが発生しました。 インタフェースをダウンさせた側で送っているルートであった場合、ロス無く切り替わります。 上記二点より、freeRouterでのOSPFプロトコルのECMP仕様は、上り、下りで分けていることが判明しました。 トラフィック量に応じて、特定のトランジット経路へ動的に分散される挙動は見受けられませんでした。 AS間直結のリンク(B1)をダウンさせるとパケットロスが発生し、ルートの切り替わりが発生しました。 Lo1のeBGPはダウンし、Lo2で張ったeBGPのダウンは発生しないため想定通りです。 閉塞解除後は全てパケットロスは見受けられませんでした。 7.最後に freeRouterは非常に高機能ですが、その独特な設計思想や特有の挙動に苦労しました。 しかし、Inter-AS OptionC も含め、一つずつ紐解くことで、設計方針 / ポイント に基づいたSR-MPLS網を構築することが出来ます。 そして、ACLを設定したことにより、結果として、別ユーザからの通信もPEルータが検閲して通さないです。 今回、構築した環境は git clone https://github.com/MizutoArakawa/freeRTR_InterAS_OptionC.git で試すことが出来ます。 containerlab、Docker、freeRouterイメージが搭載されていることが前提とはなりますが、是非見てみてください。 また、freeRouterを使ってみて素晴らしいと感じましたら、開発者にビールを奢ってあげてください。 PE_A#show version freeRouter v24.3.30-cur, done by sprscc13@mrn0b0dy. place on the web: http://www.freertr.org/ license: http://creativecommons.org/licenses/by-sa/4.0/ the beer-ware,abandon-ware license for selected group of people: sprscc13@mrn0b0dy wrote these files. as long as you retain this notice you can do whatever you want with this stuff. if we meet some day, and you think this stuff is worth it, you can buy me a beer in return 2026年4月時点では、格言が追加されていました PE_A#show version freeRouter v26.4.28-cur, done by sprscc13@mrn0b0dy. place on the web: http://www.freertr.org/ license: http://creativecommons.org/licenses/by-sa/4.0/ the beer-ware license for selected group of people: sprscc13@mrn0b0dy wrote these files. as long as you retain this notice you can do whatever you want with this stuff. if we meet some day, and you think this stuff is worth it, you can buy me a beer in return quotes from devvies like sprscc13@mrn0b0dy: true random comes from weather forecasts and political announcements if a machine can learn the value of human life, maybe we can too be liberal in what you accept, and conservative in what you send rough consensus and running code, keep it stupid simple dont drive faster than your guardian angel can fly care about the bits and not the bits of the bits let bloom all the flowers, make the world better the great power comes great responsibility every tool could be used for good or bad do or not to do but dont try 今後の課題 私個人としては、どうしても「 RT(Route Target)による柔軟なVRF分離 」 でユーザ通信の分離を実現したいです。 その方が、 美しい と考えるからです。 とはいえ、freeRouterの設計理念が脳裏によぎります。 no global routing table: every routed interface must be in a virtual routing table freeRouterは1つのBGPプロセスに1つのVRFしか適用できませんし、そもそもグローバルルーティングテーブルを持ちません。 この点は、主流なNOSと大きく違う点です。 そのため、従来のVRFにおけるRT(Route Target)の概念をそのまま適用することには再考の余地があります。 如何に最適化されたコンフィグで再現が出来るか、今後も継続して検証を続けていきます。 更に、本構成内にL2VPNも含め、ユーザ通信を論理的に分離させることが可能か追加で取り組むことを検討しています。 EVPNへの拡張: L2VPN(E-LAN方式)を設定追加することで、同一装置にてL3VPNとL2VPNと論理的に分割が可能か実験。 経路最適化: コントローラを用いたPCEP連携による、遅延に基づいた最適経路の動的制御。 執筆者 荒川 瑞斗(NTTフィールドテクノ サービスマネジメント部所属) 現在は、監視・保守業務に従事している方のためのツール、及び搭載しているVMの維持、メンテナンスに携わっています。 好きなラックサーバはHPE ProLiant DL360 Generation 9(初めて自宅に迎え入れた子なため)です。 参考資料・出典 本記事を執筆するにあたり、以下のサイトを参考にしました。 Containerlab : https://containerlab.dev/ freeRouter : http://www.freertr.org/ freeRouter(GitHub) : https://github.com/rare-freertr/freeRtr-containerlab Inter-AS OptionC : https://www.cisco.com/c/en/us/support/docs/multiprotocol-label-switching-mpls/mpls/200523-Configuration-and-Verification-of-Layer.html Inter-AS OptionC : https://nsrc.org/workshops/2015/apricot2015/raw-attachment/wiki/Track3MPLS/9-Apriot_2015_Inter-AS.2.pdf 商標 「Cisco、Cisco IOS、Cisco IOS XR」は、米国およびその他の国における Cisco Systems, Inc. の商標または登録商標です。 「Docker」は、Docker, Inc.の米国およびその他の国における商標または登録商標です。 「freeRouter」は、Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) および作者独自の「Beer-ware License」に基づき提供されています。 「HPE ProLiant」は、Hewlett Packard Enterprise Development LPの商標です。 その他、本記事に記載されている会社名、製品名は、各社の商標または登録商標です。 免責事項 本記事に掲載された手法を実施した結果発生する損失・損害については責任を負いかねます。 また、実際の通信事業用ネットワークを模擬する際、IPアドレスやホスト名、ポートコンベンション等は同じ、もしくは類似させるようなことはせず、推測されないような値にしてください。
はじめに NTTビジネスソリューションズ / NTT西日本の樋口です。 本記事は2026年4月時点の情報に基づきます。 大きな組織の開発現場で、こんなことを感じたことはありませんか。筆者の正直なホンネを3つ並べてみます。 「社内の標準認証基盤(OA の Entra ID)で、開発ツールにもログインさせたい。組織がデカくなるほど巨大なID基盤での調整には時間がかかってしまう。『これだと自分で別にアカウント作った方が早いのではないか?』と、一瞬よぎってしまう」 「とはいえ、セキュリティもガバナンスも守らなければならない。不用意にアカウントを増やしてしまうと管理は煩雑になってしまい、単純にセキュリティリスクも増大する」 「でも、開発環境や AI 環境をちゃんと整えなければ、結局のところ社員は個人アカウントで生成 AI に質問して、その答えを人力で社内システムに打ち込むような運用になってしまいかねない」 本記事は、こうしたもどかしさを抱える大きな組織の開発チームに向けて、Microsoft Entra ID の B2B ゲスト招待と SCIM 自動プロビジョニングを組み合わせ、 GitHub Enterprise Cloud(以下、GHE)の EMU(Enterprise Managed Users)環境を構築する 手順を紹介します。 この記事を通じてできるようになること 本体テナントに影響を与えずに、開発チーム専用の GHE 環境を構築できる Graph API を使って、B2B ゲスト招待とグループ管理を再現性のある形で運用できる SCIM オンデマンド同期で、メンバー変更を GHE に即時反映できる 大きな組織では ID 管理ポリシーや監査要件の関係で、本体テナントに外部メンバーを直接追加したり、開発専用のアプリケーションを気軽に立てたりすることが難しいケースが多くあります。その現実的な解として、 開発専用のテナントを別に用意し、B2B ゲスト招待で本体側および他社メンバーを招いたうえで、SCIM プロビジョニングで GHE に自動同期する 構成を採ります。 ポータル画面での手作業ではなく、 Microsoft Graph API を中心にした構築・運用 を軸にしています。「GUI 操作中心では再現性を担保しにくい」「Infrastructure as Code(インフラの構成をコードで管理する考え方)の思想で ID 管理もやりたい」という方には、参考になる部分があるかと思います。 正直なところ、SCIM 連携は公式ドキュメント通りにはいかない場面が多く、実際に API を叩いてみて初めてわかることがかなりありました。本記事では、筆者が実環境で得たログや API レスポンスをもとに、設計判断と実装の両面からお伝えします。 対象読者 以下に 2つ以上当てはまる なら、本記事が役立つ可能性が高いです。 大きな組織で開発チームを運営しており、 本体テナントに外部 ID や開発用アプリを追加しづらい制約 を抱えている 本体テナント管理者との調整に時間を取られ、開発スピードが落ちることに課題感がある 協力会社・メンバーを含むチームで GitHub Enterprise をセキュアに使いたい Graph API を使用した経験があり、ポータル操作より API ベースの管理に関心がある Microsoft Entra ID の基本的な概念(テナント、ユーザー、グループ)は理解している 本記事のスクリーンショット表記について 本記事に掲載するスクリーンショットは、社外公開用に一部の情報を架空の値へ置き換えています。 AAAA****-**** BBBB****-**** 等のマスク表記は、オブジェクトID / アプリケーションID / アクティビティIDなどを置き換えたダミー値です Contoso株式会社 @contoso.com contoso.onmicrosoft.com はサンプルの組織名・ドメインです ProjectA ProjectB ProjectC はサンプルプロジェクト名、個人名 Taro Yamada (山田 太郎) などもサンプル値です 実運用環境のログや API 出力と表記が食い違う箇所は、すべて意図的なマスク処理によるものです。 目次 はじめに この記事を通じてできるようになること 対象読者 本記事のスクリーンショット表記について 目次 1. 背景・目的 大きな組織の開発チームが抱える「ID 管理のジレンマ」 この記事で解決すること 2. 全体像と選定理由 構成図 なぜこの組み合わせか スコープの絞り方 3. 前提条件 3.1 本記事のスコープ 3.2 必要なライセンスと権限 3.3 本体テナント側の事前調整 4. Entra ID 側の構成(構築済み環境の確認) エンタープライズアプリケーション GitHub Enterprise Managed User アプリの概要 SCIM プロビジョニングの状態 プロビジョニングログの確認 設定時に迷った判断: 自動 vs 手動プロビジョニング 5. GHE 側の受け入れ設定 SAML SSO の有効化 SCIM トークンの発行 設定時に迷った判断: SCIM トークンの有効期限 6. B2B でのゲスト招待(Graph API 実例) Graph API の認証について なぜ Graph API を使うのか ゲスト招待: POST /invitations レスポンス例 招待後のユーザー状態 グループへのメンバー追加: POST /groups/{id}/members/$ref 招待 + グループ追加を一括で回すスクリプト例 グループからのメンバー除外: DELETE /groups/{id}/members/{userId}/$ref 7. グループメンバー変更と SCIM オンデマンド同期(実ログ付き) 7-1. メンバーの追加と除外 作業の流れ Step 1-2: 招待とグループ追加 Step 3: 追加結果の確認 ProjectA-Collab グループの確認結果 Step 4: グループからの除外 7-2. オンデマンド同期の実行 Step 5: SCIM オンデマンド同期 servicePrincipal の特定 jobId と ruleId の取得方法 オンデマンドプロビジョニングの実行 オンデマンド同期の結果 SCIM 同期対象外のユーザーを指定した場合 7-3. GHE 側での反映確認 IdP グループ一覧 グループメンバーの反映 Entra ID 側グループの最終状態(参考) この章のまとめ 8. 運用のコツ Graph API スクリプトの管理 SCIM 同期のタイミング SCIM 同期エラーへの対処フロー メンバー離任時のチェックリスト 定期的な棚卸し 9. まとめ 執筆者 参考資料・出典 商標 1. 背景・目的 大きな組織の開発チームが抱える「ID 管理のジレンマ」 大きな組織で開発チームを運営していると、こんな課題に直面します。 他社(子会社・協力会社)のメンバーに GitHub を使わせたいが、 本体テナントに外部の ID を作りたくない かといって、メンバーごとに別アカウントを発行すると ライセンスコストと管理負荷 が増大する 開発環境だけを管理する専用のテナントを立てたいが、構築方法の情報が少ない 筆者のチームでも、まさにこの状況でした。AI 開発プロジェクトで GitHub Copilot を含む GitHub Enterprise の機能をフル活用したい。しかし、本体テナントに協力会社の方のアカウントを次々作るわけにはいきません。 この記事で解決すること 本記事では、以下のアプローチで上記の課題を解決します。 開発専用テナント を開発チーム用に用意する(本体テナントに影響を与えない) B2B ゲスト招待 で他社・協力会社のメンバーを招く(既存の業務アカウントをそのまま利用) SCIM 自動プロビジョニング で Entra ID のグループ変更を GHE(GitHub Enterprise Cloud)に自動反映する いわば「 本体テナントに影響を与えずに、開発チーム専用の GitHub 環境を最小構成で構築する 」ための手順書です。 ポイント: なぜ「開発専用テナント」を立てるのか? 本体テナントの保護 : 外部 ID を本体テナントに混在させない(セキュリティ境界の分離) ライセンス境界の明確化 : 開発チーム向けに追加で発生する P1 相当ライセンスのコストを本体テナントと分離して管理できる 責任境界の明確化 : 開発チームが自律的にテナント運用できる(本体テナント管理者への依頼を最小化) コンプライアンス分離 : 開発環境のポリシーと本番環境のポリシーを独立して設計できる 2. 全体像と選定理由 構成図 構成図内の ProjectA-Proper ProjectA-Collab ProjectB-Proper ProjectB-Collab はサンプルのグループ名表記です。 構成の全体像は上図の通りです。登場する要素を整理します。 要素 役割 開発専用テナント(Entra ID) ユーザーとグループの管理拠点。SCIM のソース B2B ゲスト招待 他社・協力会社のメンバーを専用テナントに招く仕組み。ホームテナントの ID をそのまま利用できる SCIM プロビジョニング Entra ID のユーザー・グループ情報を GHE に自動同期する仕組み GitHub Enterprise(EMU) Enterprise Managed Users。Entra ID が IdP(Identity Provider)となり、ユーザーのライフサイクルを一元管理 なぜこの組み合わせか ポイント: GHE EMU(Enterprise Managed Users)では、ユーザーとグループの管理は IdP(Entra ID)から SCIM で同期する前提です。Managed User は GHE 上で個別編集できず、属性変更はすべて Entra ID 経由となります。 この前提のもと、開発チームへの GitHub 提供方法は主に2つ考えられます。 方式 メリット デメリット A. 本体テナントから直接 SCIM 管理が一元化 本体テナントに外部 ID が混在。テナント管理者の承認が必要 B. 専用テナント + B2B + SCIM(本記事) 本体テナントに影響を与えない。自動同期。既存 ID を再利用 専用テナントの運用コスト。ライセンス費用 意外かもしれませんが、方式 B は「大がかりな構成」ではありません。数十名規模の開発チームであれば、Entra ID の無料枠 + P1 相当のライセンス数本で運用可能です。 スコープの絞り方 本記事では条件付きアクセス(Conditional Access)や属性マッピングの詳細設計、多要素認証(MFA)ポリシーの統一といったトピックはあえて扱いません。これらは組織ごとの事情が大きく、独立して議論した方が見通しが良いためです。本記事では、 専用テナント + B2B + SCIM の最小構成を回せる状態まで にスコープを絞ります。 3. 前提条件 3.1 本記事のスコープ 本記事は SCIM プロビジョニングと B2B 招待の設定・運用にフォーカスしています。 以下の構築手順は記事では扱わないため、未構築の項目は Microsoft / GitHub 公式ドキュメントを参照してください。 前提となる構築項目 公式ドキュメント Entra ID テナントの新規作成 クイックスタート - アクセスして新しいテナントを作成する GitHub EMU アプリのギャラリー追加 + SAML(Security Assertion Markup Language)/ OIDC(OpenID Connect)による SSO(Single Sign-On)設定 GitHub Enterprise Managed User の SSO 設定 EMU の SCIM プロビジョニング初期設定 GitHub Enterprise Managed User の自動ユーザープロビジョニング GHE Enterprise アカウント(EMU 型)の契約 GitHub の一元管理された EMU について 3.2 必要なライセンスと権限 Microsoft Entra ID P1 相当以上 のライセンス 本記事で紹介する Entra ID の自動プロビジョニング機能(特に グループ連動の自動同期 )を使う場合に必要 該当するライセンス: Entra ID P1 / P2 単体、または Microsoft 365 E3 / E5 / F1 / F3 / Business Premium など Entra ID P1 以上を含むプラン 機能対応の詳細は公式の Microsoft Entra ライセンス を参照 Entra ID P1 単体購入の場合は月額 899 円/ユーザー(税抜・年間契約、2026年4月時点) Entra ID の自動プロビジョニング機能を使わず、GHE 側の API や Web UI で個別にユーザーを追加/削除する運用なら必須ではない B2B ゲスト MAU(Monthly Active Users:月間アクティブユーザー数)無料枠 : 最初の 50,000 MAU まで無料。以降は従量課金(Microsoft が将来変更する可能性あり) 開発専用テナントの 全体管理者 権限 GHE Enterprise の Enterprise Owner 権限 本体テナントの テナント管理者 との事前調整(次節参照) 注意: Entra ID の自動プロビジョニング機能(特にグループ連動の SCIM 同期)を使うには、 Entra ID P1 相当以上 のライセンスが必要です。 公式ライセンス一覧 によれば、Microsoft 365 E3 / E5 / F1 / F3 / Business Premium にはこれが含まれます。既にいずれかを導入済みなら追加費用は発生しませんが、単体購入の場合はコストが発生するため、導入前に既存のライセンス構成を確認しておくと判断しやすいです。 以下の Graph API 権限も必要です。 操作 必要な権限 B2B ゲスト招待 User.Invite.All または グローバル管理者 グループメンバー管理 GroupMember.ReadWrite.All SCIM プロビジョニング管理 クラウドアプリケーション管理者 以上 GHE 側の SCIM 設定 Enterprise Owner 3.3 本体テナント側の事前調整 本体テナントから開発専用テナントへ B2B 招待を成立させるには、 本体テナント管理者に以下の確認・設定が必要です。 外部コラボレーション設定 : 開発専用テナントへのゲスト招待が許可されていること 参照: 外部コラボレーションを構成する クロステナントアクセス設定 : 開発専用テナントを許可リストに含めること 参照: クロステナント アクセス設定を構成する 条件付きアクセスポリシー の影響確認 参照: 条件付きアクセス ポリシーの影響の分析 他社・協力会社など他社テナントから招待する場合も、各他社テナント側で同様の許可が必要です。 社内調整を記事の初期タスクに含めておくと手戻りが減ります。 4. Entra ID 側の構成(構築済み環境の確認) ここからは、実際に構築済みの環境を確認しながら各コンポーネントを説明します。 エンタープライズアプリケーション Entra ID ポータルで「GitHub」と検索し、EMU 環境で SCIM プロビジョニングに使う GitHub Enterprise Managed User アプリを表示します。 画像内の AAAA****-**** はオブジェクトID、 BBBB****-**** はアプリケーションIDを置き換えたマスク表記です。組織名は Contoso株式会社 に置換しています。 EMU 環境での SCIM プロビジョニングは、この GitHub Enterprise Managed User アプリが担います。以降の設定はすべてこのアプリに対して行います。 GitHub Enterprise Managed User アプリの概要 画像内の BBBB****-**** はアプリケーションID、 AAAA****-**** はオブジェクトIDのマスク表記です。 このアプリが Entra ID と GHE を結ぶ要です。SSO(シングルサインオン)と SCIM プロビジョニングの両方をこのアプリ1つで担います。 SCIM プロビジョニングの状態 画像内の AAAA****-**** はサービスプリンシパルオブジェクトID、 CCCC****-**** はジョブIDの一部、 DDDD****-**** はアクティビティIDのマスク表記です。 筆者の環境では、この時点で複数ユーザー・複数グループが同期されています。プロビジョニングモードは「自動」に設定しており、Entra ID 側の変更が定期的に GHE に反映されます。 注意: 既定のプロビジョニング間隔は 最短でも 40 分 です。メンバー追加直後に「GHE に反映されていない」と慌てる前に、この間隔を把握しておいてください。急ぎの場合は、後述のオンデマンド同期で即時反映が可能です。 プロビジョニングログの確認 画像内の aaaaaaaa-bbbb-cccc-dddd-... や 12345678-abcd-... 11112222-3333-... 等のGUID表記は、SCIM 同期時に使われた実際のID値を置き換えたマスク表記です。 プロビジョニングログでは、各同期サイクルで何が行われたかを確認できます。 Create 、 Update 、 Delete のアクション別に成否が記録されており、エラーが発生した場合の原因調査に重要です。 設定時に迷った判断: 自動 vs 手動プロビジョニング 初期構築時、プロビジョニングモードを「自動」にするか「手動」にするかで少し迷いました。手動の方が同期タイミングを完全にコントロールできる反面、メンバー変更のたびに毎回実行するのは現実的ではありません。 結局、 基本は自動(40 分サイクル)に任せて、急ぎの反映だけオンデマンド同期で即時反映する 運用に落ち着きました。この方針なら、日常運用では同期を意識せずに済み、ライセンスの棚卸しなど特定タイミングだけ手動介入すればよくなります。 5. GHE 側の受け入れ設定 本記事では SAML SSO を用いた EMU 構成 を前提としています(OIDC 構成でも SCIM の基本的な仕組みは同様ですが、設定項目が異なるため、本記事では SAML に絞って解説します)。 GHE(EMU)側で必要な設定は、大きく2点です。 SAML SSO の有効化 EMU の Enterprise 設定で SAML SSO を有効化し、Entra ID を IdP として指定します。設定項目は以下の通りです。 Sign on URL : Entra ID アプリの SAML サインオン URL Issuer : Entra ID のアプリケーション ID URI Public certificate : Entra ID からダウンロードした証明書 SCIM トークンの発行 GHE の Enterprise 設定から SCIM 用の Personal Access Token(PAT、classic 形式)を発行し、Entra ID 側のプロビジョニング設定に入力します。 このトークンは Enterprise Owner 権限を持つ EMU 管理者アカウント(いわゆる setup user 。 @<enterprise-slug>_admin 形式)で発行する必要があります。Organization Owner 権限では権限が不足します。発行時のスコープは、EMU の SCIM プロビジョニング用途では scim:enterprise を含める必要があります。 補足 : GHE 側の設定手順は GitHub 公式ドキュメントに詳しい説明があります。本記事では Entra ID 側の Graph API 操作にフォーカスするため、GHE 側は概要にとどめます。 設定時に迷った判断: SCIM トークンの有効期限 SCIM 用 PAT(Personal Access Token)の有効期限をどう設定するかは、悩みどころです。無期限にすればトークン更新の手間はなくなりますが、漏洩時の影響が大きくなります。逆に短期間にすると、更新作業が運用の負担になります。 筆者のチームでは、 90日ごとに更新する運用 を採用しています。Entra ID のプロビジョニング設定にトークンを再登録する作業は 1 分程度で済むため、継続的な運用コストとしては十分に許容できる範囲でした。この辺りは組織のセキュリティポリシーによるので、社内ガイドラインとすり合わせて決めるのが無難です。 6. B2B でのゲスト招待(Graph API 実例) ここからが本記事の本題です。協力会社のメンバーを Graph API で B2B ゲスト招待する実際の手順を紹介します。 Graph API の認証について 本記事の Graph API 操作は、Azure CLI の az account get-access-token --resource-type ms-graph で取得したアクセストークンを Authorization: Bearer <token> ヘッダに付けて呼び出す方式を前提にしています。 Microsoft Graph Explorer や Postman を使う方法もありますが、スクリプト化と監査性の観点から、筆者のチームでは CLI ベースで統一しています。 認証方式の詳細は Microsoft Graph の認証と認可の基本概念 を参照してください。 具体的にはシェル上で以下のように使っています。 # アクセストークンを取得して環境変数に格納 TOKEN=$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv) # Graph API を呼び出す例 curl -s -X POST "https://graph.microsoft.com/v1.0/invitations" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "invitedUserEmailAddress": "ichiro.sato@partner.example.com", "inviteRedirectUrl": "https://myapplications.microsoft.com/", "sendInvitationMessage": false, "invitedUserType": "Guest" }' az login 済みの状態でこの az account get-access-token を実行すれば、ログイン中のユーザー権限でトークンが取れます。CI パイプラインで使う場合は、サービスプリンシパル + az login --service-principal の組み合わせに差し替えてください。 なぜ Graph API を使うのか Entra ID ポータルからもゲスト招待は可能ですが、Graph API を使う理由は明確です。 再現性 : スクリプト化すれば、同じ手順を何度でも正確に再現できる バッチ処理 : 複数名を一括で招待できる(ポータルでは1人ずつ) 監査性 : リクエストとレスポンスをログとして残せる 自動化 : CI/CD パイプラインやスケジュール実行に組み込める ゲスト招待: POST /invitations 協力会社のメンバーを招待する Graph API リクエストの例です。 POST https://graph.microsoft.com/v1.0/invitations Content-Type: application/json { "invitedUserEmailAddress": "ichiro.sato@partner.example.com", "inviteRedirectUrl": "https://myapplications.microsoft.com/", "sendInvitationMessage": false, "invitedUserType": "Guest" } ポイント: sendInvitationMessage を false にしています。招待メールを送らずにゲストユーザーを作成するパターンです。チーム内では、チャットで直接「このURLからサインインしてね」と伝える方が効率的なことが多いです。 レスポンス例 { " @odata.context ": " https://graph.microsoft.com/v1.0/$metadata#invitations/$entity ", " id ": " 9c50ecdd-xxxx-xxxx-xxxx-8332d9b7d2d0 ", " invitedUserEmailAddress ": " ichiro.sato@partner.example.com ", " invitedUserType ": " Guest ", " sendInvitationMessage ": false , " status ": " PendingAcceptance ", " invitedUser ": { " id ": " 089891dc-xxxx-xxxx-xxxx-05f85883a15f ", " userPrincipalName ": " ichiro.sato_partner.example.com#EXT#@contoso.onmicrosoft.com " } } 注目すべきは invitedUser.id です。このユーザー ID は後続のグループ追加( POST /groups/{id}/members/$ref )で使います。また、 userPrincipalName が 元のメールアドレス_ドメイン#EXT#@テナントドメイン という独特のフォーマットになる点も把握しておいてください。 招待後のユーザー状態 画像内のユーザー名 ichiro.sato kenji.watanabe jiro.suzuki や、ドメイン contoso.onmicros... はサンプル表記です(実環境の B2B ゲストユーザー情報を置き換えたもの)。 招待直後のユーザーは PendingAcceptance (承諾待ち)の状態です。ただし、SCIM プロビジョニングの観点では、招待承諾前でもグループに追加してプロビジョニング対象にすることが可能です。これは運用上かなり便利で、「招待 → グループ追加 → SCIM 同期」を一連の流れで実行できます。 グループへのメンバー追加: POST /groups/{id}/members/$ref 招待したゲストユーザーを SCIM 同期対象のグループに追加します。 POST https://graph.microsoft.com/v1.0/groups/{group-id}/members/$ref Content-Type: application/json { "@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/089891dc-xxxx-xxxx-xxxx-05f85883a15f" } 成功すると 204 No Content が返ります。レスポンスボディは空です。 実際に確認してみると、この API は冪等(べきとう)ではありません。既にグループに所属しているユーザーを再追加しようとすると 400 Bad Request になります。スクリプトで一括処理する場合は、事前にメンバー一覧を取得して存在チェックを入れるか、エラーハンドリングで対処してください。 招待 + グループ追加を一括で回すスクリプト例 ここまでの招待・グループ追加の操作を、シェルスクリプトにまとめるとこんな感じになります。筆者のチームではこれをベースに運用ツール化しています。 #!/usr/bin/env bash set -euo pipefail TOKEN=$(az account get-access-token --resource-type ms-graph --query accessToken -o tsv) GROUP_ID="<SCIM同期対象のグループID>" EMAILS=( "ichiro.sato@partner.example.com" "kenji.watanabe@partner.example.com" "jiro.suzuki@fabrikam.example.com" ) for EMAIL in "${EMAILS[@]}"; do # 1) B2B 招待 USER_ID=$(curl -s -X POST "https://graph.microsoft.com/v1.0/invitations" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"invitedUserEmailAddress\":\"$EMAIL\",\"inviteRedirectUrl\":\"https://myapplications.microsoft.com/\",\"sendInvitationMessage\":false,\"invitedUserType\":\"Guest\"}" \ | jq -r '.invitedUser.id') # 2) グループへ追加 curl -s -o /dev/null -w "[%{http_code}] " -X POST \ "https://graph.microsoft.com/v1.0/groups/$GROUP_ID/members/\$ref" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"@odata.id\":\"https://graph.microsoft.com/v1.0/directoryObjects/$USER_ID\"}" echo "$EMAIL -> $USER_ID" done ポイントは以下です。 set -euo pipefail で途中失敗時に止まるようにする(サイレント失敗防止) 招待レスポンスから invitedUser.id を抽出してグループ追加に渡す グループ追加の HTTP ステータス( 204 が成功)をログに残しておくと、一部失敗時の切り分けが早い エラーハンドリング( 400 Bad Request で既にメンバーの場合のスキップ等)を足せば、日常運用に耐えるレベルになります。 グループからのメンバー除外: DELETE /groups/{id}/members/{userId}/$ref メンバーの異動や離任時には、グループから除外します。 DELETE https://graph.microsoft.com/v1.0/groups/{group-id}/members/{user-id}/$ref こちらも成功すると 204 No Content が返ります。 ポイント: グループからの除外と、テナントからのゲストユーザー削除は別の操作です。筆者のチームでは、グループ除外のみ行い、テナントからの削除は行わない運用にしています。 同じメンバーが将来別のプロジェクトグループに参加する可能性がある テナントに残っているゲストユーザーは、アクティブでなければ B2B の MAU 課金対象にならない 7. グループメンバー変更と SCIM オンデマンド同期(実ログ付き) ここでは、メンバーの入れ替え(追加3 名・除外3 名)を行い、SCIM オンデマンド同期で GHE に即時反映する一連の流れを、実際の API ログとともに紹介します。 7-1. メンバーの追加と除外 作業の流れ 今回の作業は以下のステップで行いました。 Step 操作 Graph API 1 新メンバー3 名を B2B 招待 POST /invitations 2 招待したユーザーを対象グループに追加 POST /groups/{id}/members/$ref 3 追加結果を確認 GET /groups/{id}/members 4 異動メンバー3 名をグループから除外 DELETE /groups/{id}/members/{userId}/$ref 5 SCIM オンデマンド同期を実行 POST /servicePrincipals/{id}/synchronization/jobs/{jobId}/provisionOnDemand Step 1-2: 招待とグループ追加 3 名のメンバーを招待し、それぞれ対象のグループに追加しました。 ichiro.sato@partner.example.com → ProjectA-Collab グループ kenji.watanabe@partner.example.com → ProjectA-Collab グループ jiro.suzuki@fabrikam.example.com → ProjectA-Proper グループ 招待の API レスポンスは前章で紹介した通りです。3 名とも status: 201 (Created)で正常に招待が完了し、続くグループ追加も status: 204 (No Content)で成功しました。 Step 3: 追加結果の確認 グループのメンバー一覧を取得して、追加が正しく反映されたことを確認します。 GET https://graph.microsoft.com/v1.0/groups/{group-id}/members?$select=id,displayName,userPrincipalName ProjectA-Collab グループの確認結果 { " value ": [ { " id ": " eee55555-6666-7777-8888-xxxxxxxxxxxx ", " displayName ": " ichiro.sato ", " userPrincipalName ": " ichiro.sato_partner.example.com#EXT#@contoso.onmicrosoft.com " } , { " id ": " fff66666-7777-8888-9999-xxxxxxxxxxxx ", " displayName ": " kenji.watanabe ", " userPrincipalName ": " kenji.watanabe_partner.example.com#EXT#@contoso.onmicrosoft.com " } ] } ここで気づくのは、B2B 招待直後のユーザーは displayName がメールアドレスのローカルパート( ichiro.sato など)になっている点です。ホームテナント側で設定された表示名は、招待承諾後に反映されます。SCIM 同期では、この displayName がそのまま GHE 側のプロフィール名として使われるため、運用上は招待承諾を待ってから SCIM 同期を実行する方が望ましい場合もあります。 Step 4: グループからの除外 異動メンバー3 名をそれぞれのグループから除外しました。 Kazuma Suzuki → ProjectB-Collab から除外 Kousuke Sakai → ProjectA-Collab から除外 Yoshimichi Yokota → ProjectA-Collab から除外 ※上記は架空名に置換しています。 除外はすべて status: 204 で成功。除外前には GET /groups/{id}/members/{userId} で対象ユーザーの所属を確認してから実行しています。「削除する対象が本当に正しいか」を API で事前検証する手順は、本番運用では省略しないでください。 7-2. オンデマンド同期の実行 Step 5: SCIM オンデマンド同期 通常の SCIM プロビジョニングは 40 分間隔の自動実行ですが、メンバー変更の即時反映が必要な場合は オンデマンドプロビジョニング を使います。 servicePrincipal の特定 まず、SCIM プロビジョニングが設定されているエンタープライズアプリの servicePrincipal を特定します。 GET https://graph.microsoft.com/v1.0/servicePrincipals?$filter=displayName eq 'GitHub Enterprise Managed User'&$select=id,displayName,appId { " value ": [ { " id ": " 7c0a9e47-xxxx-xxxx-xxxx-899ba255d7df ", " displayName ": " GitHub Enterprise Managed User ", " appId ": " 62dd8251-xxxx-xxxx-xxxx-abb393966451 " } ] } SCIM プロビジョニングの対象は id が 7c0a9e47-... の GitHub Enterprise Managed User です。この id を次のオンデマンドプロビジョニング API のパスに使います。 jobId と ruleId の取得方法 provisionOnDemand のリクエストには、プロビジョニングジョブの jobId と同期ルールの ruleId が必要です。これらは以下の API で取得できます。 GET https://graph.microsoft.com/v1.0/servicePrincipals/{sp-id}/synchronization/jobs レスポンスに含まれる id がジョブ ID、 schema.synchronizationRules[].id が ruleId です。筆者の環境では、GitHub Enterprise Managed User アプリのプロビジョニング設定ジョブがひとつだけなので、先頭の jobs[0].id をそのまま使っています。 オンデマンドプロビジョニングの実行 POST https://graph.microsoft.com/v1.0/servicePrincipals/{sp-id}/synchronization/jobs/{job-id}/provisionOnDemand Content-Type: application/json { "parameters": [ { "ruleId": "{provisioning-rule-id}", "subjects": [ { "objectId": "{group-id}", "objectTypeName": "Group" } ] } ] } このAPIでグループ単位のオンデマンド同期を実行できます。 補足: Graph API の provisionOnDemand はグループ単位でも実行可能ですが、Azure ポータルの「オンデマンドプロビジョニング」UI では既定でユーザー単位の操作になります。API のほうが柔軟性が高く、本記事のようなバッチ変更時にはこちらを使うほうが実用的です。 オンデマンド同期の結果 画像内のグループ名 GG-GH-ProjectA-Proper はサンプル表記、 12340000-aaaa-bbbb-cccc-... はグループオブジェクトIDをマスクした値です。 オンデマンド同期を実行すると、各メンバーの同期ステータスが返ってきます——初めてこれを見たとき、筆者は RedundantExport という見慣れないステータスに「何かミスったか?」とヒヤッとしました。結論から言うと、これはエラーではありません。 画像内のグループ名 GG-GH-ProjectA-Proper はサンプル表記です。 RedundantExport は「エクスポート先(GHE 側)に既に同じ状態のオブジェクトが存在するため、書き込みをスキップした」という意味です。つまり GHE 側が既に最新状態であることを示しており、同期処理としては正常です。ログを眺めているとドキッとしますが、動作としては期待通りなので安心してください。 SCIM 同期対象外のユーザーを指定した場合 画像内のユーザー名 Taro Contoso 、メールアドレス taro.contoso@contoso.onmicrosoft.com はサンプル/マスク表記です。 プロビジョニング対象のグループに所属していないユーザーを指定してオンデマンド同期を実行すると、 OutOfScope (スコープ外)となります。エラーにはなりませんが、同期も行われません。 7-3. GHE 側での反映確認 SCIM オンデマンド同期が完了したら、GHE 側で結果を確認します。 IdP グループ一覧 画像内のグループ名 GG-GH-ProjectA/B/C-Proper/Collab はサンプル表記です(実環境の組織別グループ名を置き換えたもの)。組織名 Contoso株式会社 も同様にサンプルです。 GHE の Enterprise 設定 > Identity provider groups に、Entra ID 側のグループが同期されていることを確認できます。 グループメンバーの反映 画像内のグループ名 GG-GH-ProjectA-Collab 、ユーザー名/ハンドル ichiro.sato kenji.watanabe 、SCIM Group ID 12340000-... はすべてサンプル/マスク表記です。 画像内のグループ名 GG-GH-ProjectA-Proper 、ユーザー名/ハンドル Taro Yamada jiro.suzuki Hanako Tanaka John Smith 、SCIM Group ID 12340000-... はすべてサンプル/マスク表記です。 Entra ID 側でのグループメンバー変更(追加・除外)が、GHE 側にも反映されています。追加したメンバーは GHE の IdP グループに表示され、除外したメンバーは GHE 側からも削除されています。 Entra ID 側グループの最終状態(参考) Entra ID 側のグループメンバーも確認しておきます。 画像内のグループ名 GG-GH-ProjectA-Proper 、ユーザー名 Taro Yamada jiro.suzuki Hanako Tanaka John Smith 、メールアドレス @contoso.com 、オブジェクトID aaa11111-... 等はすべてサンプル/マスク表記です。 画像内のグループ名 GG-GH-ProjectA-Collab 、ユーザー名 ichiro.sato kenji.watanabe 、メールアドレス @contoso.com 、オブジェクトID eee55555-... fff66666-... はすべてサンプル/マスク表記です。 この章のまとめ Graph API で招待 → グループ追加 → オンデマンド同期の一連のフローを、実 API ログと GHE 側の反映結果で確認しました 同期結果に出てくる RedundantExport OutOfScope は正常なステータスです。エラーではないことを把握しておくと、ログ読みの混乱が減ります 急ぎの反映以外は、既定の 40 分サイクルに任せる運用が API レート制限の観点でも安全です 8. 運用のコツ 実際に運用してみて気づいたポイントをいくつか共有します。 Graph API スクリプトの管理 筆者のチームでは、メンバーの招待・グループ追加・除外の操作をシェルスクリプトにまとめています。手作業を避けることで、「誰が」「いつ」「何を変更したか」がログとして残ります。Infrastructure as Code の考え方を ID 管理にも適用している形です。 SCIM 同期のタイミング 基本的には40 分間隔の自動同期に任せ、急ぎの場合だけオンデマンド同期を使う運用がおすすめです。オンデマンド同期は便利ですが、API 呼び出しにはレート制限(単位時間あたりの呼び出し回数上限)があるため、頻繁に実行するとスロットリング(一時的な要求拒否)が発生する可能性があります。 SCIM 同期エラーへの対処フロー SCIM 同期が失敗する典型的なパターンとして、属性の必須チェック漏れ( displayName 未設定など)や、GHE 側で既に同名ユーザーが存在するケースに遭遇します。筆者の環境では、エラー発生時の確認順序を以下のように決めています。 プロビジョニングログで該当ユーザー/グループのエラー内容を特定 Entra ID 側で対象ユーザーの属性を確認(特に UPN(User Principal Name)・メール・表示名) GHE 側の管理画面で既存の重複がないかを確認 属性を修正して手動でオンデマンド同期を再実行 段階的に切り分けることで、「再同期しても直らない」という事態をかなり減らせます。 メンバー離任時のチェックリスト メンバーが離任する際、筆者のチームでは以下の順序でクリーンアップします。 Entra ID のグループから除外(本記事 6 章の Graph API 操作) 必要に応じて B2B ゲストユーザー自体を削除(長期間再参加の見込みがない場合) GHE 側で SCIM 同期の結果として除外されていることを確認 もし GHE 上に残っている場合は、プロビジョニングログで原因を特定 「グループから外したつもりが GHE に残っていた」は意外と起こるため、同期後の確認は省略しない方が安全です。 定期的な棚卸し 筆者のチームでは、月次で以下の棚卸しを行っています。 Entra ID 側のグループメンバー一覧と、GHE 側の IdP グループメンバー一覧の突合 長期間ログインのないゲストユーザーの棚卸し(B2B MAU の抑制にもつながります) プロビジョニングログの定期確認(直近 30 日のエラー傾向を眺めて、運用改善ポイントがないか検討) ID 管理は「作って終わり」ではなく、運用で育てるものだと感じています。 9. まとめ 本記事では、Entra ID の B2B ゲスト招待と SCIM 自動プロビジョニングを組み合わせて、本体テナントを開発用途に直接使えない大きな組織の開発チーム向けに GHE EMU 環境を構築する方法を紹介しました。 ポイントを整理します。 専用テナント + B2B + SCIM の組み合わせで、本体テナントに影響を与えずに GitHub 環境を構築できる Graph API を使うことで、再現性と監査性のある ID 管理が実現できる SCIM の オンデマンドプロビジョニング を活用すれば、メンバー変更を即時反映できる 数十名規模の開発チームであれば、Entra ID P1 相当 + EMU の最小構成で運用可能(筆者の環境での実績に基づく) 正直なところ、SCIM 連携は「設定すれば終わり」ではなく、運用の中で API レスポンスの読み方やエラーパターンを把握していく必要があります。本記事の Graph API 実例やログが、これから同じ構成を組む方の参考になれば幸いです。 執筆者 樋口竣一(NTTビジネスソリューションズ / NTT西日本) NTTグループ内をわりと転々としております。 参考資料・出典 本記事を執筆するにあたり、以下のサイトを参考にしました。 Microsoft Entra ID での B2B コラボレーション ユーザーのプロパティ Microsoft Graph API を使用して招待を送信する GitHub Enterprise Managed Users について Microsoft Entra ID を使用して GitHub Enterprise Managed User への認証とプロビジョニングを構成する オンデマンド プロビジョニング (Microsoft Entra ID) Microsoft Graph - グループ メンバーの追加 商標 「Microsoft」「Microsoft Entra ID」「Microsoft Azure」「Microsoft Graph」は、米国 Microsoft Corporation の米国およびその他の国における登録商標または商標です。 「GitHub」「GitHub Enterprise」「GitHub Copilot」は、GitHub, Inc. の商標または登録商標です。 記載の会社名・製品名はそれぞれの会社の商標もしくは登録商標です。
はじめに NTTビジネスソリューションズ / NTT西日本の樋口です。 本記事は2026年4月時点の情報に基づきます。 先日、案件で「このモデルにデータを入れて本当に大丈夫?」と聞かれて、即答できない自分がいました。公式ドキュメントは読んでいたはずなのに、です。悔しかったので、腰を据えてAzure CLIを叩きながらMicrosoft Foundryを一つひとつ確認してみたところ、2026年4月時点では状況が想定以上に整理されていることがわかりました。本記事は、その確認プロセスを同じ疑問を持つエンジニアに共有したいという思いで書いています。 Microsoft Foundry では1,900以上のAIモデルが利用可能です( Microsoft Foundry Models overview より、2026年4月時点)。しかし「自社データを投入して大丈夫か」「学習に使われないか」は、公式ドキュメントを読んでもわかりにくいのが実情です。特にChatGPT(GPT系モデル)、Claude、Geminiといった主要モデルについて「Azure経由で使えるのか」「データは安全なのか」を知りたい方は多いのではないでしょうか。 本記事では、Azure CLIと実際のポータル画面から2026年4月時点のファクトを確認し、 データ学習ポリシーの明確さ という評価軸に絞って各モデルの利用規約を整理しました。料金、Service Level Agreement(SLA)、レイテンシ、機能網羅性は本記事では扱いません。 ※ 本記事は筆者個人の調査に基づくもので、所属組織の公式見解ではありません。実務適用時は公式ドキュメントと自組織の法務確認をお願いします。 対象読者 以下に 2つ以上当てはまる なら、本記事が役立つ可能性が高いです。 Azure環境でChatGPT、Claude、GeminiなどのAIモデルの導入を検討している AIモデルへの自社データ投入に際し、データ学習ポリシーを確認したい Microsoft Foundry のモデルカタログにおけるデータプライバシーの仕組みを理解したい Azureの利用経験がある(AZ-900(Microsoft Azure Fundamentals)程度の前提知識を想定) Azure CLIでのモデル可用性確認を自分の環境でも再現してみたい 目次 1. モデルの2大分類を正しく理解する 2. Azure 直販モデルのデータ学習ポリシー(ChatGPT / GPT系を含む) 3. Claude in Foundry はAnthropic商用規約が適用される 4. リージョン別 利用可能状況 5. 各モデルのライセンス(実機ポータル確認結果) 6. データ保持とオプトアウト 7. 実務での判断ポイント(ChatGPT / Claude / Gemini) 8. まとめ 1. モデルの2大分類を正しく理解する Microsoft Foundry のモデルカタログは、公式ドキュメント上、以下の 2つの主分類 で構成されています。 分類 概要 代表モデル Azure Direct Models(以下、Azure 直販モデル) Microsoft が直接ホスト・管理。Azure 課金、Microsoft Service Level Agreement(SLA)適用 OpenAI GPT 系(GPT-5.x など) , DeepSeek, Grok(xAI), Mistral, Meta Llama, Cohere, Kimi(MoonshotAI) Models from Partners and Community パートナーやコミュニティが提供。Serverless API または Managed Compute でデプロイ Claude(Anthropic)など イメージとしては、 モデルカタログはスーパーの食品棚 のようなものです。「Azure 直販棚」と「Partners & Community棚」という2つの棚があり、それぞれ品質保証のロゴ(=利用規約)が異なります。同じ "AIモデル" というラベルでも、棚ごとに適用されるルールが別、というイメージを持つと後続の話が整理しやすくなります。 注意 : Managed Compute はモデルのカテゴリではなくデプロイオプションです。モデル分類とデプロイ方式は分けて理解する必要があります。 ポイント : 意外かもしれませんが、DeepSeek、Grok、Mistral などは一見サードパーティに見えますが、実際には「Azure 直販モデル」に分類されています。つまり、これらのモデルも Azure OpenAI(GPT 系)と同等のデータ保護ポリシーの対象です。 出典: - Microsoft Foundry Models overview - Foundry Models sold directly by Azure 2. Azure 直販モデルのデータ学習ポリシー(ChatGPT / GPT系を含む) Azure 直販モデルには GPT-5.x 系(Azure OpenAI Service 経由で利用する ChatGPT の基盤モデル) が含まれます。これらについて、Microsoft Learn 公式ドキュメントでは以下が明確に記載されています。 プロンプトと出力はモデル内に保存されない(ステートレス(stateless:推論時にデータを保持しない仕組み)) 他の顧客のデータとは分離されている OpenAI などのモデル提供者にもデータは渡らない 提供者の改善にも使われない 許可なく基盤モデルの学習・再学習・改善に使われない 箇条書きにすると当たり前に見えますが、特に「OpenAI などのモデル提供者にもデータは渡らない」という条項は、OpenAI 直接利用時との違いとして重要なポイントです。実際にポータルのライセンスタブを開いて「Microsoft Product Terms が適用される」と確認できたときは、想定していた以上にデータ保護ポリシーが明確で、一定の安心材料になりました。 つまり、 GPT 系(ChatGPT の基盤モデル) をはじめ、Azure 直販モデルである DeepSeek、Grok、Mistral、Meta Llama などを利用する場合、 許可なく、プロンプトや出力がモデル学習に使われることはありません 。Azure OpenAI Service は Azure 直販モデルの代表格であり、データ学習ポリシーの観点では、Microsoft Product Termsによる非学習保証が明文化されている選択肢の一つです。 非学習保証とAbuse Monitoringの違い ここで誤解を避けるために整理しておきます。 非学習保証 : Microsoft Product Terms により、プロンプトと出力は基盤モデルの学習・再学習・改善には使われません Abuse Monitoring : 悪用検知のため、プロンプトと出力の 一部が最大30日間 保持され、必要に応じてMicrosoft従業員によるレビューが行われる場合があります この2つは別の話です。「非学習保証がある」=「データがどこにも残らない」という意味ではありません。保存をゼロにするには、後述のZero Data Retention(ZDR)の申請が必要です。 出典: Data, privacy, and security for Azure Direct Models in Microsoft Foundry 3. Claude in Foundry はAnthropic商用規約が適用される 正直なところ、ここが一番ややこしい部分です。結論から言うと、Foundry 経由の Claude はデフォルトでは非学習と読むのが妥当です。ただし Azure 直販モデルとは仕組みが異なります。以下で詳しく整理します。 Claude(Anthropic)は Azure 直販モデルとは異なる扱いです。Microsoft Learn の専用ページで、以下が明記されています。 Anthropic がデータプロセッサー として機能する Anthropic のサービスがモデルをホスト・管理する Microsoft は API endpoint、デプロイ基盤、課金・利用情報の一部を管理する Serverless API deployment の一般ルールでは Microsoft がデータプロセッサーですが、Claude は現時点で公開資料上、専用のデータプライバシーページが設けられている代表的な例外です。 では Claude in Foundry のデータは学習に使われるのか? Anthropic の商用向け Privacy Center では、Claude for Work や Anthropic API(Application Programming Interface)などの commercial products について、「 デフォルトでは入力 / 出力(inputs / outputs)をモデル学習に使わない 」と記載されています。例外は、明示的なフィードバック / バグレポート、または明示的にデータ利用を許可した場合です。 Foundry 上の Claude モデルページにも「Usage is governed by Anthropic's Commercial Terms of Service for API access」と記載されており、商用 API 扱いとしてこのデフォルト非学習ポリシーに乗る可能性が高いと読むのが自然です。 留保事項 「Microsoft Foundry 経由の Claude は Anthropic commercial products のこの条項に含まれる」と名指しで断言した公式文書は、現時点では確認できていません。法務や監査で確実を期す場合は、Anthropic またはMicrosoftの営業・法務窓口から書面での契約条件確認を行うことを推奨します。 出典: - Data, privacy, and security for Claude models in Microsoft Foundry (preview) - Anthropic Privacy Center: Is my data used for model training? 4. リージョン別 利用可能状況 前提条件 本セクションの Azure CLI コマンドを実行する前に、以下を確認してください。 Azure CLI 2.60以降がインストール済みであること( az --version で確認) az login で Azure にサインイン済みであること az account set --subscription <サブスクリプションID> で対象サブスクリプションが選択済みであること 対象サブスクリプションで Cognitive Services(Azure AI Services)の読み取り権限を持っていること サブスクリプションの種類(Enterprise Agreement(EA)または Microsoft Customer Agreement(MCA)(いずれもマイクロソフトとの大口契約形態)/ Pay-As-You-Go 等)やアカウントの権限レベルによって、表示されるモデル一覧が異なる場合があります。 Azure CLIの az cognitiveservices model list --location <region> コマンドで、各リージョンのモデル利用可能状況を確認しました(2026年4月13日時点)。 主な結果として、 Claude(Anthropic)は日本リージョンでは利用できません 。 プロバイダー Japan East East US 2 Sweden Central OpenAI(GPT 系) 利用可 利用可 利用可 Anthropic(Claude) 利用不可 利用可 利用可 Google(Gemini) カタログ外 カタログ外 カタログ外 DeepSeek 利用可 利用可 利用可 xAI(Grok) 利用可 利用可 利用可 Mistral AI 利用可 利用可 利用可 Meta(Llama) 利用可 利用可 利用可 Cohere 利用可 利用可 利用可 Kimi(MoonshotAI) 利用可 利用可 利用可 注 : Gemini(Google)については、2026年4月13日時点で筆者環境の Foundry ポータルおよび Azure CLI では確認できませんでした。Gemini を利用する場合は Google Cloud の Vertex AI 経由となります。 注 : 上記の表は各プロバイダーの代表的なモデル / Stock Keeping Unit(SKU、製品の識別単位)を基にした確認結果です。プロバイダー内の全モデルが同一リージョンで利用可能とは限りません。モデルごとの可用性は SKU やデプロイ方式によって異なります。 Microsoft Foundry ではプロジェクト単位でモデルを管理します。Claude を利用するには East US 2、West Central US、Sweden Central のいずれかのリージョンでプロジェクトを作成する必要があります。 また、最新モデルのリージョン展開は米国リージョンが先行する傾向があります。たとえば GPT-5.4系は2026年4月時点で Japan East でも利用可能ですが、リリース直後は East US 2 など米国リージョンのみで提供され、日本リージョンへの展開は数週間から数か月遅れる場合がありました。本番導入時はリージョンの可用性だけでなく、展開タイミングも考慮に入れておくと安心です。 筆者も最初は Claude が Japan East で使えると思い込んでいましたが、CLI で確認したところ Anthropic フォーマットのモデルが1件も返ってこず、調べ直すきっかけになりました。 みなさんの環境でも、以下のコマンドで同じ確認が可能です。 確認方法: # Claude(Anthropic)が Japan East で利用可能か確認 az cognitiveservices model list --location japaneast \ --query "[?model.format=='Anthropic'].model.name" --output table 期待される出力例(2026年4月時点): (何も返らない) 何も返らない=そのリージョンでは利用不可、と判断できます。 ※ クエリで使用している model.format フィールド名は Azure CLI のバージョンによって異なる可能性があります。 az cognitiveservices model list --help でスキーマを確認してください。 # Azure 直販モデル(DeepSeek)の Japan East での利用可否を確認 az cognitiveservices model list --location japaneast \ --query "[?model.format=='DeepSeek'].model.name" --output table 期待される出力例: Result -------------- DeepSeek-V3.2 DeepSeek-R2 # Claude が使えるリージョンを一括確認 for region in japaneast eastus2 swedencentral westcentralus; do echo "=== $region ===" az cognitiveservices model list --location $region \ --query "[?model.format=='Anthropic'].model.name" --output table done 期待される出力例: === japaneast === (何も返らない) === eastus2 === Result ------------------- Claude-sonnet-4 Claude-opus-4 === swedencentral === Result ------------------- Claude-sonnet-4 # 特定モデルのデプロイ方式(SKU)を確認 az cognitiveservices model list --location japaneast \ --query "[?model.name=='DeepSeek-V3.2'].{name:model.name, sku:model.skus[0].name}" \ --output table 期待される出力例: Name Sku -------------- --------------- DeepSeek-V3.2 GlobalStandard 出典: Deploy and use Claude models in Microsoft Foundry 5. 各モデルのライセンス(実機ポータル確認結果) Microsoft Foundry ポータルの各モデル詳細ページにある「ライセンス」タブを実際に確認しました。以下は 2026年4月13日時点のポータル表示内容です。 ※公式ドキュメント上の記載ではなく、ポータルの実機確認結果です。 モデル ライセンスタブの表示内容 DeepSeek-V3.2 Microsoft Product Terms(Universal License Terms for Microsoft Generative AI Services)が適用 Grok-4-20-reasoning 同上 Mistral-Large-3 同上 Claude 系(sonnet/opus/haiku) Anthropic's Commercial Terms of Service for API access Kimi-K2.5 Modified MIT License + Microsoft Product Terms(※ ポータル表示の原文を転記しています。詳細は Microsoft Foundry ポータルのライセンスタブを参照してください) Llama-4 Maverick Direct from Azure: Microsoft Product Terms / Managed Compute: Llama 4 Community License Agreement Azure 直販モデル(DeepSeek, Grok, Mistral など)には Microsoft Product Terms が一律で適用されるのに対し、Claude だけが Anthropic の商用利用規約が適用される構造になっています。こうして並べてみると、Claude だけ明らかに毛色が違うのがわかります。 6. データ保持とオプトアウト Azure 直販モデルでは、悪用監視(Abuse Monitoring)の一環として、一部のプロンプトと出力が最大 30 日間保持される場合があります。Modified Abuse Monitoring が承認されている場合は、この保存と人手によるレビューは行われません。 この保持をゼロにする(Zero Data Retention / ZDR)には以下が必要です: Enterprise Agreement(EA)または Microsoft Customer Agreement(MCA)契約であること Microsoft アカウントチーム経由で Modified Abuse Monitoring を申請すること セルフサービスのポータル設定ではなく、申請ベース 実際のところ、ZDRの申請はセルフサービスではないため、ハードルは高めです。 Claude in Foundry について: Microsoft の Azure OpenAI 型 ZDR は Claude に直接適用されません。Claude 側のデータ保持や ZDR については、Anthropic の API 利用規約で確認する必要があります。Anthropic API の Messages API(メッセージ送信用API)は ZDR eligible とされています。 出典: Limited Access features for Foundry Tools 7. 実務での判断ポイント(ChatGPT / Claude / Gemini) 実務での判断ポイントを、一緒に整理してみましょう。ここまでの情報をもとに、よく聞かれる3モデルについてまとめます。 ChatGPT(GPT系モデル) → Azure OpenAI Service として利用 結論から言えば、データ学習ポリシーの観点で最もシンプルに説明しやすいのがこのモデルです。筆者が案件で「大丈夫?」と聞かれたとき、一番回答しやすかったのもここでした。 分類 : Azure 直販モデル データ保護 : Microsoft Product Terms による明確な非学習保証あり リージョン : 日本リージョン含め広く利用可能 評価 : データ学習ポリシーの観点では、Microsoft Product Termsによる非学習保証が明文化されている選択肢の一つ 。Azureの SLA・コンプライアンス体制がそのまま適用されます 確認アクション : Azure OpenAI Service のリソースを作成するか、Microsoft Foundry ポータルから直接デプロイすることで、Japan East を含む多くのリージョンで利用可能です 社内の法務やセキュリティ部門に説明する際も、「Microsoft Product Terms に明記されています」と一言で伝えられるのは大きな利点です。 Claude(Anthropic) → Foundry 経由で利用 分類 : Models from Partners and Community(Anthropic がデータプロセッサー) データ保護 : Anthropic の商用 API 規約に従い、デフォルトでは非学習と読むのが妥当 リージョン : 日本リージョンでは利用不可 (East US 2、West Central US、Sweden Central) 評価 : Azure 直販モデルと同一の ZDR ではない点に注意。確実を期す場合は書面確認を推奨します 確認アクション : 利用するには East US 2、West Central US、Sweden Central のいずれかにプロジェクトを作成する必要があります 筆者のプロジェクトでは、日本リージョン要件があったため Claude は早々に候補から外れました。グローバル環境での検証には有力候補の一つです。 Gemini(Google) → Foundry では利用不可 分類 : 2026年4月13日時点で、筆者環境の Foundry ポータルでは確認できなかった 利用方法 : Google Cloud の Vertex AI 経由で利用する必要があります 評価 : Azure 環境内で完結させたい場合は選択肢に入りません。マルチクラウド構成を許容できる場合に検討してください 確認アクション : Google Cloud の Vertex AI コンソールから利用を検討してください Vertex AI 経由での利用は、マルチクラウド構成の前提が必要になります。筆者の環境では Azure に統合したい要件があったため、今回は検証対象から外しています。 その他の Azure 直販モデル(DeepSeek, Grok, Mistral, Meta Llama など) Microsoft Product Terms による明確な非学習保証があります。ChatGPT(GPT系モデル)と同等のデータ保護ポリシーが適用されます。 個人的には、DeepSeek や Grok が Azure 直販モデルに含まれていた事実が意外でした。DeepSeek は Japan East でも利用可能で、遅延面でも現実的な選択肢として候補に入ります。 Managed Compute(自前デプロイ) モデルごとのオープンソースライセンスに従います。データ処理の責任は利用者側に寄る度合いが高くなります。 8. まとめ ChatGPT(GPT系モデル) : Azure OpenAI Service としてAzure 直販モデルに含まれ、Microsoft Product Terms による非学習保証が明文化されています。データ学習ポリシーの観点では、Microsoft Product Termsによる非学習保証が明文化されている選択肢の一つです Claude : Foundry 経由で利用可能ですが、Anthropic がデータプロセッサーとなる別構造です。デフォルトでは非学習と読むのが妥当ですが、日本リージョンでは利用できません Gemini : Microsoft Foundry のカタログには含まれていません。Google Cloud Vertex AI 経由での利用となります Microsoft Foundry のモデルは「Azure 直販」と「Partners & Community」の 2 分類で、DeepSeek、Grok、Mistral などのほとんどのモデルは Azure 直販に含まれます 情報は急速に変わるため、公式ドキュメントの定期確認を推奨します 個人的な感想としては、Azure 直販モデルの範囲がここまで広がっているのは想定以上の結果でした。2020年代前半は、サードパーティが提供するモデルの規約やデータ保護ポリシーが整備途上だった時期もありましたが、2026年4月時点ではAzure 直販モデル群を中心に規約整備が進んでいます。本記事で確認したとおり、データ学習ポリシーの観点では各社の規約が一定の形に整ってきています。 本記事の確認方法: - Azure CLI: az cognitiveservices model list --location <region> - Microsoft Foundry ポータル: 各モデルの「詳細」「ライセンス」タブ 執筆者 樋口竣一(NTTビジネスソリューションズ / NTT西日本) NTTグループ内を転々としており最近NTT西グループに帰ってきました。 資格: E資格(JDLA Deep Learning for ENGINEER) CITP 認定情報技術者 技術士補(そろそろ経験的年次的に二次試験受けたい) クラウド関連資格 Microsoft Azure: AZ-104, AZ-204, AZ-305, AZ-400, AZ-700, AI-102 AWS: 主要認定資格ほぼ全て(AIP除く)   参考資料・出典 本記事を執筆するにあたり、以下のサイトを参考にしました。 Microsoft Foundry Models overview Foundry Models sold directly by Azure Data, privacy, and security for Azure Direct Models in Microsoft Foundry Data, privacy, and security for Claude models in Microsoft Foundry (preview) Anthropic Privacy Center: Is my data used for model training? Deploy and use Claude models in Microsoft Foundry Limited Access features for Foundry Tools 商標 Microsoft、Azure、Azure OpenAI Service、Microsoft Foundry は、米国 Microsoft Corporation の米国およびその他の国における登録商標または商標です。 OpenAI、ChatGPT、GPT は、OpenAI, Inc. の商標または登録商標です。 Claude は、Anthropic, PBC の商標または登録商標です。 Gemini は、Google LLC の商標または登録商標です。 DeepSeek は、DeepSeek(深度求索)の商標または登録商標です。 Grok は、xAI Corp. の商標または登録商標です。 Meta、Llama は、Meta Platforms, Inc. の商標または登録商標です。 Mistral は、Mistral AI の商標または登録商標です。 Cohere は、Cohere, Inc. の商標または登録商標です。 Kimi および MoonshotAI(月之暗面)は、Moonshot AI Inc. の商標または登録商標です。 Google Cloud、Vertex AI は、Google LLC の商標または登録商標です。 記載の会社名・製品名はそれぞれの会社の商標もしくは登録商標です。
はじめに NTT西日本の平岡です。 前編では、Harvester 標準の harvester-longhorn 、TrueNAS iSCSI LUN を Longhorn に統合した longhorn-iscsi 、そして csi-driver-nfs による nfs-csi の3方式を構築し、fio ベンチマークで性能を比較しました。Longhorn のレプリカ同期が書き込みオーバーヘッドとなること、NFS/CSI が Longhorn をバイパスすることで高い転送速度を示すことを確認しています。 後編では、Longhorn を経由しない別のアプローチとして、以下の3方式を追加検証しました。 democratic-csi : TrueNAS SCALE の iSCSI LUN を Kubernetes PVC として直接マウントする CSI ドライバです。Longhorn を経由せず、RWO アクセスを提供します。 OpenEBS Dynamic NFS Provisioner : democratic-csi が提供する RWO PVC をバックエンドとし、その上にカーネルモード NFS サーバー Pod を自動生成することで RWX アクセスを実現します。 nfs-ganesha-server-provisioner : 同じく democratic-csi の RWO PVC をバックエンドとしますが、NFS-Ganesha(ユーザー空間 NFS サーバー)を常駐 Pod として配置し、RWX アクセスを提供します。 3方式はいずれも最終的に TrueNAS SCALE の iSCSI LUN にデータを格納しますが、I/O パスの段数、NFS 実装の有無と種類、Pod のライフサイクルが異なります。これらの違いが性能・可用性・運用性にどう影響するかを、構成図・ベンチマーク・障害分析を通じて明らかにします。 なお、本検証は筆者の自宅ラボに構築した Proxmox VE 上のネステッド仮想化環境で実施しており、所属組織の環境は使用していません。ベンチマークの絶対値はベアメタル環境と異なる可能性がありますが、3方式間の相対的な傾向(順位・倍率)は有効な比較指標となります。 対象読者 Harvester や KubeVirt ベースの仮想化基盤を検討している方 democratic-csi、OpenEBS、nfs-ganesha の違いを比較したい方 RWX ストレージの実装パターンやバックアップ制約を把握したい方 この記事で分かること Longhorn を経由しない 3 方式( democratic-csi / OpenEBS Dynamic NFS Provisioner / nfs-ganesha )の構造差 Pod 内 fio による比較結果と、RWX 実装の違いが性能や障害影響にどう表れるか 全 6 方式を踏まえた選定指針と、どのユースケースにどの方式が向くか ベンチマーク結果を先に見たい方は 8. ベンチマーク 、選定指針を先に見たい方は 9.2. ユースケース別の選定指針 をご覧ください。 1. RWO と RWX ― NFS サーバー Pod による変換パターン 2. 検証環境 2.1. ハードウェアとインフラ構成 2.2. 前編で構築済みの StorageClass 2.3. 後編で追加する StorageClass 3. 全体アーキテクチャ 3.1. 3方式の I/O パス概要 3.2. 方式1: democratic-csi(直接 iSCSI) 3.3. 方式2: OpenEBS Dynamic NFS Provisioner(カーネル NFS) 3.4. 方式3: nfs-ganesha-server-provisioner(ユーザー空間 NFS) 3.5. 3方式の構造比較 3.6. PVC のライフサイクル 4. democratic-csi の導入 4.1. democratic-csi とは 4.2. Helm リポジトリの追加とインストール 4.3. TrueNAS API 設定 4.4. Helm インストール 4.5. StorageClass の確認 4.6. 動作確認: PVC の作成とマウント 5. OpenEBS Dynamic NFS Provisioner の導入 5.1. なぜ NFS Provisioner が必要か 5.2. インストール手順 5.3. Harvester Admission Webhook の回避 5.4. StorageClass の作成 5.5. 動作確認: RWX PVC の作成 5.6. 3ノード同時書き込みテスト 5.7. クリーンアップと自動削除の確認 6. nfs-ganesha-server-provisioner の導入 6.1. OpenEBS との構造的な違い 6.2. インストール手順 6.3. Pod とバックエンド PVC の確認 6.4. 動作確認: RWX PVC の作成 6.5. 3ノード同時書き込みテスト 6.6. クリーンアップ時の動作の違い 7. OS イメージのインポート検証 7.1. 検証目的 7.2. 検証結果 7.3. 失敗原因の分析 7.4. 検証から得られた知見 8. ベンチマーク 8.1. 測定条件 8.2. PVC 作成 8.3. 測定コマンド 8.4. 測定結果 8.4.1. キャッシュなし(direct=1)― ストレージ純粋性能 8.4.2. キャッシュあり(direct=0)― 実運用に近い性能 8.5. 考察 8.5.1. 想定と実測の比較 8.5.2. NFS レイヤのオーバーヘッド 8.5.3. ユーザー空間 NFS の書き込みペナルティ 8.5.4. キャッシュの効果 8.5.5. 前編ベンチマーク(VM 方式)との関係 8.5.6. ネステッド仮想化環境における留意事項 9. 総合比較と選定指針 9.1. 前編・後編 全6方式の横断比較 9.2. ユースケース別の選定指針 9.3. 検証全体のまとめ 9.4. バックアップに関する制約と代替手段 10. 参考情報 10.1. 公式ドキュメント 10.2. GitHub リポジトリ 11. 商標について 12. 免責事項 12.1. データ損失に関する注意事項 13. 執筆者 1. RWO と RWX ― NFS サーバー Pod による変換パターン Kubernetes のストレージには、アクセスモードという概念があります。RWO(ReadWriteOnce)は単一ノードからのみ読み書き可能なモード、RWX(ReadWriteMany)は複数ノードから同時に読み書き可能なモードです。 iSCSI や FC(ファイバチャネル)などのブロックストレージは、プロトコルの性質上 RWO に限定されます。ブロックデバイスは同時に複数のノードからマウントするとファイルシステムの破損を招くためです。一方、VM のライブマイグレーションや複数 Pod からの共有アクセスには RWX が必要となる場面があります。 この RWO と RWX のギャップを埋める一般的なパターンが「NFS サーバー Pod による変換」です。具体的には、RWO のブロック PVC をバックエンドとして NFS サーバー Pod を配置し、その NFS サーバー Pod がファイルシステムレベルで NFS プロトコルを提供することで、複数ノードからの同時アクセス(RWX)を実現します。 本記事で検証する3方式のうち、OpenEBS Dynamic NFS Provisioner と nfs-ganesha-server-provisioner はまさにこのパターンの実装です。両者とも democratic-csi が提供する RWO の iSCSI PVC をバックエンドとし、その上に NFS サーバー Pod を配置して RWX を実現します。違いは NFS サーバーの実装方式(カーネルモード vs ユーザー空間)と Pod のライフサイクル管理(PVC 単位 vs 共有)にあります。 なお、この「RWO ブロック PVC を NFS で RWX に変換する」という設計思想は、本検証のような OSS 構成に限らず、エンタープライズ環境でも広く採用されています。たとえば、HPE CSI Driver for Kubernetes にも NFS Server Provisioner コンポーネントが用意されており、HPE Alletra などのブロックストレージを RWX として提供する際に同様のアーキテクチャが使われています。バックエンドのストレージ製品は異なっても、「ブロック → NFS → RWX」という変換パターン自体は共通の設計手法であり、本記事の検証結果はこうしたエンタープライズ構成を検討する際の参考にもなります。 2. 検証環境 2.1. ハードウェアとインフラ構成 検証環境は前編と同一で、いずれも筆者の自宅ラボ上に構築したものです。 項目 仕様 Proxmox VE ホスト AMD Ryzen 5 5600G, 128 GB RAM, local-zfs 5.6 TB, Proxmox VE 8.3.0 (kernel 6.8.12-4-pve) Harvester クラスタ 3ノード (harvester01-03), 各 8 vCPU / 16 GB RAM / 400 GB disk, Management VIP 192.0.2.70, Harvester v1.7.1 TrueNAS SCALE VM ID 102, IP 192.0.2.4, TrueNAS SCALE 24.10.2, democratic-csi 用 Dataset: <your-dataset-path>, プロトコル: iSCSI ネットワーク 単一仮想ブリッジ vmbr0 (192.0.2.0/24) 2.2. 前編で構築済みの StorageClass StorageClass バックエンド プロトコル アクセスモード 管理レイヤ harvester-longhorn ノードローカルディスク ローカル RWO/RWX Longhorn longhorn-iscsi TrueNAS iSCSI LUN iSCSI RWO/RWX Longhorn nfs-csi TrueNAS NFS 共有 NFS RWX csi-driver-nfs 2.3. 後編で追加する StorageClass StorageClass バックエンド プロトコル アクセスモード 管理レイヤ truenas-iscsi TrueNAS iSCSI LUN (zvol) iSCSI RWO democratic-csi openebs-rwx-iscsi truenas-iscsi + カーネル NFS iSCSI + NFS RWX OpenEBS + democratic-csi nfs-ganesha truenas-iscsi + NFS-Ganesha iSCSI + NFS RWX nfs-ganesha + democratic-csi 3. 全体アーキテクチャ 3.1. 3方式の I/O パス概要 ここから後編の本題に入ります。まずは全体像をつかんでいただくため、3方式の I/O パスを並べて見ていきます。 後編で検証する3方式は、すべて TrueNAS SCALE の iSCSI LUN を最終的なデータ格納先とします。違いは、Pod からデータが LUN に到達するまでの経路(I/O パス)です。 3.2. 方式1: democratic-csi(直接 iSCSI) democratic-csi は Longhorn を経由せず、Kubernetes の CSI インターフェースを通じて truenas-iscsi の RWO PVC を作成し、そのバックエンドとして TrueNAS の iSCSI LUN(zvol)を利用します。図で示したとおり、Kubernetes リソース層では「ユーザー Pod → RWO PVC」、実ストレージ層では「iSCSI Initiator → TrueNAS SCALE」という流れになっています。I/O パスは2段階(Pod → iSCSI → TrueNAS)であり、3方式の中で最も短くなります。ただしアクセスモードは RWO に限定されるため、複数ノードからの同時マウントや VM のライブマイグレーションには対応できません。 この方式は、RWX が不要で、まずは Longhorn を介さない iSCSI 直結の特性を確認したい場面に向いています。 シングルポイント : TrueNAS SCALE が停止すると、このストレージを使用するすべての Pod が I/O エラーとなります。zvol は TrueNAS 上に1コピーのみ存在し、Longhorn のようなノード間レプリケーションは行われません。データ冗長性は TrueNAS 側の ZFS 構成(ミラー、RAIDZ 等)に完全に依存します。 3.3. 方式2: OpenEBS Dynamic NFS Provisioner(カーネル NFS) OpenEBS Dynamic NFS Provisioner は、RWX PVC が要求されると、図の Kubernetes リソース層に示した以下のリソースを自動的に作成します。 ここでいう Service は、Kubernetes 内で NFS サーバー Pod への接続先を安定して提供するためのリソースです。NFS サーバー Pod 自体は再作成や再配置で実体が変わる可能性がありますが、ユーザー Pod は Pod の実体を直接参照するのではなく、この Service を経由することで安定してアクセスできます。 バックエンド RWO PVC(StorageClass truenas-iscsi を使用) その PVC をマウントした NFS サーバー Pod(knfsd / カーネルモード) NFS サーバー Pod を公開する Service ユーザー Pod は Service を経由して NFS サーバー Pod に接続し、NFS サーバー Pod はバックエンド RWO PVC を介して TrueNAS SCALE の iSCSI Target を利用します。I/O パスは3段階(Pod → NFS → NFS サーバー Pod → iSCSI → TrueNAS)です。 RWX を確保しつつ、PVC ごとに独立した NFS サーバー Pod とバックエンド RWO PVC を自動管理したい場合には、この方式が有力です。 シングルポイントは2箇所 あります。第一に TrueNAS SCALE の停止、第二に NFS サーバー Pod の停止です。NFS サーバー Pod は PVC ごとに1つ生成されるため、特定の PVC の NFS サーバー Pod が停止した場合、その PVC を使用するすべての Pod が影響を受けます。ただし他の PVC には影響しません。PVC を削除すると、NFS サーバー Pod・バックエンド RWO PVC・TrueNAS の zvol と iSCSI Target がすべて自動的にクリーンアップされます。 3.4. 方式3: nfs-ganesha-server-provisioner(ユーザー空間 NFS) nfs-ganesha-server-provisioner は、Helm インストール時に単一の NFS-Ganesha Pod を常駐させます。この Pod はユーザー空間で NFS プロトコルを処理し、図で示した共有バックエンド RWO PVC(本検証では 50 GiB、StorageClass truenas-iscsi )をマウントします。これは、OS イメージのインポート可否も確認しつつ、RWX PVC を複数作成して比較できるだけの余地を確保するための検証用サイズです。ユーザーが RWX PVC を作成すると、この共有バックエンド RWO PVC 上にサブディレクトリが作成されます。 I/O パスの段数は OpenEBS と同じ3段階ですが、NFS 実装がカーネルモード(knfsd)ではなくユーザー空間(NFS-Ganesha)である点が異なります。knfsd は Linux カーネル内で動作し、Linux から通常のファイルシステムとして見えるストレージをそのまま NFS で公開するのに向いています。一方、NFS-Ganesha は通常のアプリケーションとしてユーザー空間で動作するため、カーネル空間との間で処理の受け渡しが増えやすく、一般にスループットではカーネルモードに劣る傾向があります。その代わり、ユーザー空間ソフトウェアとして実装の自由度が高く、バックエンド構成の選択肢を広げやすいという特徴があります。 単一の共有基盤で RWX を提供し、PVC ごとに Pod 数が増える構成を避けたい場合には、この方式は有力な選択肢になります。 シングルポイントは2箇所 あります。TrueNAS SCALE の停止に加え、NFS-Ganesha Pod の停止があります。OpenEBS と異なり、NFS-Ganesha Pod は共有バックエンド RWO PVC を利用する単一 Pod であるため、この Pod が停止するとすべての RWX PVC が同時にアクセス不能となります。障害の影響範囲が OpenEBS より広い点に注意が必要です。PVC を削除した場合はサブディレクトリのみが削除され、共有バックエンド RWO PVC と NFS-Ganesha Pod は存続します。 3.5. 3方式の構造比較 比較項目 democratic-csi OpenEBS NFS nfs-ganesha StorageClass truenas-iscsi openebs-rwx-iscsi nfs-ganesha アクセスモード RWO RWX RWX 主な I/O 経路 ユーザー Pod → RWO PVC → iSCSI → TrueNAS SCALE ユーザー Pod → Service → NFS サーバー Pod → バックエンド RWO PVC → iSCSI → TrueNAS SCALE ユーザー Pod → NFS-Ganesha Pod → 共有バックエンド RWO PVC → iSCSI → TrueNAS SCALE NFS 実装 なし カーネルモード(knfsd) ユーザー空間(NFS-Ganesha) Service なし あり なし NFS サーバー Pod / NFS-Ganesha Pod なし バックエンド RWO PVC ごとに1つ自動生成 全 PVC で1つ共有(常駐) バックエンド RWO PVC 1 PVC = 1 zvol 1 PVC = 1 zvol(PVC ごと) 1つの共有バックエンド RWO PVC(50 GiB) TrueNAS リソース RWO PVC 数分の zvol + iSCSI Target バックエンド RWO PVC 数分の zvol + iSCSI Target 1つの zvol + 1つの iSCSI Target PVC 削除時の動作 zvol + iSCSI Target 自動削除 NFS サーバー Pod + バックエンド RWO PVC + zvol + iSCSI Target を全自動削除 サブディレクトリのみ削除 Single Point of Failure(SPOF) TrueNAS SCALE TrueNAS SCALE + 該当 NFS サーバー Pod TrueNAS SCALE + NFS-Ganesha Pod(全 PVC に影響) VM ライブマイグレーション 不可(RWO) 可能(RWX) 可能(RWX) コンテキストスイッチ 最小 少 多(ユーザー空間 NFS) リソース消費 最小 PVC 数に比例して増加 単一 Pod で一定 プロジェクト コミュニティ CNCF Sandbox Kubernetes SIG Storage 3.6. PVC のライフサイクル Kubernetes に馴染みのない読者のために、PVC(PersistentVolumeClaim)のライフサイクルを簡単に整理します。PVC は Kubernetes におけるストレージの要求単位であり、従来の仮想化基盤における仮想ディスク(例: vSphere の VMDK ファイル)に相当する概念です。 PVC のライフサイクルは、大きく4つのフェーズに分かれます。まず「作成(Provisioning)」では、ユーザーが PVC マニフェストを適用すると、StorageClass に紐づいた CSI ドライバがバックエンドストレージ上にボリュームを自動作成します。本検証では democratic-csi が TrueNAS 上に zvol を作成する処理がこれに該当します。vSphere でいえば、データストア上に VMDK ファイルが作成される段階です。 次に「バインド(Binding)」では、作成されたボリューム(PV: PersistentVolume)と PVC が紐づけられます。PVC のステータスが Pending から Bound に変わり、利用可能な状態になります。 「マウント(Mounting)」では、Pod が起動する際に PVC で指定されたボリュームがノード上にマウントされます。iSCSI の場合はノードの iSCSI Initiator がターゲットに接続し、NFS の場合は NFS クライアントがマウントを行います。vSphere でいえば、VM に仮想ディスクをアタッチする操作に相当します。 最後に「削除(Reclaiming)」では、PVC が削除された際のボリュームの扱いが StorageClass の reclaimPolicy によって決まります。 Delete ポリシーの場合、PVC の削除と同時にバックエンドのボリュームも自動削除されます。 Retain ポリシーの場合、PVC を削除してもバックエンドのボリュームは保持されます。 ただし、削除時の実際の挙動は StorageClass の内部構造によって異なります。 democratic-csi や openebs-rwx-iscsi のように PVC とバックエンドボリュームの対応が明確な構成では、 Delete により TrueNAS 上の zvol や iSCSI Target まで自動削除されます。一方、 nfs-ganesha のように複数の PVC が1つの共有バックエンド RWO PVC を利用する構成では、個別の PVC を削除しても共有バックエンド全体は削除されず、該当サブディレクトリのみが削除されます。 本検証の StorageClass はすべて Delete ポリシーを使用していますが、どこまで削除されるかは上記の構造差に依存します。重要なデータを扱う場合は Retain ポリシーの採用や定期的なバックアップの実施を検討してください。 4. democratic-csi の導入 4.1. democratic-csi とは democratic-csi は、TrueNAS の API を利用して zvol の作成・削除・iSCSI ターゲットの公開を自動化する CSI ドライバです。Longhorn を経由せず、Kubernetes の PVC 要求に応じて TrueNAS 上に直接 zvol を作成し、iSCSI 経由でノードにマウントします。 Harvester の標準構成では、外部 iSCSI LUN は Longhorn のディスクとして追加されます(前編の longhorn-iscsi )。この場合、Longhorn のレプリカ同期が行われるため、データ冗長性が確保される代わりに書き込みオーバーヘッドが発生します。democratic-csi はこのレプリカ同期をバイパスするため、書き込み性能の向上が期待できますが、データ冗長性は TrueNAS 側の ZFS 構成に完全に依存することになります。 4.2. Helm リポジトリの追加とインストール Harvester ノード上で Helm が利用可能であることを確認します。 helm version version.BuildInfo{Version:"v3.19.1", GitCommit:"...", GitTreeState:"clean", GoVersion:"go1.23.8"} democratic-csi の Helm リポジトリを追加します。 helm repo add democratic-csi https://democratic-csi.github.io/charts/ helm repo update 4.3. TrueNAS API 設定 democratic-csi は TrueNAS の REST API を使用して zvol と iSCSI ターゲットを操作します。TrueNAS SCALE の Web UI から API キーを発行し、values ファイルに設定します。主要な設定項目は以下のとおりです。 driver : iSCSI プロトコルを指定 httpConnection.host : TrueNAS の IP アドレス(192.0.2.4) httpConnection.apiKey : TrueNAS API キー iscsi.targetPortal : TrueNAS の iSCSI ポータル(192.0.2.4:3260) zfs.datasetParentName : zvol の作成先(例: <your-dataset-path> ) 4.4. Helm インストール helm install truenas-iscsi democratic-csi/democratic-csi \ --namespace democratic-csi \ --create-namespace \ -f democratic-csi-values.yaml Pod の起動を確認します。 kubectl get pods -n democratic-csi controller Pod と node Pod(各ノードに1つ)が Running となれば成功です。 4.5. StorageClass の確認 Helm インストールにより、StorageClass truenas-iscsi が自動生成されます。 kubectl get sc truenas-iscsi NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE truenas-iscsi org.democratic-csi.iscsi Delete Immediate true 30s 4.6. 動作確認: PVC の作成とマウント テスト用の PVC を作成します。 apiVersion : v1 kind : PersistentVolumeClaim metadata : name : test-iscsi-pvc spec : accessModes : - ReadWriteOnce storageClassName : truenas-iscsi resources : requests : storage : 5Gi kubectl apply -f test-iscsi-pvc.yaml kubectl get pvc test-iscsi-pvc PVC が Bound となったことを確認します。TrueNAS の Web UI で、zvol と iSCSI ターゲットが自動生成されていることも併せて確認してください。 テスト Pod をデプロイして書き込みと読み取りを行い、動作を確認した後にクリーンアップします。 kubectl delete pvc test-iscsi-pvc PVC の削除に伴い、TrueNAS 上の zvol と iSCSI ターゲットが自動的に削除されることを確認します。 5. OpenEBS Dynamic NFS Provisioner の導入 5.1. なぜ NFS Provisioner が必要か democratic-csi が提供する truenas-iscsi は RWO アクセスに限定されます。iSCSI LUN はブロックデバイスであり、同時に複数ノードからマウントすることはできません。しかし Harvester 上で VM のライブマイグレーションを行うには RWX(複数ノード同時読み書き可能)なストレージが必要です。 OpenEBS Dynamic NFS Provisioner は、この問題を解決するために RWO PVC の上に自動的にカーネルモード NFS サーバー Pod を生成し、RWX アクセスを提供する仕組みです。 5.2. インストール手順 Helm リポジトリを追加します。 helm repo add openebs https://openebs.github.io/charts helm repo update NFS Provisioner のみを有効化する values ファイル( openebs-nfs.yaml )を作成します。Mayastor、LVM Local PV、ZFS Local PV、LocalPV Provisioner などの不要なコンポーネントは無効化し、StorageClass の自動作成も無効にします。 engines : replicated : mayastor : enabled : false local : lvm : enabled : false zfs : enabled : false openebs-crds : csi : volumeSnapshots : enabled : false localpv-provisioner : enabled : false nfs-provisioner : enabled : true storageClass : enabled : false この values ファイルの目的は、OpenEBS を総合ストレージ基盤として導入するのではなく、Dynamic NFS Provisioner だけを最小構成で利用することです。 5.3. Harvester Admission Webhook の回避 OpenEBS の Helm チャートはデフォルトで複数の StorageClass を作成しようとしますが、Harvester の Admission Webhook は CDI アノテーションのない StorageClass を拒否します。この問題を回避するため、Helm テンプレートをファイルに出力し、StorageClass の定義を除外してから適用します。 # マニフェスト全体を出力 helm template openebs-nfs openebs/openebs \ --namespace openebs \ --values openebs-nfs.yaml > openebs-all.yaml Python 3 で StorageClass の定義を除外します。 python3 -c " import sys docs = open('openebs-all.yaml').read().split('---') for doc in docs: if 'kind: StorageClass' not in doc: print('---') print(doc) " > openebs-no-sc.yaml 除外結果を確認します。StorageClass が含まれていなければ問題ありません。 grep "kind: StorageClass" openebs-no-sc.yaml # 出力なしであれば OK マニフェストを適用します。 kubectl apply -f openebs-no-sc.yaml NFS Provisioner の Pod が Running であることを確認します。 kubectl get pods -n openebs NAME READY STATUS RESTARTS AGE openebs-nfs-nfs-provisioner-xxxxx-xxxxx 1/1 Running 0 3m 補足として、 openebs-nfs-localpv-provisioner や openebs-nfs-ndm-* といった Pod が起動する場合がありますが、本構成では使用しません。Helm チャートの内部依存により作成されるものであり、Dynamic NFS Provisioner の動作自体には影響しません。 5.4. StorageClass の作成 Admission Webhook を回避して手動で StorageClass を作成します。 apiVersion : storage.k8s.io/v1 kind : StorageClass metadata : name : openebs-rwx-iscsi annotations : openebs.io/cas-type : nfsrwx cdi.harvesterhci.io/storageProfileVolumeModeAccessModes : '{"Filesystem":["ReadWriteMany"]}' cas.openebs.io/config : | - name : NFSServerType value : kernel - name : BackendStorageClass value : truenas-iscsi provisioner : openebs.io/nfsrwx reclaimPolicy : Delete allowVolumeExpansion : true BackendStorageClass に truenas-iscsi を指定し、カーネルモード NFS を使用します。 NFSServerType: kernel は knfsd(カーネル NFS サーバー)を意味します。 kubectl apply -f openebs-rwx-iscsi-sc.yaml kubectl get sc openebs-rwx-iscsi 5.5. 動作確認: RWX PVC の作成 apiVersion : v1 kind : PersistentVolumeClaim metadata : name : test-openebs-rwx spec : accessModes : - ReadWriteMany storageClassName : openebs-rwx-iscsi resources : requests : storage : 5Gi kubectl apply -f test-openebs-rwx.yaml kubectl get pvc test-openebs-rwx PVC が Bound となったことを確認します。バックエンドで以下のリソースが自動生成されます。 NFS サーバー Pod(カーネルモード knfsd) バックエンド RWO PVC(StorageClass truenas-iscsi ) TrueNAS 上の zvol と iSCSI ターゲット 5.6. 3ノード同時書き込みテスト DaemonSet を使用して、3つの Harvester ノードから同一 PVC に同時書き込みを行います。 apiVersion : apps/v1 kind : DaemonSet metadata : name : rwx-test spec : selector : matchLabels : app : rwx-test template : metadata : labels : app : rwx-test spec : containers : - name : writer image : busybox command : [ "/bin/sh" , "-c" ] args : - hostname > /mnt/data/rwx-test-$(hostname).txt && sleep 3600 volumeMounts : - name : shared mountPath : /mnt/data volumes : - name : shared persistentVolumeClaim : claimName : test-openebs-rwx kubectl apply -f rwx-test-daemonset.yaml 各ノードの Pod からファイルの存在を確認します。 kubectl exec -it $(kubectl get pod -l app=rwx-test -o jsonpath='{.items[0].metadata.name}') -- ls /mnt/data/ rwx-test-harvester01.txt rwx-test-harvester02.txt rwx-test-harvester03.txt 3ノードすべてから書き込まれたファイルが確認できました。RWX アクセスが正常に機能していることが実証されています。 5.7. クリーンアップと自動削除の確認 kubectl delete daemonset rwx-test kubectl delete pvc test-openebs-rwx PVC の削除に伴い、以下が自動的にクリーンアップされます。 OpenEBS NFS サーバー Pod NFS Service バックエンド RWO PVC TrueNAS 上の zvol と iSCSI ターゲット TrueNAS の Web UI で zvol と iSCSI ターゲットが削除されていることを確認します。全リソースの自動ライフサイクル管理が正常に動作しています。 6. nfs-ganesha-server-provisioner の導入 6.1. OpenEBS との構造的な違い nfs-ganesha-server-provisioner は OpenEBS と同じく RWX を提供しますが、アーキテクチャが根本的に異なります。OpenEBS は PVC ごとに NFS サーバー Pod を生成する「1 PVC = 1 NFS サーバー」モデルであるのに対し、nfs-ganesha は単一の常駐 NFS-Ganesha Pod がすべての RWX PVC を処理する「N PVC = 1 NFS サーバー」モデルです。 この違いは運用に大きな影響を与えます。 OpenEBS の場合、PVC 数が増えると NFS サーバー Pod の数も比例して増加し、クラスタのリソース消費が増えます。一方、ある PVC の NFS サーバー Pod が停止しても、他の PVC は影響を受けません。障害の影響範囲が PVC 単位に限定されます。 nfs-ganesha の場合、PVC 数が増えても NFS-Ganesha Pod は1つのまま一定であり、リソース消費は安定します。しかし、この単一 Pod が停止するとすべての RWX PVC が同時にアクセス不能となります。障害の影響範囲が広いため、この Pod の可用性が運用上の重要な関心事となります。 6.2. インストール手順 Helm リポジトリを追加します。 helm repo add nfs-ganesha-server-and-external-provisioner \ https://kubernetes-sigs.github.io/nfs-ganesha-server-and-external-provisioner/ helm repo update values ファイル( nfs-ganesha.yaml )を作成します。 persistence : enabled : true storageClass : truenas-iscsi size : 50Gi storageClass : name : nfs-ganesha defaultClass : false reclaimPolicy : Delete allowVolumeExpansion : true persistence.storageClass に truenas-iscsi を指定することで、NFS-Ganesha のバックエンドとして democratic-csi 経由の iSCSI LUN を使用します。 size: 50Gi はバックエンド PVC のサイズであり、この PVC 上にすべての RWX PVC のデータがサブディレクトリとして格納されます。 helm install nfs-ganesha nfs-ganesha-server-and-external-provisioner/nfs-ganesha-server-and-external-provisioner \ --namespace nfs-ganesha \ --create-namespace \ -f nfs-ganesha.yaml 6.3. Pod とバックエンド PVC の確認 kubectl get pods -n nfs-ganesha NFS-Ganesha Pod が Running であることを確認します。 kubectl get pvc -n nfs-ganesha バックエンド PVC(50 GiB、StorageClass truenas-iscsi 、RWO)が Bound であることを確認します。TrueNAS 上に対応する zvol と iSCSI ターゲットが1つ生成されています。 6.4. 動作確認: RWX PVC の作成 2つの RWX PVC を作成して動作を確認します。 apiVersion : v1 kind : PersistentVolumeClaim metadata : name : test-ganesha-1 spec : accessModes : - ReadWriteMany storageClassName : nfs-ganesha resources : requests : storage : 5Gi --- apiVersion : v1 kind : PersistentVolumeClaim metadata : name : test-ganesha-2 spec : accessModes : - ReadWriteMany storageClassName : nfs-ganesha resources : requests : storage : 3Gi kubectl apply -f test-ganesha-pvcs.yaml kubectl get pvc test-ganesha-1 test-ganesha-2 2つの PVC が Bound となります。ここで重要なのは、バックエンド PVC は依然として1つ(50 GiB)のままであるという点です。新しい zvol や iSCSI ターゲットは追加生成されません。OpenEBS との構造的な違いがここに明確に表れています。 6.5. 3ノード同時書き込みテスト OpenEBS と同様に DaemonSet を使用して3ノード同時書き込みを検証します。 kubectl apply -f rwx-test-daemonset-ganesha.yaml kubectl exec -it $(kubectl get pod -l app=rwx-test-ganesha -o jsonpath='{.items[0].metadata.name}') -- ls /mnt/data/ rwx-test-harvester01.txt rwx-test-harvester02.txt rwx-test-harvester03.txt 3ノードからの同時書き込みが成功しました。 6.6. クリーンアップ時の動作の違い kubectl delete daemonset rwx-test-ganesha kubectl delete pvc test-ganesha-1 test-ganesha-2 PVC を削除すると、バックエンド PVC 上のサブディレクトリのみが削除されます。NFS-Ganesha Pod、バックエンド PVC、TrueNAS 上の zvol と iSCSI ターゲットはすべて存続します。これは OpenEBS の全自動クリーンアップとは対照的であり、nfs-ganesha の「共有バックエンド」モデルによる設計上の違いです。 NFS-Ganesha 自体を撤去するには、 helm uninstall を実行する必要があります。 7. OS イメージのインポート検証 7.1. 検証目的 Harvester で VM を作成するには、OS イメージを StorageClass に登録する必要があります。前編では harvester-longhorn 、 longhorn-iscsi 、 nfs-csi の3方式でイメージ登録が成功しました。後編の3方式でも同様にイメージ登録が可能かを検証します。 7.2. 検証結果 テスト対象の OS イメージは前編と同一の openSUSE Leap 15.6 Minimal VM(約 270 MB、qcow2 形式)です。 StorageClass イメージインポート 結果 備考 truenas-iscsi URL 指定 失敗 CDI アノテーションが Block PVC のみ対応。Filesystem PVC が必要なイメージインポートに非対応。 openebs-rwx-iscsi URL 指定 失敗 qcow2 → raw 変換後のサイズ(約 877 MiB)が PVC の実効容量(約 822 MiB、NFS ファイルシステムオーバーヘッド約 55 MiB を差し引き)を超過。 nfs-ganesha URL 指定 成功 バックエンド PVC が 50 GiB と大きいため、変換後のイメージサイズを十分に収容可能。 7.3. 失敗原因の分析 democratic-csi( truenas-iscsi )の失敗は、CDI(Containerized Data Importer)が iSCSI PVC を Block モードで認識することに起因します。Harvester のイメージインポートは Filesystem モードの PVC を必要とするため、democratic-csi の iSCSI PVC は直接利用できません。 OpenEBS( openebs-rwx-iscsi )の失敗は、サイズ計算のミスマッチに起因します。ユーザーが指定する PVC サイズ(例: 1 GiB)に対し、NFS ファイルシステムのメタデータとオーバーヘッドが約 55 MiB を消費するため、実効容量が不足します。PVC サイズを大きく指定すれば回避可能ですが、イメージごとに適切なサイズを見積もる必要があります。 nfs-ganesha の成功は、50 GiB の共有バックエンド PVC に十分な空き容量があったためです。ただし、イメージを大量に登録すると 50 GiB の容量を圧迫する可能性があります。OpenEBS 側で想定どおりに進まなかったことで、NFS オーバーヘッドや実効容量の見積もりが想像以上にシビアだと実感できたのは、この検証で得られた大きな学びでした。 7.4. 検証から得られた知見 イメージインポートの可否は StorageClass の技術的な制約に依存します。VM のデータストアとしての利用とイメージインポートは異なる要件を持つため、すべての StorageClass がイメージインポートに対応するとは限りません。実運用では、イメージインポート用の StorageClass と VM データストア用の StorageClass を分けて設計することも有効な選択肢です。 8. ベンチマーク 8.1. 測定条件 本ベンチマークでは、前編の VM 内測定とは異なり、Pod 内から直接 fio を実行しています。当初は前編と同じ VM 方式を予定していましたが、 truenas-iscsi が VM イメージのインポートに非対応、 openebs-rwx-iscsi が容量不足でインポートに失敗したため、3方式の条件を揃えるために Pod 方式に統一しました。 VM 経由の場合は QEMU の仮想ディスクエミュレーション層が追加されるため絶対値は異なりますが、このオーバーヘッドは3方式すべてに等しく加わるため、方式間の相対比較には影響しません。 本来のユースケースは Harvester 上の VM ディスクとしての利用ですが、後編では3方式すべてで同一条件の VM を用意できなかったため、測定対象を Pod 内 fio に統一しました。そのため、本節の結果は「Harvester 上の VM そのものの絶対性能」ではなく、「各 StorageClass のバックエンド構成差が相対的な I/O 特性にどう表れるか」を見るためのものとして解釈してください。 結果として、VM 方式で揃えられなかったこと自体が、各方式の制約を整理するうえで大きな収穫でした。 項目 値 測定ツール fio(コンテナイメージ: xridge/fio:latest) テストファイルサイズ 1 GB 実行時間 30秒/テスト ジョブ数 1 PVC サイズ 10 GiB(各方式) 実行ノード harvester01(同一ノードに統一) I/O パターン Sequential Read 128k, Sequential Write 128k, Random Read 4k, Random Write 4k キャッシュモード direct=1(キャッシュ無効)、direct=0(キャッシュ有効) ネステッド仮想化環境での測定であるため、絶対値はベアメタル環境と異なる可能性があります。本ベンチマークの目的は3方式間の相対的な性能傾向を把握することにあります。 8.2. PVC 作成 3つの StorageClass それぞれに PVC(10 GiB)を作成しています。 apiVersion : v1 kind : PersistentVolumeClaim metadata : name : bench-democratic namespace : default spec : accessModes : [ "ReadWriteOnce" ] storageClassName : truenas-iscsi resources : requests : storage : 10Gi --- apiVersion : v1 kind : PersistentVolumeClaim metadata : name : bench-openebs namespace : default spec : accessModes : [ "ReadWriteMany" ] storageClassName : openebs-rwx-iscsi resources : requests : storage : 10Gi --- apiVersion : v1 kind : PersistentVolumeClaim metadata : name : bench-ganesha namespace : default spec : accessModes : [ "ReadWriteMany" ] storageClassName : nfs-ganesha resources : requests : storage : 10Gi 8.3. 測定コマンド キャッシュなし(direct=1)の測定コマンドです。 echo "=== [Cache OFF] Sequential Read (128k) ===" && \ fio --name=test --filename=/data/fio --size=1G --direct=1 \ --bs=128k --rw=read --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "READ:|iops|bw=" && \ echo "=== [Cache OFF] Sequential Write (128k) ===" && \ fio --name=test --filename=/data/fio --size=1G --direct=1 \ --bs=128k --rw=write --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "WRITE:|iops|bw=" && \ echo "=== [Cache OFF] Random Read (4k) ===" && \ fio --name=test --filename=/data/fio --size=1G --direct=1 \ --bs=4k --rw=randread --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "READ:|iops|bw=" && \ echo "=== [Cache OFF] Random Write (4k) ===" && \ fio --name=test --filename=/data/fio --size=1G --direct=1 \ --bs=4k --rw=randwrite --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "WRITE:|iops|bw=" && \ rm -f /data/fio && echo "=== Cache OFF DONE ===" キャッシュあり(direct=0)の測定コマンドです。 echo "=== [Cache ON] Sequential Read (128k) ===" && \ fio --name=test --filename=/data/fio --size=1G --direct=0 \ --bs=128k --rw=read --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "READ:|iops|bw=" && \ echo "=== [Cache ON] Sequential Write (128k) ===" && \ fio --name=test --filename=/data/fio --size=1G --direct=0 \ --bs=128k --rw=write --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "WRITE:|iops|bw=" && \ echo "=== [Cache ON] Random Read (4k) ===" && \ fio --name=test --filename=/data/fio --size=1G --direct=0 \ --bs=4k --rw=randread --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "READ:|iops|bw=" && \ echo "=== [Cache ON] Random Write (4k) ===" && \ fio --name=test --filename=/data/fio --size=1G --direct=0 \ --bs=4k --rw=randwrite --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "WRITE:|iops|bw=" && \ rm -f /data/fio && echo "=== Cache ON DONE ===" 8.4. 測定結果 8.4.1. キャッシュなし(direct=1)― ストレージ純粋性能 OS のページキャッシュをバイパスし、ストレージへの直接 I/O で測定した結果です。 テスト truenas-iscsi openebs-rwx-iscsi nfs-ganesha Seq Read 128k 332 MiB/s(2,655 IOPS) 220 MiB/s(1,761 IOPS) 212 MiB/s(1,694 IOPS) Seq Write 128k 159 MiB/s(1,276 IOPS) 162 MiB/s(1,293 IOPS) 42.6 MiB/s(341 IOPS) Rand Read 4k 19.4 MiB/s(4,963 IOPS) 15.8 MiB/s(4,046 IOPS) 15.6 MiB/s(3,993 IOPS) Rand Write 4k 17.6 MiB/s(4,513 IOPS) 15.0 MiB/s(3,849 IOPS) 2.9 MiB/s(743 IOPS) 8.4.2. キャッシュあり(direct=0)― 実運用に近い性能 OS のページキャッシュを利用した測定結果です。 テスト truenas-iscsi openebs-rwx-iscsi nfs-ganesha Seq Read 128k 1,097 MiB/s(8,787 IOPS) 346 MiB/s(2,768 IOPS) 355 MiB/s(2,844 IOPS) Seq Write 128k 373 MiB/s(5,005 IOPS) 371 MiB/s(3,057 IOPS) 197 MiB/s(1,576 IOPS) Rand Read 4k 17.7 MiB/s(4,693 IOPS) 14.5 MiB/s(3,699 IOPS) 15.2 MiB/s(3,887 IOPS) Rand Write 4k 257 MiB/s(127,085 IOPS) 170 MiB/s(60,885 IOPS) 94.6 MiB/s(32,303 IOPS) 8.5. 考察 8.5.1. 想定と実測の比較 I/O パスの段数と NFS 実装の違いに基づいて想定した順位と、実測結果を比較します。 方式 想定 実測結果 democratic-csi 最速(iSCSI 直接、2段パス) 1位(ほぼ全テストでトップ) OpenEBS NFS 2位(カーネル NFS、3段パス) 2位 nfs-ganesha 最遅(ユーザー空間 NFS、3段パス) 3位(特に Write が顕著に遅い) I/O パスの段数どおりの順位となり、前編の nfs-csi のような「想定外の結果」は出ませんでした。これは今回の3方式がすべて TrueNAS の同一 iSCSI バックエンドを使っており、差異が NFS レイヤの有無と実装方式のみであるためです。Longhorn のレプリカ同期のような変動要因がなく、アーキテクチャの違いが素直に結果に反映されています。 8.5.2. NFS レイヤのオーバーヘッド democratic-csi は iSCSI ブロックデバイスに直接アクセスするため、NFS プロトコルの解析やデータコピーが一切発生しません。キャッシュなしのシーケンシャル読み取りで比較すると、この差が明確です。 方式 スループット democratic-csi 比 democratic-csi 332 MiB/s 1.00 倍 OpenEBS NFS 220 MiB/s 0.66 倍 nfs-ganesha 212 MiB/s 0.64 倍 読み取りでは OpenEBS NFS と nfs-ganesha の差は小さく(220 vs 212 MiB/s)、NFS プロトコル処理そのもののオーバーヘッドはカーネル実装でもユーザー空間実装でも読み取りに関しては大差がないことを示しています。 8.5.3. ユーザー空間 NFS の書き込みペナルティ 書き込み性能では nfs-ganesha が大きく劣ります。特にキャッシュなしの結果で顕著です。 テスト democratic-csi OpenEBS NFS nfs-ganesha ganesha / democratic 比 Seq Write 159 MiB/s 162 MiB/s 42.6 MiB/s 0.27 倍 Rand Write 17.6 MiB/s 15.0 MiB/s 2.9 MiB/s 0.16 倍 nfs-ganesha のシーケンシャル書き込みは democratic-csi の約 1/4、ランダム書き込みは約 1/6 です。一方で OpenEBS NFS のシーケンシャル書き込みは democratic-csi とほぼ同等(162 vs 159 MiB/s)です。 この差はカーネル空間とユーザー空間の処理方式の違いに起因します。OpenEBS NFS が使用するカーネル NFS(knfsd)はシステムコール内でデータを処理するため、ユーザー空間とカーネル空間の間のコンテキストスイッチやデータコピーが発生しません。nfs-ganesha はユーザー空間プロセスとして動作するため、すべての I/O でこれらのオーバーヘッドが加わります。書き込みではデータの永続化保証(fsync)が必要なため、このコストがより顕著に現れています。 8.5.4. キャッシュの効果 キャッシュあり(direct=0)では3方式とも性能が向上しますが、恩恵の度合いは方式によって異なります。 方式 Seq Read(direct=1 → 0) Rand Write(direct=1 → 0) democratic-csi 332 → 1,097 MiB/s(3.3 倍) 4,513 → 127,085 IOPS(28.2 倍) OpenEBS NFS 220 → 346 MiB/s(1.6 倍) 3,849 → 60,885 IOPS(15.8 倍) nfs-ganesha 212 → 355 MiB/s(1.7 倍) 743 → 32,303 IOPS(43.5 倍) democratic-csi はブロックデバイスであるため OS のページキャッシュとの親和性が最も高く、キャッシュあり時のシーケンシャル読み取りで 1,097 MiB/s を記録しています。NFS 方式ではクライアント側のキャッシュに加え NFS サーバー側の同期動作が介在するため、キャッシュの効果が限定的です。 ランダム書き込み(Cache ON)で3方式とも IOPS が桁違いに上昇しているのは、OS のページキャッシュが小さなランダム書き込みをメモリ上にバッファリングし、バックグラウンドでまとめてフラッシュするためです。特に democratic-csi の 127,085 IOPS、OpenEBS NFS の 60,885 IOPS は「ストレージの実力」というより「OS キャッシュの効果」を測っている点に留意が必要です。 8.5.5. 前編ベンチマーク(VM 方式)との関係 前編では VM 内から fio を実行し、後編(本記事)では Pod 内から実行しているため、絶対値の直接比較はできません。VM 方式では QEMU の仮想ディスクエミュレーション層が追加されるため、一般にスループットは低下します。 ただし、前編で確認された傾向 ― NFS/CSI 経由の外部ストレージが Longhorn のレプリカ同期を伴わないために高い転送速度を示すこと ― は、後編の結果とも整合しています。Longhorn のレプリカ同期をバイパスする方式は、一貫して書き込み性能の向上が確認されています。一方、このバイパスはデータの冗長性を外部ストレージ(TrueNAS の ZFS 構成)に委ねることを意味しており、性能と冗長性のトレードオフであることに変わりはありません。 8.5.6. ネステッド仮想化環境における留意事項 本検証は Proxmox VE 上のネステッド仮想化環境で実施しているため、すべての I/O は Proxmox ホストの仮想化レイヤを経由します。この追加レイテンシにより、ベアメタル環境と比較して絶対値は低下する傾向があります。しかし、3方式すべてが同一のネステッド仮想化環境で測定されているため、方式間の相対的な比較(順位・倍率)は有効な指標です。 また、テストファイル 1 GB に対して Pod が動作するノードのメモリは 16 GiB であり、cache-on(direct=0)時に OS ページキャッシュへのヒット率が高くなります。cache-off(direct=1)の結果をストレージの実力として参照し、cache-on は実運用時のキャッシュ効果を含む参考値として位置づけるのが適切です。 9. 総合比較と選定指針 9.1. 前編・後編 全6方式の横断比較 前編の3方式と後編の3方式を合わせた全6つの StorageClass について、主要な特性を横断的に比較します。 比較項目 harvester-longhorn longhorn-iscsi nfs-csi truenas-iscsi openebs-rwx-iscsi nfs-ganesha バックエンド ノードディスク TrueNAS iSCSI LUN TrueNAS NFS 共有 TrueNAS iSCSI zvol TrueNAS iSCSI zvol TrueNAS iSCSI zvol プロトコル ローカル iSCSI NFS iSCSI iSCSI + NFS iSCSI + NFS 管理レイヤ Longhorn Longhorn csi-driver-nfs democratic-csi OpenEBS + democratic-csi nfs-ganesha + democratic-csi アクセスモード RWO/RWX RWO/RWX RWX RWO RWX RWX レプリカ数 3 3 1(TrueNAS 依存) 1(TrueNAS 依存) 1(TrueNAS 依存) 1(TrueNAS 依存) 書き込み同期 3ノード同期書き込み 3ノード同期書き込み 単一書き込み 単一書き込み 単一書き込み 単一書き込み イメージ管理 Backing Image(Copy-on-Write: CoW) Backing Image(Copy-on-Write: CoW) PVC フルコピー インポート不可 インポート不可 PVC(サブディレクトリ) 10GB VM 1台の消費容量 約30GB 約30GB 約10GB+イメージ分 —(VM非対応) —(VM非対応) 約10GB+イメージ分 VM ライブマイグレーション 可能 可能 可能 不可 可能 可能 Single Point of Failure(SPOF) なし(3レプリカ、ノード障害耐性あり) TrueNAS TrueNAS TrueNAS TrueNAS + NFS Pod TrueNAS + NFS-Ganesha Pod I/O パス段数 1段 2段 2段 2段 3段 3段 Longhorn レプリカ同期 あり あり なし なし なし なし 9.2. ユースケース別の選定指針 StorageClass の選定は、要件に応じて使い分けるのが現実的です。すべてのユースケースに対応する単一の最適解は存在しません。ここで言いたいのは Longhorn が不適切だということではなく、要件によっては Longhorn を経由しない構成の方が適する場面もある、という点です。 Harvester 標準の harvester-longhorn は、3レプリカによるノードレベル冗長性と Backing Image(CoW)による効率的なイメージ管理を備えており、外部ストレージを持たない環境や、データ保全を最優先する場合に適しています。ただし、レプリカ同期による書き込みオーバーヘッドがあります。 longhorn-iscsi は TrueNAS の大容量ストレージを Longhorn に統合する方式であり、ノードローカルディスクの容量を拡張する目的に適しています。Longhorn の冗長性と管理機能をそのまま利用できますが、iSCSI ネットワークホップとレプリカ同期の二重のオーバーヘッドにより、6方式の中で最も低い性能となる傾向があります。 nfs-csi は Longhorn を完全にバイパスし、TrueNAS の NFS 共有を直接マウントします。I/O パスが短く、OS ページキャッシュの恩恵も大きいため高い性能を示します。構成もシンプルで追加の Pod が不要ですが、冗長性は TrueNAS の ZFS 構成に依存します。 truenas-iscsi (democratic-csi)は Longhorn をバイパスした iSCSI 直接接続であり、NFS レイヤもないため後編の3方式の中で最も低レイテンシです。ただし RWO 制約があり、VM ライブマイグレーションには対応しません。イメージインポートにも非対応であるため、用途は限定的です。 openebs-rwx-iscsi は democratic-csi の性能をベースに RWX を追加する方式です。PVC ごとに NFS サーバー Pod が自動生成・削除されるため、ライフサイクル管理が自動化されています。障害影響が PVC 単位に限定される点は運用上の利点ですが、PVC 数の増加に伴うリソース消費に注意が必要です。 nfs-ganesha は、本検証条件の範囲ではリソース消費が一定で、イメージインポートにも成功した方式でした。ただし単一の NFS-Ganesha Pod がすべての PVC を処理するため、この Pod の停止は全 PVC に波及します。また書き込み性能がユーザー空間 NFS のオーバーヘッドにより大きく低下する点、バックエンド PVC の容量(50 GiB)が上限となる制約もあります。 9.3. 検証全体のまとめ 本検証シリーズでは、SUSE Harvester v1.7.1 上で利用可能なストレージ構成を6方式にわたって構築・比較しました。 前編では Harvester 標準の Longhorn を軸とした3方式を検証し、Longhorn のレプリカ同期が書き込み性能に与える影響と、NFS/CSI によるバイパスが性能を向上させることを確認しました。 後編では Longhorn を使用しない3方式を追加検証し、democratic-csi による直接 iSCSI 接続、OpenEBS によるカーネル NFS の自動生成、nfs-ganesha によるユーザー空間 NFS の常駐配置をそれぞれ構築しました。各方式の I/O パス構造、Single Point of Failure(SPOF)、リソース消費、PVC ライフサイクル、イメージインポートの可否を比較し、ユースケースに応じた選定指針を整理しています。特に OpenEBS の容量見積もりでつまずいた経験は、設計段階で「理論上の容量」と「実効容量」を分けて考える重要性を再認識するきっかけになりました。 6方式の中に「万能な正解」はありません。性能・冗長性・運用性・対応機能のバランスを考慮し、ワークロードの要件に合わせて組み合わせることが現実的な設計判断となります。 9.4. バックアップに関する制約と代替手段 Harvester には標準の VM バックアップ機能が搭載されていますが、この機能は Longhorn ボリュームのみを対象としています。具体的には、 harvester-longhorn および longhorn-iscsi の StorageClass に格納された VM データはバックアップ・リストアが可能ですが、democratic-csi、OpenEBS、nfs-ganesha など Longhorn を経由しない StorageClass に格納されたデータは Harvester 標準のバックアップ機能では保護できません。 この制約を踏まえ、Longhorn 以外の StorageClass を使用する場合は、以下の代替手段を検討する必要があります。 1つ目の代替手段は、ゲストエージェント方式によるバックアップです。VM 内にバックアップエージェントをインストールし、ゲスト OS のファイルシステムレベルでデータを保護します。この方式はストレージの StorageClass に依存しないため、本記事で検証したすべての方式で利用可能です。ただし、VM ごとにエージェントのインストールと設定が必要であり、Kubernetes リソース(PVC 定義や VM メタデータ等)自体のバックアップは範囲外となります。 2つ目の代替手段は、Kubernetes ネイティブのバックアップ製品を利用する方法です。たとえば Veeam Kasten(旧 Kasten K10)は、Kubernetes のリソース(Pod、PVC、ConfigMap、Secret 等)と PVC データを一括してバックアップ・リストアするデータ管理プラットフォームであり、公式ドキュメントでも SUSE Virtualization(Harvester)上の VM バックアップとリストアに関する情報が公開されています。一方で、Kubernetes や KubeVirt 向けのバックアップ製品は他にも存在しますが、Harvester に対する公式サポート範囲や制約は製品ごとに異なります。採用にあたっては、各ベンダーの最新ドキュメントとサポートポリシーを確認する必要があります。 実運用においては、これらの手段を併用する構成が有効です。ゲストエージェント方式で VM 内部のアプリケーションデータやデータベースを保護し、Veeam Kasten で Kubernetes リソース(VM 定義、PVC、ネットワーク設定等)を包括的に保護することで、ストレージ障害・VM 障害・クラスタ障害のそれぞれに対応できる多層的なバックアップ体制を構築できます。 10. 参考情報 10.1. 公式ドキュメント Proxmox VE 8.3 リリース情報: https://pve.proxmox.com/mediawiki/index.php?title=Downloads#Proxmox_Virtual_Environment_8.3_.28ISO_Image.29 Proxmox VE Storage: LVM: https://pve.proxmox.com/pve-docs/pve-storage-lvm-plain.html Harvester 公式ドキュメント: https://docs.harvesterhci.io/v1.7.1 Longhorn 公式ドキュメント: https://longhorn.io/docs/ TrueNAS SCALE ドキュメント: https://www.truenas.com/docs/scale/ Kubernetes CSI 仕様: https://kubernetes-csi.github.io/docs/ Harvester VM Backup, Snapshot & Restore: https://docs.harvesterhci.io/v1.7/vm/backup-restore/ Veeam Kasten: Protecting SUSE Virtualization (Harvester) VMs and Images: https://docs.kasten.io/latest/usage/harvester_protection/ Veeam Kasten: SUSE Virtualization (Harvester) VM Backup and Restore Support Limitations: https://docs.kasten.io/latest/usage/harvester_limitations/ 10.2. GitHub リポジトリ democratic-csi: https://github.com/democratic-csi/democratic-csi OpenEBS Dynamic NFS Provisioner: https://github.com/openebs/dynamic-nfs-provisioner nfs-ganesha-server-and-external-provisioner: https://github.com/kubernetes-sigs/nfs-ganesha-server-and-external-provisioner NFS-Ganesha 公式: https://nfs-ganesha.github.io/ 11. 商標について 本記事で使用している以下の名称は、各社の商標または登録商標です。 Proxmox、Proxmox VE は、Proxmox Server Solutions GmbH の商標です。 SUSE、Harvester、Longhorn は、SUSE LLC の商標または登録商標です。 TrueNAS、TrueNAS SCALE は、iXsystems, Inc. の商標または登録商標です。 AMD、Ryzen は、Advanced Micro Devices, Inc. の商標です。 Kubernetes は、The Linux Foundation の登録商標です。 HPE、HPE CSI Driver for Kubernetes は、Hewlett Packard Enterprise Development LP の商標または登録商標です。 Veeam、Veeam Kasten は、Veeam Software Group GmbH の商標または登録商標です。 OpenEBS、OpenEBS Dynamic NFS Provisioner は、OpenEBS プロジェクトの名称です。 その他、本記事に記載されている会社名、製品名は、各社の商標または登録商標です。 12. 免責事項 本記事の内容は、2026年4月時点の情報に基づいており、将来的に変更される可能性があります。本記事の内容を実施したことにより発生した損害について、筆者およびNTT西日本は一切の責任を負いかねます。本記事の内容を参考に作業を行う場合は、自己責任でお願いいたします。 本記事に記載されているコマンド、設定値、構成例は、特定の検証環境で動作を確認したものであり、すべての環境で同一の結果が得られることを保証するものではありません。本番環境への適用にあたっては、十分な検証とバックアップを実施してください。 12.1. データ損失に関する注意事項 本記事で紹介する StorageClass のうち、Longhorn をバイパスする方式(democratic-csi、OpenEBS、nfs-ganesha、nfs-csi)はノードレベルのレプリケーションを行いません。データの冗長性は外部ストレージ(TrueNAS)側の構成に依存します。 reclaimPolicy: Delete を設定した StorageClass では、PVC の削除に伴いデータが自動的に削除されます。意図しないデータ損失を防ぐため、重要なデータには Retain ポリシーの使用や定期的なバックアップを検討してください。 nfs-ganesha のバックエンド PVC を削除または NFS-Ganesha を helm uninstall すると、そのバックエンド上のすべての RWX PVC のデータが失われます。 ネステッド仮想化環境では、Proxmox ホストの障害がすべてのゲスト VM に波及します。本検証環境はシングルホスト構成であり、本番利用には適しません。 13. 執筆者 平岡 征一朗(NTT西日本) 文教(大学)担当のシステムエンジニアです。インフラからアプリまでトラブルシュートが大好きです。
はじめに NTT西日本の中川 拓哉です。 本記事では、Three.js(WebGLをラップするJavaScriptライブラリ)で春夏秋冬を表すサンプルを作り、各シーンで使っているWebGL/Three.jsの機能を解説します。 本記事は2026年4月時点の情報に基づきます。 Webの特にフロントエンドに携わりたいと思う人の多くは、まず見た目の動く画面の魅力を感じてフロントエンドに関わりたいと思われたのではないでしょうか。 フロントエンドはデータの管理なども受け持つことが多いですが、なんと言ってもリッチな見た目やぬるぬる動く動作が最大の魅力の一つです。 私自身、ぬるぬる動く見た目からフロントエンドに魅力を感じた一人です。 今回ご紹介するThree.jsは「3Dが描けるライブラリ」です。ゲームなども作成でき、非常に多彩な使い方ができますが、個人的には、UIや可視化に 季節感・空気感 を少ない要素で足すための“表現”として使う場面が多いです。季節的なプロモーションサイトの背景や、一つの商品を多角的なイメージで伝えたいランディングページなどです。この記事では、WebGLの利用イメージを掴んでいただくための入口として四季を題材にしつつ、各ポイントについて解説していきます。 四季サンプルの完成イメージ(春・夏・秋・冬) 対象読者 本記事が想定する対象読者は以下の通りです。 本記事では、「ブレンディング」や「フォグ」といった聞きなれない用語が出てきますが、Webで3Dを表現するにあたっては専門的な用語が多数存在します。一つ一つを解説していると用語の解説記事になってしまうため、この記事は一定の知識がある方を前提としています。 フロントエンドのリッチな表現を試してみたい方 3D描画について一定の知識がある方 Webサイトに「立体的な演出」を入れたい方 目次 はじめに 対象読者 目次 1.背景・目的 2.前提条件・動作環境 2.1 うまく動かないときの最短チェック 2.2 簡単な語句の説明 3.まず動かす(四季サンプルの全体像) 3.1 サンプルの構成(Input / Update / Render) 4.春:桜の降る景色(パーティクル + アルファブレンディング) 4.1 利用する主な機能 4.2 桜っぽく見せるコツ(形状 + 動き) サンプル作成で苦労したこと 4.3 つまずきポイント(透明表現・深度) 4.4 カスタマイズする場合のポイント 5.夏:花火の打ち上げ(打ち上げ + 加算合成 + バースト) 5.1 利用する主な機能 5.2 「打ち上がり」を見せる(ロケット + 軌跡) サンプル作成で苦労したこと 5.3 注意点(加算は“使い過ぎると白飛び”する) 5.4 カスタマイズする場合のポイント 6.秋:紅葉の景色(InstancedMesh + 風の揺れ) サンプル作成で苦労したこと 6.1 利用する主な機能 6.2 注意点 6.3 コードのポイント(InstancedMeshの更新) 6.4 カスタマイズする場合のポイント 7.冬:雪景色(フォグ + ライティング + パーティクル) サンプル作成で苦労したこと 7.1 利用する主な機能 7.2 コードのポイント(Fogと雪の見せ方) 7.3 カスタマイズする場合のポイント 8.まとめ 付録:フルセットのコード 執筆者 参考資料・出典 商標 1.背景・目的 Webサイトやデモに大きな動きを入れようとすると、派手に作ろうとするほど実装が破綻しがちです。一方で、表現を絞って「それっぽさ」を出すことに用途を絞ると、短い時間で体験を底上げできます。 本記事の目的は、四季の表現を題材にして、以下の2点を満たすことです。 できるだけ小さな仕組み (Points/InstancedMesh/Fog/Blending など)で演出を作る 何を有効にすると何が起きるか (WebGLの機能)を説明できるようになる 2.前提条件・動作環境 Three.js: 0.164.1 (今回のサンプルでの利用バージョン+CDNで読み込み) 起動方法: file:// ではなく http://localhost などで配信する(VS CodeのLive ServerやApache HTTP Server環境など) 対応ブラウザ: Chromium系 / Firefox / Safari など、WebGLが有効な環境(端末差が出る場合があります) 補足 : Three.jsやWebGLは非常に奥深い技術です。本記事のサンプルは、概要レベルの説明であるため、外部画像の利用はせず、テクスチャもCanvasで生成します。 利点として、ネットワークが不安定な環境でも再現しやすいですが、画像を利用しない為、リアルな表現ではないことをご留意ください。 2.1 うまく動かないときの最短チェック この手の記事は、コードの記述よりも前にそもそも「動かし方・開き方」でつまずくことがあります。 私自身も最初は Three.jsのコードより前に、実行環境で止まったり詰まったりすることが何度かありました。そのため、この記事ではできるだけ簡単に描画を体験していただけるような構成にしていますので、ぜひいろいろ試してみてください。 実行環境に関しては、まずは次の3点だけ見れば十分です。 file:// で開いていないか:原則として http://localhost などで開く 画面が真っ黒なままか:コンソールエラーとWebGLの有効/無効を確認する 動くが動作が重いなどがないか:まず描画の数やインスタンス数を半分にして、差が出るかを見る 「まず描画の数やインスタンス数を半分にして、差が出るかを見る」は、地味ですが一番効く切り分けです。 2.2 簡単な語句の説明 板ポリ:板ポリゴン(平面のポリゴン1枚に、透明テクスチャを貼ったもの) Fog(フォグ):3D描画で「遠いものほど霞んで見える」ようにする霧・空気遠近法の表現 3.まず動かす(四季サンプルの全体像) 本記事は この記事の中だけで完結 するように、サンプルコードもすべて本文内に掲載します。 起動方法の詳細は前節の通りです。ローカルサーバー経由で開いてください。 画面左上の切り替えで、春(桜)・夏(花火)・秋(紅葉)・冬(雪)を切り替えられます。 季節切り替えUI 以降はポイントだけコード抜粋し、フルコードは最後にまとめます。 3.1 サンプルの構成(Input / Update / Render) 構成は、実務でも使えるように次のような構成にしています。 Input : ボタン操作(季節切り替え)を状態に落とす Update : 季節ごとの「状態更新」 Render : renderer.render(scene, camera) は基本1行(重い処理を集中させない) 最初から全部の処理を追うより、以下のように 一つの季節ずつその季節ごとの処理の特徴を理解しながら試す ほうが全体的に理解しやすいです。 春の桜:透明・深度のクセが分かりやすい 夏の花火:ロケット、軌跡、バーストで「演出の組み立て」が分かりやすい 秋の紅葉: InstancedMesh の実務的な活用法が分かりやすい 冬の雪:Fogとライトだけでも十分に演出できることが分かりやすい 「全部を理解する」というより、「この季節のこの見え方は自分の案件でも使えそう」というようにいずれかの技術に注力した方が3D系は理解しやすいと思います。 4.春:桜の降る景色(パーティクル + アルファブレンディング) 春は、落ちる粒を“桜の花びら”に見せられると、一気に季節感が出やすいです。 ただし THREE.Points は「点スプライト」なので、調整が甘いと ピンクの雪 っぽくなってしまいます。そこで本サンプルでは、桜だけは InstancedMesh (板ポリ)で花びらを描くようにしています。 ※余談ですが、こういった試行錯誤も3D描画の表現の楽しみだと個人的には感じます。 ※ InstancedMesh はThree.jsのクラスとなります。( Three.js - InstancedMesh ) 4.1 利用する主な機能 BufferGeometry : 粒の位置・速度などをTypedArrayで持つ(更新が速い) 透明(alpha) : アルファ付きテクスチャで花びら形に見せる Blending : NormalBlending (自然に重なる) InstancedMesh : 花びらを“板ポリ”として大量描画する(回転が絵に乗る) 4.2 桜っぽく見せるコツ(形状 + 動き) 「ピンクの雪」に見える場合、原因は、次の2つが多いです。 形が丸すぎる (粒に見えると雪に見えがちです) 動きが単調すぎる (雪の落下に近い速度になってしまっている) 対策として、(1) 花びら形のテクスチャを作り、(2) 板ポリを回転させながら落とします(ひらひら感を出します)。 サンプル作成で苦労したこと 最初は Points にピンクの円形テクスチャを当てて「これで桜っぽいはず」と思っていましたが、実際に動かすとほぼ雪でした。 試行錯誤して、決定的な原因だなと思ったのは “形”(ほぼ丸) と “回転が絵に乗るか” でした。 そのため、桜だけ InstancedMesh に切り替えることで対策しました。 テクスチャ側も、輪郭を少し入れて「丸」ではない形にするだけで、印象がかなり変わりますので、是非色々試してみてください。 function makePetalTexture () { const c = document . createElement ( 'canvas' ) ; c . width = 64 ; c . height = 64 ; const g = c . getContext ( '2d' ) ; g . clearRect ( 0 , 0 , 64 , 64 ) ; g . translate ( 32 , 32 ) ; g . rotate ( -0 . 25 ) ; const base = g . createRadialGradient ( 0 , -6 , 2 , 0 , -6 , 30 ) ; base . addColorStop ( 0 , 'rgba(255,210,225,0.95)' ) ; base . addColorStop ( 0 . 55 , 'rgba(255,175,205,0.75)' ) ; base . addColorStop ( 1 , 'rgba(255,175,205,0.0)' ) ; g . fillStyle = base ; g . beginPath () ; g . moveTo ( 0 , -28 ) ; g . bezierCurveTo ( 18 , -22 , 18 , 10 , 0 , 26 ) ; g . bezierCurveTo ( -18 , 10 , -18 , -22 , 0 , -28 ) ; g . closePath () ; g . fill () ; g . globalCompositeOperation = 'destination-out' ; g . beginPath () ; g . ellipse ( 0 , 18 , 7 . 5 , 5 . 5 , 0 , 0 , Math . PI * 2 ) ; g . fill () ; g . globalCompositeOperation = 'source-over' ; g . strokeStyle = 'rgba(255,120,160,0.28)' ; g . lineWidth = 2 ; g . stroke () ; return new THREE . CanvasTexture ( c ) ; } 板ポリ( InstancedMesh )で「ひらひら」を出す最小例です。 Points と違い、回転が絵に乗りやすいのがポイントです。 function makeSakuraPetals ({ count = 820 , texture }) { const geo = new THREE . PlaneGeometry ( 0 . 28 , 0 . 28 ) ; const mat = new THREE . MeshStandardMaterial ({ map : texture , transparent : true , depthWrite : false , side : THREE . DoubleSide , }) ; const mesh = new THREE . InstancedMesh ( geo , mat , count ) ; mesh . instanceMatrix . setUsage ( THREE . DynamicDrawUsage ) ; // 位置・落下・回転などの個体差を持ち、updateで行列を更新する return mesh ; } 4.3 つまずきポイント(透明表現・深度) 透明表現は、有効にするだけでは不自然になりやすいです。崩れたときは、まず次の点を確認してください。 depthWrite: false : 透明が深度バッファに書き込むと、後ろの粒が不自然に欠けることがあります transparent: true : ブレンディングを有効化する基本条件 4.4 カスタマイズする場合のポイント 桜の表現をカスタマイズするなら、まずは次の3つで十分です。 count : 花びらの枚数。増やすと華やかですが、やりすぎると一気に重くなります fall / drift : 落ち方。ここを触ると「雪っぽさ」と「桜っぽさ」の差が出やすいです テクスチャの輪郭色 : 輪郭を少し入れるだけで、粒感が減って花びらに寄ります 実際、私もチューニングしたのはここです。モデルを凝るより、まず動きと密度を合わせたほうが“らしさ”が早く出ます。(本サンプルのような画像を使わずにコードだけで表現する場合は特に効果的かなと思います。) 5.夏:花火の打ち上げ(打ち上げ + 加算合成 + バースト) 花火は「点の集合」でもそれっぽく見えます。 5.1 利用する主な機能 AdditiveBlending : 明るいところほど光が足される(花火・光跡向き) 寿命(life) : 粒ごとにフェードアウトさせる(ずっと残り続けずに消す) ガンマ/トーン (簡易): ここでは強いポスト処理は入れず、色設計とブレンディングで寄せる 5.2 「打ち上がり」を見せる(ロケット + 軌跡) 爆発(バースト)だけだと「花火が突然出た」ように見えます(ただの破裂みたいにしか見えず、花火には見えなくなります)。 そのため、ロケットを上昇させ、軌跡(トレイル)を残してから爆発させるのが重要です。 主に下記が大事かなと思います。 サンプル作成で苦労したこと 最初はバーストだけを描画していました。 でも、実際の花火の映像を見てみるとやっぱり打ち上げ中の光があって、それがバーストするという流れが大事だなと感じました。 そのため、ロケット(上昇する点)を一つ置いて視線の追従先を作り、トレイルを少し太く(1フレームで複数粒)することで花火っぽい演出が出るようにしています。 ロケット状態 を一つ持つ(位置と速度) 上昇中に、フェードアウトするトレイル粒を少しずつ生成する 目標高度に達したら burst(origin) をコールするようにする // ロケット(1発) const rocket = { active : false , x : 0 , y : 0 , z : 0 , vx : 0 , vy : 0 , vz : 0 , targetY : 5 . 0 } ; function updateRocket ( dt ) { if ( ! rocket . active ) return; rocket . x += rocket . vx * dt ; rocket . y += rocket . vy * dt ; rocket . z += rocket . vz * dt ; rocketTrail . userData . spawnTrail ( rocket . x , rocket . y , rocket . z , ( Math . random () - 0 . 5 ) * 0 . 2 , -0 . 4 - Math . random () * 0 . 5 , ( Math . random () - 0 . 5 ) * 0 . 2 , 0 . 55 + Math . random () * 0 . 35 ) ; if ( rocket . y >= rocket . targetY ) { rocket . active = false ; fireworks . userData . burst ( new THREE . Vector3 ( rocket . x , rocket . y , rocket . z )) ; } } 5.3 注意点(加算は“使い過ぎると白飛び”する) AdditiveBlendingは見栄えが良い反面、設定によっては白飛びしやすいです。次を意識するとコントロールしやすいかなと思います。 加算合成の白飛び例(粒の数が多く大きい場合、粒が重なって潰れ、白い大きな塊に見える) 白飛びを抑えた調整例(細かい粒子もしっかり見えてバランスがいい) 粒の 数を増やしすぎない opacity と size を控えめにする 背景を真っ黒にせず、少し色を入れる(目が疲れにくい) 5.4 カスタマイズする場合のポイント 花火はパラメータの効き方が分かりやすく、調整も楽しいです。まずは次の3つから触るのがおすすめです。 targetY : どの高さで花火が開くか。これだけで印象がかなり変わります トレイルの粒数と寿命 : 打ち上がりの“筋”が見えるかどうかを決めます バースト粒の数 : 豪華さに直結しますが、白飛びと重さも増えます 個人的には、粒を増やすより 「打ち上がる途中が見えるか」 を先に整えたほうが、花火らしさは出しやすいです。 6.秋:紅葉の景色(InstancedMesh + 風の揺れ) 秋は、落ち葉や紅葉の「枚数」が重要です。 本サンプルでは InstancedMesh を使い、同じジオメトリを大量に描いても耐えやすい形にします。 サンプル作成で苦労したこと 紅葉のような舞い散る要素は、たくさん要素を増やした方が雰囲気が出ますが、CPU側で毎フレーム行列更新をしていると、急に重く感じることがあります。 そのため、本サンプルでは、まず “枚数を出す” を InstancedMesh で確保しつつ、更新部分は軽めにして「カクカクせずに動く範囲」にしています。 6.1 利用する主な機能 InstancedMesh : 1種類の葉を、行列(transform)だけ変えて大量描画する(ドローコール削減) Matrix4 : setMatrixAt で位置・回転・スケールをまとめて設定 疑似風 : sin で揺れを足す(物理の代わり) 6.2 注意点 インスタンシングは描画が強い一方、 毎フレーム全個体の行列を更新 するとCPU側が重くなることがあります。サンプルでは数を控えめにし、更新も軽い式にしています。カスタマイズする際は「更新が必要な個体だけ更新する」などの工夫が効果的です。 6.3 コードのポイント(InstancedMeshの更新) 紅葉は「枚数」が雰囲気に直結します。一方で、葉1枚1枚を Mesh で作るとドローコールが増えやすいので、 InstancedMesh に寄せます。 ここでのポイントは、 setMatrixAt(i, dummy.matrix) で 各個体の行列を更新 している点です。 const mesh = new THREE . InstancedMesh ( geo , mat , count ) ; mesh . instanceMatrix . setUsage ( THREE . DynamicDrawUsage ) ; function update ( dt , t ) { for ( let i = 0 ; i < count ; i ++ ) { // d.x / d.y / d.z を更新して落下させる // sinで揺れを足して風っぽくする dummy . position . set ( d . x + sx , d . y , d . z ) ; dummy . rotation . set ( 0 , d . ry + sx * 0 . 7 , d . rz + Math . sin ( t * 1 . 2 + d . phase ) * 0 . 4 ) ; dummy . updateMatrix () ; mesh . setMatrixAt ( i , dummy . matrix ) ; } mesh . instanceMatrix . needsUpdate = true ; } 6.4 カスタマイズする場合のポイント 秋は、見た目を変えるより「重くしすぎない」調整が大事です。 count : まずはここ。増やすと雰囲気は出ますが、CPU更新コストが増えます sway : 風の強さ。ここが強すぎると、落ち葉というより紙吹雪っぽく見えます 更新頻度 : 本番では毎フレーム更新しなくても、十分それらしく見えます 7.冬:雪景色(フォグ + ライティング + パーティクル) 冬の表現は、雪そのものより 空気(白っぽさ・奥行き) が重要に感じます。ここではフォグとライトで「奥行き」を作ります。 サンプル作成で苦労したこと 雪は粒を増やすほど季節感が出る反面、増やしすぎると「画面がうるさい」方向に寄りがちです。 個人的には、雪そのものより フォグで遠景を溶かす ほうが“冬っぽさ”が出やすく、粒は控えめにして揺れも弱める、くらいが扱いやすいと感じています。 7.1 利用する主な機能 Fog(フォグ) : 遠景を白っぽく(雪/霧の空気感) DirectionalLight + AmbientLight : 影は使わず、軽いライトで雰囲気を出す 雪パーティクル : 春のパーティクルを流用しつつ、落下と揺れを弱める 7.2 コードのポイント(Fogと雪の見せ方) 雪景色は、雪の粒そのものより「遠景が白っぽい」「空気が冷たい」印象のほうが効くことが多いです。そこで次の2つを組み合わせます。 ogがほぼ効いてないケース(ほぼ霧が掛からず、遠景がくっきり残る) Fogが効きすぎているケース(背景色に溶けすぎて、ものすごく霧がかかったような背景になる) Fog : 遠いものほど背景色に溶ける(白/青寄りにすると冬っぽい) 雪パーティクル : サイズを控えめにし、揺れも弱める(吹雪にしない) // Fog(冬は近めから効かせる) scene . fog = new THREE . Fog ( 0x0a1322 , 6 , 45 ) ; // 雪(深度書き込みはしない:透明の欠けを避ける) const mat = new THREE . PointsMaterial ({ map : circleTexture , transparent : true , depthWrite : false , }) ; 7.3 カスタマイズする場合のポイント 冬は、派手さより「足し算しすぎない」ほうがうまくいきます。 Fogの near / far : 空気感の主役です。雪粒の数より先にここを触る価値があります 雪粒のサイズ : 大きくしすぎると急に人工的な見た目に寄ります ライトの強さ : 青寄りの冷たさを出したいか、やわらかい雪景色にしたいかで調整します 冬の表現は、四季の中でも「少ない要素でそれらしく見える」ので、WebGLを触り始めた人が最初に成功体験を得やすいパートだと思っています。 8.まとめ 四季の表現は、複雑なモデルや高価なポスト処理がなくても、次の組み合わせでそれっぽく作れます。 春(桜): 透明パーティクル と深度の扱い 夏(花火): 加算合成 とフェード 秋(紅葉): インスタンシング で枚数を出す 冬(雪): フォグとライト で空気を作る 本記事のサンプルは「再現しやすい」ことを優先して、1ファイル・外部画像なしで作っています。ここから、背景の作り込み、モデル差し替え、ポスト処理追加など、用途に応じて段階的に伸ばしてください。 最後に個人的な感覚ですが、四季のような“わかりやすいテーマ”は、WebGLの機能を用いて 「何を足すと体験がどう変わるか」 を掴みやすい題材だと思っています。 まずはこのまま動かして、気に入った季節のパラメータ(枚数、速度、色)などを少しずつ触ってみてください。見た目の変化が素直なので、作っていて楽しいところでもあります。 最初の一歩としておすすめは、桜か雪です。春は「形と動き」で印象が変わる面白さがあり、冬は少ない要素で空気感が作れます。 付録:フルセットのコード 以下に、四季切り替え版のフルコードを掲載します。 <!doctype html> < html lang = "ja" > < head > < meta charset = "utf-8" /> < meta name = "viewport" content = "width=device-width,initial-scale=1" /> < title > Four Seasons (Three.js) </ title > < style > html , body { height : 100% ; } body { margin : 0 ; overflow : hidden ; background : #0b1020 ; font-family : system-ui , -apple-system , "Hiragino Sans" , "Noto Sans JP" , sans-serif ; } canvas { display : block ; } .ui { position : fixed ; top : 12px ; left : 12px ; display : flex ; gap: 8px ; flex-wrap : wrap ; z-index : 2 ; padding : 10px ; border-radius : 14px ; background : rgba( 0 , 0 , 0 , 0.35 ) ; border : 1px solid rgba( 255 , 255 , 255 , 0.10 ) ; backdrop- filter : blur( 10px ) ; color : rgba( 255 , 255 , 255 , 0.85 ) ; } button { appearance : none ; border : 1px solid rgba( 255 , 255 , 255 , 0.14 ) ; background : rgba( 255 , 255 , 255 , 0.06 ) ; color : rgba( 255 , 255 , 255 , 0.9 ) ; padding : 8px 10px ; border-radius : 12px ; font-weight : 650 ; cursor : pointer ; } button [ aria-pressed = "true" ] { background : linear-gradient( 135deg , rgba( 61 , 220 , 151 , .22 ), rgba( 122 , 162 , 255 , .18 )) ; border-color : rgba( 255 , 255 , 255 , 0.18 ) ; } .note { max-width : 420px ; font-size : 12px ; line-height : 1.5 ; opacity : 0.9 ; } </ style > </ head > < body > < div class = "ui" role = "group" aria-label = "season switch" > < button id = "spring" aria-pressed = "true" > 春(桜) </ button > < button id = "summer" aria-pressed = "false" > 夏(花火) </ button > < button id = "autumn" aria-pressed = "false" > 秋(紅葉) </ button > < button id = "winter" aria-pressed = "false" > 冬(雪) </ button > < div class = "note" > 起動は Live Server などで http://localhost として開いてください(file:// は不可)。 </ div > </ div > < script type = "importmap" > { "imports" : { "three" : "https://unpkg.com/three@0.164.1/build/three.module.js" } } </ script > < script type = "module" > import * as THREE from 'three' ; const Season = Object . freeze ({ spring : 'spring' , summer : 'summer' , autumn : 'autumn' , winter : 'winter' }) ; let season = Season . spring ; const btns = { spring : document . getElementById ( 'spring' ) , summer : document . getElementById ( 'summer' ) , autumn : document . getElementById ( 'autumn' ) , winter : document . getElementById ( 'winter' ) , } ; function setPressed ( s ) { for ( const [ k , el ] of Object . entries ( btns )) el . setAttribute ( 'aria-pressed' , String ( k === s )) ; } for ( const [ k , el ] of Object . entries ( btns )) { el . addEventListener ( 'click' , () => { season = k ; setPressed ( k ) ; applySeasonLook () ; }) ; } const renderer = new THREE . WebGLRenderer ({ antialias : true , alpha : false , powerPreference : 'high-performance' }) ; renderer . setPixelRatio ( Math . min ( devicePixelRatio , 2 )) ; renderer . setSize ( innerWidth , innerHeight ) ; document . body . appendChild ( renderer . domElement ) ; const scene = new THREE . Scene () ; const camera = new THREE . PerspectiveCamera ( 55 , innerWidth / innerHeight , 0 . 1 , 200 ) ; camera . position . set ( 0 , 3 . 0 , 10 . 5 ) ; const amb = new THREE . AmbientLight ( 0xffffff , 0 . 55 ) ; const dir = new THREE . DirectionalLight ( 0xffffff , 1 . 0 ) ; dir . position . set ( 6 , 10 , 6 ) ; scene . add ( amb , dir ) ; const ground = new THREE . Mesh ( new THREE . PlaneGeometry ( 120 , 120 ) , new THREE . MeshStandardMaterial ({ color : 0x0e1733 , roughness : 1 . 0 , metalness : 0 . 0 }) ) ; ground . rotation . x = - Math . PI / 2 ; ground . position . y = -0 . 6 ; scene . add ( ground ) ; function makeCircleTexture ({ color = '#ffffff' , soft = true } = {}) { const c = document . createElement ( 'canvas' ) ; c . width = 64 ; c . height = 64 ; const g = c . getContext ( '2d' ) ; g . clearRect ( 0 , 0 , c . width , c . height ) ; const r = 26 ; const cx = 32 , cy = 32 ; const grd = g . createRadialGradient ( cx , cy , soft ? 4 : 20 , cx , cy , r ) ; grd . addColorStop ( 0 , color ) ; grd . addColorStop ( 1 , 'rgba(255,255,255,0)' ) ; g . fillStyle = grd ; g . beginPath () ; g . arc ( cx , cy , r , 0 , Math . PI * 2 ) ; g . fill () ; const tex = new THREE . CanvasTexture ( c ) ; tex . needsUpdate = true ; return tex ; } function makePetalTexture () { const c = document . createElement ( 'canvas' ) ; c . width = 64 ; c . height = 64 ; const g = c . getContext ( '2d' ) ; g . clearRect ( 0 , 0 , 64 , 64 ) ; g . translate ( 32 , 32 ) ; g . rotate ( -0 . 25 ) ; // 花びら感を出すために「先端が丸く、根元に切れ込みがある」形を描く const base = g . createRadialGradient ( 0 , -6 , 2 , 0 , -6 , 30 ) ; base . addColorStop ( 0 , 'rgba(255,210,225,0.95)' ) ; base . addColorStop ( 0 . 55 , 'rgba(255,175,205,0.75)' ) ; base . addColorStop ( 1 , 'rgba(255,175,205,0.0)' ) ; g . fillStyle = base ; g . beginPath () ; g . moveTo ( 0 , -28 ) ; g . bezierCurveTo ( 18 , -22 , 18 , 10 , 0 , 26 ) ; g . bezierCurveTo ( -18 , 10 , -18 , -22 , 0 , -28 ) ; g . closePath () ; g . fill () ; // 根元の切れ込み(少しだけ透明に抜く) g . globalCompositeOperation = 'destination-out' ; g . fillStyle = 'rgba(0,0,0,0.6)' ; g . beginPath () ; g . ellipse ( 0 , 18 , 7 . 5 , 5 . 5 , 0 , 0 , Math . PI * 2 ) ; g . fill () ; g . globalCompositeOperation = 'source-over' ; // 輪郭をほんの少し(雪っぽさを減らす) g . strokeStyle = 'rgba(255,120,160,0.28)' ; g . lineWidth = 2 ; g . beginPath () ; g . moveTo ( 0 , -27 ) ; g . bezierCurveTo ( 17 , -21 , 17 , 9 , 0 , 25 ) ; g . bezierCurveTo ( -17 , 9 , -17 , -21 , 0 , -27 ) ; g . closePath () ; g . stroke () ; const tex = new THREE . CanvasTexture ( c ) ; tex . needsUpdate = true ; return tex ; } function makeLeafTexture () { const c = document . createElement ( 'canvas' ) ; c . width = 64 ; c . height = 64 ; const g = c . getContext ( '2d' ) ; g . clearRect ( 0 , 0 , 64 , 64 ) ; g . translate ( 32 , 32 ) ; const grd = g . createLinearGradient ( -10 , -26 , 12 , 26 ) ; grd . addColorStop ( 0 , 'rgba(255,122,48,0.95)' ) ; grd . addColorStop ( 1 , 'rgba(170,40,0,0.0)' ) ; g . fillStyle = grd ; g . beginPath () ; g . ellipse ( 0 , 0 , 14 , 24 , 0 . 6 , 0 , Math . PI * 2 ) ; g . fill () ; g . strokeStyle = 'rgba(255,255,255,0.25)' ; g . lineWidth = 2 ; g . beginPath () ; g . moveTo ( -6 , -18 ) ; g . lineTo ( 8 , 18 ) ; g . stroke () ; const tex = new THREE . CanvasTexture ( c ) ; tex . needsUpdate = true ; return tex ; } function makeFallingPoints ({ count , texture , color , size , area }) { const geo = new THREE . BufferGeometry () ; const pos = new Float32Array ( count * 3 ) ; const vel = new Float32Array ( count * 3 ) ; for ( let i = 0 ; i < count ; i ++ ) { const x = ( Math . random () - 0 . 5 ) * area . x ; const y = Math . random () * area . y ; const z = ( Math . random () - 0 . 5 ) * area . z ; pos [ i * 3 + 0 ] = x ; pos [ i * 3 + 1 ] = y ; pos [ i * 3 + 2 ] = z ; vel [ i * 3 + 0 ] = ( Math . random () - 0 . 5 ) * 0 . 3 ; vel [ i * 3 + 1 ] = - ( 0 . 25 + Math . random () * 0 . 35 ) ; vel [ i * 3 + 2 ] = ( Math . random () - 0 . 5 ) * 0 . 3 ; } geo . setAttribute ( 'position' , new THREE . BufferAttribute ( pos , 3 )) ; geo . setAttribute ( 'velocity' , new THREE . BufferAttribute ( vel , 3 )) ; const mat = new THREE . PointsMaterial ({ color , size , map : texture , transparent : true , depthWrite : false , blending : THREE . NormalBlending , }) ; const pts = new THREE . Points ( geo , mat ) ; // 毎フレーム getAttribute を呼ばないよう、参照を保持しておく pts . userData . area = area ; pts . userData . posAttr = geo . getAttribute ( 'position' ) ; pts . userData . velAttr = geo . getAttribute ( 'velocity' ) ; return pts ; } // 桜は Points だと「粒」に寄りやすいので、板ポリ(InstancedMesh)で花びら感を出す function makeSakuraPetals ({ count = 700 , texture }) { const geo = new THREE . PlaneGeometry ( 0 . 28 , 0 . 28 ) ; const mat = new THREE . MeshStandardMaterial ({ color : 0xffffff , map : texture , transparent : true , depthWrite : false , side : THREE . DoubleSide , roughness : 0 . 95 , metalness : 0 . 0 }) ; const mesh = new THREE . InstancedMesh ( geo , mat , count ) ; mesh . instanceMatrix . setUsage ( THREE . DynamicDrawUsage ) ; mesh . frustumCulled = false ; const data = [] ; const dummy = new THREE . Object3D () ; for ( let i = 0 ; i < count ; i ++ ) { data . push ({ x : ( Math . random () - 0 . 5 ) * 18 , y : Math . random () * 10 , z : ( Math . random () - 0 . 5 ) * 18 , fall : 0 . 55 + Math . random () * 0 . 55 , drift : 0 . 35 + Math . random () * 0 . 55 , spin : 1 . 2 + Math . random () * 2 . 6 , wobble : 1 . 0 + Math . random () * 1 . 6 , phase : Math . random () * 10 , ry : Math . random () * Math . PI * 2 , }) ; } function update ( dt , t ) { for ( let i = 0 ; i < count ; i ++ ) { const d = data [ i ] ; d . phase += dt ; d . y -= d . fall * dt ; const wx = Math . sin ( d . phase * d . wobble ) * d . drift ; const wz = Math . cos ( d . phase * ( d . wobble * 0 . 9 )) * d . drift ; d . x += wx * dt ; d . z += wz * dt ; if ( d . y < -0 . 6 ) { d . x = ( Math . random () - 0 . 5 ) * 18 ; d . y = 10 + Math . random () * 4 ; d . z = ( Math . random () - 0 . 5 ) * 18 ; } const tilt = Math . sin ( d . phase * 2 . 1 ) * 0 . 7 ; dummy . position . set ( d . x , d . y , d . z ) ; dummy . rotation . set ( tilt , d . ry + d . phase * 0 . 35 , d . phase * d . spin ) ; const s = 0 . 75 + Math . sin ( d . phase * 1 . 7 ) * 0 . 08 ; dummy . scale . set ( s , s , s ) ; dummy . updateMatrix () ; mesh . setMatrixAt ( i , dummy . matrix ) ; } mesh . instanceMatrix . needsUpdate = true ; } mesh . userData . update = update ; return mesh ; } const sakura = makeSakuraPetals ({ count : 820 , texture : makePetalTexture () }) ; scene . add ( sakura ) ; const snow = makeFallingPoints ({ count : 1600 , texture : makeCircleTexture ({ color : 'rgba(255,255,255,0.9)' , soft : true }) , color : 0xffffff , size : 0 . 08 , area : new THREE . Vector3 ( 18 , 10 , 18 ) }) ; snow . visible = false ; scene . add ( snow ) ; function makeFireworks ({ maxParticles = 1800 }) { const geo = new THREE . BufferGeometry () ; const pos = new Float32Array ( maxParticles * 3 ) ; const vel = new Float32Array ( maxParticles * 3 ) ; const life = new Float32Array ( maxParticles ) ; for ( let i = 0 ; i < maxParticles ; i ++ ) { pos [ i * 3 + 0 ] = 0 ; pos [ i * 3 + 1 ] = -999 ; pos [ i * 3 + 2 ] = 0 ; vel [ i * 3 + 0 ] = 0 ; vel [ i * 3 + 1 ] = 0 ; vel [ i * 3 + 2 ] = 0 ; life [ i ] = 0 ; } geo . setAttribute ( 'position' , new THREE . BufferAttribute ( pos , 3 )) ; geo . setAttribute ( 'velocity' , new THREE . BufferAttribute ( vel , 3 )) ; geo . setAttribute ( 'life' , new THREE . BufferAttribute ( life , 1 )) ; const mat = new THREE . PointsMaterial ({ color : 0xfff4b0 , size : 0 . 12 , map : makeCircleTexture ({ color : 'rgba(255,220,120,0.95)' , soft : true }) , transparent : true , depthWrite : false , blending : THREE . AdditiveBlending }) ; const pts = new THREE . Points ( geo , mat ) ; pts . frustumCulled = false ; // 参照をキャッシュ(update/burst で高速化) const posAttr = geo . getAttribute ( 'position' ) ; const velAttr = geo . getAttribute ( 'velocity' ) ; const lifeAttr = geo . getAttribute ( 'life' ) ; function burst ( origin ) { const p = posAttr ; const v = velAttr ; const l = lifeAttr ; const need = 600 ; let spawned = 0 ; for ( let i = 0 ; i < l . count && spawned < need ; i ++ ) { if ( l . array [ i ] > 0 ) continue; const theta = Math . random () * Math . PI * 2 ; const phi = Math . acos ( THREE . MathUtils . randFloat ( -1 , 1 )) ; const sp = 2 . 8 + Math . random () * 2 . 4 ; v . array [ i * 3 + 0 ] = Math . cos ( theta ) * Math . sin ( phi ) * sp ; v . array [ i * 3 + 1 ] = Math . cos ( phi ) * sp ; v . array [ i * 3 + 2 ] = Math . sin ( theta ) * Math . sin ( phi ) * sp ; p . array [ i * 3 + 0 ] = origin . x ; p . array [ i * 3 + 1 ] = origin . y ; p . array [ i * 3 + 2 ] = origin . z ; l . array [ i ] = 1 . 6 + Math . random () * 0 . 8 ; spawned ++; } p . needsUpdate = true ; v . needsUpdate = true ; l . needsUpdate = true ; } pts . userData . burst = burst ; pts . userData . posAttr = posAttr ; pts . userData . velAttr = velAttr ; pts . userData . lifeAttr = lifeAttr ; return pts ; } const fireworks = makeFireworks ({ maxParticles : 2200 }) ; fireworks . visible = false ; scene . add ( fireworks ) ; // 打ち上げ(ロケット)+軌跡(トレイル) function makeRocketTrail ({ max = 900 }) { const geo = new THREE . BufferGeometry () ; const pos = new Float32Array ( max * 3 ) ; const vel = new Float32Array ( max * 3 ) ; const life = new Float32Array ( max ) ; for ( let i = 0 ; i < max ; i ++ ) { pos [ i * 3 + 0 ] = 0 ; pos [ i * 3 + 1 ] = -999 ; pos [ i * 3 + 2 ] = 0 ; vel [ i * 3 + 0 ] = 0 ; vel [ i * 3 + 1 ] = 0 ; vel [ i * 3 + 2 ] = 0 ; life [ i ] = 0 ; } geo . setAttribute ( 'position' , new THREE . BufferAttribute ( pos , 3 )) ; geo . setAttribute ( 'velocity' , new THREE . BufferAttribute ( vel , 3 )) ; geo . setAttribute ( 'life' , new THREE . BufferAttribute ( life , 1 )) ; const mat = new THREE . PointsMaterial ({ color : 0xfff0c8 , size : 0 . 11 , map : makeCircleTexture ({ color : 'rgba(255,220,160,0.85)' , soft : true }) , transparent : true , depthWrite : false , blending : THREE . AdditiveBlending }) ; const pts = new THREE . Points ( geo , mat ) ; pts . frustumCulled = false ; const posAttr = geo . getAttribute ( 'position' ) ; const velAttr = geo . getAttribute ( 'velocity' ) ; const lifeAttr = geo . getAttribute ( 'life' ) ; function spawnTrail ( x , y , z , vx , vy , vz , ttl ) { const p = posAttr ; const v = velAttr ; const l = lifeAttr ; for ( let i = 0 ; i < l . count ; i ++ ) { if ( l . array [ i ] > 0 ) continue; p . array [ i * 3 + 0 ] = x ; p . array [ i * 3 + 1 ] = y ; p . array [ i * 3 + 2 ] = z ; v . array [ i * 3 + 0 ] = vx ; v . array [ i * 3 + 1 ] = vy ; v . array [ i * 3 + 2 ] = vz ; l . array [ i ] = ttl ; p . needsUpdate = true ; v . needsUpdate = true ; l . needsUpdate = true ; return; } } pts . userData . spawnTrail = spawnTrail ; pts . userData . posAttr = posAttr ; pts . userData . velAttr = velAttr ; pts . userData . lifeAttr = lifeAttr ; return pts ; } const rocketTrail = makeRocketTrail ({ max : 1100 }) ; rocketTrail . visible = false ; scene . add ( rocketTrail ) ; const rocket = { active : false , x : 0 , y : 0 , z : 0 , vx : 0 , vy : 0 , vz : 0 , targetY : 5 . 0 } ; // ロケット本体(明るい点を動かして「打ち上げ」を視認させる) const rocketSprite = new THREE . Sprite ( new THREE . SpriteMaterial ({ map : makeCircleTexture ({ color : 'rgba(255,255,255,0.95)' , soft : true }) , color : 0xfff0d5 , transparent : true , depthWrite : false , blending : THREE . AdditiveBlending }) ) ; rocketSprite . scale . set ( 0 . 6 , 0 . 6 , 0 . 6 ) ; rocketSprite . visible = false ; scene . add ( rocketSprite ) ; function makeLeaves ({ count = 420 , texture }) { const geo = new THREE . PlaneGeometry ( 0 . 5 , 0 . 5 ) ; const mat = new THREE . MeshStandardMaterial ({ color : 0xffffff , map : texture , transparent : true , depthWrite : false , side : THREE . DoubleSide , roughness : 0 . 9 , metalness : 0 . 0 }) ; const mesh = new THREE . InstancedMesh ( geo , mat , count ) ; mesh . instanceMatrix . setUsage ( THREE . DynamicDrawUsage ) ; mesh . frustumCulled = false ; const data = [] ; const dummy = new THREE . Object3D () ; for ( let i = 0 ; i < count ; i ++ ) { data . push ({ x : ( Math . random () - 0 . 5 ) * 16 , y : Math . random () * 8 + 1 , z : ( Math . random () - 0 . 5 ) * 16 , ry : Math . random () * Math . PI * 2 , rz : ( Math . random () - 0 . 5 ) * 0 . 6 , fall : 0 . 4 + Math . random () * 0 . 6 , sway : 0 . 6 + Math . random () * 1 . 4 , phase : Math . random () * 10 }) ; } function update ( dt , t ) { for ( let i = 0 ; i < count ; i ++ ) { const d = data [ i ] ; d . phase += dt ; d . y -= d . fall * dt ; if ( d . y < -0 . 2 ) { d . y = 8 + Math . random () * 3 ; d . x = ( Math . random () - 0 . 5 ) * 16 ; d . z = ( Math . random () - 0 . 5 ) * 16 ; } const sx = Math . sin ( d . phase * d . sway ) * 0 . 35 ; dummy . position . set ( d . x + sx , d . y , d . z ) ; dummy . rotation . set ( 0 , d . ry + sx * 0 . 7 , d . rz + Math . sin ( t * 1 . 2 + d . phase ) * 0 . 4 ) ; const s = 0 . 65 + Math . sin ( d . phase ) * 0 . 08 ; dummy . scale . set ( s , s , s ) ; dummy . updateMatrix () ; mesh . setMatrixAt ( i , dummy . matrix ) ; } mesh . instanceMatrix . needsUpdate = true ; } mesh . userData . update = update ; return mesh ; } const leaves = makeLeaves ({ count : 520 , texture : makeLeafTexture () }) ; leaves . visible = false ; scene . add ( leaves ) ; function resetSummerArtifacts () { // ロケット状態を止める rocket . active = false ; rocketSprite . visible = false ; // trail / fireworks の残り粒を強制的に消す for ( const pts of [ rocketTrail , fireworks ]) { const p = pts . userData . posAttr ; const l = pts . userData . lifeAttr ; if ( ! p || ! l ) continue; for ( let i = 0 ; i < l . count ; i ++ ) { l . array [ i ] = 0 ; p . array [ i * 3 + 1 ] = -999 ; } p . needsUpdate = true ; l . needsUpdate = true ; } } function applySeasonLook () { if ( season === Season . spring ) { resetSummerArtifacts () ; renderer . setClearColor ( 0x0b1020 , 1 ) ; scene . fog = new THREE . Fog ( 0x0b1020 , 10 , 60 ) ; ground . material . color . setHex ( 0x0e1733 ) ; amb . intensity = 0 . 55 ; dir . intensity = 0 . 9 ; sakura . visible = true ; snow . visible = false ; fireworks . visible = false ; leaves . visible = false ; } else if ( season === Season . summer ) { renderer . setClearColor ( 0x070a14 , 1 ) ; scene . fog = new THREE . Fog ( 0x070a14 , 14 , 70 ) ; ground . material . color . setHex ( 0x0a0f22 ) ; amb . intensity = 0 . 35 ; dir . intensity = 0 . 55 ; sakura . visible = false ; snow . visible = false ; fireworks . visible = true ; leaves . visible = false ; rocketTrail . visible = true ; rocketSprite . visible = true ; } else if ( season === Season . autumn ) { resetSummerArtifacts () ; renderer . setClearColor ( 0x120a08 , 1 ) ; scene . fog = new THREE . Fog ( 0x120a08 , 9 , 55 ) ; ground . material . color . setHex ( 0x2a160f ) ; amb . intensity = 0 . 6 ; dir . intensity = 1 . 05 ; sakura . visible = false ; snow . visible = false ; fireworks . visible = false ; leaves . visible = true ; } else if ( season === Season . winter ) { resetSummerArtifacts () ; renderer . setClearColor ( 0x0a1322 , 1 ) ; scene . fog = new THREE . Fog ( 0x0a1322 , 6 , 45 ) ; ground . material . color . setHex ( 0x1a2438 ) ; amb . intensity = 0 . 75 ; dir . intensity = 0 . 95 ; sakura . visible = false ; snow . visible = true ; fireworks . visible = false ; leaves . visible = false ; rocketTrail . visible = false ; rocketSprite . visible = false ; } } applySeasonLook () ; function resize () { camera . aspect = innerWidth / innerHeight ; camera . updateProjectionMatrix () ; renderer . setSize ( innerWidth , innerHeight ) ; } addEventListener ( 'resize' , resize ) ; function updateFalling ( points , dt ) { const p = points . userData . posAttr ; const v = points . userData . velAttr ; const a = points . userData . area ; for ( let i = 0 ; i < p . count ; i ++ ) { p . array [ i * 3 + 0 ] += v . array [ i * 3 + 0 ] * dt ; p . array [ i * 3 + 1 ] += v . array [ i * 3 + 1 ] * dt ; p . array [ i * 3 + 2 ] += v . array [ i * 3 + 2 ] * dt ; if ( p . array [ i * 3 + 1 ] < -0 . 2 ) { p . array [ i * 3 + 0 ] = ( Math . random () - 0 . 5 ) * a . x ; p . array [ i * 3 + 1 ] = a . y ; p . array [ i * 3 + 2 ] = ( Math . random () - 0 . 5 ) * a . z ; } v . array [ i * 3 + 0 ] += ( Math . random () - 0 . 5 ) * 0 . 02 * dt ; v . array [ i * 3 + 2 ] += ( Math . random () - 0 . 5 ) * 0 . 02 * dt ; } p . needsUpdate = true ; v . needsUpdate = true ; } function updateFireworks ( dt ) { const p = fireworks . userData . posAttr ; const v = fireworks . userData . velAttr ; const l = fireworks . userData . lifeAttr ; for ( let i = 0 ; i < l . count ; i ++ ) { const life = l . array [ i ] ; if ( life <= 0 ) continue; l . array [ i ] = Math . max ( 0 , life - dt ) ; p . array [ i * 3 + 0 ] += v . array [ i * 3 + 0 ] * dt ; p . array [ i * 3 + 1 ] += v . array [ i * 3 + 1 ] * dt ; p . array [ i * 3 + 2 ] += v . array [ i * 3 + 2 ] * dt ; v . array [ i * 3 + 1 ] -= 3 . 6 * dt ; v . array [ i * 3 + 0 ] *= ( 1 - 0 . 18 * dt ) ; v . array [ i * 3 + 1 ] *= ( 1 - 0 . 18 * dt ) ; v . array [ i * 3 + 2 ] *= ( 1 - 0 . 18 * dt ) ; if ( l . array [ i ] === 0 ) p . array [ i * 3 + 1 ] = -999 ; } p . needsUpdate = true ; v . needsUpdate = true ; l . needsUpdate = true ; } function updateRocket ( dt ) { if ( ! rocket . active ) { rocketSprite . visible = false ; return; } rocket . x += rocket . vx * dt ; rocket . y += rocket . vy * dt ; rocket . z += rocket . vz * dt ; // 軌跡(少し散らす) // 1フレームで複数粒を出して、筋っぽく見せる for ( let i = 0 ; i < 3 ; i ++ ) { rocketTrail . userData . spawnTrail ( rocket . x + ( Math . random () - 0 . 5 ) * 0 . 05 , rocket . y + ( Math . random () - 0 . 5 ) * 0 . 05 , rocket . z + ( Math . random () - 0 . 5 ) * 0 . 05 , ( Math . random () - 0 . 5 ) * 0 . 22 , -0 . 55 - Math . random () * 0 . 65 , ( Math . random () - 0 . 5 ) * 0 . 22 , 0 . 75 + Math . random () * 0 . 45 ) ; } rocketSprite . position . set ( rocket . x , rocket . y , rocket . z ) ; rocketSprite . visible = true ; // 到達したら爆発 if ( rocket . y >= rocket . targetY ) { rocket . active = false ; rocketSprite . visible = false ; fireworks . userData . burst ( new THREE . Vector3 ( rocket . x , rocket . y , rocket . z )) ; } } function updateTrailPoints ( points , dt ) { const p = points . userData . posAttr ; const v = points . userData . velAttr ; const l = points . userData . lifeAttr ; for ( let i = 0 ; i < l . count ; i ++ ) { const life = l . array [ i ] ; if ( life <= 0 ) continue; l . array [ i ] = Math . max ( 0 , life - dt ) ; p . array [ i * 3 + 0 ] += v . array [ i * 3 + 0 ] * dt ; p . array [ i * 3 + 1 ] += v . array [ i * 3 + 1 ] * dt ; p . array [ i * 3 + 2 ] += v . array [ i * 3 + 2 ] * dt ; v . array [ i * 3 + 1 ] -= 0 . 9 * dt ; v . array [ i * 3 + 0 ] *= ( 1 - 0 . 35 * dt ) ; v . array [ i * 3 + 1 ] *= ( 1 - 0 . 35 * dt ) ; v . array [ i * 3 + 2 ] *= ( 1 - 0 . 35 * dt ) ; if ( l . array [ i ] === 0 ) p . array [ i * 3 + 1 ] = -999 ; } p . needsUpdate = true ; v . needsUpdate = true ; l . needsUpdate = true ; } let t = 0 ; let last = performance . now () ; let fireTimer = 0 ; function loop ( now ) { const dt = Math . min (( now - last ) / 1000 , 0 . 05 ) ; last = now ; t += dt ; camera . position . x = Math . sin ( t * 0 . 25 ) * 0 . 35 ; camera . lookAt ( 0 , 1 . 0 , 0 ) ; if ( season === Season . spring ) { sakura . userData . update ( dt , t ) ; } else if ( season === Season . winter ) { updateFalling ( snow , dt ) ; snow . material . size = 0 . 075 + Math . sin ( t * 0 . 6 ) * 0 . 01 ; } else if ( season === Season . summer ) { // 打ち上げ→爆発→余韻(軌跡)まで見せる updateFireworks ( dt ) ; updateTrailPoints ( rocketTrail , dt ) ; updateRocket ( dt ) ; fireTimer -= dt ; if ( fireTimer <= 0 ) { fireTimer = 1 . 0 + Math . random () * 0 . 9 ; // ロケットを再発射(発射中は追加しない) if ( ! rocket . active ) { rocket . active = true ; rocket . x = ( Math . random () - 0 . 5 ) * 3 . 2 ; rocket . y = -1 . 2 ; rocket . z = -4 . 0 + ( Math . random () - 0 . 5 ) * 1 . 4 ; rocket . vx = ( Math . random () - 0 . 5 ) * 0 . 18 ; rocket . vy = 6 . 2 + Math . random () * 0 . 8 ; rocket . vz = ( Math . random () - 0 . 5 ) * 0 . 12 ; rocket . targetY = 3 . 9 + Math . random () * 1 . 7 ; } } } else if ( season === Season . autumn ) { leaves . userData . update ( dt , t ) ; } renderer . render ( scene , camera ) ; requestAnimationFrame ( loop ) ; } requestAnimationFrame ( loop ) ; </ script > </ body > </ html > 執筆者 中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属) NTT西日本のWebアプリケーションの開発・運営に従事。 好きな技術スタック:TypeScript, Vue.js, GraphQL, Laravel 参考資料・出典 本記事を執筆するにあたり、以下のサイト・資料を参考にしました。 Three.js documentation: https://threejs.org/docs/ MDN Web Docs — WebGL API: https://developer.mozilla.org/ja/docs/Web/API/WebGL_API MDN Web Docs — WebGL best practices: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices MDN Web Docs — requestAnimationFrame : https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame 商標 「JavaScript」は、Oracle Corporation およびその子会社の米国およびその他の国における商標または登録商標です。 「Firefox」は、Mozilla Foundation の商標です。 「Safari」はApple Inc. の商標です。 「Chromium」はGoogle LLC に関連するプロジェクト名です。 「Google Chrome」は、Google LLC の商標です。 「Three.js」は、Three.jsプロジェクトに関連する名称です(詳細は公式ドキュメントを参照してください)。 「Visual Studio Code」はMicrosoft Corporation の商標です。 「Apache HTTP Server」および「Apache」は、Apache Software Foundation の商標です。 記載のその他の会社名・製品名は、それぞれ各社の商標もしくは登録商標です。
はじめに NTT西日本の平岡です。 本記事では、 SUSE Harvester のストレージ機能を検証します。 Harvester は、Kubernetes を基盤として仮想マシンやストレージを統合的に管理できる HCI(Hyper-Converged Infrastructure)ソフトウェアです。内蔵の分散ブロックストレージ Longhorn に加え、外部ストレージとの連携も可能であり、ストレージ構成の選択肢が多岐にわたります。本シリーズでは、合計6つのストレージ構成を自宅ラボで実際に構築し、fio ベンチマークで横断比較します。 本検証は、筆者の自宅ラボに構築した Proxmox VE 上のネステッド仮想化環境で実施しています。そのため、ベンチマークの絶対値(MB/s や IOPS)はベアメタル環境と異なる可能性があります。本シリーズでは絶対値よりも、方式間の相対的な傾向(順位や倍率)を読み取ることを目的としています。 なお、本シリーズの検証では「ローカルディスクが最も高速」「ブロック接続がファイル共有に勝る」といった直感的な想定が、Longhorn のレプリカ同期やネステッド仮想化の影響で覆される場面がありました。アーキテクチャの想定と実測の乖離を明らかにすることも、本検証の重要なテーマです。 本シリーズは以下の前編・後編構成です。 回 内容 前編(本記事) Harvester の概要、3ノードクラスタの構築、3方式(Longhorn内蔵 / 外部iSCSI / 外部NFS)の構成とベンチマーク比較 後編 democratic-csi による Longhorn バイパス方式、カーネルNFS vs ユーザー空間NFS(NFS-Ganesha)の構成とベンチマーク比較、シリーズ全体のまとめ 前編で構築する3方式はいずれも Harvester の VM 起動に対応したストレージです。後編ではさらに democratic-csi による Longhorn バイパス方式や、カーネル NFS vs ユーザー空間 NFS(NFS-Ganesha)の比較にも踏み込みます。なお、後編ではイメージインポートの制約により VM ではなく Pod から fio を実行するため、前編と後編のベンチマーク絶対値は直接比較できません。方式間の相対的な傾向を読み取ってください。 対象読者 本記事は以下のような方を対象としています。 仮想化基盤の導入・移行を検討しており、Kubernetes ベースの HCI に興味がある方 Harvester のストレージ構成の選択肢と特性を知りたい方 Longhorn、iSCSI、NFS の性能差を実測で比較したい方 この記事で分かること Harvester で使える 3 方式( harvester-longhorn / longhorn-iscsi / nfs-csi )の構造差 3 方式を同一環境で比較した fio ベンチマーク結果と、性能差が生じた主な要因 後編で扱う Longhorn バイパス構成につながる論点 急ぎで結果を確認したい方は 8.3. 測定結果 、考察を先に読みたい方は 8.4. 考察 をご覧ください。 1. 前提知識 1.1. Kubernetes の基本用語 1.2. CSI ドライバとは ― Kubernetes ストレージの標準インターフェース 2. SUSE Harvester の概要 2.1. Harvester とは 2.2. VM の起動シーケンス 3. 自宅ラボの検証環境 3.1. ハードウェア・ソフトウェア 3.2. 構成図 3.3. 検証環境詳細 4. クラスタ構築 4.1. 事前準備 ネステッド仮想化の確認 ISO のアップロード IP アドレスの空き確認 4.2. VM の作成 4.3. Harvester のインストール(1号機) ISO の取り外し(重要) 4.4. 2号機・3号機のインストール 4.5. クラスタ構築完了確認 5. ストレージ方式の構成 5.1. 方式1: harvester-longhorn(デフォルト) 5.2. 方式2: longhorn-iscsi(外部 iSCSI LUN) 構成の概要 TrueNAS iSCSI Target の設定 iSCSI LUN の接続と VM へのアタッチ ディスクのフォーマットとマウント Longhorn ディスク登録 StorageClass の作成 レプリカ動作テスト 5.3. 方式3: nfs-csi(外部 NFS 共有) NFS CSI ドライバのインストール StorageClass の作成 RWX 動作テスト 6. 3方式の比較 6.1. StorageClass 一覧 6.2. I/O パスの違い 6.3. Backing Image と フルコピー 6.4. Harvester 公式対応 CSI ドライバ 7. VM 起動とディスク格納先 7.1. VM の作成 7.2. ディスク格納先の確認 8. ベンチマーク 8.1. 測定方法 8.2. 測定コマンド 8.3. 測定結果 キャッシュなし(direct=1)— ストレージ純粋性能 キャッシュあり(direct=0)— 実運用に近い性能 8.4. 考察 想定と実測の比較 要因1: Longhorn のレプリカ同期オーバーヘッド 要因2: iSCSI の二重ネットワーク経路 要因3: NFS のキャッシュ効率 要因4: 本環境固有の制約 9. トラブルシューティング 10. まとめ 11. 参考情報 12. 商標について 13. 免責事項 13.1. データ損失に関する注意事項 14. 執筆者 1. 前提知識 1.1. Kubernetes の基本用語 Harvester の内部構造を理解するうえで必要となる Kubernetes 用語を、本記事で登場する範囲に絞って説明します。 Pod — Kubernetes におけるアプリケーションの最小実行単位です。1つ以上のコンテナをまとめたもので、Harvester では仮想マシンも Pod の中で動作します。Pod はクラスタ内のいずれかのノードに配置され、Kubernetes のスケジューラが配置先を決定します。 PVC(Persistent Volume Claim) — Pod が永続的なストレージを要求するための仕組みです。「10GB のディスクが欲しい」といった要求を宣言的に記述すると、Kubernetes が条件に合うストレージを自動的に割り当てます。Harvester では VM のディスクが PVC として管理されます。 StorageClass — PVC に対して「どのストレージバックエンドを使うか」を定義するリソースです。Harvester では、この StorageClass を切り替えることで、Longhorn 内蔵ディスク・外部 iSCSI・外部 NFS など異なるストレージ方式を選択します。 Longhorn — Kubernetes 向けの分散ブロックストレージです。Harvester に標準搭載されており、データを複数ノードにレプリカとして分散保存します。デフォルトではレプリカ数3で、3ノード全てにデータのコピーを持ちます。 CSI(Container Storage Interface) — Kubernetes が外部ストレージと連携するための標準インターフェースです。この CSI を通じて、NFS・iSCSI 等の多様なストレージを Kubernetes から利用できます。 Deployment — 指定した数の Pod レプリカを維持するリソースです。Pod が異常終了した場合、自動的に新しい Pod を起動して指定数を保ちます。Harvester API サーバや Rancher など、クラスタ全体で1つまたは少数稼働するコンポーネントが Deployment として管理されています。 DaemonSet — クラスタの全ノード(または条件を満たすノード)に1つずつ Pod を配置するリソースです。Longhorn のストレージエンジンや、CSI ドライバのノード側コンポーネントなどが DaemonSet として動作します。 namespace — Kubernetes クラスタ内でリソースをグループ分けする仕組みです。Harvester では longhorn-system(ストレージ関連)、harvester-system(Harvester 本体)など、機能ごとに namespace が分かれています。 1.2. CSI ドライバとは ― Kubernetes ストレージの標準インターフェース 本シリーズでは複数の CSI ドライバ(csi-driver-nfs、democratic-csi 等)を使用してストレージを構成します。ここでは CSI ドライバの位置づけを補足します。 CSI(Container Storage Interface)は、CNCF(Cloud Native Computing Foundation)が策定したオープンな業界標準仕様です。Kubernetes とストレージシステムの間に標準化されたインターフェースを定義し、ストレージベンダーに依存しない形でブロック/ファイルストレージを利用可能にすることを目的としています。CSI は 2018 年に Kubernetes 1.13 で正式版(GA)となりました。 CSI ドライバの役割は、Kubernetes の PVC 要求を受け取り、実際のストレージ上でボリュームの作成・削除・マウント・スナップショット等の操作を実行することです。StorageClass に「どの CSI ドライバを使うか」を指定することで、同一クラスタ上で複数のストレージバックエンドを使い分けることができます。 この関係は、Linux カーネルとディストリビューションの関係と類似しています。Linux カーネル自体は Linus Torvalds 氏と Linux Foundation のコミュニティが開発しており、各社(Red Hat、SUSE、Canonical 等)はカーネルをベースにしたディストリビューション(RHEL、SLES、Ubuntu)を提供しています。同様に、Kubernetes 自体は CNCF コミュニティが開発しており、SUSE の RKE2(Harvester の内部 Kubernetes)はその「ディストリビューション」のひとつです。CSI はこの Kubernetes の標準仕様の一部であり、特定ベンダーの独自仕様ではありません。 本シリーズで使用する CSI ドライバはいずれもオープンソースであり、Harvester(RKE2)上で動作します。前編では csi-driver-nfs(Kubernetes SIG Storage が開発)を、後編では democratic-csi(コミュニティ開発)を使用します。エンタープライズ環境では、HPE(3PAR/Primera/Alletra)、Dell、NetApp 等のストレージベンダーが自社ストレージ向けの CSI ドライバを提供しており、同じ CSI 標準仕様に準拠しているため Harvester 上で利用可能です。 2. SUSE Harvester の概要 2.1. Harvester とは SUSE Harvester は、Kubernetes をベースとしたオープンソースの HCI(Hyper-Converged Infrastructure)ソフトウェアです。従来の仮想化基盤(VMware vSphere、Proxmox VE 等)と同様に仮想マシンの作成・管理が可能ですが、内部的にはすべてが Kubernetes のリソースとして動作する点が大きく異なります。 従来のハイパーバイザーが「1つのソフトウェア」として仮想化機能を提供するのに対し、Harvester は複数のオープンソースコンポーネントを Kubernetes 上で統合したプラットフォームです。以下の表に主要コンポーネントとその役割を示します。 レイヤー コンポーネント 役割 OS SLE Micro(Elemental) イミュータブル設計の軽量 Linux OS。再起動で OS ファイルが事前構成済み状態に戻る コンテナ基盤 RKE2(Kubernetes) 全コンポーネントの実行基盤。CNCF 適合性認定取得済み 仮想マシン管理 KubeVirt + QEMU/KVM VM の作成・実行・ライブマイグレーション ストレージ Longhorn(標準搭載) 分散ブロックストレージ。外部 CSI ドライバとの併用も可能 クラスタ管理 Rancher(組み込み) Web UI の提供、マルチクラスタ管理 監視 Grafana + Prometheus メトリクス収集・可視化・アラート この構造が Harvester の柔軟性と拡張性の源泉であり、同時に本シリーズで検証する「多様なストレージ構成の選択肢」を生み出しています。 Harvester は以下のコンポーネントで構成されています。 RKE2 — Rancher が開発する Kubernetes ディストリビューションです。Harvester のベースとなる Kubernetes クラスタを提供します。 KubeVirt — Kubernetes 上で仮想マシンを動作させるためのアドオンです。VM は virt-launcher Pod の中で QEMU/KVM プロセスとして実行されます。 Longhorn — Harvester に標準搭載される分散ブロックストレージです。各ノードのローカルディスクを束ねて、レプリケーション付きのブロックストレージを提供します。 Rancher — Kubernetes クラスタの管理ツールです。Harvester の Web UI を提供し、VM やストレージの操作画面として機能します。 2.2. VM の起動シーケンス Harvester で VM が起動する流れを整理します。ユーザーが Web UI または API で VM の作成を指示すると、KubeVirt が VirtualMachine リソースを作成します。Kubernetes のスケジューラが配置先ノードを決定し、そのノードで稼働する virt-handler(DaemonSet)が virt-launcher Pod を起動します。virt-launcher Pod 内で QEMU/KVM プロセスが立ち上がり、Longhorn または外部ストレージから提供される PVC をディスクとしてアタッチして VM がブートします。 3. 自宅ラボの検証環境 3.1. ハードウェア・ソフトウェア 項目 値 Proxmox VE 8.3.0(カーネル: 6.8.12-4-pve) CPU AMD Ryzen 5 5600G(6コア/12スレッド) メモリ 128 GB ストレージ local-zfs 約 5.6 TB ネットワーク vmbr0(192.0.2.0/24)、VLAN なし Harvester v1.7.1 外部ストレージ TrueNAS SCALE ElectricEel-24.10.2 3.2. 構成図 3.3. 検証環境詳細 Harvester ノード(VM × 3台) ノード Virtual Machine ID(VMID) IP vCPU メモリ ディスク harvester01 131 192.0.2.71 8 16 GB 400 GB harvester02 132 192.0.2.72 8 16 GB 400 GB harvester03 133 192.0.2.73 8 16 GB 400 GB Management Virtual IP(VIP): 192.0.2.70 外部ストレージ(TrueNAS SCALE VM) 項目 値 VMID 102 IP 192.0.2.4 iSCSI LUN lun1〜lun3(各 100 GiB、Zvol) NFS 共有 /mnt/zpool/pool/NFS(約 225 GB、NFSv4) 4. クラスタ構築 4.1. 事前準備 ネステッド仮想化の確認 Proxmox ホストのシェルで、ネステッド仮想化が有効であることを確認します。 # AMD CPUの場合 cat /sys/module/kvm_amd/parameters/nested 1 1 または Y が表示されれば有効です。 ISO のアップロード Harvester の公式サイトから harvester-v1.7.1-amd64.iso (約 7.3GB)をダウンロードし、Proxmox にアップロードします。 ls -lh /var/lib/vz/template/iso/ | grep harvester -rw-r--r-- 1 root root 7.3G Mar 8 16:50 harvester-v1.7.1-amd64.iso IP アドレスの空き確認 for ip in 192.0.2.70 192.0.2.71 192.0.2.72 192.0.2.73; do ping -c 1 -W 1 $ip > /dev/null 2>&1 && echo "$ip: IN USE" || echo "$ip: available" done 192.0.2.70: available 192.0.2.71: available 192.0.2.72: available 192.0.2.73: available 4.2. VM の作成 3台の VM を Proxmox シェルから作成します。 # harvester01 qm create 131 \ --name harvester01 \ --memory 16384 \ --cores 8 \ --cpu cputype=host \ --bios ovmf \ --machine q35 \ --efidisk0 local-zfs:1,efitype=4m,pre-enrolled-keys=0 \ --net0 virtio,bridge=vmbr0,firewall=0 \ --scsihw virtio-scsi-single \ --scsi0 local-zfs:400,iothread=1 \ --ide2 local:iso/harvester-v1.7.1-amd64.iso,media=cdrom \ --boot order=ide2 \ --ostype l26 \ --numa 1 harvester02(VMID 132)、harvester03(VMID 133)も同様に作成します。IP アドレスとホスト名のみ異なります。 CPU type は host を指定します。ネステッド仮想化にはホスト CPU のパススルーが必要です。3台合計で 24vCPU となり物理 12 スレッドに対してオーバーコミットになりますが、全 VM が同時に CPU を 100% 使用することはないため、検証用途では問題ありません。 qm list | grep harvester 131 harvester01 stopped 16384 0.00 0 132 harvester02 stopped 16384 0.00 0 133 harvester03 stopped 16384 0.00 0 4.3. Harvester のインストール(1号機) qm start 131 Proxmox Web UI から harvester01 のコンソールを開きます。ISO からブートし、Harvester Installer の TUI が起動します。 最初に Hardware Checks の警告が表示されます。CPU・メモリともに Harvester の推奨要件を下回っていますが、検証目的のため「Yes」で続行します。 +------------------------------------------------------------------+ | Hardware Checks | | | | Only 8 CPU cores detected. Harvester requires at least 16 for | | production use. | | Only 16GiB RAM detected. Harvester requires at least 32GiB for | | testing and 64GiB for production use. | | System is virtualized (kvm) which is not supported in production. | | | | > [Yes] | +------------------------------------------------------------------+ TUI での主要設定値は以下の通りです。 項目 値 Installation mode Create a new Harvester cluster Installation role Default Role Installation disk sda 400G Management NIC enp6s18 IPv4 Method Static IPv4 Address 192.0.2.71 Gateway 192.0.2.1 DNS 1.1.1.1 VIP 192.0.2.70 Cluster token <DUMMY_TOKEN> NTP 0.suse.pool.ntp.org ISO の取り外し(重要) インストール完了後のカウントダウンが表示されたら、 CTRL+C で再起動を一時停止 し、Proxmox シェルで ISO を取り外します。 注意 : CTRL+C によるカウントダウンの一時停止は、Harvester v1.7.1 のインストーラで動作を確認しています。他のバージョンでは動作が異なる可能性があります。カウントダウンを停止できない場合は、Proxmox Web UI から VM を一旦停止し、ISO を取り外してから再起動してください。 qm set 131 --ide2 none,media=cdrom --boot order=scsi0 Proxmox VM では ISO が自動的に取り外されません。ISO が接続されたまま再起動すると再び ISO からブートしてインストーラが起動し、2回目のインストーラが中途半端に実行されるとブートローダーが破損して起動不能になります。これが本構築における最大のハマりポイントでした。 Harvester のコンソールに戻り、手動で再起動します。 reboot 初回起動時は Kubernetes クラスタの初期化(RKE2、Longhorn、Rancher 等)が行われるため、Status が「Setting up」の状態が10〜20分程度続きます。最終的に Cluster / Node ともに「Ready」になれば1号機のインストールは完了です。 4.4. 2号機・3号機のインストール 2号機・3号機は既存クラスタへの join モードでインストールします。 項目 harvester02 harvester03 Installation mode Join an existing Harvester cluster Join an existing Harvester cluster IPv4 Address 192.0.2.72 192.0.2.73 HostName harvester02 harvester03 Management address 192.0.2.70 192.0.2.70 Cluster token <DUMMY_TOKEN> <DUMMY_TOKEN> ISO の取り外しも同様に行います。 qm set 132 --ide2 none,media=cdrom --boot order=scsi0 qm set 133 --ide2 none,media=cdrom --boot order=scsi0 4.5. クラスタ構築完了確認 ブラウザから https://192.0.2.70 にアクセスし、Web UI にログインします。 Harvester Cluster: local Version: v1.7.1 3 Hosts 0 Virtual Machines 0 Virtual Machine Networks 0 Images 0 Volumes 3 Disks Capacity CPU Reserved 9.17 / 21.08 43.52% Memory Reserved 12 / 47 Gi 26.29% Storage Allocated 0 Ti / 1.3 Ti 0.00% ノード State Host IP Disk State harvester01 Active 192.0.2.71 Healthy harvester02 Active 192.0.2.72 Healthy harvester03 Active 192.0.2.73 Healthy 3ノードすべてが Active / Healthy で稼働しています。 5. ストレージ方式の構成 5.1. 方式1: harvester-longhorn(デフォルト) harvester-longhorn は Harvester インストール時に自動作成される StorageClass です。各ノードのインストールディスク(/dev/sda 400GB)上に Longhorn のストレージ領域が確保され、レプリカ数3でデータが3ノードに分散保存されます。追加の設定は不要です。 5.2. 方式2: longhorn-iscsi(外部 iSCSI LUN) 構成の概要 TrueNAS SCALE の iSCSI Target から提供される LUN を、Longhorn のディスクとして追加登録する方式です。 外部 iSCSI LUN を Harvester VM に認識させる方法は2通りあります。Proxmox ホスト側で iSCSI 接続し VM に仮想ディスクとしてパススルーする方式と、VM 内部から iscsiadm で直接接続する方式です。本検証では Proxmox 側パススルーを採用しました。Harvester のベース OS(SLE Micro)は immutable OS であり、iscsiadm パッケージの永続化に追加の考慮が必要になるためです。この方式により、VM 内部からは通常の SCSI ディスク(/dev/sdb)として見え、Harvester 側の追加設定が不要になります。 TrueNAS iSCSI Target の設定 TrueNAS SCALE で Zvol を3つ作成し(各 100 GiB)、iSCSI Target に紐づけます。 +------------------------------------------------------------+ | Extent Name | Device/File | LUN ID | |----------------|----------------------|--------| | harvester-lun1 | zvol/zpool/pool/lun1 | 0 | | harvester-lun2 | zvol/zpool/pool/lun2 | 1 | | harvester-lun3 | zvol/zpool/pool/lun3 | 2 | +------------------------------------------------------------+ Proxmox からの疎通確認: root@pve:~# iscsiadm -m discovery -t sendtargets -p 192.0.2.4:3260 192.0.2.4:3260,1 iqn.2005-10.org.freenas.ctl:harvester iSCSI LUN の接続と VM へのアタッチ # iSCSI ログイン root@pve:~# iscsiadm -m node -T iqn.2005-10.org.freenas.ctl:harvester \ -p 192.0.2.4:3260 --login Login to [...] successful. # LUN 認識確認 root@pve:~# lsblk -d -o NAME,SIZE,MODEL,TRAN | grep -i iscsi sdc 100G iSCSI Disk iscsi sdd 100G iSCSI Disk iscsi sde 100G iSCSI Disk iscsi # 各 VM にアタッチ root@pve:~# qm set 131 --scsi1 /dev/sdc root@pve:~# qm set 132 --scsi1 /dev/sdd root@pve:~# qm set 133 --scsi1 /dev/sde Harvester VM 内でディスクが認識されたことを確認します。 harvester01:~ # lsblk -d -o NAME,SIZE,MODEL NAME SIZE MODEL sda 400G QEMU HARDDISK sdb 100G QEMU HARDDISK ディスクのフォーマットとマウント 各ノードで /dev/sdb を ext4 でフォーマットし、マウントします。 # harvester01 harvester01:~ # sudo mkfs.ext4 -F /dev/sdb harvester01:~ # sudo mkdir -p /var/lib/harvester/extra-disks/iscsi-lun1 harvester01:~ # sudo mount /dev/sdb /var/lib/harvester/extra-disks/iscsi-lun1 # harvester02 harvester02:~ # sudo mkfs.ext4 -F /dev/sdb harvester02:~ # sudo mkdir -p /var/lib/harvester/extra-disks/iscsi-lun2 harvester02:~ # sudo mount /dev/sdb /var/lib/harvester/extra-disks/iscsi-lun2 # harvester03 harvester03:~ # sudo mkfs.ext4 -F /dev/sdb harvester03:~ # sudo mkdir -p /var/lib/harvester/extra-disks/iscsi-lun3 harvester03:~ # sudo mount /dev/sdb /var/lib/harvester/extra-disks/iscsi-lun3 harvester01:~ # df -h /var/lib/harvester/extra-disks/iscsi-lun1 Filesystem Size Used Avail Use% Mounted on /dev/sdb 98G 2.1M 93G 1% /var/lib/harvester/extra-disks/iscsi-lun1 Longhorn ディスク登録 Proxmox からパススルーした仮想ディスクは Harvester の NDM(Node Disk Manager)に認識されず、Web UI の Add Disk に表示されません。そのため Longhorn のノード設定を kubectl patch で直接編集し、マウント済みのディレクトリを手動登録します。 patch コマンドに既存の default-disk エントリを含めないと、Longhorn がそのディスクを削除してしまうため、必ず事前に既存のディスク名を確認します。 harvester01:~ # kubectl -n longhorn-system get nodes.longhorn.io harvester01 \ -o yaml | grep -A 5 " disks:" disks: default-disk-f4fae7db6516d736: allowScheduling: true diskType: filesystem kubectl -n longhorn-system patch nodes.longhorn.io harvester01 --type merge -p ' { "spec": { "disks": { "default-disk-f4fae7db6516d736": { "allowScheduling": true, "diskDriver": "", "diskType": "filesystem", "evictionRequested": false, "path": "/var/lib/harvester/defaultdisk", "storageReserved": 0, "tags": [] }, "iscsi-lun1": { "allowScheduling": true, "diskDriver": "", "diskType": "filesystem", "evictionRequested": false, "path": "/var/lib/harvester/extra-disks/iscsi-lun1", "storageReserved": 0, "tags": ["iscsi"] } } } }' harvester02、harvester03 についても同様に実行します(default-disk 名と LUN 番号をノードに合わせて変更)。 登録状態を確認します。 harvester01:~ # kubectl -n longhorn-system get nodes.longhorn.io harvester01 \ -o yaml | grep -A 10 "iscsi-lun1:" iscsi-lun1: conditions: - message: Disk iscsi-lun1(/var/lib/harvester/extra-disks/iscsi-lun1) on node harvester01 is ready status: "True" type: Ready - message: Disk iscsi-lun1(/var/lib/harvester/extra-disks/iscsi-lun1) on node harvester01 is schedulable status: "True" type: Schedulable ノード ディスク名 Ready Schedulable harvester01 iscsi-lun1 True True harvester02 iscsi-lun2 True True harvester03 iscsi-lun3 True True StorageClass の作成 diskSelector: "iscsi" により、タグ iscsi が付与されたディスクにのみレプリカを配置します。 kubectl apply -f - << 'EOF' apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: longhorn-iscsi provisioner: driver.longhorn.io parameters: numberOfReplicas: "3" staleReplicaTimeout: "30" diskSelector: "iscsi" reclaimPolicy: Delete volumeBindingMode: Immediate allowVolumeExpansion: true EOF storageclass.storage.k8s.io/longhorn-iscsi created レプリカ動作テスト テスト PVC と Pod を作成し、3ノードへのレプリカ分散を確認します。 # テスト PVC 作成 kubectl apply -f - << 'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-iscsi-pvc namespace: default spec: accessModes: - ReadWriteMany storageClassName: longhorn-iscsi resources: requests: storage: 10Gi EOF # テスト Pod 作成 kubectl apply -f - << 'EOF' apiVersion: v1 kind: Pod metadata: name: test-iscsi-pod namespace: default spec: containers: - name: test image: busybox command: ["sleep", "3600"] volumeMounts: - name: data mountPath: /data volumes: - name: data persistentVolumeClaim: claimName: test-iscsi-pvc EOF # レプリカ分散確認 harvester01:~ # VOL=$(kubectl get pvc test-iscsi-pvc -n default -o jsonpath='{.spec.volumeName}') harvester01:~ # kubectl -n longhorn-system get replicas.longhorn.io -l longhornvolume=$VOL \ -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeID,STATE:.status.currentState NAME NODE STATE pvc-9086eb3a-...-r-70d7d1e2 harvester01 running pvc-9086eb3a-...-r-731de1e0 harvester02 running pvc-9086eb3a-...-r-e3c18b53 harvester03 running 3つのレプリカが3ノードに分散配置されています。 # クリーンアップ kubectl delete pod test-iscsi-pod -n default kubectl delete pvc test-iscsi-pvc -n default 5.3. 方式3: nfs-csi(外部 NFS 共有) NFS CSI ドライバのインストール Harvester(RKE2)に同梱された Helm を使い、csi-driver-nfs をインストールします。 harvester01:~ # helm version version.BuildInfo{Version:"v3.19.1", ...} harvester01:~ # helm repo add csi-driver-nfs \ https://raw.githubusercontent.com/kubernetes-csi/csi-driver-nfs/master/charts "csi-driver-nfs" has been added to your repositories harvester01:~ # helm install csi-driver-nfs csi-driver-nfs/csi-driver-nfs \ --namespace kube-system \ --set controller.replicas=1 harvester01:~ # kubectl --namespace=kube-system get pods \ --selector="app.kubernetes.io/instance=csi-driver-nfs" NAME READY STATUS AGE csi-nfs-controller-75dffdcf96-zk7hj 5/5 Running 23s csi-nfs-node-2x8jv 3/3 Running 18s csi-nfs-node-ljpvp 3/3 Running 18s csi-nfs-node-rtvcq 3/3 Running 18s csi-driver-nfs は Controller Pod と Node Pod の2種類で構成されますが、NFS の通信を中継するわけではありません。Controller Pod は PVC の作成・削除時に NFS サーバ上のサブディレクトリを管理し、Node Pod は各ノード上で mount コマンドを実行する仲介役です。マウント完了後のデータ読み書きは、ノードの OS カーネルが NFS クライアントとして TrueNAS に直接通信します。 StorageClass の作成 kubectl apply -f - << 'EOF' apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: nfs-csi provisioner: nfs.csi.k8s.io parameters: server: 192.0.2.4 share: /mnt/zpool/pool/NFS reclaimPolicy: Delete volumeBindingMode: Immediate mountOptions: - nfsvers=4.1 - hard - nconnect=8 EOF storageclass.storage.k8s.io/nfs-csi created マウントオプション 説明 nfsvers=4.1 NFSv4.1 を使用 hard NFS サーバ無応答時にリトライを継続(データ破損防止) nconnect=8 NFS 接続を8本並列化しスループットを向上 RWX 動作テスト harvester01 の Writer Pod と harvester02 の Reader Pod で、ノード間共有(ReadWriteMany)を確認します。 # PVC 作成 kubectl apply -f - << 'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-nfs-pvc namespace: default spec: accessModes: - ReadWriteMany storageClassName: nfs-csi resources: requests: storage: 10Gi EOF # Writer Pod(harvester01) kubectl apply -f - << 'EOF' apiVersion: v1 kind: Pod metadata: name: test-nfs-writer namespace: default spec: nodeName: harvester01 containers: - name: writer image: busybox command: ["sh", "-c", "echo 'Hello from harvester01' > /data/test.txt && sleep 3600"] volumeMounts: - name: nfs-vol mountPath: /data volumes: - name: nfs-vol persistentVolumeClaim: claimName: test-nfs-pvc EOF # Reader Pod(harvester02) kubectl apply -f - << 'EOF' apiVersion: v1 kind: Pod metadata: name: test-nfs-reader namespace: default spec: nodeName: harvester02 containers: - name: reader image: busybox command: ["sh", "-c", "cat /data/test.txt && sleep 3600"] volumeMounts: - name: nfs-vol mountPath: /data volumes: - name: nfs-vol persistentVolumeClaim: claimName: test-nfs-pvc EOF harvester01:~ # kubectl logs test-nfs-reader -n default Hello from harvester01 harvester01 で書き込んだデータを harvester02 から正常に読み取れました。 TrueNAS 上でも、CSI ドライバが作成したサブディレクトリを確認できます。 root@truenas:~ # ls -la /mnt/zpool/pool/NFS/ drwxrwxrwx 3 root root 3 Mar 13 13:32 . drwxr-xr-x 6 root root 8 Mar 8 14:34 .. drwxrwxrwx 2 root root 3 Mar 13 13:34 pvc-e97d67fe-ab61-40a6-ae87-71f84f984682 # クリーンアップ kubectl delete pod test-nfs-writer test-nfs-reader -n default kubectl delete pvc test-nfs-pvc -n default 6. 3方式の比較 6.1. StorageClass 一覧 項目 harvester-longhorn longhorn-iscsi nfs-csi バックエンド ノード内蔵ディスク TrueNAS iSCSI LUN TrueNAS NFS 共有 プロトコル ローカルディスク iSCSI(ブロック) NFS v4.1(ファイル) Longhorn 経由 あり あり なし アクセスモード RWX RWX RWX レプリカ数 3 3 1(ストレージ側冗長化) 書き込み同期 3ノード同期書き込み 3ノード同期書き込み 単一書き込み イメージ管理 Backing Image(Copy-on-Write: CoW) Backing Image(Copy-on-Write: CoW) PVC(フルコピー) 10GB VM 1台の消費容量 約30GB 約30GB 約10GB+イメージ分 6.2. I/O パスの違い 方式1(harvester-longhorn) : VM のディスク I/O は、virt-launcher Pod → Longhorn Engine → 各ノードの内蔵ディスク上の Longhorn Replica という経路をたどります。書き込み時は3つの Replica すべてに同期的に書き込みが完了するまで応答を待つため、レプリカ数がそのまま書き込みレイテンシに影響します。 方式2(longhorn-iscsi) : I/O パスは方式1と同様に Longhorn Engine を経由しますが、Replica の書き込み先が内蔵ディスクではなく iSCSI LUN です。iSCSI LUN は Proxmox ホストを経由して TrueNAS の ZFS 上にあり、方式1と比較してネットワーク層が1段追加されます。レプリカ同期のオーバーヘッドに加え、iSCSI のネットワークレイテンシが加算される構造です。 方式3(nfs-csi) : VM のディスク I/O は Longhorn を経由せず、NFS CSI Driver が直接 TrueNAS の NFS 共有にアクセスします。Replica の概念がなく、書き込みは単一の NFS サーバに対して1回で完了します。冗長性は TrueNAS 側の ZFS(RAIDZ など)に依存します。 6.3. Backing Image と フルコピー harvester-longhorn と longhorn-iscsi は、Longhorn の Backing Image 機能により Copy-on-Write 方式で差分のみを記録します。同じ OS イメージから複数の VM を作成しても、追加の容量消費は変更分のみです。一方、nfs-csi は Backing Image に対応しておらず、VM ごとにイメージ全体がコピーされます。 Longhorn ベースの場合、Image 登録時に longhorn-image-xxxxx という専用の StorageClass が自動生成されます。 harvester01:~ # kubectl get virtualmachineimages.harvesterhci.io -n default \ -o custom-columns=NAME:.metadata.name,DISPLAY:.spec.displayName,STORAGECLASS:.status.storageClassName NAME DISPLAY STORAGECLASS image-dsnm9 openSUSE-Leap-15.6-iscsi longhorn-image-dsnm9 image-hjpz6 openSUSE-Leap-15.6 longhorn-image-hjpz6 image-xl5hb openSUSE-Leap-15.6-nfs nfs-csi nfs-csi のイメージには自動生成 StorageClass はなく、OS イメージは通常の PVC として格納されます。 6.4. Harvester 公式対応 CSI ドライバ 本シリーズでは Longhorn と csi-driver-nfs を使用していますが、Harvester v1.7 では以下の4種類の CSI ドライバが検証済みとして公式にサポートされています。ストレージ構成を検討する際の参考として、機能比較を示します。 機能 Longhorn V2 LVM NFS Rook Ceph VM イメージ保存 対応 対応 対応 対応 ライブマイグレーション 対応 非対応 対応 対応 スナップショット 対応 対応(dm-thin) 対応 対応 バックアップ(S3/NFS) 対応 非対応 非対応 非対応 導入難度 低(標準搭載) 中(実験的) 中 高 Longhorn V2 は Harvester のデフォルトストレージであり、バックアップを含むすべての機能に対応しています。LVM は実験的(Experimental)なアドオンとして提供されており、ローカルストレージのため RWX 非対応でライブマイグレーションができません。NFS は本シリーズで使用している csi-driver-nfs に相当し、外部 NFS サーバが必要です。Rook Ceph は Ceph クラスタを Harvester 上に構築する方式で、高い拡張性を持ちますが構築・運用の複雑さが高くなります。 Harvester 標準のバックアップ機能(S3/NFS への VM バックアップ)は Longhorn ボリュームのみに対応しており、他の CSI ドライバを使用する場合はバックアップ手段を別途検討する必要があります。この制約については後編で詳しく触れます。 上記4種に加え、HPE(3PAR/Primera/Alletra)、Dell、NetApp 等のエンタープライズストレージベンダーの CSI ドライバも KubeVirt 互換として動作が確認されています。 7. VM 起動とディスク格納先 7.1. VM の作成 3種類の StorageClass それぞれの OS イメージ(openSUSE Leap 15.6 Cloud Image、約 270 MB)から VM を1台ずつ作成しました。 VM 名 StorageClass CPU メモリ ディスク 状態 test-longhorn-vm harvester-longhorn 1 2 GiB 10 GiB Running test-iscsi-vm longhorn-iscsi 1 2 GiB 10 GiB Running test-nfs-vm nfs-csi 1 2 GiB 10 GiB Running 3方式すべてで VM が正常に起動しました。 7.2. ディスク格納先の確認 harvester-longhorn : 3つのレプリカが各ノードの /var/lib/harvester/defaultdisk に分散配置されています。 harvester01:~ # kubectl -n longhorn-system get replicas.longhorn.io \ -l longhornvolume=pvc-b03e2931-8839-4c1d-a73f-5b317e64d8d2 \ -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeID,DISK:.spec.diskPath NAME NODE DISK pvc-b03e2931-...-r-605c4409 harvester01 /var/lib/harvester/defaultdisk pvc-b03e2931-...-r-62d524a3 harvester02 /var/lib/harvester/defaultdisk pvc-b03e2931-...-r-6e93d12d harvester03 /var/lib/harvester/defaultdisk longhorn-iscsi : レプリカが各ノードの iSCSI LUN( /var/lib/harvester/extra-disks/iscsi-lunX )に分散配置されています。 diskSelector: "iscsi" による振り分けが正しく機能しています。 harvester01:~ # kubectl -n longhorn-system get replicas.longhorn.io \ -l longhornvolume=pvc-a873f384-bb51-41dd-95e4-617f086a4451 \ -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeID,DISK:.spec.diskPath NAME NODE DISK pvc-a873f384-...-r-084daeb5 harvester03 /var/lib/harvester/extra-disks/iscsi-lun3 pvc-a873f384-...-r-408296f0 harvester01 /var/lib/harvester/extra-disks/iscsi-lun1 pvc-a873f384-...-r-98a669ad harvester02 /var/lib/harvester/extra-disks/iscsi-lun2 nfs-csi : Longhorn を経由しないため、レプリカの概念がありません。TrueNAS の NFS 共有上に直接格納されます。 root@truenas:~ # ls -lh /mnt/zpool/pool/NFS/pvc-3f9db7a1-a3cf-41d2-8784-38b0b3cd7a23/ -rw-r--r-- 1 root root 10G Mar 13 14:20 disk.img 8. ベンチマーク 8.1. 測定方法 fio(Flexible I/O Tester)を使い、各 VM のディスク I/O パフォーマンスを測定します。 ベンチマークの位置づけ : 本検証は Proxmox VE 上のネステッド仮想化環境で実施しているため、絶対値(MB/s や IOPS)はベアメタル環境と異なる可能性があります。3方式間の相対的な傾向(順位や倍率)を読み取ることが目的です。 各テスト VM には cloud-init で fio(v3.23)をインストール済みです。 測定条件は以下のとおりです。 項目 値 測定日 2026年3月13日 fio バージョン 3.23 テストファイルサイズ 1 GB 測定時間 各テスト 30 秒 並列ジョブ数 1 VM vCPU 1 VM メモリ 2 GiB VM OS openSUSE Leap 15.6(Cloud イメージ) 測定パターン ブロックサイズ 想定用途 シーケンシャル読み取り 128k 大容量ファイルの読み込み シーケンシャル書き込み 128k ログ書き込み、ファイルコピー ランダム読み取り 4k データベース検索、OS 起動 ランダム書き込み 4k データベース更新、VM 動作 各パターンについて、キャッシュなし( --direct=1 、ストレージ純粋性能)とキャッシュあり( --direct=0 、実運用に近い性能)の2モードを測定します。 共通パラメータ: --size=1G --numjobs=1 --runtime=30 --time_based --group_reporting 8.2. 測定コマンド 各 VM に SSH 接続し、以下のコマンドを実行します(キャッシュなしの例)。 echo "=== [Cache OFF] Sequential Read (128k) ===" && \ sudo fio --name=test --filename=/tmp/fio --size=1G --direct=1 \ --bs=128k --rw=read --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "READ:|iops|bw=" && \ echo "=== [Cache OFF] Sequential Write (128k) ===" && \ sudo fio --name=test --filename=/tmp/fio --size=1G --direct=1 \ --bs=128k --rw=write --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "WRITE:|iops|bw=" && \ echo "=== [Cache OFF] Random Read (4k) ===" && \ sudo fio --name=test --filename=/tmp/fio --size=1G --direct=1 \ --bs=4k --rw=randread --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "READ:|iops|bw=" && \ echo "=== [Cache OFF] Random Write (4k) ===" && \ sudo fio --name=test --filename=/tmp/fio --size=1G --direct=1 \ --bs=4k --rw=randwrite --numjobs=1 --runtime=30 --time_based \ --group_reporting 2>&1 | grep -E "WRITE:|iops|bw=" && \ sudo rm -f /tmp/fio && echo "=== Cache OFF DONE ===" 8.3. 測定結果 キャッシュなし(direct=1)— ストレージ純粋性能 test-longhorn-vm(harvester-longhorn): === [Cache OFF] Sequential Read (128k) === READ: bw=85.7MiB/s (89.8MB/s), io=2570MiB, run=30001msec iops : avg=687.41 === [Cache OFF] Sequential Write (128k) === WRITE: bw=49.5MiB/s (51.9MB/s), io=1486MiB, run=30001msec iops : avg=400.34 === [Cache OFF] Random Read (4k) === READ: bw=4677KiB/s (4789kB/s), io=137MiB, run=30001msec iops : avg=1170.22 === [Cache OFF] Random Write (4k) === WRITE: bw=3461KiB/s (3545kB/s), io=101MiB, run=30001msec iops : avg=866.36 test-iscsi-vm(longhorn-iscsi): === [Cache OFF] Sequential Read (128k) === READ: bw=63.5MiB/s (66.6MB/s), io=1906MiB, run=30001msec iops : avg=507.92 === [Cache OFF] Sequential Write (128k) === WRITE: bw=33.0MiB/s (34.6MB/s), io=991MiB, run=30001msec iops : avg=267.51 === [Cache OFF] Random Read (4k) === READ: bw=3852KiB/s (3944kB/s), io=113MiB, run=30001msec iops : avg=964.64 === [Cache OFF] Random Write (4k) === WRITE: bw=3068KiB/s (3141kB/s), io=89.9MiB, run=30001msec iops : avg=769.10 test-nfs-vm(nfs-csi): === [Cache OFF] Sequential Read (128k) === READ: bw=256MiB/s (268MB/s), io=7674MiB, run=30001msec iops : avg=2048.44 === [Cache OFF] Sequential Write (128k) === WRITE: bw=77.0MiB/s (81.8MB/s), io=2339MiB, run=30001msec iops : avg=622.24 === [Cache OFF] Random Read (4k) === READ: bw=10.3MiB/s (10.8MB/s), io=308MiB, run=30001msec iops : avg=2634.47 === [Cache OFF] Random Write (4k) === WRITE: bw=4170KiB/s (4270kB/s), io=122MiB, run=30001msec iops : avg=1043.58 キャッシュなし比較表: テスト harvester-longhorn longhorn-iscsi nfs-csi Seq Read 128k 85.7 MiB/s(687 IOPS) 63.5 MiB/s(508 IOPS) 256 MiB/s(2048 IOPS) Seq Write 128k 49.5 MiB/s(400 IOPS) 33.0 MiB/s(268 IOPS) 77.0 MiB/s(622 IOPS) Rand Read 4k 4.6 MiB/s(1170 IOPS) 3.8 MiB/s(965 IOPS) 10.3 MiB/s(2634 IOPS) Rand Write 4k 3.4 MiB/s(866 IOPS) 3.0 MiB/s(769 IOPS) 4.1 MiB/s(1044 IOPS) キャッシュあり(direct=0)— 実運用に近い性能 test-longhorn-vm(harvester-longhorn): === [Cache ON] Sequential Read (128k) === READ: bw=214MiB/s (224MB/s), io=6417MiB, run=30001msec iops : avg=1755.83 === [Cache ON] Sequential Write (128k) === WRITE: bw=128MiB/s (135MB/s), io=3852MiB, run=30003msec iops : avg=1029.61 === [Cache ON] Random Read (4k) === READ: bw=4717KiB/s (4831kB/s), io=138MiB, run=30001msec iops : avg=1180.27 === [Cache ON] Random Write (4k) === WRITE: bw=24.5MiB/s (25.7MB/s), io=734MiB, run=30008msec iops : avg=6276.66 test-iscsi-vm(longhorn-iscsi): === [Cache ON] Sequential Read (128k) === READ: bw=203MiB/s (213MB/s), io=6102MiB, run=30004msec iops : avg=1627.02 === [Cache ON] Sequential Write (128k) === WRITE: bw=80.5MiB/s (84.4MB/s), io=2417MiB, run=30007msec iops : avg=646.47 === [Cache ON] Random Read (4k) === READ: bw=3562KiB/s (3648kB/s), io=104MiB, run=30001msec iops : avg=905.57 === [Cache ON] Random Write (4k) === WRITE: bw=16.4MiB/s (17.2MB/s), io=492MiB, run=30011msec iops : avg=4211.17 test-nfs-vm(nfs-csi): === [Cache ON] Sequential Read (128k) === READ: bw=1081MiB/s (1134MB/s), io=31.7GiB, run=30049msec iops : avg=8794.64 === [Cache ON] Sequential Write (128k) === WRITE: bw=357MiB/s (375MB/s), io=10.5GiB, run=30001msec iops : avg=2854.86 === [Cache ON] Random Read (4k) === READ: bw=9426KiB/s (9653kB/s), io=276MiB, run=30001msec iops : avg=2355.08 === [Cache ON] Random Write (4k) === WRITE: bw=31.1MiB/s (32.6MB/s), io=933MiB, run=30006msec iops : avg=7946.20 キャッシュあり比較表: テスト harvester-longhorn longhorn-iscsi nfs-csi Seq Read 128k 214 MiB/s(1756 IOPS) 203 MiB/s(1627 IOPS) 1081 MiB/s(8795 IOPS) Seq Write 128k 128 MiB/s(1030 IOPS) 80.5 MiB/s(646 IOPS) 357 MiB/s(2855 IOPS) Rand Read 4k 4.7 MiB/s(1180 IOPS) 3.6 MiB/s(906 IOPS) 9.4 MiB/s(2355 IOPS) Rand Write 4k 24.5 MiB/s(6277 IOPS) 16.4 MiB/s(4211 IOPS) 31.1 MiB/s(7946 IOPS) 8.4. 考察 想定と実測の比較 StorageClass 想定 実測結果 harvester-longhorn 最も高速と想定(ローカルディスク直接アクセス) 2位 longhorn-iscsi 中程度(ネットワーク経由 iSCSI) 3位(最も遅い) nfs-csi 中程度(ネットワーク経由 NFS) 1位(全テストでトップ) 正直、最初は「ローカルディスク構成が素直に最も高速だろう」と考えていました。ところが実測では nfs-csi が全テストパターンで上回り、この検証で予想と異なる結果になりました。 要因1: Longhorn のレプリカ同期オーバーヘッド harvester-longhorn と longhorn-iscsi は、書き込み時に3つのレプリカすべてへ同期的にデータを書き込みます。3つのレプリカすべてが書き込み完了を返すまでアプリケーションへの応答を待つため、書き込み性能が大きく制約されます。nfs-csi は TrueNAS 上に単一コピーを書き込むだけで完了するため、レプリカ同期のオーバーヘッドがありません。 テスト harvester-longhorn nfs-csi 倍率 Seq Write(direct=1) 49.5 MiB/s 77.0 MiB/s 1.6倍 Rand Write(direct=1) 3.4 MiB/s 4.1 MiB/s 1.2倍 要因2: iSCSI の二重ネットワーク経路 要因2: iSCSI の二重ネットワーク経路 longhorn-iscsi が最も遅い理由は、I/O パスが二重にネットワークを経由するためです。Longhorn は受け取った書き込みを3ノードのレプリカに同期しますが、各レプリカの実体は TrueNAS の iSCSI LUN 上にあります。そのため、レプリカ同期のノード間通信に加え、各ノードから TrueNAS への iSCSI 通信が発生し、ネットワークレイテンシが二重に加算されます。 StorageClass Seq Read(direct=1) harvester-longhorn 比 harvester-longhorn 85.7 MiB/s 1.0倍 longhorn-iscsi 63.5 MiB/s 0.74倍 nfs-csi 256 MiB/s 2.99倍 要因3: NFS のキャッシュ効率 キャッシュあり(direct=0)の Seq Read で nfs-csi が 1081 MiB/s を記録しています。これは物理的なネットワーク帯域(1 GbE ≒ 125 MB/s)を大幅に超過しており、OS のページキャッシュからの読み取りが大部分を占めていることを示しています。テストファイル(1 GB)が VM メモリ(2 GiB)に収まるため、どの方式でもキャッシュに載る条件は同じですが、nfs-csi ではレプリカ同期が不要なため、キャッシュフラッシュ時のオーバーヘッドも小さく、キャッシュの恩恵がより大きく現れています。 StorageClass direct=1 → direct=0(Seq Read) harvester-longhorn 85.7 → 214 MiB/s(2.5倍) longhorn-iscsi 63.5 → 203 MiB/s(3.2倍) nfs-csi 256 → 1081 MiB/s(4.2倍) direct=1 と direct=0 の差分を見ると、キャッシュ効果の寄与は nfs-csi で特に大きく、本環境ではストレージそのものの帯域だけでなく OS ページキャッシュの効き方が結果に強く影響していることが分かります。今回は単回測定であり厳密な切り分けまではできていませんが、少なくとも「NFS が常に純粋なディスク性能で勝つ」というより、「レプリカ同期の有無とキャッシュの効き方が重なって差が拡大した」と捉えるのが自然です。 要因4: 本環境固有の制約 本検証は、筆者の自宅ラボに構築した Proxmox VE 上のネステッド仮想化環境で実施しています。すべての VM が同一 Proxmox ホストの CPU・メモリ・ストレージを共有しており、仮想ネットワーク(vmbr0)経由の通信は物理 NIC を経由しません。ネステッド仮想化では I/O パスが長くなり、Longhorn のレプリカ同期コストが相対的に増大します。ベアメタル環境ではローカルディスクアクセスの優位性がより明確に出る可能性があります。 この結果から得られる重要な知見は、「Longhorn のレプリカ同期は安全性と引き換えに書き込み性能のコストがある」という点です。レプリカ数を削減する、あるいは Longhorn を経由しない構成を検討する動機がここから生まれます。 この結果を見たとき、次は「Longhorn をバイパスすると I/O パスの差がそのまま性能差として表れるのか」を切り分けてみたい、と強く感じました。後編では、Longhorn をバイパスする democratic-csi を使った構成で、この仮説を検証します。 9. トラブルシューティング 本構築で遭遇した問題とその対処法をまとめます。 ISO が取り外されず再起動後にインストーラが再起動する — Proxmox VM では ISO が自動的に取り外されません。インストール完了後のカウントダウンで CTRL+C を押し、 qm set <VMID> --ide2 none,media=cdrom --boot order=scsi0 を実行してから手動で reboot します。ISO 接続のまま再起動すると、2回目のインストーラがブートローダーを破損させます。 vCPU 4コアでは Cluster が Ready にならない — Harvester のシステム Pod の CPU request 合計が4コアを超えるため、コアコンポーネントがスケジュールできません。最低8コアの割り当てが必要です。 kubectl describe pod -n harvester-system harvester-7956844fbd-2dkkt | tail -5 Warning FailedScheduling default-scheduler 0/1 nodes are available: 1 Insufficient cpu. Web UI のパスワードが通らない — harvester01 のコンソールで Ctrl+Alt+F2 を押してシェルに切り替え、以下でリセットします。 kubectl -n cattle-system exec \ $(kubectl -n cattle-system get pods -l app=rancher \ --no-headers -o custom-columns=":metadata.name" | head -1) \ -- reset-password kubectl patch で default-disk が消失する — patch コマンドに既存の default-disk エントリを含めないと、Longhorn がそのディスクを削除します。必ず既存ディスク名を確認してから patch してください。 NFS の PVC が Pending のまま Bound にならない — TrueNAS 側の NFS エクスポートディレクトリのパーミッションが不足しています。 sudo chmod 777 /mnt/zpool/pool/NFS で解決します(自宅ラボ向け設定)。 10. まとめ 本記事では、Proxmox VE 上に Harvester v1.7.1 の3ノードクラスタを構築し、3種類のストレージ方式(harvester-longhorn / longhorn-iscsi / nfs-csi)の構成とベンチマーク比較を行いました。 本検証では、Longhorn を経由しない nfs-csi が最も高い性能を示し、Longhorn のレプリカ同期が書き込み性能に影響することを確認できました。一方で、 harvester-longhorn と longhorn-iscsi は、性能面では不利になるものの、Longhorn の管理機能や冗長性を活用できる点が強みです。 つまり、性能を優先するなら nfs-csi 、Harvester 標準の運用性や Longhorn の冗長性を重視するなら harvester-longhorn 、外部 iSCSI ストレージを Longhorn に統合したいなら longhorn-iscsi という整理になります。どの方式が最適かは、性能・冗長性・容量効率のどれを優先するかによって変わります。 11. 参考情報 Harvester 公式ドキュメント: https://docs.harvesterhci.io/v1.7.1 Longhorn 公式ドキュメント: https://longhorn.io/docs/ KubeVirt ユーザーガイド: https://kubevirt.io/user-guide/ csi-driver-nfs: https://github.com/kubernetes-csi/csi-driver-nfs TrueNAS SCALE ドキュメント: https://www.truenas.com/docs/scale/ Kubernetes CSI 仕様: https://kubernetes-csi.github.io/docs/ 12. 商標について 本記事で使用している以下の名称は、各社の商標または登録商標です。 Proxmox、Proxmox VE は、Proxmox Server Solutions GmbH の商標です。 SUSE、Harvester、Longhorn は、SUSE LLC の商標または登録商標です。 TrueNAS、TrueNAS SCALE は、iXsystems, Inc. の商標または登録商標です。 AMD、Ryzen は、Advanced Micro Devices, Inc. の商標です。 Kubernetes は、The Linux Foundation の登録商標です。 HPE、HPE CSI Driver for Kubernetes は、Hewlett Packard Enterprise Development LP の商標または登録商標です。 その他、本記事に記載されている会社名、製品名は、各社の商標または登録商標です。 13. 免責事項 本記事の内容は、2026年3月時点の情報に基づいており、将来的に変更される可能性があります。本記事の内容を実施したことにより発生した損害について、筆者およびNTT西日本は一切の責任を負いかねます。本記事の内容を参考に作業を行う場合は、自己責任でお願いいたします。 本記事に記載されているコマンド、設定値、構成例は、特定の検証環境で動作を確認したものであり、すべての環境で同一の結果が得られることを保証するものではありません。本番環境への適用にあたっては、十分な検証とバックアップを実施してください。 13.1. データ損失に関する注意事項 本記事で紹介する StorageClass のうち、Longhorn をバイパスする方式(nfs-csi)はノードレベルのレプリケーションを行いません。データの冗長性は外部ストレージ(TrueNAS)側の構成に依存します。 reclaimPolicy: Delete を設定した StorageClass では、PVC の削除に伴いデータが自動的に削除されます。意図しないデータ損失を防ぐため、重要なデータには Retain ポリシーの使用や定期的なバックアップを検討してください。 ネステッド仮想化環境では、Proxmox ホストの障害がすべてのゲスト VM に波及します。本検証環境はシングルホスト構成であり、本番利用には適しません。 14. 執筆者 平岡 征一朗(NTT西日本) 文教(大学)担当のシステムエンジニアです。インフラからアプリまでトラブルシュートが大好きです。
はじめに NTT西日本の中川です。 本記事では、ブラウザのメインスレッドを占有しないよう Web Worker を使って重い処理をバックグラウンドへ逃がす方法を、実験コードつきで解説します。シングルスレッドのJavaScriptでも、UXを落とさずに計算処理と描画を両立するための実装パターンをまとめました。 本記事は2026年3月時点の情報に基づきます。 仕様書やMDNなどを読んでも、「Workerに逃がす」と口で言うのと、 画面上で動きの差を一度見る のとでは、腹落ちの深さがかなり違います。私自身も最初の頃はイベントループの説明と体感がなかなか結びつかず、実験用の短い for を回して初めて「占有」の意味が自分のものになった経験があります。同じように手を動かしながら確認したい方に向け、デモを多めに載せていますので、ぜひ試してみてください。 設計思想やデバッグ手法など、コードの品質を高める方法はさまざまです。しかし、どれほどきれいなコードを書いても、ユーザー体験(UX)を損なう「画面のフリーズ」が発生してしまうと、アプリケーションの価値は大きく損なわれてしまいます。 JavaScriptはメインスレッド上でUIとスクリプトが順番に処理されるため、重い処理をそのまま走らせると描画が止まります。今回は、その限界を緩和する標準機能である Web Worker に焦点を当ててご紹介します。 対象読者 本記事が想定する対象読者は以下の通りです。 JavaScriptのイベントループや実行コンテキストなど基礎知識をお持ちの方 大量データの加工や複雑な計算で、画面が一瞬カクついたり止まったりする課題を抱えている方 「JavaScriptはシングルスレッドだから重い処理は避けるべき」という前提の先にある、並行に近い実行モデルを知りたい方 目次 はじめに 対象読者 目次 1.背景・目的 2.スレッドとは 3.なぜ「画面が止まる」のか(イベントループの制約) 実験:メインスレッドをブロックしてみる 結果 4.Web Workerとは何か 実装時の主なルール 5.【実践】Web Workerを導入してフリーズを和らげる ステップ1:worker.js ステップ2:index.html(メインスレッド側) 結果 補足:メインとWorkerのやり取りのコスト 6.よくあるユースケースと簡易コード(何が起きるかをセットで理解する) ケース1:回転する「動き続けるUI」と重い処理(ループ) 結果 ケース2:進捗の逐次通知(重い処理のプログレスバー) 結果 7.使い分け:Workerを使うべきとき・避けた方がよいとき 検証ツールで処理負荷を“測定”して可視化する Workerが向いている処理の例 Workerが向かない、または慎重になる処理の例 8.さらに高度な活用:転送可能オブジェクト(Transferable) 9.まとめ 執筆者 参考資料・出典 商標 1.背景・目的 モダンなWebアプリケーションでは、ブラウザ上で実行されるロジックが肥大化し続けています。一方で、ユーザーは滑らかなアニメーションと即座のレスポンスを期待しているケースが多いです。 本記事の目的は、 メインスレッドをUI応答に使い続けつつ、重い計算をWorker側へ委ねる という構成を、実際にブラウザで動かしながら体感し、実装の型を身につけていただくことです。 業務のコードでも、いったんメインに載せた処理を抱え込んだまま進め、 計測で長いタスクがはっきり見えてから Workerへ切り出す、という順番になりがちです。後から直せばよいのですが、手戻りが発生するとどんどん期間的な余裕がなくなっていくケースが多いと思います。だからこそ設計の早い段階で「メインはUIと短い応答に寄せる」という前提を共有しておくと楽になる場面が、私がこれまで経験してきた案件でも少なくありませんでした。本記事は、その前提づくりのための土台として整理しました。 2.スレッドとは OSやCPUの厳密な定義に踏み込むと長くなるため、 この記事を読むための最小限のイメージ だけを解説します。 簡易な説明となりますので、あくまでイメージと捉えてください。 スレッド(thread) 処理の流れを運ぶ 作業ライン のようなものです。レストランでいえば、調理や提供を進めるための「通路」に近いイメージです。 シングルスレッド 同時に使える通路が1本だけ で、そこに仕事が順番に並ぶ状態です。JavaScriptの メインスレッド は、基本この「1本の通路」上で動きます。 メインスレッド ブラウザでは、この1本の通路のうち、 画面の表示やユーザー操作(クリック・入力など)への反応を扱う主役のライン を指すことが多いです。本文では「メイン」と略します。 マルチスレッド(複数スレッド) 複数の通路を並行して使える イメージです。Web Workerは、メインとは別のスレッド上でスクリプトを実行できます。ただし WorkerからはDOM(Document Object Model)を直接操作できない など、制約があります。 この記事では「 重い計算をメインの通路にずっと置くと、画面の更新が後回しになる。だからWorkerという別の通路に逃がす 」というイメージを持って読み進めていただくと、後の内容を理解する助けになります。 ※用語の厳密な定義や、ブラウザ実装ごとの差異は、参考資料のMDNや仕様書で確認してください。 3.なぜ「画面が止まる」のか(イベントループの制約) JavaScriptの実行環境(メインスレッド)は、 UIの描画更新とスクリプトの実行を、ひとつのキューに乗ったタスクとして順番に処理する モデルが基本です(イベントループ)。 メインスレッドとイベントループの参考イメージ(タスクキューから実行・重い処理時のブロック) ※図内の「rAF」とは requestAnimationFrame() の略です。 上段は「順番待ちのモデル」、下段は「 重い1件の間、ほかの仕事が先に進みにくい 」ことをイメージした図です(厳密なブラウザ内部モデルではなく、説明用の略図です)。 例えば、数秒かかるループ処理をメインスレッドで実行すると、その間は「次の描画」に回る前に長いタスク占有が発生します。結果として、アニメーションやクリック応答が止まったように見えます。これがいわゆるメインスレッドのブロックです。 実験:メインスレッドをブロックしてみる 以下を index.html として保存し、ローカルで開いて試してください( file:// ではなく 簡易HTTPサーバー で配信する方が挙動が安定します)。 <!doctype html> < html lang = "ja" > < head > < meta charset = "utf-8" /> < meta name = "viewport" content = "width=device-width,initial-scale=1" /> < title > メインスレッドをブロックする実験 </ title > </ head > < body > < h2 > メインスレッドをブロックする実験 </ h2 > < p id = "status" > 待機中です。ボタンで重い処理を開始します。 </ p > < pre id = "log" style = "background:#f5f5f5;padding:8px;border-radius:4px;font-size:13px;" ></ pre > < div id = "box" style = "width:50px; height:50px; background:red; position:relative;" ></ div > < div > < small > このボタンでは、わざと「重い処理」を走らせて、画面(メインスレッド)が止まる感じを体感します。 < br /> 走る計算は2つです。 < br /> < br /> 1) < b > 累積和(計算結果の表示) </ b >< br /> 0〜n-1 の合計(累積和)を表示します。 < br /> < br /> 2) < b > 負荷をかけるループ(フリーズ体感用) </ b >< br /> こちらはn回ループしてCPUを使い、メインスレッドを占有させるための処理です。 < br /> < code > dummy </ code > はループ中に更新する作業用の変数で、値そのものに意味はありません。 < br /> なお内部的には32bit整数として更新しているため、表示は符号なし(0〜4,294,967,295)に変換しています。 < br /> < br /> </ small > </ div > < button id = "runBtn" type = "button" > 重い処理を実行(フリーズします) </ button > < script > const statusEl = document . getElementById ( "status" ) ; const logEl = document . getElementById ( "log" ) ; const runBtn = document . getElementById ( "runBtn" ) ; let pos = 0 ; function animate () { pos = ( pos + 2 ) % 300 ; document . getElementById ( "box" ) . style . left = pos + "px" ; requestAnimationFrame ( animate ) ; } animate () ; function fmtTime ( d ) { return ( d . toLocaleTimeString ( "ja-JP" , { hour12 : false }) + "." + String ( d . getMilliseconds ()) . padStart ( 3 , "0" ) ) ; } function heavyTask () { logEl . textContent = "" ; runBtn . disabled = true ; statusEl . textContent = "次の描画フレームのあと、重い処理を開始します…" ; requestAnimationFrame (() => { requestAnimationFrame (() => { const wallStart = new Date () ; statusEl . textContent = "実行中です(メインスレッド占有中)。赤いボックスの動きと、この文が更新されない時間に注目してください。" ; const t0 = performance . now () ; const n = 1_000_000_000 ; let dummy = 0 ; for ( let i = 0 ; i < n ; i ++ ) { dummy = ( dummy + ( i & 7 )) | 0 ; } const dummyU32 = dummy >>> 0 ; const result = ( BigInt ( n - 1 ) * BigInt ( n )) / 2 n ; const elapsed = Math . round ( performance . now () - t0 ) ; const wallEnd = new Date () ; statusEl . textContent = "重い処理が終わりました。メインスレッドが解放され、ボックスが再び動き出します。" ; logEl . textContent = "【画面ログ】\n" + "開始時刻: " + fmtTime ( wallStart ) + "\n" + "終了時刻: " + fmtTime ( wallEnd ) + "\n" + "所要時間: " + elapsed + " ms\n" + "累積和 : " + result . toString () + "\n" + "ダミー値(符号なし32bit): " + dummyU32 ; runBtn . disabled = false ; }) ; }) ; } runBtn . addEventListener ( "click" , heavyTask ) ; </ script > </ body > </ html > 結果 メインスレッドをブロックしてみるの参考イメージ ボタンを押すと、赤いボックスの動きが止まります。これがメインスレッドが長時間占有されている状態です。あわせて、 実行中は画面上部の状態メッセージが更新されない (フリーズしている)こと、 終了後にログ欄へ開始・終了時刻と所要時間がまとめて表示される ことも確認できます。ループ回数や端末性能など、かかる時間は環境ごとに異なります。 所要時間に表示された時間分だけ、メインスレッドが占有されていたということになります。 4.Web Workerとは何か Web Workerは、Webアプリケーションの メイン実行スレッドとは別のバックグラウンド上でスクリプトを動かす仕組み です(実装はブラウザ依存ですが、「メインとは別の並行の実行コンテキスト」という理解で問題ありません)。重い計算をWorkerに逃がすと、メイン側はブロックされにくくなり、ユーザーの操作や画面の再描画を順に進めやすくなります。しかし、メインとWorkerの間のメッセージ処理そのものもメインスレッドで扱うため、通信のやりすぎは別のボトルネックになる可能性があり、注意が必要です。 実装時の主なルール スクリプトはURLとして渡して起動する :別ファイルの .js を指すのが一般的ですが、Blobから生成したURLのように、ファイルを分けずに渡す方法もあります。読み込むWorkerスクリプトは 呼び出し元と同一オリジン であることが基本で、クロスオリジンで読み込む場合は追加の条件(CORSなど)が必要です。 DOM操作は不可 :Workerの実行環境には window や document はなく、ページのDOMを直接触れません(仕様上、Dedicated Workerのグローバルは WorkerGlobalScope 系です)。 メッセージでやり取りする : postMessage と message イベント(または addEventListener('message',…) )でデータを受け渡しします。 不要になったら終了する :都度Workerを新規作成する場合は、処理完了後に terminate() するか、 1つのWorkerを使い回す 設計にします(放置するとリソースを食い続けます)。 5.【実践】Web Workerを導入してフリーズを和らげる 先ほどの実験を、Web Workerで書き直します。同じフォルダに index.html と worker.js を置いてください。 ここで表したいのは、「非常に重いCPU計算(= メインスレッドを占有しがちなループ)」をWorkerへ逃がすことで、メイン側のアニメーション(赤いボックス)が止まらない、という効果です。 この例で画面に出している数値(累積和)は、 「処理が最後まで完了した」ことを確認するための目印 です。 経過時間ではないことに注意してください。 Worker用のスクリプトは、 HTMLに <script src="worker.js"> と書いて読み込みません 。メイン側の new Worker('worker.js') のときに、ブラウザが別スレッド用として worker.js を取得・実行します(メイン用の <script> で読むと、Worker向けコードがメインスレッドで動いてしまい意図とずれます)。 ステップ1: worker.js // 重いループを最後まで回し切った「完了の証拠」として値をメインへ返す。 self .onmessage = function () { const n = 1_000_000_000 ; // ダミー計算(CPUを使う/32bitで回し続けて“走った痕跡”を残す) let dummy = 0 ; for ( let i = 0 ; i < n ; i ++ ) { dummy = ( dummy + ( i & 7 )) | 0 ; } const dummyU32 = dummy >>> 0 ; // 結果の目印(0〜n-1 の合計を BigInt で厳密に) const sum = ( BigInt ( n - 1 ) * BigInt ( n )) / 2 n ; self . postMessage ({ n , sum : sum . toString () , dummy : dummyU32 }) ; } ; メインに届く値: 上記の postMessage(result) の引数が、メイン側の message ハンドラでは e.data として受け取れます。 ステップ2: index.html (メインスレッド側) 先ほどの実験コードのうち、 ボタンを押したときの処理(重いループ部分)だけ をWorker呼び出しに差し替えた例です。ボタン連打でWorkerが増殖しないよう、 前回のWorkerは終了してから 新しく起動しています。ログ欄に出す e.data は、 ステップ1の result と同じ値 です。 <!doctype html> < html lang = "ja" > < head > < meta charset = "utf-8" /> < meta name = "viewport" content = "width=device-width,initial-scale=1" /> < title > Web Workerで計算を逃がす </ title > </ head > < body > < h2 > Web Workerで計算を逃がす </ h2 > < p id = "status" > ボタンでWorkerに計算を依頼できます。 </ p > < pre id = "log" style = "background:#f5f5f5;padding:8px;border-radius:4px;font-size:13px;min-height:3em;" > (結果はここに表示されます) </ pre > < div > < small > このボタンでは、重い計算をWorkerに任せて、画面(メインスレッド)の描画が止まりにくくなることを体感します。 < br /> Workerが返す値は2つです。 < br /> < br /> 1) < b > 累積和(計算結果の表示) </ b >< br /> 0〜n-1 の合計(累積和)を表示します。 < br /> < br /> 2) < b > ダミー値(符号なし32bit) </ b >< br /> Worker側で負荷をかけるループの中で更新している作業用の値です(値そのものに意味はありません)。 < br /> </ small > </ div > < div id = "box" style = "width:50px; height:50px; background:red; position:relative;" ></ div > < br /> < button type = "button" id = "runBtn" > 重い処理をWorkerへ(描画は動き続ける想定) </ button > < script > const statusEl = document . getElementById ( "status" ) ; const logEl = document . getElementById ( "log" ) ; const runBtn = document . getElementById ( "runBtn" ) ; let pos = 0 ; function animate () { pos = ( pos + 2 ) % 300 ; document . getElementById ( "box" ) . style . left = pos + "px" ; requestAnimationFrame ( animate ) ; } animate () ; let myWorker = null ; function heavyTask () { logEl . textContent = "" ; runBtn . disabled = true ; statusEl . textContent = "Workerへ処理を送りました。計算中は画面の指示に従い、赤いボックスの動きも見てください。" ; if ( myWorker ) { myWorker . terminate () ; } try { myWorker = new Worker ( "worker.js" ) ; } catch ( err ) { statusEl . textContent = "Workerを起動できませんでした。" ; logEl . textContent = "起動エラー: " + ( err && err . message ? err . message : String ( err )) + "\n" + "(worker.js のパス、http:// で開いているか、ブラウザ設定などを確認してください)" ; myWorker = null ; runBtn . disabled = false ; return; } myWorker . onmessage = function ( e ) { // worker.js は { sum, dummy } の形で返す想定ですが、コピペのズレ等で形が違う場合もあり得るので // 表示は防御的に扱います。 const data = e . data ; const sum = data && typeof data === "object" ? ( data . sum ?? data . result ) : data ; const dummy = data && typeof data === "object" ? data . dummy : undefined ; const endTime = new Date () . toLocaleTimeString ( "ja-JP" , { hour12 : false , }) ; statusEl . textContent = "Workerから結果を受け取りました(" + endTime + ")。" ; logEl . textContent = "累積和(0〜999,999,999 の合計): " + sum + "\n" + "ダミー値(符号なし32bit): " + ( dummy ?? "(未取得)" ) ; myWorker . terminate () ; myWorker = null ; runBtn . disabled = false ; } ; myWorker . onerror = function ( e ) { statusEl . textContent = "Workerの読み込み、または実行でエラーが発生しました。" ; logEl . textContent = "エラー: " + ( e && e . message ? e . message : "(詳細不明)" ) + "\n" + "(worker.js が存在しない、パスが違う、http:// で開いていない、同一オリジンでないなどの可能性があります)" ; myWorker . terminate () ; myWorker = null ; runBtn . disabled = false ; } ; myWorker . onmessageerror = function () { statusEl . textContent = "Workerとのメッセージの受け渡しに失敗しました。" ; logEl . textContent = "messageerror: Workerから受け取ったデータを復元できませんでした。" ; myWorker . terminate () ; myWorker = null ; runBtn . disabled = false ; } ; myWorker . postMessage ( null ) ; } runBtn . addEventListener ( "click" , heavyTask ) ; </ script > </ body > </ html > 結果 Web Workerを導入してフリーズを和らげるの参考イメージ 先ほどとは異なり、ボタン押下中も赤いボックスのアニメーションが動き続け、数秒後に ログ欄へ累積和(0〜999,999,999の合計) が表示されます。状態メッセージで、送信中・受信済みの流れも追えます。挙動はブラウザ実装・CPU負荷・他タブの状況により変わるため、 可能であれば手元で確認 してください。 補足:メインとWorkerのやり取りのコスト メインからWorkerへデータを渡す処理では、原則として データの複製 が発生します。巨大なオブジェクトを頻繁に往復させると、通信そのものがボトルネックになることがあるため、注意してください。コピーを避ける高度な手段は本記事の後半で紹介します。 6.よくあるユースケースと簡易コード(何が起きるかをセットで理解する) ここからは 1ファイルで試せる 例です。 new Worker(URL.createObjectURL(blob)) により、別ファイルを置かずにWorkerスクリプトを渡しています(本番ではファイル分割やバンドラ連携が一般的です)。 各コードの直後に、 ブラウザ上でどう見えるか・何が得られるか をまとめています。 ファイルを増やしたくないときに Blob からWorkerを起動するのは、個人的にも手習い向きで重宝しています。本番とは設計が異なる点は後述の通りですが、「まず挙動を掴む」には向いています。 ケース1:回転する「動き続けるUI」と重い処理(ループ) 重いCPUループは本来メインを占有しやすいことはこれまでの内容でお伝えしてきましたが、ここでは 回転する赤い四角 を置き、 「止まらない=メインの描画処理が生きている」 ことを一目で確認できるように画面上の「経過時間(メイン)」は、メインが動いている間だけ増え続けるようにしています。 Worker処理完了時に出る 巨大な数値(累計) は、ループを最後まで回し切った 完了の目印 です。 <!doctype html> < html lang = "ja" > < head > < meta charset = "utf-8" /> < meta name = "viewport" content = "width=device-width,initial-scale=1" /> < title > ケース1:回転UIと重い処理(Worker) </ title > </ head > < body > < h3 > 回転が止まらないか、注目してみてください </ h3 > < p > 赤い四角は < code > requestAnimationFrame </ code > で回転しています。 </ p > < div > < small > この例は、重い計算をWorkerに任せても「メイン側の描画(回転)」が止まりにくいことを体感するためのものです。 < br /> < br /> - 確認するポイント: < b > 赤い四角の回転 </ b > と < b > 経過時間 </ b > が止まらないか < br /> - Workerが返す値: < b > 累積和(計算結果) </ b > と < b > ダミー値(符号なし32bit) </ b >< br /> ※ダミー値は負荷をかけるループ中に更新している作業用の値で、値そのものに意味はありません。 < br /> </ small > </ div > < div id = "spin" style = "width:48px;height:48px;background:#d33;margin:12px 0;transform-origin:center center;" ></ div > < p > 経過時間(メイン): < span id = "tick" > 0 </ span > ms(フレームが進むほど増えます) </ p > < p id = "st" > 待機中 </ p > < button type = "button" id = "run" > Workerで重いループ(約2億ステップ) </ button > < script > const spinEl = document . getElementById ( "spin" ) ; const tickEl = document . getElementById ( "tick" ) ; const st = document . getElementById ( "st" ) ; const runBtn = document . getElementById ( "run" ) ; let deg = 0 ; ( function spinLoop () { deg = ( deg + 4 ) % 360 ; spinEl . style . transform = "rotate(" + deg + "deg)" ; requestAnimationFrame ( spinLoop ) ; })() ; let t0 = performance . now () ; ( function tickLoop () { tickEl . textContent = String ( Math . round ( performance . now () - t0 )) ; requestAnimationFrame ( tickLoop ) ; })() ; runBtn . onclick = () => { runBtn . disabled = true ; st . textContent = "Workerで計算中…(この間も回転と経過表示が止まらないはず)" ; const src = [ "self.onmessage = () => {" , " const n = 200000000;" , " let dummy = 0;" , " for (let i = 0; i < n; i++) dummy = (dummy + (i & 7)) | 0;" , " const dummyU32 = dummy >>> 0;" , " const sum = (BigInt(n - 1) * BigInt(n)) / 2n;" , " self.postMessage({ steps: n, sum: sum.toString(), dummy: dummyU32 });" , "};" , ] . join ( "\n" ) ; const w = new Worker ( URL . createObjectURL ( new Blob ([ src ] , { type : "application/javascript" }) , ) , ) ; w . onmessage = ( e ) => { st . textContent = "完了(Worker): ステップ数 " + e . data . steps + " の累積和 = " + e . data . sum + "(ダミー値・符号なし32bit: " + e . data . dummy + ")" ; runBtn . disabled = false ; w . terminate () ; } ; w . onerror = ( e ) => { st . textContent = "Workerエラー: " + e . message ; runBtn . disabled = false ; w . terminate () ; } ; w . postMessage ( null ) ; } ; </ script > </ body > </ html > 結果 ケース1:回転する「動き続けるUI」と重い処理(ループ)の参考イメージ このケースでは、ボタン押下後も 赤い四角の回転が途切れない ので、「重い処理」をWorkerへ逃がしたときの体感が掴みやすいです。 「経過時間(メイン)」 が止まらず増え続けるのは、メインがイベントループと描画を進め続けている証拠となります。 最後に表示される 巨大な累計 は、約2億ステップ分のループを終えた結果の 完了の目印 です。端末性能により待ち時間は変わります。長すぎる場合はWorker内の n を小さく調整してください。 ケース2:進捗の逐次通知(重い処理のプログレスバー) Worker内でループを区切り、 何%進んだか を何度かメインへ送り、画面上のプログレスバーだけを更新してみます。 <!doctype html> < html lang = "ja" > < head > < meta charset = "utf-8" /> < meta name = "viewport" content = "width=device-width,initial-scale=1" /> < title > ケース2:進捗の逐次通知(Worker) </ title > </ head > < body > < div > < small > この例は、Workerから「いま何%まで進んだか」を何回か受け取り、プログレスバーだけを更新するサンプルです。 < br /> 見どころは、重い計算中でも < b > バーが段階的に伸びる </ b > 点です。 < br /> ※最後に返ってくる < code > work </ code > は、Worker側の重いループで作った「最終的な計算値」です。 < br /> 処理時間ではなく、あくまで「最後まで計算が走り切った」ことを確認するために表示しています(中身を解釈する必要はありません)。 < br /> </ small > </ div > < div id = "bar" style = "height:12px;width:0;background:#06c;" ></ div > < p id = "pct" > 0% </ p > < button type = "button" id = "run" > Workerで進捗付き処理 </ button > < script > const runBtn = document . getElementById ( "run" ) ; runBtn . onclick = () => { if ( runBtn . disabled ) return; runBtn . disabled = true ; document . getElementById ( "bar" ) . style . width = "0%" ; document . getElementById ( "pct" ) . textContent = "0%" ; const src = ` self.onmessage = () => { const steps = 5; let work = 0; for (let s = 1; s <= steps; s++) { let x = 0; for (let i = 0; i < 80000000; i++) x += i % 7; work = x; self.postMessage({ progress: Math.round((s / steps) * 100) }); } self.postMessage({ done: true, work }); }; ` ; const w = new Worker ( URL . createObjectURL ( new Blob ([ src ] , { type : "application/javascript" }) , ) , ) ; w . onmessage = ( e ) => { if ( "progress" in e . data ) { const p = e . data . progress ; document . getElementById ( "bar" ) . style . width = p + "%" ; document . getElementById ( "pct" ) . textContent = p + "%" ; } if ( e . data . done ) { document . getElementById ( "pct" ) . textContent += " (完了 / work=" + e . data . work + ")" ; runBtn . disabled = false ; w . terminate () ; } } ; w . onerror = function () { document . getElementById ( "pct" ) . textContent += " (Workerエラー)" ; runBtn . disabled = false ; w . terminate () ; } ; w . postMessage ( null ) ; } ; </ script > </ body > </ html > 結果 ケース2:進捗の逐次通知(重い処理のプログレスバー)の参考イメージ 青いバーの幅が 20% → 40% → … → 100% のように段階的に伸びる(Workerから進捗メッセージが届くたびに更新)。 完了後に「(完了)」が付く。内側のループ回数は端末に合わせて調整してください(軽すぎると一瞬で終わり、重すぎると待ち時間が長くなります)。 実行中はボタンが無効化され、連打でWorkerが複数起動しにくいようにしています。 補足: 進捗通知を細かくしすぎると、メインとWorkerの間のやり取りの回数が増え、かえってオーバーヘッドになることがあります。実務では一定間隔・一定チャンクごとに送るのが無難です。 7.使い分け:Workerを使うべきとき・避けた方がよいとき 検証ツールで処理負荷を“測定”して可視化する ブラウザの検証ツールを使うと処理負荷を可視化することができ、一層イメージが持ちやすいです。 DevToolsを開く → Performance DevToolsを開く → Performance 記録開始(Record)→ サンプルのボタンを押して重い処理を走らせる → 停止 記録開始(Record)→ サンプルのボタンを押して重い処理を走らせる → 停止 タイムライン上で、メインスレッドの Long Task(長いタスク) や、描画(Frames)の詰まり方を確認する タイムライン上で、Worker処理が確認できる 「メインで実行した場合」と「Workerに逃がした場合」で、メインスレッド上の長い塊の出方が変わるのがポイントです。 ここまででWorkerの便利な動きについて紹介してきましたが、 「何でもWorkerに投げればよい」わけではありません。 どんな機能にも向き不向きがあり、Workerも例外ではありません。 それぞれ一部を紹介します。 Workerが向いている処理の例 数十万件以上の配列のフィルタリング・集計・変換 画像やバイナリの加工、暗号処理など、DOMに触れない重い計算 インタラクティブなUIの応答性を優先し、メインスレッドの占有時間を短く保ちたい場面 Workerが向かない、または慎重になる処理の例 数ミリ秒で終わる軽い計算(起動とメッセージのオーバーヘッドの方が大きくなることがある) DOMを頻繁に更新する処理(Workerからは直接操作できないため、結果をメインへ戻して反映が必要) 巨大なデータを高頻度で往復させる処理(コピーまたは転送戦略の設計が必須) 8.さらに高度な活用:転送可能オブジェクト(Transferable) 巨大なバイナリデータ(型付き配列の裏側のバッファなど)をWorkerへ送る場合、デフォルトの複製ではメモリと時間を消費します。メッセージを送るAPIのオプションで転送リストを渡すと、バッファの所有権をWorker側へ移し、コピーを避けられる場合があります(転送後、メインスレッド側ではそのバッファは利用できなくなります)。 高度な機能となるため、ここではほんの少しだけ、一部を紹介します。 // メイン側(例:100MBのバッファを転送) const buffer = new Uint8Array ( 1024 * 1024 * 100 ) . buffer ; myWorker . postMessage ({ type : 'binary' , payload : buffer } , [ buffer ]) ; // 転送後、メインスレッド側では buffer はデタッチされ、触れない想定になる Worker側では、メインが送ったデータの中身として受け取ります。用途に応じて複数スレッドで同じメモリを共有する仕組みを検討する道もありますが、 サイト全体のセキュリティ設定(クロスオリジン隔離など)が必要になってくる ため、まずは同一オリジンでさまざまな使い方を模索してみるのが良いと思います。 9.まとめ Web Workerを使うと、JavaScriptのシングルスレッドという制約のもとでも、メインスレッドの占有時間を減らし、UXと重い計算の両立を狙いやすくなります。 ボトルネックの特定 :ブラウザの開発者ツールで、長いタスクがどこで発生しているかを把握する(性能分析の画面や、計測用のAPIを使う方法があります)。 純粋な計算の分離 :画面の要素を直接いじらない処理をWorkerへ切り出す。 メッセージ設計 :やり取りするデータのサイズと頻度を抑え、必要なら後述の「転送」による最適化で転送コストを下げる。 「ブラウザ向けJavaScriptは重い処理に向かない」と言われがちですが、Web Workerのような標準機能を適切に組み合わせれば、フロントエンドでも実用的なアプリケーションを構築できます。 ループ回数は端末の性能などで体感が変わります。動かしてみて「差が分かりにくい」と感じたら、本記事の3節目のブロック実験だけループを半分にしてみたり、5節目のサンプルにてWorker側はそのまま、といった 片側だけ編集してみるなどの比較 を試してみてください。私も記事を整えるときは、何度かこの比較に立ち返って文言を直しました。開発者ツールの性能パネルを開いたまま読み返すと、長いタスクの見え方もイメージしやすくなるはずです。是非試してみてください。 執筆者 中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属) NTT西日本のWebアプリケーションの開発・運営に従事。 好きな技術スタック:TypeScript, Vue.js, GraphQL, Laravel 参考資料・出典 本記事を執筆するにあたり、以下のサイト・資料を参考にしました。 MDN Web Docs — Web Workers API MDN Web Docs — Worker MDN Web Docs — postMessage HTML Living Standard — postMessage with transfer WHATWG HTML — Safe passing of structured data 商標 「JavaScript」は、Oracle Corporation およびその子会社の米国およびその他の国における商標または登録商標です。 「Google Chrome」は、Google LLC の商標です。 記載のその他の会社名・製品名は、それぞれ各社の商標もしくは登録商標です。
はじめに NTT西日本の山塚です。普段はOCIを活用したインフラ設計・構築に携わっています。 AWSには「Amazon FSx for Windows File Server」というフルマネージドのWindowsファイルサーバーサービスがあります。SMB共有、Active Directory連携、自動バックアップ、マルチAZ構成など、Windowsファイルサーバーに必要な機能が揃っており、数クリックで構築できる便利なサービスです。 一方、 OCIには同等のマネージドサービスがありません (2026年3月時点)。 「OCIでもFSx for Windowsのようなことはできないのか」という声をきっかけに、 OCIの既存サービスを組み合わせてFSx for Windowsの主要機能をどこまで再現できるか を検証してみました。 ※私個人としてもActive Directoryをあまり触ったことがなかったため、これを機に学んでみようという想いもありました。 本記事では、基本的なファイル共有機能だけでなく、 マルチAZ相当の可用性構成 や フルマネージド相当の運用自動化 まで、可能な限りの再現に挑戦しました。さらに、 実際に疑似的に障害を発生させて、どこまで自動復旧できるのか を検証した結果もまとめます。 この記事は2026年3月時点の情報に基づきます。本記事の内容は筆者個人の検証・見解であり、所属組織の公式見解・推奨構成を示すものではありません。 対象読者 本記事は、以下の方を対象としています。 OCI環境でWindowsファイルサーバーの構築・運用を検討しているエンジニア AWSのFSx for WindowsをOCI上で代替できるか調査している方 DFS-R/DFS-Nを活用したマルチリージョン冗長構成に興味のある方 クラウド間のサービス比較・移行検討をされている方 前提知識: 本記事では、以下の知識があることを前提としています。 OCIのコンソール操作とCompute Instanceの基本的な作成 Windows Server(PowerShell操作、役割・機能の追加)の基本操作 Amazon FSx for Windows File Serverの基本的な概念 背景・目的 OCI環境を主に利用している場合、AWS上のFSx for Windows File Serverに相当するマネージドサービスは存在しません。しかし、OCIのCompute InstanceやBlock Volume、OS Management Hubといった既存サービスを組み合わせることで、類似の機能を自前で構築できる可能性があります。 本記事の目的は、 FSx for Windowsの主要機能(SMB共有・Active Directory連携・バックアップ・マルチAZ相当の可用性・フルマネージド相当の運用自動化)をOCI上で再現し、その再現度・コスト・運用工数を定量的に評価すること です。 なお、本記事で取り上げる再現度の評価は、筆者が今回の検証で確認した機能の範囲に限ったものです。FSx for Windowsのすべての機能を網羅的に検証したものではなく、AWSがマネージドサービスとして内部で担保している詳細な実装についても評価対象外としています。 目次 FSx for Windowsの主要機能を整理する FSx for Windowsの前提条件 検証対象とする機能 FSx for Windowsの本質的な価値 OCI再現構成の設計 段階的なアプローチ 最終構成図 使用するOCIサービス Phase 1:基本構成の構築 Phase 2:FSx相当機能の実装 Phase 3:マルチリージョン構成 Phase 4:フルマネージド相当の運用自動化 Phase 5:障害検証 検証結果:各機能の再現度 コスト比較 まとめ 構築時のトラブルシューティング 1. FSx for Windowsの主要機能を整理する 1.1 FSx for Windowsの前提条件 FSx for Windowsは Active Directoryへの参加が必須 です。単体では動作しません。 AWSでFSxを使う場合、以下のいずれかのADが必要です。 ADの種類 管理者 特徴 AWS Managed Microsoft AD AWS フルマネージド、パッチ適用も自動 Self-Managed AD ユーザー EC2やオンプレミスで自前構築 今回のOCI構成では、FSxを動かすための土台としてADサーバーも自前で構築します。ただし、 検証の主眼はあくまでFSxの機能再現 であり、ADの機能検証ではありません。 図1. Amazon FSx for Windows File Server — AWSコンソールの作成画面 1.2 検証対象とする機能 FSx for Windowsには多くの機能がありますが、今回は以下の 主要機能 に着目して検証を行いました。 カテゴリ 機能 概要 ファイル共有 SMB(Server Message Block)プロトコル SMB 2.0〜3.1.1対応 ファイル共有 SMB暗号化 転送中のデータを暗号化 認証・アクセス制御 Active Directory連携 ドメインユーザーでの認証 認証・アクセス制御 Windows ACL(Access Control List) NTFS(NT File System)アクセス権限 認証・アクセス制御 ABE(Access-Based Enumeration) 権限のないファイルを非表示 データ保護 シャドウコピー(VSS) 「以前のバージョン」での復元 データ保護 自動バックアップ 日次バックアップ データ保護 保管時暗号化 ストレージの暗号化 パフォーマンス データ重複排除 ストレージ効率化 可用性 マルチAZ構成 自動フェイルオーバー 運用 フルマネージド パッチ適用をAWSが自動実行 1.3 FSx for Windowsの本質的な価値 FSx for Windowsの価値は、機能面だけではありません。 以下をすべてAWSが担当してくれる という点にあります。 AWSが担当すること 内容 構築 コンソールから数クリックで完了 パッチ適用 メンテナンスウィンドウで自動実行 障害検知 CloudWatchで自動監視 障害復旧 マルチAZで自動フェイルオーバー バックアップ 日次自動バックアップ ハードウェア管理 完全にAWS側で管理 FSxのパッチ適用について FSx for Windowsでは、パッチ適用のタイミング(曜日・時間)はメンテナンスウィンドウで指定できますが、 パッチを適用しないという選択はできません 。14日以内にメンテナンスが実行されない場合、セキュリティと信頼性を確保するためにAWSがパッチ適用を続行します。(2026年3月時点の情報。詳細はAWS公式ドキュメントをご確認ください。) 一方、OCI構成では「パッチを当てるかどうか」も含めてユーザーが判断できます。 今回の検証では、これらの マネージド部分も可能な限り再現することに挑戦 し、 実際に障害を発生させて自動復旧の挙動を確認 しました。 2. OCI再現構成の設計 2.1 段階的なアプローチ 検証は以下の5つのPhaseに分けて実施しました。 Phase 内容 目的 Phase 1 基本構成 SMB共有、AD連携の実現 Phase 2 FSx相当機能 VSS、暗号化、ABE、重複排除の実装 Phase 3 マルチリージョン マルチAZ相当の可用性を実現 Phase 4 運用自動化 フルマネージド相当の運用を実現 Phase 5 障害検証 実際に障害を起こして自動復旧を検証 2.2 最終構成図 図2. OCI上に構築したFSx再現構成の全体図 今回は上記の構成で検証しています。構成図にはプライベートサブネットからインターネットへの外向き通信に使用するNATゲートウェイも含まれています。 2.3 使用するOCIサービス FSx機能 OCI対応サービス ファイルサーバー Compute Instance(Windows Server 2022) ストレージ Block Volume Active Directory Compute Instance(AD DS役割) バックアップ Block Volume Backup Policy 暗号化 Block Volume暗号化(デフォルト有効) マルチAZ マルチリージョン( + DFS-R(Distributed File System Replication)) 監視 OCI Monitoring + Alarms パッチ適用 OS Management Hub 再現をするにあたってインスタンスを始めとする上記サービスを用いて検証を実施しました。 3. Phase 1:基本構成の構築 このPhaseのゴール FSx for Windowsの核心は「ADと連携したSMBファイル共有」です。Phase 1ではこの最小構成を東京リージョンに立ち上げます。OCIにはマネージドADサービスがないため、ADサーバー自体もCompute Instanceで自前構築するのがポイントです。 Phase 1では、FSx for Windowsの最も基本的な機能である「Active Directoryと連携したSMBファイル共有」をOCIで再現します。 3.1 構成概要 今回の東京リージョンの構成は以下の通りです。ADサーバーとファイルサーバーをそれぞれ別のインスタンスとして用意し、Block Volumeをファイル保存用のストレージとして使用します。 リソース スペック IPアドレス 役割 ADサーバー VM.Standard.E5.Flex(2OCPU(Oracle Compute Unit) / 16GB) 10.0.3.10 ユーザー認証を担当 ファイルサーバー VM.Standard.E5.Flex(4OCPU / 32GB) 10.0.3.20 ファイル保存・共有を担当 Block Volume 100GB(Balanced) - ファイル保存用ストレージ ドメイン名 fsx-poc.local - AD管理単位の名前 3.2 ネットワーク(VCN)の作成 サーバーを動かすには、まずネットワーク基盤が必要です。OCIでは「VCN(Virtual Cloud Network)」を作成します。 リソース 設定値 用途 VCN 10.0.0.0/16 サーバーを配置する仮想ネットワーク サブネット 10.0.3.0/24(プライベート) サーバーを配置するセグメント NAT Gateway - プライベートサブネットからインターネットへの外向き通信用 ポイント プライベートサブネットを使うことで、サーバーがインターネットから直接アクセスされることを防ぎます。セキュリティの基本です。 3.3 ADサーバーの構築 FSx for WindowsはActive Directoryと連携してユーザー認証を行います。FSxでは「AWS Managed AD」が利用できますが、OCIにはこのサービスがないため、自分でADサーバーを構築します。 AD DSのインストール Windows ServerにActive Directory機能を追加します。 # Active Directoryドメインサービス(AD DS)をインストール # -IncludeManagementTools で管理ツールも一緒にインストール Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools 新規フォレストの作成 ADドメインを新規作成します。「フォレスト」はADの最上位の管理単位です。 # ローカルAdministratorにパスワードを設定 # ドメイン作成時、このアカウントがドメイン管理者になるため必須 net user Administrator "**********" # ドメイン復旧モード用のパスワードを設定 $SafeModePassword = ConvertTo-SecureString "**********" -AsPlainText -Force Install-ADDSForest ` -DomainName "fsx-poc.local" ` # ドメインのDNS名 -DomainNetbiosName "FSXPOC" ` # 短縮名(FSXPOC\userのように使う) -ForestMode "WinThreshold" ` # Windows Server 2016以降の機能を有効化 -DomainMode "WinThreshold" ` -InstallDns:$true ` # DNSサーバーも一緒にインストール -SafeModeAdministratorPassword $SafeModePassword ` -Force:$true 注意 ドメイン作成後のログインは FSXPOC\Administrator (ドメイン管理者)で行います。ローカルのAdministratorではログインできなくなります。 3.4 ファイルサーバーの構築 ドメイン参加 ファイルサーバーをADドメインに参加させます。ドメインに参加することで、ドメインユーザーにファイルアクセス権限を設定できるようになります。 # DNSサーバーをADサーバーに向ける # ドメイン名「fsx-poc.local」を解決するにはADのDNSが必要 $adapterIndex = (Get-NetAdapter | Where-Object {$_.Status -eq "Up"}).ifIndex Set-DnsClientServerAddress -InterfaceIndex $adapterIndex -ServerAddresses "10.0.3.10" # ドメインに参加(再起動が必要) Add-Computer -DomainName "fsx-poc.local" -Credential (Get-Credential) -Restart 3.5 Block Volumeの追加 ファイルを保存するための追加ストレージを接続します。OCIでは「Block Volume」を作成し、iSCSI(Internet Small Computer System Interface)でサーバーにアタッチします。 # iSCSI Initiatorサービスを自動起動に設定 # Block VolumeはiSCSIプロトコルで接続する Set-Service -Name msiscsi -StartupType Automatic Start-Service msiscsi # iSCSI接続(OCIコンソールで表示されるコマンドを使用) # -IsPersistent $true で再起動後も自動的に再接続される Connect-IscsiTarget ` -NodeAddress "iqn.2015-12.com.oracleiaas:xxxxxxxx" ` -TargetPortalAddress 169.254.2.2 ` -IsPersistent $true 重要 -IsPersistent $true を忘れると、再起動時にディスクが消えたように見えます。必ず指定してください。 # ディスクをGPT形式で初期化 Initialize-Disk -Number 1 -PartitionStyle GPT # パーティションを作成してDドライブに割り当て New-Partition -DiskNumber 1 -UseMaximumSize -DriveLetter D # NTFSでフォーマット Format-Volume -DriveLetter D -FileSystem NTFS -NewFileSystemLabel "FileData" -Confirm:$false 図3. ディスクの管理画面 — Block VolumeがDドライブとして認識された状態 3.6 SMB共有の作成 Block Volumeをアタッチしただけでは、他のコンピューターからアクセスできません。「SMB共有」を作成することで、ネットワーク経由でファイルにアクセスできるようになります。 # フォルダ構造を作成 New-Item -Path "D:\Shares\Common" -ItemType Directory -Force # SMB共有を作成 # -FullAccess: フルコントロール(すべての操作が可能) # -ChangeAccess: 変更権限(読み書き可能、権限変更は不可) New-SmbShare -Name "Common" ` -Path "D:\Shares\Common" ` -FullAccess "FSXPOC\Domain Admins" ` -ChangeAccess "FSXPOC\Domain Users" ` -Description "共通ファイル共有" 図4. エクスプローラーからSMB共有( \\FS01\Common )へのアクセスが成功した状態 4. Phase 2:FSx相当機能の実装 このPhaseのゴール Phase 1で「ファイルを置ける」状態になりました。Phase 2では、FSx for Windowsが標準で備えている付加価値機能、特にデータ保護(VSS・バックアップ)、セキュリティ(SMB暗号化・ABE)、ストレージ効率化(重複排除)をWindows Server標準機能とOCIサービスで再現します。これらはFSxでは「デフォルトで有効」または「数クリック」で済む機能ですが、OCI構成では一つひとつ手動で有効化・検証する必要があります。 Phase 1で基本的なファイル共有ができました。Phase 2では、FSx for Windowsが標準で提供する追加機能を実装します。 4.1 シャドウコピー(VSS)の設定 シャドウコピーは、ファイルの「スナップショット」を定期的に取得する機能です。ユーザーがファイルを誤って削除・上書きした場合、「以前のバージョン」から復元できます。 FSx for Windowsではデフォルトで有効ですが、OCI構成では手動で設定します。 # シャドウコピー用の領域を確保 # Dドライブの10GBをシャドウコピー保存用に使用 vssadmin resize shadowstorage /for=D: /on=D: /maxsize=10GB # 動作確認用にシャドウコピーを手動作成 vssadmin create shadow /for=D: # 定期実行のスケジュールタスクを作成(毎日7:00と12:00) $trigger1 = New-ScheduledTaskTrigger -Daily -At 7:00AM $trigger2 = New-ScheduledTaskTrigger -Daily -At 12:00PM $action = New-ScheduledTaskAction -Execute "vssadmin" -Argument "create shadow /for=D:" $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount Register-ScheduledTask -TaskName "VSS-ShadowCopy-D" ` -Trigger $trigger1,$trigger2 ` -Action $action ` -Principal $principal 図5. 設定済みシャドウコピーの一覧(スケジュール実行分を含む) VSSの動作確認 実際にファイルを復元できることを確認しました。 # 1. テストファイル作成 "Original content - 2026/03/23 08:10" | Out-File "D:\Shares\Common\vss-test.txt" # 2. シャドウコピー作成 vssadmin create shadow /for=D: # 3. ファイルを変更 "Modified content - 2026/03/23 08:11" | Out-File "D:\Shares\Common\vss-test.txt" # 4. シャドウコピーから復元 $shadowCopyPath = "\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy4\Shares\Common\vss-test.txt" Copy-Item -LiteralPath $shadowCopyPath -Destination "D:\Shares\Common\vss-test.txt" -Force # 5. 復元後の内容確認 → "Original content" に戻っていることを確認 Get-Content "D:\Shares\Common\vss-test.txt" 項目 結果 シャドウコピー作成 成功 以前のバージョンから復元 成功 (Modified → Original に復元) 4.2 Windows ACLの設定 補足 Windows ACL(NTFS権限)はNTFSでフォーマットされたボリューム上であれば標準で利用できます。今回の検証では、3.5節でNTFSフォーマットしたDドライブ上に共有フォルダを作成しており、ACLは既に機能している状態です。個別の権限設定の検証は4.4節のABE動作確認の中で実施しています(admin-onlyフォルダへのアクセス制御をNTFS権限で設定)。 4.3 SMB暗号化の有効化 サーバーとクライアント間の通信を暗号化します。社内ネットワークでも、データの盗聴を防ぐために有効化しておくことが推奨されます。 # サーバー全体でSMB暗号化を有効化 # SMB 3.0以降のクライアントとの通信が暗号化される Set-SmbServerConfiguration -EncryptData $true -Force 設定確認: Get-SmbServerConfiguration | Select-Object EncryptData, RejectUnencryptedAccess 項目 値 EncryptData True RejectUnencryptedAccess True 4.4 ABE(Access-Based Enumeration)の有効化 ABEを有効にすると、ユーザーがアクセス権限を持たないファイルやフォルダが一覧に表示されなくなります。「見えるけど開けない」という状況を防げます。 # Common共有でABEを有効化 Set-SmbShare -Name "Common" -FolderEnumerationMode AccessBased -Force ABEの動作確認 Domain Adminsのみアクセス可能なフォルダを作成し、一般ユーザー(testuser01)から見えないことを確認しました。 Administratorで見えるフォルダ(7個): admin-only/ dedup-test/ dfsn-test.txt replication-test.txt test.txt testuser01.txt vss-test.txt testuser01で見えるフォルダ(6個): dedup-test/ dfsn-test.txt replication-test.txt test.txt testuser01.txt vss-test.txt 項目 結果 ABE有効化前 admin-only が見える ABE有効化後 admin-only が 見えない ✓ 4.5 データ重複排除の有効化 同じデータが複数のファイルに含まれている場合、1つだけ保存して容量を節約する機能です。特に、Office文書やVMイメージなど似たファイルが多い環境で効果を発揮します。 # データ重複排除機能をインストール Install-WindowsFeature -Name FS-Data-Deduplication -IncludeManagementTools # Dドライブで有効化 Enable-DedupVolume -Volume "D:" # 作成直後のファイルも対象にする(デフォルトは3日経過後) Set-DedupVolume -Volume "D:" -MinimumFileAgeDays 0 重複排除の効果測定 同一ファイル(ntoskrnl.exe、約11MB)を10個コピーして効果を測定しました。 # テスト用に同じファイルを10個コピー 1..10 | ForEach-Object { Copy-Item "C:\Windows\System32\ntoskrnl.exe" "D:\Shares\Common\dedup-test\copy$_.exe" } # 重複排除を即時実行 Start-DedupJob -Volume "D:" -Type Optimization -Wait # 効果を確認 Get-DedupStatus -Volume "D:" 項目 値 最適化ファイル数 10個 元のサイズ 約110MB 節約容量 約104MB 節約率 約94% 図6. 重複排除実行後の状態 — 節約率94%(SavedSpaceが約104MB)を確認 4.6 保管時暗号化について 補足 OCIのBlock Volumeはデフォルトで保管時暗号化(AES-256)が有効になっており、ユーザーによる追加設定は不要です。今回の検証でも、Block Volumeを作成した時点で自動的に暗号化が有効な状態です。このため、個別の設定手順は省略しています。 4.7 バックアップポリシーの設定 OCIコンソールでBlock Volume Backup Policyを設定し、日次で自動バックアップを取得します。 ストレージ → ブロック・ボリューム → バックアップ・ポリシー ポリシーを作成(日次、7日間保持) Block Volume(bv-filedata)にポリシーを割り当て 図7. Block Volumeに割り当てたバックアップポリシーの設定内容(7日間保持) バックアップからの復元テスト 実際にバックアップから復元できることを確認しました。 手順: 1. OCIコンソールでBlock Volumeバックアップから新しいボリュームを作成 2. FS01にiSCSIでアタッチ 3. Eドライブとしてマウント 4. データを確認 結果: 項目 Eドライブ(復元) Dドライブ(現在) ファイル数 4個 7個 admin-only なし あり(3/23作成) dedup-test なし あり(3/23作成) vss-test.txt なし あり(3/23作成) バックアップ時点(3/20頃)のデータが正確に復元されました。 5. Phase 3:マルチリージョン構成 このPhaseのゴール FSx for WindowsのマルチAZ構成は「同一リージョン内の2つのAZにファイルサーバーを分散配置し、障害時に自動フェイルオーバーする」という設計です。OCIの東京・大阪リージョンをはじめ多くのリージョンがシングルAD構成のため、これを再現するには リージョンをまたいだ冗長化 が必要になります。DRGによるリージョン間接続、DFS-Rによるファイル同期、DFS-N(Distributed File System Namespace)による透過的なアクセスパスの3点が連携して初めて成立する構成です。 FSx for WindowsのマルチAZ構成では、2つのAZにファイルサーバーを配置し、障害時に自動フェイルオーバーします。 OCIはほとんどのリージョンがシングルADのため、同等の可用性を実現するには マルチリージョン構成 が必要です。 5.1 構成概要 東京・大阪それぞれのリージョンにADサーバーとファイルサーバーを1台ずつ配置します。IPアドレスは以下の通りで、東京側がプライマリ、大阪側がスタンバイという役割分担です。 リージョン ADサーバー ファイルサーバー 東京 AD01(10.0.3.10) FS01(10.0.3.20) 大阪 AD02(172.16.3.10) FS02(172.16.3.20) 5.2 リージョン間接続(リモートVCNピアリング) 東京と大阪のVCNを接続し、プライベートネットワーク経由で通信できるようにします。 東京・大阪それぞれにDRG(動的ルーティング・ゲートウェイ)を作成 リモートVCNピアリングで接続 ルートテーブルを更新 東京VCNのルート 大阪VCNのルート 172.16.0.0/16 → DRG 10.0.0.0/16 → DRG 図8. 東京-大阪間のリモートVCNピアリングが「ピアリング済み」になった状態 5.3 追加ドメインコントローラーの構成 大阪リージョンにセカンダリDCを構築します。 # 大阪ADサーバー(AD02)で実行 # 東京ADに参加するための資格情報を指定 $SafeModePassword = ConvertTo-SecureString "**********" -AsPlainText -Force $DomainCred = Get-Credential -Message "FSXPOC\Administrator のパスワードを入力" Install-ADDSDomainController ` -DomainName "fsx-poc.local" ` -InstallDns:$true ` -Credential $DomainCred ` -SafeModeAdministratorPassword $SafeModePassword ` -Force:$true 5.4 DFS-Rの構成 DFS-R(分散ファイルシステムレプリケーション)を使って、東京-大阪間でファイルを自動同期します。 # 両方のファイルサーバー(FS01・FS02)で実行 Install-WindowsFeature -Name FS-DFS-Replication, FS-DFS-Namespace -IncludeManagementTools # 東京ファイルサーバー(FS01)で実行 # レプリケーショングループを作成 New-DfsReplicationGroup -GroupName "FSx-Replication" New-DfsReplicatedFolder -GroupName "FSx-Replication" -FolderName "Common" # メンバーを追加 Add-DfsrMember -GroupName "FSx-Replication" -ComputerName "FS01.fsx-poc.local" Add-DfsrMember -GroupName "FSx-Replication" -ComputerName "FS02.fsx-poc.local" # 接続を追加 Add-DfsrConnection -GroupName "FSx-Replication" ` -SourceComputerName "FS01.fsx-poc.local" ` -DestinationComputerName "FS02.fsx-poc.local" # フォルダパスを設定(東京をプライマリに) Set-DfsrMembership -GroupName "FSx-Replication" -FolderName "Common" ` -ComputerName "FS01.fsx-poc.local" -ContentPath "D:\Shares\Common" -PrimaryMember $true Set-DfsrMembership -GroupName "FSx-Replication" -FolderName "Common" ` -ComputerName "FS02.fsx-poc.local" -ContentPath "D:\Shares\Common" # 設定の更新 Update-DfsrConfigurationFromAD -ComputerName "FS01.fsx-poc.local" Update-DfsrConfigurationFromAD -ComputerName "FS02.fsx-poc.local" 図9. DFS-Rレプリケーションの同期状態 — 東京・大阪間で「同期済み」になった状態 注意 DFS-Rの設定後、レプリケーションが開始されるまで数分かかる場合があります。うまく同期されない場合は、両方のファイルサーバーでDFSRサービスを再起動( Restart-Service DFSR )してください。 5.5 DFS名前空間の構成 DFS-Nを使うと、ユーザーは \\fsx-poc.local\files という単一のパスでアクセスでき、自動的に近いサーバーに接続されます。 # 東京ファイルサーバー(FS01)で実行 # 名前空間を作成 New-DfsnRoot -Path "\\fsx-poc.local\files" ` -TargetPath "\\FS01.fsx-poc.local\Common" ` -Type DomainV2 # 大阪のターゲットをルートターゲットとして追加 New-DfsnRootTarget -Path "\\fsx-poc.local\files" ` -TargetPath "\\FS02.fsx-poc.local\Common" 図10. DFS名前空間の構成 — FS01・FS02の両ターゲットがOnlineで登録されていることを確認 6. Phase 4:フルマネージド相当の運用自動化 このPhaseのゴール FSx for Windowsの最大の価値のひとつは「運用をAWSに任せられる」点です。パッチ適用・死活監視・障害通知はすべてAWS側が自動で行います。OCI構成でこれを再現するには、OS Management Hub(パッチ)・OCI Monitoring + Alarms(監視・通知)・PowerShellスクリプト+タスクスケジューラ(サービス自動復旧)の3層で自動化を積み上げる設計にしました。 FSx for Windowsでは、パッチ適用、監視、障害復旧などをAWSが担当します。OCI構成でこれを再現するには、自分で自動化を構築する必要があります。 6.1 パッチ適用の自動化(OS Management Hub) OS Management Hubを使うと、Windows Updateの適用をOCIコンソールから管理できます。今回はインスタンスの登録までを確認しました。 図11. OS Management Hubに東京リージョンの2台が「Active」として登録された状態(インスタンス表示名はOCIコンソール上の名称。ホスト名はAD01・FS01に対応。また大阪リージョン側も同様の画面になっている。) 設定の流れと詰まりポイント 有効化までにいくつか事前設定が必要で、順番を間違えると詰まります。 1. プロファイルの作成 OS Management Hubはリージョン固有のリソースです。今回は東京・大阪それぞれのコンパートメントに同じ設定でプロファイルを1つずつ作成しました。 2. 動的グループの作成 ALL {instance.compartment.id = '<コンパートメントOCID>'} OCIDはOCIコンソール → アイデンティティ → コンパートメント → 対象コンパートメントのOCIDをコピーして使用します。 3. IAMポリシーの追加 プロファイルを作成してエージェントを有効化しても、IAMポリシーが不足していると管理コンソールにインスタンスが表示されません。今回は検証のためテナンシ全体を対象にポリシーを作成しています。実運用では最小権限の原則に基づき、対象コンパートメントに絞ることを推奨します。 allow dynamic-group OracleIdentityCloudService/<動的グループ名> to {OSMH_MANAGED_INSTANCE_ACCESS} in tenancy where request.principal.id = target.managed-instance.id allow dynamic-group OracleIdentityCloudService/<動的グループ名> to read instance-images in tenancy allow dynamic-group OracleIdentityCloudService/<動的グループ名> to read instances in tenancy 4. エージェントの有効化 OCIコンソールからエージェントを有効化します。有効化直後はコンソール上のステータスが「実行中」にならない場合がありますが、VMにRDPしてサービスの状態を確認すると Running になっていることがあります。反映に数分〜数十分かかる場合があるため、少し待ってから再確認してください。 補足 インスタンスの登録後は、OS Management Hubのメニューからジョブを作成し、パッチ適用のスケジュールと対象を設定することで自動パッチ適用が可能になります。今回の検証ではインスタンスの登録確認までにとどめており、パッチ適用ジョブの実行は未確認です。 6.2 監視アラームの設定 OCI Monitoringでサーバーの状態を監視し、異常時にアラートを発報します。 図12. OCI Monitoringに登録したアラーム一覧(AD01・FS01のインスタンス停止検知。大阪側も同様に設定。) メトリクス 閾値 意味 インスタンス状態 STOPPED サービス断 今回は検証のため、簡易的にVMのヘルスチェックのメトリクスのみアラーム定義で作成して設定しています。 6.3 サービス監視スクリプト OCIの監視機能はインスタンスレベルの監視ですが、Windowsサービス(SMBなど)の監視は標準では対応していません。PowerShellスクリプトで死活監視と自動復旧を実装します。 # C:\Scripts\monitor-services.ps1 $logFile = "C:\Scripts\monitor-services.log" $services = @( @{Name="LanmanServer"; DisplayName="SMB Server"}, @{Name="Netlogon"; DisplayName="Netlogon"} ) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" foreach ($svc in $services) { $status = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue if ($status.Status -ne "Running") { # サービスが停止していたらログに記録して再起動 "$timestamp - WARNING: $($svc.DisplayName) is not running. Restarting..." | Out-File -Append $logFile Start-Service -Name $svc.Name -ErrorAction SilentlyContinue "$timestamp - INFO: $($svc.DisplayName) restart attempted." | Out-File -Append $logFile } } # 1分ごとに実行するタスクスケジュールを作成 $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) ` -RepetitionInterval (New-TimeSpan -Minutes 1) $action = New-ScheduledTaskAction -Execute "PowerShell.exe" ` -Argument "-ExecutionPolicy Bypass -File C:\Scripts\monitor-services.ps1" $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable Register-ScheduledTask -TaskName "Monitor-Services" ` -Trigger $trigger -Action $action -Principal $principal -Settings $settings 7. Phase 5:障害検証 このPhaseのゴール 構成を「作った」だけでは再現できたとは言えません。FSx for Windowsが保証する自動復旧をOCI構成が実際の障害でどこまで模倣できるか、この検証こそが本記事の核心です。「ファイルサーバーの停止」「SMBサービスの停止」の2シナリオで実際に障害を発生させ、切り替わり時間・復旧時間を実測しました。 FSx for WindowsのマルチAZ構成では、障害発生時に数秒〜数十秒で自動フェイルオーバーします。OCI構成で同等の自動復旧ができるのか、 実際に障害を発生させて検証 しました。 7.1 検証シナリオ 今回は以下の2つのシナリオで障害を疑似的に発生させ、それぞれ自動復旧の挙動を確認しました。なお、インスタンスの完全停止(シナリオ①)の中でOCI Alarmsによるアラート通知の検知についても合わせて確認しています。 # 障害シナリオ 検証内容 1 ファイルサーバー(FS01)の停止 DFS-NでFS02にフェイルオーバーするか。またOCI Alarmsでインスタンス停止を検知できるか 2 SMBサービスの停止 監視スクリプトで自動復旧するか 7.2 検証①:ファイルサーバー停止時のフェイルオーバー 検証手順: 大阪のクライアント(FS02経由Bastion)から \\fsx-poc.local\files にアクセス中 OCIコンソールからFS01(東京)を強制停止 アクセスがFS02(大阪)に切り替わるか確認 検証結果: 項目 結果 フェイルオーバー 成功 切り替わり時間 約1分 アラート通知 約20秒で到達 ユーザー操作 不要(自動切り替え) 図13. FS01停止後にFS02へ切り替わった状態(アクセスパスは \\fsx-poc.local\files のまま) FS01停止後、エクスプローラーの読み込みバーがゆっくりと進み、約1分後にFS02経由でファイル一覧が表示されました。DFS-Nの名前空間により同じパスでアクセスが継続しましたが、切り替わりにかかる時間はFSxのマルチAZとは差があります。 7.3 検証②:SMBサービス停止時の自動復旧 検証手順: # FS01で実行 # 現在時刻を確認 Get-Date -Format "HH:mm:ss" # 17:00:23 # SMBサービスを強制停止 Stop-Service -Name LanmanServer -Force 検証結果: 項目 結果 サービス停止時刻 17:00:23 自動復旧時刻 17:01:12 検知〜復旧時間 約49秒 自動復旧 成功 図14. 監視スクリプトが出力したログ — サービス停止から49秒で自動復旧したことが記録されている ※この画面をキャプチャするまで複数回検証したため、画像下部のログ内に4回分の停止と起動に関するログが記載されております。 7.4 障害検証のまとめ シナリオ FSx OCI構成 差分 ファイルサーバー障害 数秒で自動フェイルオーバー 約1分で切り替え完了(DFS-N) 切り替え時間に差あり アラート通知 CloudWatch連携 約20秒 ほぼ同等 サービス停止 自動検知・復旧(数秒) 約49秒 若干の遅延 インスタンス停止時の復旧 自動フェイルオーバー 本検証構成では手動対応が必要 (アラート通知後にコンソールから再起動) 手動対応必要 インスタンス停止時の復旧について インスタンスが停止した場合、OCIでもOCI Functionsや通知サービスを組み合わせることで、アラート契機に自動再起動する仕組みを構築することは可能です。ただし今回の検証構成ではその自動化は含めていないため、アラート通知後はコンソールからの手動再起動が必要な状態です。 検証から得られた教訓 OCI構成でもDFS-Nを活用することで、ファイルサーバー障害時に同じパスでのアクセスを継続できました。ただし切り替わりに約1分かかっており、FSxのマルチAZ(数秒)と比べると差があります。サービスレベルの監視と自動復旧も、監視間隔を1分に設定することで実用的なレベルになります。 FSxのような「AWSが全責任を持つ」という安心感は得られません。自前構築では「半自動復旧 + 迅速な手動対応」という運用設計が現実的です。 8. 検証結果:各機能の再現度 8.1 機能別の検証結果サマリー カテゴリ 機能 再現度 検証内容 備考 ファイル共有 SMB(Server Message Block)プロトコル ○ SMB共有作成・アクセス確認 Windows Server標準機能 ファイル共有 SMB暗号化 ○ EncryptData: True 確認 設定で有効化 認証・アクセス制御 Active Directory連携 ○ ドメイン参加・認証確認 自己管理ADを構築 認証・アクセス制御 Windows ACL(Access Control List) ○ NTFS権限設定・確認(ABE検証内で実施) NTFS権限で同等 認証・アクセス制御 ABE(Access-Based Enumeration) ○ 権限なしユーザーで非表示確認 設定で有効化 データ保護 シャドウコピー(VSS) ○ ファイル復元を実際に確認 スケジュール設定が必要 データ保護 自動バックアップ ○ バックアップから復元テスト Block Volume Backup Policy データ保護 保管時暗号化 ○ OCIデフォルトで有効(AES-256) 追加設定不要 パフォーマンス データ重複排除 ○ 94%の節約率を確認 Windows Server機能 可用性 マルチAZ構成 ○ FS01停止→約1分でFS02へ切り替え DFS-R/DFS-Nで実現。切り替え時間はFSxとの差あり 運用 フルマネージド(サービス自動復旧) ○ 約49秒で自動復旧 監視スクリプトで実現 運用 フルマネージド(パッチ自動化) △ OS Management Hub設定 インスタンス登録まで確認済み。パッチ適用ジョブの実行は未検証 8.2 再現度の評価 評価の前提 以下の再現度は、本記事で実施した検証項目の範囲内での評価です。FSx for Windowsのすべての機能を網羅的に検証したものではなく、AWSがマネージドサービスとして内部で担保する詳細な実装については評価対象外としています。 領域 再現度 コメント 機能面 95%以上 検証対象とした主要機能はすべて動作確認済み 可用性 75% DFS-R/DFS-Nで切り替え可能だが約1分の遅延あり。FSxの数秒とは差がある マネージド 60〜70% 自動化は可能だが運用工数は発生する 総合 約80% 機能面はほぼ再現可能 9. コスト比較 9.1 前提条件 コスト比較にあたって、以下の条件を前提として試算しています。単価の根拠は末尾の参考資料に記載の各社公式料金ページを参照しています。 項目 値 ストレージ 1TB スループット 32MB/s相当 バックアップ保持 7日間 リージョン 東京(+大阪 for マルチリージョン) 9.2 構成別コスト比較 構成 月額インフラコスト 初期構築工数 月間運用工数 FSx シングルAZ $267 数時間 ほぼゼロ FSx マルチAZ $400〜500 数時間 ほぼゼロ OCI シングル構成 $218 1〜2日 5〜10時間 OCI マルチリージョン $450〜500 2〜3週間 10〜20時間 補足 コスト試算の前提となる単価は各社公式の料金ページを参照しています。詳細は末尾の参考資料をご確認ください。 注意 インフラコストだけを見るとOCI構成が安く見えますが、構築・運用の人件費を含めると、多くのケースでFSxの方がTCO(Total Cost of Ownership)が低くなると考えられます。 10. まとめ 10.1 どこまで再現できたか 「OCIでFSx for Windowsをどこまで再現できるか」の結論: 機能面 :95%以上再現可能(検証対象とした主要機能はすべて動作確認済み) 可用性 :75%再現可能(DFS-R/DFS-Nで切り替え可能だが約1分の遅延あり) マネージド :60〜70%が限界(自動化可能だが運用工数は発生) 総合 :約80%の再現度 10.2 検証で確認できたこと カテゴリ 機能 結果 ファイル共有 SMBプロトコル ○ 成功 ファイル共有 SMB暗号化 ○ 有効化確認 認証・アクセス制御 Active Directory連携 ○ 成功 認証・アクセス制御 Windows ACL ○ 動作確認(ABE検証内で実施) 認証・アクセス制御 ABE ○ 動作確認 データ保護 シャドウコピー(VSS) ○ ファイル復元成功 データ保護 自動バックアップ ○ 復元成功 データ保護 保管時暗号化 ○ OCIデフォルトで有効 パフォーマンス データ重複排除 ○ 94%節約 可用性 マルチAZ相当(フェイルオーバー) ○ 成功(約1分で切り替え) 運用 SMBサービス自動復旧 ○ 約49秒 運用 アラート通知 ○ 約20秒 10.3 FSx for Windowsの価値 今回の検証を通じて、 FSx for Windowsのマネージドサービスとしての価値 が明確になりました。 ゼロ運用 → OCI構成では月10〜20時間の運用工数 SLAによる保証 → OCI構成は自己責任 数クリックで構築 → OCI構成は2〜3週間 パッチ適用の完全自動化 → OCI構成では設定と判断が必要 10.4 選定の考え方 OCI構成を検討する価値があるケース: OCI環境への統一が必須要件 Windows Server運用の体制・ノウハウがある 構築・運用の工数を許容できる パッチ適用のタイミングを完全にコントロールしたい FSxが適切なケース: 運用負荷を最小化したい 高可用性(マルチAZ)が必須 少人数チームで運用する 最終的な判断は、コストだけでなく、 運用体制、リスク許容度、RTO(Recovery Time Objective)要件 を総合的に考慮して行う必要があります。 構築時のトラブルシューティング 今回の検証で遭遇した問題と解決方法をまとめます。 私自身Active Directory(AD)を触るのが初めてだったこともあり、検証の本筋とは異なる部分でエラーが生じた箇所もありましたが、こちらについても参考として記載しております。 問題 原因 解決方法 PowerShellでAccess denied 管理者権限で起動していない スタートメニュー右クリック→「Windows PowerShell (管理者)」 ドメイン作成時にパスワードエラー ローカルAdministratorのパスワード未設定 net user Administrator "パスワード" で事前設定 ドメイン参加後ログインできない ローカルユーザーではログイン不可 FSXPOC\Administrator (バックスラッシュ)でログイン 再起動後にDドライブが消える iSCSI接続の永続化設定漏れ -IsPersistent $true を指定 RDPでログインできない Remote Desktop Usersグループが空、またはネットワークプロファイルがPublic Domain Adminsをグループに追加、プロファイルをPrivateに変更 DFS-Rが同期しない 設定反映に時間がかかる 両サーバーでDFSRサービスを再起動 OS Management Hubが有効化されない IAMポリシー不足 VMが置かれているコンパートメントにポリシー追加 ABEが効かない FolderEnumerationModeがUnrestricted Set-SmbShare -Name "共有名" -FolderEnumerationMode AccessBased 重複排除が効かない MinimumFileAgeDaysがデフォルト3日 Set-DedupVolume -Volume "D:" -MinimumFileAgeDays 0 執筆者 山塚 友貴 NTT西日本 ビジネス営業本部 主にOCIのインフラ設計や構築、運用に携わっています。 保有資格:Oracle Cloud Infrastructure 2024 Architect Professional、AWS Certified Solutions Architect - Professional、Azure Solutions Architect Expert(AZ-305)など。 参考資料 Amazon FSx for Windows File Server ドキュメント Amazon FSx for Windows File Server の料金 Amazon FSx メンテナンスウィンドウの使用 OCI Block Volume ドキュメント OCI OS Management Hub OCI Cost Estimator DFS-R の概要 商標 OracleとJavaは、Oracle Corporationおよびその関連企業の登録商標です。 Amazon Web Services、AWS、Amazon FSxは、米国その他の諸国における、Amazon.com, Inc.またはその関連会社の商標です。 Microsoft、Windows、Windows Server、Active Directoryは、米国Microsoft Corporationの米国およびその他の国における登録商標または商標です。
はじめに NTT西日本の近藤です。 本記事では、Cloudflare が提供する Cloudflare One(Cloudflare Zero Trust) において、 Identity Provider(以下、IdP。ユーザー認証を担う外部認証基盤)として Okta を OpenID Connect(OIDC)で連携する方法 を紹介します。 Cloudflare One は、ゼロトラストの考え方を前提としたプラットフォームであり、 ユーザー認証を Cloudflare 側で実施するのではなく、 外部 IdP によって認証された結果をもとにアクセス制御を行う構成 が基本となります。 そのため、IdP 連携は Cloudflare One を利用する上で重要な初期設定の一つです。 なお、本記事で扱う IdP(Identity Provider) とは、 ユーザーが「本人であること」を確認する役割を担う外部認証基盤を指します。 ID・パスワード認証や MFA(多要素認証)などは IdP 側で実施され、 Cloudflare One は「認証結果」を受け取ってアクセス可否を判断します。 本記事では、 Okta 側で Cloudflare One 連携用アプリケーションを追加する流れ Cloudflare One 側で Okta を IdP として設定する流れ 各設定画面で「何をしているのか」「どの値が必要なのか」 を、 実際の管理画面スクリーンショットに沿って 解説します。 なお、本記事では OIDC を用いた連携構成 を対象とし、 SAML との機能比較や詳細な方式選定は行いません。 あくまで「Cloudflare One の管理画面上で何を設定しているのかを理解する」ことを目的としています。 本記事は 2026 年 4 月時点の情報 に基づいています。 対象読者 本記事が想定する対象読者は以下の通りです。 Cloudflare One(Cloudflare Zero Trust)を検証・導入している方 Okta を IdP として既に利用している、または検討している方 ゼロトラスト環境における IdP 連携の具体的な設定イメージを把握したい方 目次 はじめに 対象読者 目次 1. 背景・目的 2. Cloudflare One における IdP 連携の考え方 3. Okta 側の設定 3.1 アプリケーション一覧の表示 3.2 App Integration Catalog の検索 3.3 Cloudflare One 連携アプリの詳細表示 3.4 Cloudflare One アプリケーションの追加 3.5 一般設定(General Settings) 3.6 ユーザー/グループの割り当て 3.6.1 ユーザーへの割り当て 3.7 Sign On 設定(OIDC) 4. Cloudflare One 側の設定 4.1 Cloudflare One ダッシュボード 4.2 Identity Provider の一覧 4.3 IdP の追加 4.4 Okta 連携設定画面 5. 認証動作の確認 6. SSO設定後のユーザーエクスペリエンス 7. 技術的な補足 8. まとめ 執筆者 参考資料・出典 商標 免責事項 1. 背景・目的 Cloudflare One におけるアクセス制御は、「ネットワークの内外」ではなく ユーザーのアイデンティティを起点に制御する ことを前提としています。 Cloudflare One 自体はユーザー認証を行わず、 外部 IdP(Okta など)によって認証された結果をもとにアクセス可否を判断します。 Okta と Cloudflare One の連携方式には OIDC と SAML がありますが、 Cloudflare One の管理画面では OIDC がデフォルトとなっており、 設定項目も比較的シンプルです。 【Tips】OIDC を前提とする理由 Cloudflare One の管理画面は OIDC を前提とした構成になっており、 まずは OIDC を対象に設定内容を追うことで、 各項目がどの役割を持っているのかを理解しやすくなります。 本記事では、 OIDC を用いた Okta × Cloudflare One 連携 を対象に、 管理画面での設定内容を整理することを目的としています。 2. Cloudflare One における IdP 連携の考え方 Cloudflare Oneにおける認証の役割分担は次の通りです。 Cloudflare One: アプリケーションへのアクセス要求を受け付け、認証を要求する Okta(IdP): ユーザー認証(ID・パスワード・MFA など)を実施する Cloudflare One: 認証結果を基にアクセスポリシーを評価し、許可または拒否 この構成により、 認証ポリシーは IdP 側に集約 Cloudflare 側はアクセス制御に専念 という疎結合な設計が実現されます。 【Tips】なぜ認証とアクセス制御を分離するのか IdP 側で MFA の追加や条件変更を行っても、 Cloudflare One 側の設定を変更せずに挙動を反映できる点は、 ゼロトラスト設計における重要な考え方です。 3. Okta 側の設定 ここからは、Okta 管理コンソールでの設定を順に確認します。 3.1 アプリケーション一覧の表示 Okta 管理コンソールで Applications を開きます。 この画面では、既存のアプリケーションと新規追加が行えます。 3.2 App Integration Catalog の検索 「Browse App Catalog」を選択し、検索欄に cloudflare と入力します。 検索結果から Cloudflare One を選択します。 3.3 Cloudflare One 連携アプリの詳細表示 Cloudflare One のアプリ詳細画面が表示されます。 ここでは、 対応しているシングルサインオン方式 アプリケーションの概要 が確認できます。OIDC に対応していることが分かります。 3.4 Cloudflare One アプリケーションの追加 「Add Integration」を選択します。 これにより、Okta テナント内に Cloudflare One 用のアプリケーションが作成されます。 3.5 一般設定(General Settings) アプリケーションの基本設定画面が表示されます。 この画面では、以下を設定します。 Application label : Okta 管理画面やユーザー画面に表示されるアプリ名 Team Domain : Cloudflare One 側のチームドメイン 【Tips】Team Domain を設定する意味 Team Domain は、 「この Okta アプリがどの Cloudflare One 環境向けの認証か」を Okta 側が識別するための情報です。 値が一致していない場合、後続の認証フローが正しく動作しません。 入力後、「Done」を選択します。 3.6 ユーザー/グループの割り当て 作成した Cloudflare One アプリケーションに対して、 認証を許可するユーザーやグループを割り当てます。 【Tips】割り当てが必要な理由 Okta では、アプリケーションごとに 「どのユーザーがサインイン可能か」を明示的に管理します。 ユーザーやグループを割り当てていない場合、 設定が正しくても認証は失敗します。 3.6.1 ユーザーへの割り当て 「Assign」→「Assign to People」を選択します。 対象ユーザーを選択し、「Assign」を押下します。 3.7 Sign On 設定(OIDC) 次に、「Sign On」タブを開きます。 ここで確認できる Client ID と Client Secret は、 後ほど Cloudflare One 側の設定で使用します。 【Tips】Client ID / Client Secret の役割 これらはユーザーの ID やパスワードではなく、 Cloudflare One が 「正規に登録されたアプリケーションである」ことを Okta に示すための識別情報です。 4. Cloudflare One 側の設定 続いて、Cloudflare One 側の設定を行います。 4.1 Cloudflare One ダッシュボード Cloudflare ダッシュボードから Cloudflare One を開きます。 4.2 Identity Provider の一覧 左メニューから以下を選択します。 Integrations Identity providers 初期状態では、One-time PIN のみが表示されている場合があります。 4.3 IdP の追加 「Add an identity provider」を選択します。 IdP の一覧から Okta を選択します。 4.4 Okta 連携設定画面 Okta の設定画面が表示されます。 ブログ掲載用のスクリーンショットでは入力値を黒塗りしています。 ここでは各項目の意味を整理します。 項目 内容 Name Cloudflare One のログイン画面に表示される IdP 名 App ID Okta 側で発行された Client ID Client secret Okta 側で発行された Client Secret Okta account URL Okta テナントの URL 【Tips】Okta account URL について この URL は、 Cloudflare One が認証リクエストを送信する先の Okta テナントを識別するための情報です。 本番・検証環境を使い分けている場合は特に注意が必要です。 5. 認証動作の確認 設定後、Cloudflare One のテスト画面を開きます。 「Your connection works!」と表示されていれば、 Okta と Cloudflare One の OIDC 連携は正常に動作しています。 【Tips】この画面が示していること Cloudflare One が Okta から ユーザー情報を OIDC(JWT)として 正しく取得できていることを示しています。 6. SSO設定後のユーザーエクスペリエンス SSOの設定が完了した後、実際にユーザーがアプリケーションへアクセスする際のログインフローは以下のようになります。 1 Cloudflare Oneのログイン画面 ユーザーがCloudflare Oneアプリケーションにアクセス(今回はWARPにアクセス)すると、認証画面が表示されます。ここで設定したIdP(Okta)でのログインを選択し、ボタンをクリックします。 2 Oktaへリダイレクトと認証実施 ボタンをクリックすると、Oktaのログイン画面に自動的にリダイレクトされます。ユーザーはここでOktaのID・パスワードの入力や、MFA(多要素認証)などの認証プロセスを実施します。 3 RP側に戻されてログイン完了 Oktaでの認証が成功すると、再びCloudflare One側にリダイレクトされます。認証情報が正しく評価され、目的のアプリケーションへのアクセスが可能になります。 7. 技術的な補足 本構成は RP Initiated OIDC のフローを採用しています。 認証の開始点は Cloudflare One Okta が認証結果を JWT として返却 Cloudflare Oneがアクセスポリシーを評価 【Tips】ゼロトラスト設計上の利点 IdP 側の認証ポリシーを変更しても、 Cloudflare One 側の設定を変更せずに反映できる点は、 ゼロトラスト構成における大きなメリットです。 8. まとめ 本記事では、Cloudflare One において IdP として Okta を OIDC で連携する設定内容 を、 実際の管理画面スクリーンショットに沿って紹介しました。 Cloudflare One 導入時の IdP 設定を検討する際の 参考情報として活用いただければ幸いです。 執筆者 近藤 隆太 (NTT西日本 エンタープライズビジネス営業部 N&S部門 N&S推進担当(福岡)) 九州・沖縄エリアのクラウド・セキュリティ案件推進に携わっています。 AWS Community Builder (AI Engineering) 2025 Japan AWS Top Engineers (Services) 2024 Japan AWS Jr. Champions 参考資料・出典 Cloudflare One ドキュメント https://developers.cloudflare.com/cloudflare-one/ Okta OpenID Connect 概要 https://developer.okta.com/docs/concepts/oauth-openid/ 商標 「Cloudflare」「Cloudflare One」は、Cloudflare, Inc. の商標または登録商標です。 「Okta」は、Okta, Inc. の商標または登録商標です。 免責事項 本記事は情報提供を目的としたものであり、 記載内容の正確性、完全性、将来的な動作を保証するものではありません。
はじめに NTT西日本の近藤です。 AWSでシステムを構築する際、意外と悩まされるのがネットワークのCIDR設計ではないでしょうか。 私自身も、後からアカウントやVPCが増えたタイミングで 「最初のCIDR設計、もう少し考えておけばよかった…」と感じた経験があります。 CIDRは一度決めると簡単には見直せない要素であり、 その判断が後工程に影響しやすい、設計上の“前提条件”の一つです。 AWSはネットワーク設計の自由度が高く、VPCやサブネットを柔軟に構成できる一方で、 CIDRの割り当てを起点として構成全体が決まっていくため、 構築初期の設計が数年後の拡張性や運用性に影響するケースも少なくありません。 本記事では、AWS上でシステムを構築・運用する際に重要となる ネットワークCIDR設計の考え方と、将来的な拡張を見据えたアドレス管理のポイント について整理します。 特に、以下のような環境ではCIDR設計の重要性が高まります。 システムやアカウントの増加が見込まれる 複数リージョンやDR構成を前提としている オンプレミス環境や外部ネットワークとの接続を想定している 本記事では、AWS公式ドキュメントで示されている一般的な設計指針を踏まえつつ、 AWS環境全体からサブネットまでを段階的に整理するCIDR設計アプローチ を一例として紹介します。 なお、本記事で紹介する構成やCIDRは あくまで一例 です。 システム要件や将来計画に応じて、適切に設計を見直す前提でお読みください。 本記事は 2026年3月時点の情報 に基づいています。 対象読者 本記事が想定する対象読者は以下の通りです。 AWSでネットワーク設計・レビューを担当するエンジニア VPCやサブネットのCIDR設計を一通り経験した方 将来のシステム拡張やアカウント増加を見据えた設計を行いたい方 AWS Solutions Architect – Associate 相当の基礎知識をお持ちの方 目次 はじめに 対象読者 目次 1. AWSネットワーク設計においてCIDRが重要となる理由 2. AWS環境全体で利用するCIDR設計 AWS全体CIDR設計の一例 3. リージョン単位でのCIDR割り当て リージョン分割のメリット 割り当て例 4. VPC単位でのCIDR設計 5. AZ・サブネット設計の考え方 6. AWSにおける予約IPアドレスの仕様 利用できないIPアドレス(例:10.0.0.0/24) 7. (発展)IPAMを前提としたCIDR管理 8. 本記事の位置付けと注意事項 9. まとめ 執筆者 参考資料・出典 商標 免責事項 1. AWSネットワーク設計においてCIDRが重要となる理由 AWSのネットワーク設計において、CIDR設計は全体構成の土台となる要素です。 VPCやサブネットは後から追加できる場合もありますが、 一度割り当てたCIDRそのものを柔軟に変更することは難しい という制約があります。 そのため、CIDR設計では次のような点を意識した検討が重要になります。 将来的なリソース増加やシステム追加 リージョン追加や災害対策構成への対応 ネットワーク統合や再構成のしやすさ 本記事では、CIDR設計を以下の粒度で段階的に整理していきます。 設計レイヤー 主な目的 AWS環境全体 外部接続・全体設計の整理 リージョン 障害ドメイン単位での分離 VPC 環境・用途ごとの分離 Availability Zone(AZ)・サブネット 可用性と役割設計 2. AWS環境全体で利用するCIDR設計 最初に検討するのは、AWS環境全体で利用するIPアドレス帯です。 オンプレミス環境や他クラウドと接続する可能性がある場合、 最も重要となるのが CIDRの重複を避けること です。 重複が発生すると、Site-to-Site VPNやDirect Connect、VPCピアリングなどで制約が生じます。 AWS全体CIDR設計の一例 用途 CIDR例 オンプレミス環境 10.0.0.0/8 AWS環境全体 172.16.0.0/12 AWSでは、ロードバランサーやNAT Gateway、Amazon EKSなど、 間接的にIPアドレスを消費するサービスが多く存在します。 そのため、必要最小限ではなく 将来を見据えて余裕を持ったCIDRを確保する 設計が選択されることがあります。 3. リージョン単位でのCIDR割り当て AWSリージョンは、物理的・論理的に独立した障害ドメインとして設計されています。 CIDRもリージョン単位で整理しておくことで、設計・運用の見通しが良くなります。 リージョン分割のメリット 障害影響範囲を論理的に把握しやすい DR(Disaster Recovery)構成やリージョン間接続の検討が容易 IPアドレス管理が属人化しにくい 割り当て例 リージョン CIDR例 東京リージョン 172.16.0.0/14 大阪リージョン 172.20.0.0/14 このようにリージョン単位でCIDRを整理することで、 将来的なリージョン追加時にも全体設計を崩しにくくなります。 4. VPC単位でのCIDR設計 リージョン内では、用途や役割ごとに複数のVPCを作成するケースが一般的です。 本番(PRD)環境用VPC 災害対策(DR)環境用VPC 検証・Sandbox用VPC VPCのプライマリCIDRは後から変更できないため、 サブネット追加やサービス拡張を見据えたサイズ設計が重要になります。 なお、AWSではVPCにセカンダリCIDRブロックを追加することも可能です。 しかし、後付けでCIDRを追加すると、アドレス設計の一貫性が崩れたり、 運用やセキュリティポリシーが複雑化するケースもあります。 このため、実務では「後から追加できる」ことを前提にするのではなく、 初期設計の段階で将来を見据えたCIDR設計を行うことが重要 と考えられます。 観点 目的 環境分離 障害・影響範囲の限定 セキュリティ ポリシー適用を容易に 運用性 構成把握・管理のしやすさ 5. AZ・サブネット設計の考え方 AWSでは高可用性を確保するため、 複数AZを前提とした設計 が基本となります。 サブネット設計では、以下のような方針が取られることが多くなります。 同一用途のサブネットを各AZに配置 AZごとに同じCIDRサイズを割り当てる 将来的なスケールアウトを考慮する AZごとにCIDRサイズがばらついていると、 障害対応や構成把握が難しくなるため、 多少のアドレス余裕を許容し、構成を揃える ことが実務上有効な場合もあります。 本構成例では、リージョン内ネットワークの集約点として Transit Gateway(TGW)を利用する想定とし、 TGW専用のサブネットを各AZに配置しています。 6. AWSにおける予約IPアドレスの仕様 AWSのIPv4サブネットでは、 CIDR内のすべてのIPアドレスを利用できるわけではありません 。 各サブネットごとに、以下のIPアドレスがAWSによって予約されています。 利用できないIPアドレス(例: 10.0.0.0/24 ) IPアドレス 用途 10.0.0.0 ネットワークアドレス 10.0.0.1 VPCルーター 10.0.0.2 AWS提供DNS 10.0.0.3 AWS予約(将来用途) 10.0.0.255 ブロードキャスト そのため、 /24 サブネットで利用可能なIPは 251個 となります。 CIDR 総IP数 利用可能IP数 /28 16 11 /24 256 251 /22 1024 1019 特に小さなCIDRでは、この予約分が相対的に大きな制約となるため、 ENIを多用する構成やコンテナ基盤では設計時の考慮が必要です。 7. (発展)IPAMを前提としたCIDR管理 環境規模が大きくなるにつれ、 CIDR管理そのものが運用課題となるケース があります。 AWSでは、この課題に対応する仕組みとして Amazon VPC IP Address Manager(IPAM) が提供されています。 IPAMを利用することで、以下のような管理が可能になります。 CIDR割り当て状況の一元管理 リージョン・VPC単位での可視化 意図しないCIDR重複の防止 IPAMでは、 全体プール → リージョンプール → VPC割り当て といった階層構造でCIDRを管理できるため、 本記事で紹介した設計レイヤーとの親和性も高いと言えます。 なお、IPAMはあくまで設計・管理を支援する仕組みであり、 すべての環境で必須となるものではありません。 組織規模や運用体制に応じて採用可否を検討することが重要です。 8. 本記事の位置付けと注意事項 本記事で紹介したCIDR設計は、 AWSネットワーク設計における一つの参考例 です。 利用するAWSサービス システム規模やトラフィック特性 運用体制や将来計画 によって、最適な設計は異なります。 本記事の内容をそのまま適用するのではなく、 設計検討時の参考情報としてご活用ください。 なお、本記事の記載内容に基づく構成や動作について、 筆者および所属組織が保証するものではありません。 9. まとめ AWSのCIDR設計は、後から見直しが難しい重要な設計要素です。 AWS環境全体からリージョン、VPC、サブネットへと 段階的に整理して設計することで、 将来の拡張や運用フェーズを見据えた構成を検討しやすくなります。 本記事が、AWSネットワーク設計を検討されている方の一助となれば幸いです。 執筆者 近藤 隆太 (NTT西日本 エンタープライズビジネス営業部 N&S部門 N&S推進担当(福岡)) 九州・沖縄エリアのクラウド・セキュリティ案件推進に携わっています。 AWS Community Builder (AI Engineering) 2025 Japan AWS Top Engineers (Services) 2024 Japan AWS Jr. Champions 参考資料・出典 Amazon VPC ユーザーガイド https://docs.aws.amazon.com/vpc/ Subnet sizing for IPv4 https://docs.aws.amazon.com/vpc/latest/userguide/subnet-sizing.html Amazon VPC IP Address Manager https://docs.aws.amazon.com/vpc/latest/ipam/ 商標 「AWS」「Amazon VPC」「Amazon VPC IP Address Manager」は、Amazon Web Services, Inc.またはその関連会社の商標または登録商標です。 免責事項 本記事は情報提供を目的としたものであり、 記載内容の正確性、完全性、将来的な動作を保証するものではありません。 本記事の内容を利用したことにより生じたいかなる損害についても、 筆者および所属組織は責任を負いません。 ``
はじめに NTT西日本株式会社2年目社員の山塚です。前編では、OCIアラート通知のJSON問題を解決するための設計思想と、OCI Generative AI(GenAI)プライベートエンドポイントを活用したクロスリージョン構成の全体像を解説しました。 後編となる本記事では、実際の Functionsの実装コード 、 プロンプト設計のポイント 、そして 検証結果 について詳説します。特に、「AIが生成した要約を運用でどう活かすか」「どんなハマりポイントがあるか」という実践的な内容にフォーカスします。 前編:東京リージョンからGenAIプライベートエンドポイントへ。クロスリージョン構成で実現するアラート自動要約基盤 後編:Functionsによる通知処理の実装とプロンプト設計。検証結果から見る運用改善効果(本記事) ※本記事は2026年2月時点の情報に基づきます。 ※本記事では生成AIを活用した内容が含まれており、AIによる情報の生成過程でハルシネーション(事実に基づかない情報の生成)が発生する可能性があります。AIが生成した「詳細分析」や「推奨されるアクション」は参考情報としてご活用いただき、実際の運用判断は必ずご自身で確認のうえ行ってください。 対象読者 本記事は、以下の方を対象としています。 OCI Functionsの実装例を知りたいエンジニア GenAIのプロンプト設計に興味がある方 アラート運用の効率化を具体的な数値で検討したい方 前提知識: 本記事では、以下の知識があることを前提としています。 Pythonの基本的な文法 OCI Functionsのデプロイ経験(fn deploy コマンドの実行) OCI SDKの基本的な使い方 前編で解説したクロスリージョン構成の理解 目次 Functionsの処理フロー 開発・デプロイの流れ 動的グループとIAMポリシー 動的グループの設定 IAMポリシーの設定 Functionsの実装ポイント 環境変数で設定を外出し リソースプリンシパル認証 ONS経由の「封筒」を展開する 通知タイプの判定 アーカイブ保存 プロンプト設計のポイント 通知タイプに応じた指示の切り替え 出力形式の固定 パラメータ設定 GenAI呼び出しの実装 使用モデル:Command R+ Before / After:実際の要約サンプル テストしたアラートの種類 検証結果:レイテンシとコスト レイテンシ コスト ハマりポイント・Tips DNS設定を忘れずに セキュリティリストはサブネット内通信も許可が必要 Functionsのタイムアウト設定 ONS経由の「封筒」展開を忘れない エラー時のフォールバック 今後の展望 まとめ 1. Functionsの処理フロー OCI Functionsでは、以下の流れで処理を行います。 図1. Functionsの処理フロー 各ステップの詳細は以下の通りです。 ステップ 処理内容 補足 ① JSON通知を受信 アラーム定義、イベント、Connector HubからJSON形式の通知を受信 ② ONS(Oracle Notifications Service)封筒を展開 トピック経由の場合、 {"Type": "Notification", "Message": "..."} 形式で包まれているため展開 アラーム定義経由の場合のみ ③ バケットに原本を保存 受信したJSONをそのままObject Storageに保存 ハルシネーション対策 ④ 通知タイプを判定 JSONの構造から、アラーム/イベント/ログ検知を判定 後続のプロンプト選択に使用 ⑤ GenAI APIを呼び出し プライベートエンドポイント経由でGenAIに要約をリクエスト RPC経由で大阪へ ⑥ 通知用トピックに送信 要約結果をトピックに送信し、メールなどに配信 2. 開発・デプロイの流れ Functionsのデプロイは以下の手順で行いました。 図2. 開発・デプロイの流れ ディレクトリ構成 my-function/ ├── func.py # メインのFunctionsコード ├── func.yaml # Functions設定ファイル ├── requirements.txt # Python依存パッケージ └── Dockerfile # (カスタムイメージの場合) func.yaml の例 schema_version : 20180708 name : alert-summarizer version : 0.0.1 runtime : python build_image : fnproject/python:3.9-dev run_image : fnproject/python:3.9 entrypoint : /python/bin/fdk /function/func.py handler memory : 256 timeout : 120 requirements.txt の例 fdk>=0.1.50 oci>=2.90.0 デプロイコマンド # OCIRへのログイン docker login <リージョンコード>.ocir.io # Functionsのデプロイ fn deploy --app <アプリケーション名> 3. 動的グループとIAMポリシー FunctionsからGenAIやObject Storage、通知サービスを呼び出すには、適切な権限が必要です。今回は 動的グループ と IAMポリシー を使って権限を付与しました。 3.1 動的グループの設定 動的グループは、特定の条件に一致するリソースを自動的にグループ化する機能です。Functionsを動的グループに所属させることで、コード内にクレデンシャルを埋め込む必要がなくなります。 【動的グループのマッチングルール】 ※動的グループ名は参考例として記載しています。 名前: alert-summarizer-functions マッチングルール: ALL {resource.type = 'fnfunc', resource.compartment.id = 'ocid1.compartment.oc1..xxxxx'} 3.2 IAMポリシーの設定 動的グループに対して、必要なリソースへのアクセス権限を付与します。 【ポリシーステートメント】 Allow dynamic-group alert-summarizer-functions to manage objects in compartment <コンパートメント名> Allow dynamic-group alert-summarizer-functions to read buckets in compartment <コンパートメント名> Allow dynamic-group alert-summarizer-functions to use ons-topics in compartment <コンパートメント名> Allow dynamic-group alert-summarizer-functions to use generative-ai-family in compartment <コンパートメント名> 各ポリシーの解説: ポリシー 用途 補足 manage objects Object Storageへのオブジェクト操作 ログの保存に必要 read buckets バケットのリスト取得 manage objects だけでは不十分 use ons-topics トピックへのメッセージ送信 要約結果の配信に必要 use generative-ai-family GenAI APIの呼び出し 大阪リージョンのGenAIにも適用される 注意点: manage objects と read buckets は別々のポリシーが必要 オブジェクトの操作権限( manage objects )だけでは、バケット自体をリストする操作ができません。 read buckets を追加することで、バケット名の取得やバケット一覧の参照が可能になります。 4. Functionsの実装ポイント 本章では、実装の要点となる処理を抜粋して解説します。各処理の考え方や設計の意図が伝わるよう、重要な箇所にフォーカスして紹介します。 4.1 環境変数で設定を外出し OCIDやエンドポイントなどの設定値は、Functionsの構成(Configuration)で環境変数として設定します。コードにハードコーディングしないことで、環境ごとの差分を吸収できます。 import os # --------------------------------------------------------------------------- # 設定値 (Configuration) # コンソールの Functions > Configuration で以下のキーを設定してください。 # --------------------------------------------------------------------------- # 【必須設定】ユーザー環境依存 (東京リージョン) DESTINATION_TOPIC_OCID = os.environ.get( "DESTINATION_TOPIC_OCID" ) ARCHIVE_BUCKET_NAME = os.environ.get( "ARCHIVE_BUCKET_NAME" ) ARCHIVE_NAMESPACE = os.environ.get( "ARCHIVE_NAMESPACE" ) # 【GenAI設定】大阪リージョン GENAI_ENDPOINT = os.environ.get( "GENAI_ENDPOINT" , "https://inference.generativeai.ap-osaka-1.oci.oraclecloud.com" ) # Command R+ のモデルOCID MODEL_ID = os.environ.get( "MODEL_ID" , "ocid1.generativeaimodel.oc1.ap-osaka-1.amaaaaaxxxxxxxx" ) # GenAI実行権限を持つコンパートメントID GENAI_COMPARTMENT_ID = os.environ.get( "GENAI_COMPARTMENT_ID" , "ocid1.compartment.oc1..aaaaaaaxxxxxxxx" ) プライベートエンドポイントを使用する場合: # プライベートエンドポイントを使用する場合は、 # DNS接頭辞を含むFQDNを指定 GENAI_ENDPOINT = os.environ.get( "GENAI_ENDPOINT" , "https://mygenai.inference.generativeai.ap-osaka-1.oci.oraclecloud.com" ) 図3. Functionsの構成画面(環境変数設定) ※「MODEL_ID」については、事前に入力したcommand R+を使用したため構成の設定はしていませんが、別のモデルを使用したい場合、構成で設定すると設定したほうが優先されます。その点だけお気を付けください。 4.2 リソースプリンシパル認証 OCI SDKの認証には、リソースプリンシパルを使用します。これにより、Functionsの実行環境で自動的に認証が行われます。 import oci def handler (ctx, data: io.BytesIO = None ): # リソースプリンシパル認証 # Functionsの実行環境で自動的に認証情報が取得される signer = oci.auth.signers.get_resource_principals_signer() # 各クライアントで signer を指定 os_client = oci.object_storage.ObjectStorageClient( config={}, signer=signer ) ons_client = oci.ons.NotificationDataPlaneClient( config={}, signer=signer ) genai_client = oci.generative_ai_inference.GenerativeAiInferenceClient( config={}, signer=signer, service_endpoint=GENAI_ENDPOINT # プライベートエンドポイントを指定 ) # 以降の処理... リソースプリンシパル認証のメリット: メリット 詳細 クレデンシャル不要 コードにAPIキーやシークレットを埋め込まない 自動ローテーション 認証情報は自動的に更新される IAMポリシーで制御 動的グループとポリシーで権限を管理 4.3 ONS経由の「封筒」を展開する アラーム定義からトピック経由でFunctionsを呼び出すと、JSONが「封筒」で包まれた形式で届きます。この処理を見落とすと、パースがうまくいきません。 def handler (ctx, data: io.BytesIO = None ): # ... 認証処理 ... # 1. データ取得 try : raw_body = data.getvalue() body_str = raw_body.decode( 'utf-8' ) body_json = json.loads(body_str) except Exception as e: logging.getLogger().error(f "JSON Load Error: {e}" ) return response.Response(ctx, status_code= 400 , response_data= "Invalid JSON" ) # 2. ONS(トピック)経由のラップ(封筒)を剥がす処理 # アラームがトピックを経由すると以下の形式で届く # {"Type": "Notification", "Message": "..."} target_payload = body_json # デフォルトはそのまま try : if ( isinstance (body_json, dict ) and body_json.get( "Type" ) == "Notification" and "Message" in body_json): logging.getLogger().info( "Detected ONS Notification envelope. Unwrapping..." ) # Messageの中身は文字列化されたJSONなので、再度パースする target_payload = json.loads(body_json[ "Message" ]) except Exception as e: logging.getLogger().warning(f "ONS Unwrap Warning: {e} - Proceeding with original body." ) # パース失敗時は元のデータをそのまま使う ONS封筒の構造: { " Type ": " Notification ", " MessageId ": " xxx ", " TopicArn ": " xxx ", " Subject ": " high-cpu-alarm ", " Message ": " { \" type \" : \" OK_TO_FIRING \" , \" alarmMetaData \" :[...]} ", " Timestamp ": " 2026-02-14T05:30:00.000Z " } Message フィールドの中身は 文字列化されたJSON であるため、再度 json.loads() でパースする必要があります。 4.4 通知タイプの判定 受信したJSONの構造から、どのサービスからの通知かを判定します。これにより、後続のGenAI呼び出しで適切なプロンプトを選択できます。 def parse_generic_input (body): """ 入力JSONに応じて適切な情報を抽出するパーサー """ info = {} # デフォルト値 info[ 'payload_str' ] = json.dumps(body, ensure_ascii= False , indent= 2 ) info[ 'type' ] = '【その他通知】' info[ 'title' ] = 'Notification' info[ 'timestamp' ] = datetime.now().isoformat() # --- 1. OCI Alarms (Monitoring) --- # alarmMetaData キーの存在で判定 if isinstance (body, dict ) and 'alarmMetaData' in body: info[ 'type' ] = '【アラーム】' # ... 省略 ... # --- 2. OCI Events (CloudEvents) --- # eventType キーの存在で判定 elif isinstance (body, dict ) and 'eventType' in body: info[ 'type' ] = '【イベント】' # ... 省略 ... # --- 3. Connector Hub (OCI Logging) --- # リスト形式で届く elif isinstance (body, list ) and len (body) > 0 : info[ 'type' ] = '【ログ検知】' # ... 省略(先頭10件に絞ってトークン節約)... return info 各通知タイプの判定ロジック: 通知タイプ 判定条件 主要なキー アラーム alarmMetaData キーの存在 severity, title, body イベント eventType キーの存在 eventType, eventTime, data ログ検知 リスト形式 先頭要素を解析 ※Connector Hubについては、実際にFunctionsからObject Storageに格納されたJSONを見ると、ログの文字数が100万文字以上になっていたため先頭10件に絞ってGenAIに渡すことでトークン超過を防いでいます。 4.5 アーカイブ保存 生ログはバケットに保存します。ファイル名には日時と通知タイプを含め、後から探しやすいようにしています。 def archive_raw_log (signer, raw_bytes, body_json): """ Object Storageへの保存処理 (東京) ファイル名: YYYYMMDD/HHMMSS_Type_UUID.json """ os_client = oci.object_storage.ObjectStorageClient(config={}, signer=signer) # JSTで日時を取得 now = datetime.now(timezone(timedelta(hours= 9 ))) folder = now.strftime( '%Y%m%d' ) timestamp = now.strftime( '%H%M%S' ) # 通知タイプを判定してタグを決定 source_tag = "Unknown" if isinstance (body_json, dict ): if 'alarmMetaData' in body_json: source_tag = "Alarm" elif 'eventType' in body_json: source_tag = "Event" elif isinstance (body_json, list ): source_tag = "ConnectorHub" # ファイル名を生成 object_name = f "{folder}/{timestamp}_{source_tag}_{uuid.uuid4().hex[:8]}.json" # Object Storageに保存 os_client.put_object( namespace_name=ARCHIVE_NAMESPACE, bucket_name=ARCHIVE_BUCKET_NAME, object_name=object_name, put_object_body=raw_bytes, content_type= "application/json" ) return object_name 5. プロンプト設計のポイント GenAIに要約させる際のプロンプト設計は、出力品質を大きく左右します。今回は以下の点を意識しました。 5.1 通知タイプに応じた指示の切り替え アラーム、イベント、ログ検知など、通知タイプによって伝えるべき情報が異なります。プロンプト内で条件分岐し、それぞれに適した「概要」の書き方を指示しています。 def build_prompt (parsed_info, archive_filename): """ 通知タイプに応じたプロンプトを構築 """ notification_type = parsed_info.get( 'type' , '' ) # デフォルトの指示 specific_instruction = """ 「[リソース名]にて、[事象の内容]が発生しました」という形式で記述すること。 """ if notification_type == '【ログ検知】' : specific_instruction = """ - 「[HostName]上で稼働する[サービス名/プロセス名]にて、[エラー内容]が発生しました」という形式で記述すること。 - JSON内の "ServiceLogPath" を参考に、どのミドルウェアのログか推測してサービス名を補完すること。 """ elif notification_type == '【アラーム】' : specific_instruction = """ - 「[対象リソース名]にて、[アラート名](現在の値: [数値])が発生しました」という形式で記述すること。 - 緊急度(Severity)を強調すること。 """ elif notification_type == '【イベント】' : specific_instruction = """ - 「[リソース名]に対して、[イベント操作内容]が実行されました」という形式で記述すること。 - 誰が(Principal)行った操作かが分かる場合は記載すること。 """ # プロンプト全体を構築(以下省略) # ... 注意:AIが生成する「詳細分析」や「推奨されるアクション」について これらの項目はAIが推測に基づいて生成するものであり、必ずしも正確とは限りません。実際の運用判断を行う際は、元のJSONログを確認し、ご自身で内容を検証してください。 5.2 出力形式の固定 一貫性のある出力を得るために、プロンプト内に出力構成を明示しています。 【期待する出力形式】 1. [概要] ... 通知タイプに応じた形式 ... 2. [詳細分析] ... 技術的な詳細と推測される原因 ... 3. [推奨されるアクション] 1. ... 2. ... 3. ... 4. [ログ参照] パス: YYYYMMDD/HHMMSS_Type_UUID.json Markdown禁止の理由: メール通知先ではMarkdownがレンダリングされないため、プレーンテキストで見やすい形式を指定しています。 **太字** や ## 見出し は、そのまま文字として表示されてしまいます。 5.3 パラメータ設定 出力の一貫性を高めるため、以下のパラメータを調整しました。 パラメータ 設定値 理由 temperature 0.3 事実に基づいた安定した出力を得るため max_tokens 1000 十分な長さの要約を許容 frequency_penalty 0 繰り返しのペナルティなし presence_penalty 0 新しいトピックへのペナルティなし 5.4 GenAI呼び出しの実装 def call_genai_generic (signer, parsed_info, archive_filename): """ OCI Generative AI (大阪リージョン) を呼び出して要約を生成する """ genai_client = oci.generative_ai_inference.GenerativeAiInferenceClient( config={}, signer=signer, service_endpoint=GENAI_ENDPOINT ) # プロンプトを構築 prompt_text = build_prompt(parsed_info, archive_filename) # Command R+ 推奨の Chat API を使用 chat_detail = oci.generative_ai_inference.models.ChatDetails( compartment_id=GENAI_COMPARTMENT_ID, serving_mode=oci.generative_ai_inference.models.OnDemandServingMode( model_id=MODEL_ID ), chat_request=oci.generative_ai_inference.models.CohereChatRequest( message=prompt_text, max_tokens= 1000 , temperature= 0.3 , frequency_penalty= 0 , presence_penalty= 0 ) ) logging.getLogger().info( "Invoking GenAI..." ) response = genai_client.chat(chat_detail) result_text = response.data.chat_response.text return result_text 6. 使用モデル:Command R+ 今回の検証では Cohere社のCommand R+ を使用しました。 OCI Generative AIのオンデマンドモデルでは複数のモデルが利用可能ですが、JSON要約という比較的シンプルなタスクであれば、どのモデルでも十分な品質が得られると考えています。 Command R+を選択した理由: 理由 詳細 日本語対応が良好 自然な日本語で出力される 指示追従性が高い プロンプトで指定した形式に従いやすい コストパフォーマンス オンデマンドで従量課金 他のモデルでも可能: Meta Llama 3などの他のモデルでも同様のタスクは実行可能です。モデルの選定は、要件や好みに応じて検討してください。 7. Before / After:実際の要約サンプル 実際にアラーム定義(VMのCPU使用率アラート)を発火させて、要約のBefore/Afterを確認しました。 Before(元のJSON通知) { " type ": " OK_TO_FIRING ", " alarmMetaData ": [ { " id ": " ocid1.alarm.oc1.ap-tokyo-1.aaaaaaaxxxxx ", " status ": " FIRING ", " severity ": " CRITICAL ", " query ": " CpuUtilization[1m].mean() > 80 ", " totalSuppressedResults ": 0 , " dimensions ": [ { " resourceId ": " ocid1.instance.oc1.ap-tokyo-1.aaaaaaayyyyy ", " resourceDisplayName ": " web-server-01 " } ] } ] , " title ": " high-cpu-alarm ", " body ": " Alarm: high-cpu-alarm has triggered. The alarm is now in the FIRING state. ", " timestamp ": " 2026-02-14T05:30:00.000Z ", " severity ": " CRITICAL " } After(GenAI要約後) [概要] 「web-server-01」にて、「high-cpu-alarm」(現在の値: 95%)が発生しました。 緊急度はCRITICALです。 [詳細分析] CPU使用率が閾値80%を超過し、現在95%に達しています。 メトリクス「CpuUtilization」の1分間平均値が継続的に高い状態にあります。 考えられる原因として、バッチ処理の集中実行、アプリケーションの異常動作、 または外部からの急激なアクセス増加が挙げられます。 [推奨されるアクション] 1. 該当インスタンス「web-server-01」にSSH接続し、topコマンドまたはhtopコマンドで CPU使用率の高いプロセスを特定してください。 2. OCIコンソールのメトリクス画面で、過去数時間のCPU使用率の推移を確認し、 いつから上昇が始まったか特定してください。 3. 必要に応じてインスタンスのスケールアップ、またはロードバランサー配下での スケールアウトを検討してください。 [ログ参照] パス: 20260214/053000_Alarm_a1b2c3d4.json 注意:上記の「詳細分析」および「推奨されるアクション」はAIが生成したものです。 実際の原因や対応策は状況によって異なりますので、必ず元のログやメトリクスを確認のうえ、ご自身で判断してください。 Before/Afterの比較: 項目 Before (JSON) After (要約) 可読性 × 構造の理解が必要 ◎ 一目で把握可能 対象リソース OCIDの解読が必要 リソース名が明示 深刻度 キーを探す必要あり 冒頭で強調 対応策 記載なし 具体的な3点を提示(※参考情報) 証跡 なし ログパスを記載 8. テストしたアラートの種類 今回の検証では、以下のアラートを発火させてテストしました。 サービス テスト内容 結果 アラーム定義 VMのCPU使用率アラート ✓ 正常に要約 イベント 任意のイベントを発火 ✓ 正常に要約 Connector Hub ロググループへのログ蓄積をフィルタリングで検知 ✓ 正常に要約 いずれのサービスからのJSON通知も、期待通り要約することができました。 Connector Hubのテスト構成: 今回の検証では、OCI Loggingのロググループにたまったログを対象に、Connector Hubのフィルタリング機能で特定の文言( ERROR 、 Exception など)が検知された場合に発火する構成をテストしました。 9. 検証結果:レイテンシとコスト 9.1 レイテンシ Functionsの呼び出しからGenAI要約完了までのレイテンシを計測しました。 状況 レイテンシ 備考 初回起動(コールドスタート) 約30~60秒 コンテナ起動時間を含む 連続呼び出し(ウォームスタート) 10秒前後 GenAI応答時間が主 【レイテンシの内訳(ウォームスタート時)】 Functions起動・初期化: ~1秒 JSONパース・保存: ~1秒 GenAI呼び出し: 5〜7秒 トピック送信: ~1秒 合計: 8〜10秒 補足:コールドスタートについて OCI Functionsはコールドスタート時にコンテナの起動時間がかかります。頻繁にアラートが発生する環境では、ウォームスタートが維持されやすく、レスポンスは安定します。 9.2 コスト 長期的な検証はまだ行っていませんが、おおよその目安は以下の通りです。 コスト自体は従量課金制となっています。 項目 参考 1アラートあたりの要約コスト GenAIのトークン消費量に依存 Functionsの実行コスト メモリ256MB × 実行時間 Object Storageの保存コスト ログサイズに依存 コスト試算例(月間100アラートの場合): 項目 単価 数量 月額 GenAI(オンデマンド) ~$0.02/10,000文字 100回 $2 Functions $0.00001452/GB-秒 およそ1,000秒 $0.01 Object Storage $0.0255/GB およそ0.01GB $0.01 合計 - - $2〜3 実際のコストは、アラートの発生頻度やJSONのサイズによって変動します。詳細な試算には OCI Cost Estimator をご活用ください。 10. ハマりポイント・Tips 構成を組む際に注意すべきポイントをまとめます。 10.1 DNS設定を忘れずに GenAIプライベートエンドポイントを作成すると、 DNS接頭辞 の入力が必須になります。この接頭辞を含むドメインを東京リージョンから名前解決できるようにするため、以下の設定が必要です。 【必要なDNS設定】 1. 大阪VCNのリゾルバにプライベートビューを作成 2. プライベートビューにGenAIエンドポイントのゾーンを登録 3. 大阪VCNにDNSリスナーエンドポイントを作成 4. 東京VCNにDNS転送エンドポイントを作成 5. 東京のDNS転送ルールで、GenAIのドメインを大阪リスナーに転送 プライベートエンドポイント作成時にコンソールでも案内が出ますが、忘れがちなポイントです。 10.2 セキュリティリストはサブネット内通信も許可が必要 大阪VCNのセキュリティリストでは、東京からの通信だけでなく、 大阪サブネット自身のCIDRからの通信も許可 する必要があります。 【見落としやすい設定】 送信元: 10.1.0.0/24(大阪サブネット自身) 宛先ポート: 443, 53 プロトコル: TCP, UDP これがないと、サブネット内のリソース間(例:DNSリスナーとGenAIプライベートエンドポイント間)の通信が通りません。 10.3 Functionsのタイムアウト設定 デフォルトのタイムアウトは30秒です。コールドスタート時にはこれを超える可能性があるため、必要に応じて タイムアウトを延長 してください(最大300秒まで設定可能)。 # func.yaml timeout : 120 # 2分に設定 10.4 ONS経由の「封筒」展開を忘れない アラーム定義からトピック経由で呼び出す場合、JSONが以下の形式で包まれて届きます。 { " Type ": " Notification ", " Message ": " { \" type \" : \" OK_TO_FIRING \" ,...} " } Message の中身を再度パースする処理を入れないと、期待通りに動作しません。 10.5 エラー時のフォールバック GenAI呼び出しが失敗した場合でも、通知が止まってしまうのは避けたいところです。今回の実装では、エラー時にフォールバックメッセージを送信するようにしています。 try : result_text = call_genai_generic(signer, parsed_info, archive_filename) except Exception as e: logging.getLogger().error(f "GenAI Error: {e}" ) # AIエラー時のフォールバック通知 result_text = f """[AI処理エラー] GenAIによる要約処理でエラーが発生しました。 元のログを確認してください。 ログ: {archive_filename} エラー内容: {str(e)} """ 11. 今後の展望 今回の構成を基に、以下の改善を検討しています。 温度設定の見直し 「推測される原因」「推奨されるアクション」といった推論要素を含む出力については、温度を少し上げることで、より多様な提案が得られる可能性があります。ただし、事実に基づく項目の精度とのバランスを見ながら調整が必要です。 対応状況の記録 要約を送信するだけでなく、対応状況をレポート形式で定期的にまとめておくような機能の追加を取り入れることができると考えています。 JSONの原本をObject Storageに保管しているため、GenAIをさらに組み合わせることで週次や月次のレポートを作成させるなどまだまだ応用の術があると感じています。 12. まとめ 前後編にわたって、OCI Generative AIを活用したアラート通知の自動要約構成について解説しました。 本記事の成果 成果 詳細 クロスリージョン構成の確立 東京から大阪GenAIにプライベート接続 Functionsの実装パターン リソースプリンシパル認証、ONS封筒展開、通知タイプ判定 プロンプト設計のノウハウ 通知タイプに応じた指示切り替え、出力形式の固定 検証結果の数値化 レイテンシ10秒未満、コスト数円/アラート ハマりポイントの共有 DNS設定、セキュリティリスト、タイムアウトなど 技術的なポイント GenAIプライベートエンドポイント を使えば、オンデマンドモデルでもプライベート接続が可能 DRGのRPC接続とDNS転送 で東京リージョンから大阪のGenAIを利用できる Functionsのコード内で原本を保存 しておくことで、ハルシネーション対策も万全 通知タイプに応じた プロンプトの切り替え で、適切な要約を実現 リソースプリンシパル認証 でセキュアにOCIサービスを呼び出し JSON通知の可読性に悩んでいる方は、ぜひ試してみてください! 執筆者 山塚 友貴 所属:NTT西日本 ビジネス営業本部 業務:主にOCIのインフラ設計や構築、運用に携わっています。 保有資格:Oracle Cloud Infrastructure 2024 Architect Professional、AWS Certified Solutions Architect - Professional、Azure Solution Architect Expert(AZ-305)など。 参考文献 OCI Generative AI ドキュメント OCI Functions ドキュメント OCI Notifications ドキュメント OCI SDK for Python リソースプリンシパル認証 商標 Oracle、Java、MySQL及びNetSuiteは、Oracle Corporation、その子会社及び関連会社の米国及びその他の国における登録商標です。 Cohereは、Cohere Inc.の商標または登録商標です。 Dockerは、Docker, Inc.の米国およびその他の国における登録商標です。 AWSはAmazon.com, Inc.またはその関連会社の商標です。 Microsoft AzureはMicrosoft Corporationの商標です。 その他、本記事に記載されている会社名、製品名は、各社の登録商標または商標です。
はじめに NTT西日本株式会社2年目社員の山塚です。私は現在、OCIを活用したインフラ運用の高度化に取り組んでいます。 OCIの監視アラートは運用上欠かせないものですが、 JSON形式のまま通知される ため、内容を把握するまでに時間がかかるという課題がありました。経験豊富なエンジニアであっても、JSON形式のままでは一目で状況を把握しづらく、特に深夜のオンコール対応では認知負荷が高くなりがちです。 本稿では、この「アラート通知の可読性」という課題に対し、 OCI Generative AI(GenAI)を活用して自動要約する仕組み を構築した事例をご紹介します。 なお、OCI Generative AIは日本国内では 大阪リージョンでのみ提供 されています(2026年2月時点)。一方、多くの企業やユーザーは 東京リージョンをメインリージョン として利用しています。本記事では、東京リージョンをメインで利用しながら大阪リージョンのGenAIにプライベート接続するという、実務で直面しやすいクロスリージョン構成の設計思想と全体像を詳説します。 【Before / After:要約のイメージ】 GenAIによる要約を導入すると、以下のようにJSON通知が自然言語で整理されます。 【Before:JSON形式のまま届く通知】 {"type":"OK_TO_FIRING","severity":"CRITICAL","alarmMetaData":[{"status":"FIRING",...}]} 【After:GenAIによる要約】 [概要] 「web-server-01」にて、「high-cpu-alarm」(現在の値: 95%)が発生しました。 緊急度はCRITICALです。 [詳細分析] CPU使用率が閾値80%を超過し、現在95%に達しています。 ... [推奨されるアクション] 1. 該当インスタンスにSSH接続し、topコマンドでプロセス状況を確認してください。 ... 前編:東京リージョンからGenAIプライベートエンドポイントへ。クロスリージョン構成で実現するアラート自動要約基盤(本記事) 後編:Functionsによる通知処理の実装とプロンプト設計。検証結果から見る運用改善効果 ※本記事は2026年2月時点の情報に基づきます。 ※本記事では生成AIを活用した内容が含まれており、AIによる情報の生成過程でハルシネーション(事実に基づかない情報の生成)が発生する可能性があります。AIが生成した「詳細分析」や「推奨されるアクション」は参考情報としてご活用いただき、実際の運用判断は必ずご自身で確認のうえ行ってください。 対象読者 本記事は、以下の方を対象としています。 OCIのアラート通知運用を効率化したいエンジニア OCI Generative AIの具体的な活用例を知りたい方 クロスリージョン構成やプライベートエンドポイントの設計に興味がある方 東京リージョンをメインで使いながら大阪のGenAIを利用したい方 前提知識: 本記事では、以下の知識があることを前提としています。 OCI VCN(Virtual Cloud Network)の基本的な構成(サブネット、ルート表、セキュリティリスト) DRG(Dynamic Routing Gateway)の概念 OCI Functionsの基本的な使い方 OCIコンソールでのリソース作成操作 目次 背景・課題:OCIアラート運用の現場から JSON形式の通知が抱える問題 既存の解決策とその限界 解決策:GenAIによるアラート自動要約 なぜGenAIを選んだのか 期待される効果 構成検討:立ちはだかる4つの壁 壁①:GenAIは大阪リージョンにしかない 壁②:サービスゲートウェイでは届かない 壁③:インターネット経由のセキュリティ懸念 壁④:専有モデルのコスト 解決策:GenAIプライベートエンドポイントの発見 採用した構成の全体像 東京リージョン側の構成 大阪リージョン側の構成 リージョン間接続とセキュリティの設定 通知の流れ:アラート発生から要約配信まで アラーム定義からの通知 イベント・Connector Hubからの通知 なぜ原本をバケットに保存するのか 検討して却下した構成案 まとめと後編への導線 1. 背景・課題:OCIアラート運用の現場から 1.1 JSON形式の通知が抱える問題 OCIで運用監視をしていると、以下のようなサービスからアラート通知を受け取ることがあります。 サービス 用途 JSON形式 アラーム定義 メトリクスの閾値超過監視 ※オプションで整形テキスト形式に変更可能 イベント(Event) リソースの状態変化検知 必ずJSON Connector Hub ログの転送・フィルタリング 必ずJSON これらの通知をトピック経由でメールやSlackに流すと、以下のような JSON形式のまま届く ケースがあります。(実際に検証の中で発火させたアラーム定義のJSONを、一部マスキングして掲載しています。) { " dedupeKey ": " a1b2c3d4-5e6f-7890-abcd-ef1234567890 ", " title ": " high-cpu-alarm ", " body ": " 高CPUアラート ", " type ": " OK_TO_FIRING ", " severity ": " CRITICAL ", " timestampEpochMillis ": 1739512560000 , " timestamp ": " 2026-02-14T05:36:00Z ", " alarmMetaData ": [ { " id ": " ocid1.alarm.oc1.ap-tokyo-1.aaaaaaaaxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ", " status ": " FIRING ", " severity ": " CRITICAL ", " namespace ": " oci_computeagent ", " query ": " CpuUtilization[1m]{resourceId = \" ocid1.instance.oc1.ap-tokyo-1.aaaaaaaayyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy \" }.mean() > 80 ", " totalMetricsFiring ": 1 , " dimensions ": [ { " instancePoolId ": " Default ", " resourceDisplayName ": " web-server-01 ", " faultDomain ": " FAULT-DOMAIN-1 ", " resourceId ": " ocid1.instance.oc1.ap-tokyo-1.aaaaaaaayyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy ", " availabilityDomain ": " xxxx:AP-TOKYO-1-AD-1 ", " imageId ": " ocid1.image.oc1.ap-tokyo-1.aaaaaaaa_example_image_id_xxxxxxxxxxxxxxxxxxxx ", " shape ": " VM.Standard.E4.Flex ", " dedicatedVmHostId ": " DefaultVmHostId ", " region ": " ap-tokyo-1 " } ] , " alarmUrl ": " https://cloud.oracle.com/monitoring/alarms/ocid1.alarm.oc1.ap-tokyo-1.aaaaaaaaxxxxxxxx... ", " alarmSummary ": " CPU使用率監視アラーム ", " metricValues ": [ { " CpuUtilization[1m]{resourceId = \" ocid1.instance.oc1.ap-tokyo-1.aaaaaaaayyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy \" }.mean() ": " 95.2 " } ] } ] , " notificationType ": " Grouped messages across metric streams ", " version ": 1.5 } このJSONを見て、瞬時に「何が起きたか」「どのリソースか」「どれくらい深刻か」を把握できるでしょうか? 経験豊富なエンジニアであっても、JSON形式のままでは直感的に状況を把握しづらく、特に深夜のオンコール対応ではこの情報を解読する時間が運用上のボトルネックになります。 1.2 既存の解決策とその限界 OCIには、アラーム定義に対して 「フォーマットされたメッセージの送信」 というオプションがあります。 アラーム定義の「フォーマットされたメッセージ」オプション アラーム作成時の「メッセージの書式」で フォーマットされたメッセージの送信 を選択すると、人間が読みやすい整形テキスト形式でメッセージを受け取ることができます。 しかし、この機能には以下の制約があります。 制約 詳細 アラーム定義限定 イベントやConnector Hubには適用できない フォーマットの固定 独自の形式にカスタマイズできない 対応策の提示なし 「次に何をすべきか」は含まれない こういった制約を鑑みると、すべての通知サービスが一貫した形式で、また解決策まで提示される仕組み作りを検証する価値があると感じました。 2. 解決策:GenAIによるアラート自動要約 2.1 なぜGenAIを選んだのか 上記の課題を解決するために、 OCI Generative AI(GenAI)を活用してアラート通知を自動要約する という構成を検討しました。 GenAIを選択した理由は以下の通りです。 理由 詳細 非構造データの理解 JSONの構造が異なっても、文脈を理解して要約できる 日本語での出力 誰にとっても伝わりやすい 対応策の提示 考えられる原因や推奨アクションを提示できる OCI内での完結 外部サービスを使わず、OCIのセキュリティ境界内で処理できる 2.2 期待される効果 GenAIによる自動要約で、以下の効果を期待しました。 【期待される要約出力のイメージ】 [概要] 「web-server-01」にて、「high-cpu-alarm」(現在の値: 95%)が発生しました。 緊急度はCRITICALです。 [詳細分析] CPU使用率が閾値80%を超過し、現在95%に達しています。 メトリクス「CpuUtilization」が継続的に高い状態にあり、 バッチ処理の集中実行やアプリケーションの異常動作が考えられます。 [推奨されるアクション] 1. 該当インスタンスにSSH接続し、topコマンドでプロセス状況を確認してください。 2. OCIコンソールのメトリクス画面で、過去の推移を確認してください。 3. 必要に応じてスケールアップまたはスケールアウトを検討してください。 [ログ参照] パス: 20260214/053000_Alarm_a1b2c3d4.json このように、 誰が見ても状況を把握でき、次のアクションが明確になる 形式をめざしました。 3. 構成検討:立ちはだかる4つの壁 「GenAIで要約すればいい」というアイデアは良かったのですが、実際に構成を検討すると、いくつかの壁に直面しました。 3.1 壁①:GenAIは大阪リージョンにしかない OCI Generative AIの基盤は、日本国内では 大阪リージョンでのみ提供 されています(2026年2月時点)。 しかし、多くの企業やユーザーは 東京リージョンをメインリージョン として利用しています。そのため今回の検証では、以下のリソースはすべて東京リージョンにあるものとして構成しました。 監視対象のCompute インスタンス アラーム定義、イベントルール 通知用のトピック ログ保存用のObject Storage 東京リージョンで発生したアラートを処理したいのに、GenAIは大阪にしかない——これが最初の壁でした。 図1.GenAIの提供リージョン(2026年2月時点) 参考: 生成AIリージョン 3.2 壁②:サービスゲートウェイでは届かない OCIには、パブリックIPを使わずにOCIサービスへアクセスできる サービスゲートウェイ(SGW) があります。 「SGW経由で大阪のGenAIにアクセスできないか?」と考えましたが、これは不可能でした。 理由:SGWはリージョン内のサービスに対してのみ有効 SGWはVCNと同じリージョン内のOCIサービス(Object Storage、Autonomous Databaseなど)へのアクセスを提供するものであり、クロスリージョンでのサービスアクセスには対応していません。 【NG構成】 東京VCN → 東京SGW → 大阪GenAI(×到達不可) 3.3 壁③:インターネット経由のセキュリティ懸念 検証の過程で、 NATゲートウェイ(NATGW)経由 であれば東京リージョンから大阪GenAIのパブリックHTTPSエンドポイントを叩けることは確認できました。 【検証した構成】 東京VCN → NATGW → インターネット → 大阪GenAI(○到達可能) この方法のメリットは 東京リージョンだけで構成が完結する こと。DRGやRPC(Remote Peering Connection)接続といったクロスリージョン設定が不要で、構築が比較的シンプルです。 しかし、以下の懸念がありました。 懸念点 詳細 インターネット経由 アラートログがパブリックネットワークを経由する セキュリティポリシー 組織によってはNGとなる可能性 監査・コンプライアンス プライベート通信が求められるケース アラートログには、リソース名やOCID、場合によってはエラーメッセージなど、インフラの内部情報が含まれます。これをインターネット経由で送信することに抵抗がある組織も多いでしょう。 3.4 壁④:専有モデルのコスト GenAIには 専有ホスティングモデル と オンデマンド(共有)モデル があります。 専有ホスティングモデルであれば、プライベート接続は比較的容易です。しかし、コスト面で大きな差があります。 項目 オンデマンド 専有ホスティング 課金単位 文字数(トランザクション)ベース AIユニット/時 最低利用 なし 744単位時間(約31日)/クラスタ ※価格は変動するため、最新情報は OCI生成AIの価格設定 を参照してください。 今回は検証目的ということもあり、 オンデマンドモデル を前提に構成を考えました。 4. 解決策:GenAIプライベートエンドポイントの発見 4つの壁に直面する中で、2026年1月、 GenAIのプライベートエンドポイントという機能がオンデマンドモードに対応するというリリースノート が発表されました。 この機能を使うと、 オンデマンドホストに対してもプライベートな接続ができる ことがわかりました。 プライベートエンドポイントの特徴: 特徴 詳細 VCN内に配置 プライベートサブネット内にエンドポイントが作成される プライベートIPで通信 インターネットを経由しない オンデマンドモデル対応 専有ホストでなくても利用可能 DNS登録が必要 独自のDNS接頭辞を設定し、名前解決の設定が必要 これを活用すれば、インターネットを経由せずに東京リージョンから大阪のGenAIを利用できます。 つまり、構成のポイントは以下の通りです。 東京リージョンと大阪リージョンを DRGのRPC接続 でつなぐ 大阪リージョンに GenAIプライベートエンドポイント を作成 DNS転送 を設定して、東京から大阪のプライベートエンドポイントを名前解決できるようにする 5. 採用した構成の全体像 最終的に採用した構成を図にまとめました。 図2.採用した構成の全体図 また、具体的なフローは下記の通りです。 ステップ 処理内容 ① アラーム定義やイベント、Connector HubからFunctionsへ通知を飛ばす ② ①をきっかけにして、OCIRに格納されているイメージをもとにFunctionsが起動する ③ Functions起動後、指定した保管用のObject Storageに対して、受け取ったJSONログを格納する ④ DRG経由で大阪リージョンに設定したGenAIのプライベートエンドポイントにアクセスする ⑤ プライベートエンドポイントを介してGenAIとやり取りし、再度DRG経由でFunctionsに受け取った内容を飛ばす ⑥ そのままユーザー通知用のトピックに対して飛ばす ⑦ トピック経由で通知を受け取る 5.1 東京リージョン側の構成 東京リージョンには、以下のリソースを配置しています。 リソース 用途 補足 VCN ネットワークの基盤 プライベートサブネットを1つ作成 プライベートサブネット Functionsの配置場所 パブリックIPは付与しない OCI Functions 通知受信→要約→再送信の処理 Pythonで実装 サービスゲートウェイ(SGW) Object Storageへのプライベートアクセス ログ保存時に使用 Object Storageバケット 元のJSONログを保存 ハルシネーション対策 DRG 大阪リージョンとの接続 VCNアタッチメントで接続 DNS転送エンドポイント 大阪への名前解決転送 GenAIのFQDNを転送 トピック(中継用) アラーム定義の中継 Functionsへのサブスクリプション トピック(通知用) 要約結果の配信 メールへのサブスクリプション SGWの用途について: 東京VCNにSGWを配置しているのは、FunctionsからログをObject Storageに保存する際にプライベート通信を使用するためです。SGWがないと、Object Storageへのアクセスがインターネット経由になってしまいます。 5.2 大阪リージョン側の構成 大阪リージョンには、以下のリソースを配置しています。 リソース 用途 補足 VCN ネットワークの基盤 プライベートサブネットを1つ作成 プライベートサブネット GenAIプライベートエンドポイントの配置場所 GenAIプライベートエンドポイント オンデマンドGenAIへのプライベートアクセス DNS接頭辞の設定が必要 DRG 東京リージョンとの接続 VCNアタッチメントで接続 DNSリスナーエンドポイント 東京からのDNSクエリを受信 VCNリゾルバ・ゾーン GenAIエンドポイントのドメイン登録 プライベートビュー GenAIプライベートエンドポイント作成時の設定: 【プライベートエンドポイント作成時の入力例】 名前: genai-private-endpoint コンパートメント: <コンパートメントを選択> VCN: osaka-vcn サブネット: osaka-private-subnet DNS接頭辞: mygenai ← ここが重要 作成後のFQDN例: mygenai.pe.inference.generativeai.ap-osaka-1.oci.oraclecloud.com 図3.GenAIプライベートエンドポイント作成画面 5.3 リージョン間接続とセキュリティの設定 東京と大阪を接続するために、以下の設定を行います。 DRGとRPC接続: 【設定手順の概要】 1. 東京リージョンにDRGを作成 2. 大阪リージョンにDRGを作成 3. 東京DRGにRPCアタッチメントを作成 4. 大阪DRGにRPCアタッチメントを作成 5. 双方のRPCをピアリング接続 6. 東京DRGにVCNアタッチメントを作成(東京VCNを接続) 7. 大阪DRGにVCNアタッチメントを作成(大阪VCNを接続) 8. 各DRGのルート表を設定 9. 各VCNのルート表を設定 ルート表の設定例: 【東京VCNルート表】 宛先CIDR: 10.1.0.0/16(大阪VCNのCIDR) ターゲット: DRG(東京) 【大阪VCNルート表】 宛先CIDR: 10.0.0.0/16(東京VCNのCIDR) ターゲット: DRG(大阪) DNS転送の設定: GenAIプライベートエンドポイントのFQDNを東京から名前解決できるようにするため、DNS転送を設定します。 【DNS設定の概要】 1. 大阪VCNのリゾルバにプライベートビューを作成 2. プライベートビューにGenAIエンドポイントのゾーンを登録 3. 大阪VCNにDNSリスナーエンドポイントを作成 4. 東京VCNにDNS転送エンドポイントを作成 5. 東京のDNS転送ルールで、GenAIのドメインを大阪リスナーに転送 セキュリティリストの設定: 大阪VCNのセキュリティリストでは、以下のルールを設定します。 イングレスルール(受信): プロトコル ポート 送信元CIDR 用途 TCP 443 10.0.0.0/24(東京サブネット) GenAI API通信 UDP 53 10.0.0.0/24(東京サブネット) DNS転送 TCP 443 10.1.0.0/24(大阪サブネット自身) サブネット内通信 UDP 53 10.1.0.0/24(大阪サブネット自身) サブネット内通信 エグレスルール(送信): 今回の環境においては、エグレスルールの設定は特段不要になります。 重要な注意点:サブネット内通信も許可が必要 大阪サブネット自身のCIDRからの通信も許可する必要があります。これがないと、DNSリスナーエンドポイントとGenAIプライベートエンドポイント間の通信が通りません。 6. 通知の流れ:アラート発生から要約配信まで 6.1 アラーム定義からの通知 アラーム定義からの通知は、以下の流れで処理されます。 図4.アラーム定義から通知までのフロー なぜトピックを経由するのか: アラーム定義は、Functionsを直接呼び出すことができません。そのため、一度トピック(中継用)を経由し、そのトピックのサブスクリプションとしてFunctionsを設定しています。 また、アラーム定義には「フォーマットされたメッセージの送信」オプションがありますが、今回はあえてOFFにしています。理由は、イベントやConnector Hubと同じJSON形式で統一し、Functionsで一貫した処理を行うためです。 6.2 イベント・Connector Hubからの通知 イベントやConnector Hubからの通知は、Functionsを直接呼び出すことができます。 図5.イベント・Connector Hubから通知までのフロー Connector Hubについて: 今回の検証では、OCI Loggingのロググループにたまったログを対象に、Connector Hubのフィルタリング機能で特定の文言(エラーメッセージなど)が検知された場合に発火する構成をテストしました。そのため「ログ検知」としてタイトルをつけていますが、Connector Hubの用途はログ検知に限ったものではなく、さまざまなデータ転送シナリオに対応しています。 6.3 なぜ原本をバケットに保存するのか GenAIに要約させると、元のJSONは残りません。しかし運用上、以下のケースで原本が必要になることがあります。 ケース 詳細 ハルシネーション検証 AIが誤った情報を生成していないか確認する 詳細情報の確認 要約では省略された情報を確認する 監査・コンプライアンス 元の通知内容を証跡として保持する トラブルシューティング Functionsの処理が正しいか検証する そのため、Functionsのコード内で まずJSONをバケットに保存してからGenAIを呼び出す という順序で処理しています。 保存時のファイル名は、後から探しやすいように以下の命名規則を採用しています。 【ファイル名の命名規則】 形式: YYYYMMDD/HHMMSS_Type_UUID.json 例: 20260214/053000_Alarm_a1b2c3d4.json 20260214/053015_Event_b2c3d4e5.json 20260214/053030_ConnectorHub_c3d4e5f6.json 図6.Object Storageに作成されるプレフィックス 上記のように、ログが生成されると同時に日単位のプレフィックス(フォルダのような階層構造)が作成され、その中に各JSONログが保管されます。 図7.保管された各種JSONログ ※Object Storageには厳密にはディレクトリという概念がなく、オブジェクト名の接頭辞(プレフィックス)によって階層構造を表現しています。 7. 検討して却下した構成案 今回の構成に至るまで、いくつかの案を検討しました。それぞれの案と、却下した理由を整理します。 案1:NATゲートウェイ経由(インターネット経由) 【構成】 東京VCN → NATGW → インターネット → 大阪GenAI 評価項目 評価 構成のシンプルさ ◎ 東京リージョンだけで完結 セキュリティ △ インターネットを経由する コスト ○ NATGWの料金のみ 採否 × セキュリティ懸念 案2:大阪リージョンにFunctionsを配置 【構成】 東京(アラート発生)→ 大阪Functions → 大阪GenAI 評価項目 評価 構成のシンプルさ △ 両リージョンにリソースが分散 セキュリティ ○ リージョン内で完結 運用性 △ アラーム定義の送信先はリージョンをまたいで選択することができないため、別途東京のアラートを大阪に転送する必要あり 採否 × 運用の複雑化 案3:GenAIプライベートエンドポイント+RPC接続(採用) 【構成】 東京Functions → RPC → 大阪GenAIプライベートエンドポイント 評価項目 評価 構成のシンプルさ △ クロスリージョン設定が必要 セキュリティ ◎ 完全プライベート 運用性 ○ Functionsは東京に集約 採否 ✓ 採用 セキュリティと運用性のバランスを考慮し、 プライベートエンドポイント+RPC接続 を採用しました。 8. まとめと後編への導線 本記事のまとめ 前編では、OCIアラート通知のJSON問題を解決するために、GenAIプライベートエンドポイントを活用したクロスリージョン構成の設計思想と全体像を解説しました。 今回の成果: 東京リージョンから大阪のGenAIにプライベート接続する構成を設計 オンデマンドモデルでもプライベートエンドポイントが利用可能なことを確認 DRG + RPC + DNS転送による完全プライベートな通信経路を確立 セキュリティとコストのバランスを考慮した構成を採用 前編完了時点での構築到達点: 本記事(前編)の内容を実施すると、以下のリソースが構築された状態になります。 東京リージョン:VCN、プライベートサブネット、DRG、SGW、DNS転送エンドポイント、Object Storageバケット、トピック(中継用・通知用) 大阪リージョン:VCN、プライベートサブネット、DRG、GenAIプライベートエンドポイント、DNSリスナーエンドポイント、VCNリゾルバ設定 リージョン間:RPC接続、ルート表設定、セキュリティリスト設定 次回予告:後編へ 「設計はできた。では、実際にどうやってFunctionsを実装するのか?プロンプトはどう設計するのか?」 次回の後編では、以下の内容を詳説します。 Functionsの実装コード :リソースプリンシパル認証、ONS(Oracle Notification Service)封筒の展開、通知タイプの判定 プロンプト設計 :通知タイプに応じた指示の切り替え、出力形式の固定 Before/Afterサンプル :実際の要約結果 検証結果 :レイテンシ、コスト、運用改善効果 ハマりポイント・Tips :DNS設定、セキュリティリスト、タイムアウト設定など 執筆者 山塚 友貴 所属:NTT西日本 ビジネス営業本部 業務:主にOCIの設計や構築、運用に携わっています。 保有資格:Oracle Cloud Infrastructure 2024 Architect Professional、AWS Certified Solutions Architect - Professional、Azure Solution Architect Expert(AZ-305)など。 参考文献 OCI Generative AI ドキュメント OCI Functions ドキュメント OCI DRG リモートピアリング接続 アラーム定義 - メッセージの書式 OCI生成AIの価格設定 GenAIプライベートエンドポイント 商標 Oracle、Java、MySQL及びNetSuiteは、Oracle Corporation、その子会社及び関連会社の米国及びその他の国における登録商標です。 SlackはSalesforce, Inc.の商標です。 AWSはAmazon.com, Inc.またはその関連会社の商標です。 Microsoft AzureはMicrosoft Corporationの商標です。 その他、本記事に記載されている会社名、製品名は、各社の登録商標または商標です。
はじめに NTT西日本の寺崎 智博です。 本記事では広く使われている一方で、意味が曖昧になりやすい、 Zero Trust Network Access(略してZTNA) について解説します。 私はこれまで、数々のお客様のリモートアクセス環境見直しやZTNA導入を支援してきました。その中で強く感じるのは、ZTNAが非常に注目されている一方で、言葉だけが先行し、実態が誤解されやすいテーマでもあるということです。 「ZTNAはVPNとまったく違うのか」「脱VPNは本当に必要なのか」といった疑問は、導入検討の現場で何度も向き合ってきました。そうした実務の現場で得た知見をもとに、ZTNAの考え方、VPNとの違い、導入時の注意点を整理して解説します。 本記事の内容は2026年2月時点の情報に基づきます。 対象読者 本記事が想定する対象読者は以下の通りです。 リモートアクセス環境の見直しを考えているシステム管理者の方 VPNを狙った攻撃に不安を感じている経営者の方 ゼロトラストに関心をお持ちのエンジニアの方 セキュリティに興味をお持ちの学生さん 目次 はじめに 対象読者 目次 1. 背景 2. Zero Trust Network Access (ZTNA) とは 3. 普及したきっかけ 4. ZTNAの定義 5. なぜ「脱VPN」が叫ばれるのか? 5.1. 従来型VPNの課題とZTNAの利点 5.2. IAP型ZTNA 5.3. VPN型ZTNA 参考|違う名前の同じような言葉 6. ZTNAの肝:認証と認可 6.1. Device Posture 6.2. マイクロセグメンテーション 7. ZTNAさえ導入すれば安心? 8. VPNは本当に時代遅れなのか? 9. 導入時の注意点とつまづきやすいポイント 9.1. Webアクセスセキュリティの見直しもセットになり意外と大変 9.2. VPNでつながっていた通信が通らなくなる 9.3. SaaSに登録していた固定IPアドレス問題 9.4. Active Directory (AD) との通信問題 10. おわりに 執筆者 出典 商標 1. 背景 現在、さまざまなメーカーやベンダーが「ゼロトラスト」というワードを引っ提げてマーケティングを展開しており、正直なところ 言ったもの勝ちの状態 になっています。特に Zero Trust Network Access (ZTNA) については拡大解釈が多く、導入を検討されるお客様が混乱しているケースを多々見てきました。 最近でも大手企業でVPN機器を侵入経路としたランサムウェア被害が大々的に報道され、 脱VPN や ゼロトラスト というワードがWeb上を賑わせています。しかし、具体的な仕組みを理解されている方は意外と少ないのではないでしょうか。 本記事の目的は、バズワード化した「ゼロトラスト」の中心的存在であるZTNAが一体どのような仕組みで、なぜ今必要とされているのかを正しく理解していただくことです。不確かな情報に振り回されず、自社に最適なネットワーク環境を構築するための一助となれば幸いです。 2. Zero Trust Network Access (ZTNA) とは ZTNAを一言で表すと、「インターネットを介して、場所に依存せず社内リソースへ安全にアクセスする仕組み」です。 実は界隈では、 「ZTNAはVPNとは全く違う」 と主張する派閥と、 「新しい世代のVPNもZTNAに含める」 派閥が存在します。これが大きな混乱の元なのですが、根底にはメーカー各社の考え方の違いが絡んでいます。 まずは難しく考えず、「社内リソースへ安全にアクセスする仕組み」と捉えていただければ問題ありません。 3. 普及したきっかけ ゼロトラストという概念自体は2006年頃から存在していましたが、一気にバズワード化したのは2020年の新型コロナウイルスのパンデミックがきっかけです。急遽始まった在宅勤務に対応するため、世界中の企業が突貫工事でVPN環境を整備・拡大しました。その結果、攻撃者にとって格好のターゲット(VPN機器)が世界中に爆増したのです。 ちょうどこの頃、警察庁のデータ(令和4年上半期)でも、ランサムウェア被害の 感染経路の多くがVPN機器 であったことが示されています。(下図) 令和4年上半期におけるサイバー空間をめぐる脅威の情勢等について(警察庁) 出典: https://www.npa.go.jp/publications/statistics/cybersecurity/data/R04_kami_cyber_jousei.pdf VPN機器の脆弱性を狙ったサイバー攻撃が急増したことで、リモートワーク環境のセキュリティを根本から見直すソリューションとして「ゼロトラスト」が脚光を浴び、「脱VPN」という言葉が広く使われるようになりました。 4. ZTNAの定義 では、公的な定義はどうなっているのでしょうか? ゼロトラストの拠り所となるガイドラインとして最も公的なものと言えば、アメリカのNISTが発行した「NIST 800-207 Zero Trust Architecture」というドキュメントですが、このドキュメントは非常に難解で、「ネットワークアクセス」そのものズバリの定義は薄いのが実情です。どちらかと言うとゼロトラストという考え方全体を概念的に記載したものになっています。 参考:NIST SP 800-207 Zero Trust Architecture https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-207.pdf そこで、ZTNAという言葉の生みの親であるガートナー社の定義を見てみましょう。 ZTNAは、アプリケーションやデータへのアクセスを「場所」ではなく「IDとコンテキスト」に基づいて制御する仕組みです。リソースを不可視化し、ネットワーク内の横移動(ラテラル・ムーブメント)を防ぐとともに、最小限のアクセス範囲を確保します。 出典: https://www.gartner.co.jp/ja/topics/zero-trust おそらく、この説明だけで腹落ちする方は稀でしょう。ここから、この定義を分かりやすく噛み砕いていきます。 5. なぜ「脱VPN」が叫ばれるのか? 序盤に「ZTNAはVPNとは違う」派閥と「新しい世代のVPNもZTNAに含める」派閥に分かれると述べました。 「あれ?脱VPNじゃなかったの?VPNもZTNAと呼ぶなら脱VPNじゃなくない?」と思われる方も多いと思います。実際、私も今まで数多くのお客様からこのような疑問を問いかけられました。 ここを理解するために「なぜVPNが悪者扱いされ、脱VPNと言われているのか」を解説します。 5.1. 従来型VPNの課題とZTNAの利点 従来型VPN(Virtual Private Network)は、通信内容を暗号化する技術としては優れており、盗聴の心配はほぼありません。問題は、 VPN機器の接続口(例えばHTTPSなら443番ポート)がインターネットに堂々と公開 されていることです。もちろん固定のグローバルIPアドレスを持っているため、攻撃者からすれば「ここを攻撃してください」と言わんばかりの的になっており、脆弱性が発見されると即座に狙われる傾向があります。 従来型VPN構成 一方ZTNAは、 インターネットから直接到達可能な接続口(リスニングポート)を公開しません。 外部からの接続点は、ZTNA事業者が管理する堅牢なクラウド上に存在します。この接続点は市販されているVPN機器と違い、仕様がブラックボックスになっていることに加え、接続点の脆弱性管理やアップデートもZTNA事業者が行うため、可用性・信頼性向上に加え、運用負荷が低減するという大きなメリットがあります。 ZTNA構成 ここまでで、従来型VPNとZTNAの接続点に関する違いをご説明いたしました。ここでさらに、ZTNAを実現するためのアーキテクチャ(構成方式)についても触れておきます。現在、ZTNAの構成には大きく分けて2つの方式があります。ここでは"便宜上" 「IAP型」 と 「VPN型」 と表現します。(本記事での便宜上の分類であり、一般的な用語ではありません) 5.2. IAP型ZTNA IAPとは「Identity Aware Proxy」の略で、直訳すると「ID認識型プロキシ」です。企業ネットワーク上にコネクターと呼ばれるプロキシサーバーを立てて、このプロキシサーバーがリモートアクセス通信の仲介を行います。 このプロキシサーバーは常にクラウド上の接続ポイントと通信しており、リモートアクセスの通信が来たら代理で内部のサーバーに通信を行い、結果をクライアントに返します。「ID認識型」という名前の通り、接続元のユーザーIDを識別してアクセス権がある場合にのみ通信を成立させることから、この名前が付けられています。 IAP型ZTNA 多くのサービスでは、このプロキシサーバーは簡単に拡張できるようになっています。冗長化や負荷分散の目的で、このプロキシサーバーは複数台設置し、足りなくなれば数を増やすという運用をします。 5.3. VPN型ZTNA VPN型の方は、企業ネットワーク上にはルーターを置きます。このルーターがIPsecなどの技術を使ってクラウド上の接続ポイントとトンネリングします。 VPN型ZTNA VPN型の場合は、IAP型のように足りなくなれば増やすというような柔軟な対応はあまり得意ではありませんが、そもそもルーターなので、元から結構な量のトラフィックを捌けることから、拡張性はあまり問題になりません。もしトラフィック量が増えてどうしようもなくなったら、その時はハイスペックなルーターに置き換えることになります。 この2つが最初に述べた2つの派閥で、 「VPNとZTNAは違う派」がIAP型ZTNA で、 「新しい世代のVPNもZTNAに含める派」がVPN型ZTNA です。 双方にメリット・デメリットがあり、どちらの方が優れているというものではありませんので、今後仮にどちらか片方のネガティブキャンペーンをしている話を耳にされることがあったら、この解説を思い出していただけると幸いです。 参考|違う名前の同じような言葉 少し横道にそれますが、IAPと似た概念の言葉で「SDP (Software Defined Perimeter)」という言葉があります。 直訳すると「ソフトウェアで定義された境界」です。ファイアウォールを境とした「社内ネットワーク/社外ネットワーク」という物理的な境界ではなく、ソフトウェアの力でアクセス権を動的に判断しようという、ZTNAのベースとなった概念です。 現在はIAPもSDPもZTNAという言葉の陰に隠れて、あまり聞かなくなった印象があります。 6. ZTNAの肝:認証と認可 ここからは機能の核心に迫ります。ガートナー社の定義の一つ目「場所ではなく“IDとコンテキスト”に基づいて制御」という部分です。 ID(誰が)は分かりやすいですが、コンテキストとは何でしょうか?これは 「パスワード以外の付加情報(状態・環境)」 を指します。例えば、「ついさっき日本でログインしたIDが、5分後にヨーロッパからログインを試みている」といった不自然な状況(コンテキスト)を加味してアクセスを制御することです。これを 「動的な認証認可」 と呼びます。 ここで「認証」と「認可」の違いを車の検問に例えてみます。 認証(ログイン): 「免許証を見せてください」と本人確認すること。 認可(アクセス制御): 「中型免許をお持ちですね。では通ってよし」と権限を確認すること。 動的な認証認可(ZTNAの肝): 「お酒も飲んでおらず、シートベルトも締めていますね(安全な状態ですね)。では通ってよし」と、状況に応じて許可を出すこと。 というイメージです。 実はZTNAの導入時、「認証」は既に各社の環境で使われている既存のIdP *1 (OktaやMicrosoft Entra IDなど)に任せることが大半です。ZTNAが本当に頑張るべきは、細かいコンテキストを判断する「認可」の部分なのです。 6.1. Device Posture この細かな「認可」を実現するのが、多くのZTNA製品に搭載されているDevice Postureという機能です。「Posture」を直訳すると「姿勢」なのですが、私は 「状態」 と考えるのがわかりやすいと思っています。デバイスの状態に応じたアクセス制御をする機能で、これこそがゼロトラストの真骨頂と言えます。 デバイスの状態チェックには、一例として以下のような項目があります。 事前に会社が登録した端末か OSが指定のバージョン以上にアップデートされているか 指定のセキュリティソフトが正常に稼働しているか ZTNAはこれらのチェックをクリアした 「安全性が確認できた端末」だけを社内リソースに接続 させます。 さらに、「スケジューラーは本人確認だけでOKだが、顧客データベースは会社支給かつ全セキュリティ条件をクリアした端末のみアクセス可」といったように、アクセス先のリソースごとに細かくルール(ポリシー)を設定できるのが強みです。製品によっては、EDR *2 がマルウェア感染などの危険なアラートを出していないかをチェックする機能を持つものもあり、こういった機能をうまく使うと、 「マルウェア感染の心配がない端末だけが接続できる」 というようなスマートな制御も可能となります。 6.2. マイクロセグメンテーション 次に、ガートナー社の定義の2つ目のポイントである 「リソースを不可視化し、ネットワーク内の横移動(ラテラル・ムーブメント)を防ぐとともに、最小限のアクセス範囲を確保」 という部分について解説します。これは一般に「マイクロセグメンテーション」という言葉で表現されます。 マイクロセグメンテーションとは「必要最小限のアクセス権だけ付与する」という考え方です。利便性を考えると、AさんもBさんもすべてのシステムに接続できる方が便利で設定もラクなのですが、不要なシステムへも通信できることは、当然ながらセキュリティ上は望ましくありません。 理想は「Aさんは、aシステムとbシステムだけに通信できる。Bさんはbシステムとcシステムにだけ通信できる。」という風に小さくアクセス権を付与することです。この時に、ユーザー単位のアクセス制限だけでなく、通信プロトコルやポート番号、前述のDevice Posture条件なども組み合わせて制限することでよりセキュアになります。 マイクロセグメンテーション このように必要最小限のアクセスに絞ることで、万が一攻撃者に侵入された場合も、横展開を最小限に抑えることができます。 実はこういったアクセス制御は、 ZTNAならではの機能ではありません。 従来型VPNでも同じようなことが出来るのに、多くの企業で ちゃんと設定されていなかった ものなのです。ZTNA製品ではマイクロセグメンテーションの原則にならって、小さくアクセス権を設定することが推奨されています。 なお、ZTNA製品の中でも特にIAP型の製品では、細かくアクセス権を設定しないと通信できないように作られており、面倒ながら、やらざるを得ない状況を仕様的に作り出しています。 7. ZTNAさえ導入すれば安心? ここまでZTNAのメリットをお伝えしてきましたが、「ZTNA製品を買ってくれば、即ゼロトラストで安心!」となるでしょうか? 答えは “No” です。 ZTNAはあくまで仕組みです。せっかくZTNAを導入したのに「全社員、パスワード認証さえ通れば全ての社内サーバーにアクセス可能」という緩い設定にしてしまえば、従来型VPNとセキュリティレベルはあまり向上しません。   「誰が・どのデバイスで・どの状態なら・何にアクセスしてよいか」 というポリシーを適切に設計・設定して初めて意味を成します。 したがって、ZTNA導入の際は どのシステムに対し、どんな状態であれば通信を許可するのか をじっくり考えて設定する必要があります。ここはメーカーやSIerに任せきれない部分ですので、お客様ご自身でしっかりとポリシーを検討すべきポイントです。 8. VPNは本当に時代遅れなのか? 「脱VPN」が叫ばれる中、VPNという技術自体が悪者のように扱われることがありますが、 「必ずしもそうではない」 というのが私の意見です。 世界中の数々のVPNが標的となっていますが、侵入されたケースを調べてみると、 脆弱性があるのにアップデートしていなかった IDとパスワードだけでログインできるようになっていた ログインした後はアクセス制御をしていなかった というようなケースばかりです。弊社のお客様でも、上記のような状態で大きなランサムウェア被害にあわれたお客様が何社もいらっしゃいます。 逆に言うと、たとえ従来型VPNを利用していたとしても、きちんと 最新のソフトウェアにアップデートし、認証を強化し、アクセス制御を細かく設定 していれば大多数の被害は防ぐことができます。 したがって、適切な設計と運用が実施されているVPN環境は、今後もコストとセキュリティのバランスに優れた選択肢の一つだと言えます。ただし、 これらの条件が満たされない場合 は積極的にZTNAへの移行を検討すべきでしょう。 9. 導入時の注意点とつまづきやすいポイント 最後に、私が今までのZTNA導入プロジェクトで直面してきた、つまづきポイントと注意すべきポイントを4つご紹介します。 9.1. Webアクセスセキュリティの見直しもセットになり意外と大変 ZTNAは「社内」へのアクセスを守りますが、社員はインターネット上のサービスも利用します。そのため、Webアクセス通信を守るSWG *3 も同時に導入するケースがほとんどです。 実は最近ではそもそもZTNAとSWGがセットになった製品が大半です。いわゆる「SASE *4 」とか「SSE *5 」と呼ばれるソリューションがこれに当たります。従来型VPNをZTNAに変えるだけなら、システム更改にかかる労力はそこまで大きくないかもしれませんが、SWGの導入=インターネットプロキシの更改となるので、 労力はグンと大きなもの になります。 社内の各ユーザーへの展開にもそれなりに時間がかかることを覚悟しておくことをお勧めします。 9.2. VPNでつながっていた通信が通らなくなる VPNはネットワーク全体をつなぐのに対し、ZTNAはアプリケーション単位でアクセス制御することが前提になっているものが多くあります。特にIAP型ZTNAはそうです。 先ほどのマイクロセグメンテーションの項でも触れましたが、ネットワーク全体にアクセスできるようにすることはセキュリティ上推奨されていません。しかし従来型VPN製品の場合、あまり細かいアクセス制限をせず、VPNさえ接続してしまえばあとは中で自由に通信できる状態になっているケースが非常に多いです。 こういった環境からZTNAに切り替えたとき、運用開始後に利用者から「あのサーバーにつながらない」といった申告が出てくるケースが非常に多いです。どこの会社にもシステム管理者が把握していないシステムや設定時に見落としているシステムがあるものです。ZTNA製品の場合はこれらのアプリケーション単位でポリシー設定をするため、見落としていたアプリケーションはつながらなくなってしまいます。 そのため、運用後しばらくはユーザーからの申告に基づいて、すぐにポリシー追加できるような準備をしておくのがお勧めです。 9.3. SaaSに登録していた固定IPアドレス問題 SaaSサービスでは、 「このIPアドレスからのアクセスじゃないとログインできないよ」 という風に送信元IPアドレスを用いたアクセス制限がかけられるサービスがたくさんあります。従来型VPNからZTNAに切り替えた場合、これが出来なくなって大騒ぎになるケースがあります。 IPアドレスが変わるだけなら登録しなおして終わりですが、ZTNA/SWGサービスによっては、 送信元IPアドレスを固定する機能自体を提供していないもの があります。 さらに、最近では情シスが把握していない 各事業部が独自に契約しているSaaSサービス も多く、そこで送信元IPアドレス制限機能を有効にしている場合も多いです。こういった場合、 導入後につながらなくなって初めて発覚 し、情シス担当者が大慌てしたことが過去にありました。 こういう事態を回避するためにも、ZTNA導入時は各事業部に対し、独自で契約しているSaaSサービスを 事前に確認 しておくことをお勧めします。 9.4. Active Directory (AD) との通信問題 特にIAP型のZTNA製品の場合、 ADとの通信 が重要なポイントになります。 製品によっては使用できるプロトコルに制限があり、ADで必要になる LDAPやKerberosなどの通信プロトコルが通らない ケースがあります。こういった場合、「リモートの時だけADにつながらないくらいなら問題ないよ」と割り切るのか、「ADと通信できないなんて話にならないから別製品にする」と切り替えるのかは判断が分かれるところです。 一般的に、多機能な製品ほど高価格になるので機能とコストを天秤にかけて判断を下すポイントです。「必須の通信」なのか「妥協できる通信」なのかをコスト比較しながら検討することが重要です。 Active Directoryとの通信 上記のように、多かれ少なかれZTNA導入時には、従来型VPN環境では起こらなかった問題が出てくるものです。そのため、本格導入の前に 本番環境での検証 を行い、社内のシステムがちゃんと使えるか試験運用することを "強く" お勧めします。 手間がかかりそうに感じられるかもしれませんが、これはネガティブに捉えるのではなく、 今までザルだった環境をセキュアな環境に切り替えるための必要コスト だと考えていただけると幸いです。 10. おわりに ZTNAの機能やポイントについて解説しましたが、これからZTNAを導入しようとしているシステム管理者の方に一番伝えたいメッセージは 「運用を考えた設計」 が一番大切だということです。 Device Posture機能を知ると、つい「OS最新、EDR必須、定義ファイルも最新のみ許可!」とガチガチのポリシーを作りたくなったり、ポリシーのバリエーションも増やしたくなります。しかし、最初からこういったガチガチ設計をすると、Windows Updateのタイミングが少しズレただけで翌朝社員が業務できなくなったり、情シスへのクレーム電話が鳴り止まなくなるかもしれません。 さらに、「なぜか分からないけどつながらない」みたいなケースも想定されます。「業務システムにつながらないから故障だと思ったら、OSをアップデートしていないからブロックされているだけだった」みたいなケースも出てくるかも知れません。 こういった運用トラブルを回避するためにも、最初は緩めの条件からスタートし、ログを見ながら徐々に厳格化していくスモールスタートしていくのが現実的な進め方です。 セキュリティは「ビジネスを止めるため」ではなく「ビジネスを安全に進めるため」にある という基本を忘れず、自社に最適なゼロトラスト環境を構築してください。 執筆者 寺崎 智博(NTT西日本 セキュリティ&トラスト部所属) 2020年に社内でゼロトラストプロジェクトを立ち上げ、西日本各地のお客様のセキュリティ強化支援に従事。NTT西日本社内IT環境のゼロトラスト化にも設計者として参画。 老後の夢はカレー屋。 出典 警察庁|令和4年上半期におけるサイバー空間をめぐる脅威の情勢等について https://www.npa.go.jp/publications/statistics/cybersecurity/data/R04_kami_cyber_jousei.pdf NIST|SP 800-207 Zero Trust Architecture https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-207.pdf ガートナー|ゼロトラストとは?今求められるセキュリティ戦略の基本と導入ポイント https://www.gartner.co.jp/ja/topics/zero-trust 商標 Okta は、Okta, Inc. の商標または登録商標です。 Microsoft Entra ID は、Microsoft Corporation の商標または登録商標です。 *1 : Identity Provider *2 : Endpoint Detection and Response *3 : Secure Web Gateway *4 : Secure Access Service Edge *5 : Security Service Edge
はじめに 本記事では、社内CTF大会向けに「複数の脆弱性を連鎖させて解く」形式のWeb問題を企画し、実装し、運用環境へ載せるまでの流れを問題の作成者の立場から解説します。単に脆弱性を作り込むのではなく、学習効果と競技体験を両立させるための設計・難易度調整に気を付けることで、解いていて楽しい問題をめざしました。 本記事は2026年2月時点の情報に基づきます。 掲載したソースコードの転用は禁止させていただきます。 本記事で紹介しているアプリケーションのような、脆弱なソフトウェアをインターネットに公開することはおやめください。 社内、社外限らず、CTF大会を実施する際には競技環境のセキュリティの安全確保に努めてください。 対象読者 本記事が想定する対象読者は以下の通りです。社内CTFの運営・問題作成に関わるセキュリティ担当者、教育担当者、または「CTFに出る側」から一歩進んで「作る側」に回りたいセキュリティエンジニアです。Webアプリの基本(ログイン、データベース、API)と、クラウド上でサービスを動かす基本(Linux、ポート開放、デプロイ)をうっすら理解していると読み進めやすいと思います。 目次 はじめに 対象読者 目次 1. 背景・目的 2. CTFとは 2-1. CTFの概要 2-2. 問題の種類 2-3. 国内/海外の大会の例 2-4. 社内CTF大会の概要 3. CTF問題作成について 3-1. 脆弱性の検討 3-2. 難易度の考慮 4. Webアプリケーションの作成 4-1. ベースアプリケーションの流用 4-2. 技術スタックと既存機能 4-3. API機能の追加 4-3-1. /api/v1/auth:JWT認証の実装と脆弱性 4-3-2. /api/v1/search:検索APIとSQLインジェクション 4-3-3. 平文の認証情報を発見させる 5. 解法(WriteUp)の作成 6. まとめ 執筆者 商標 1. 背景・目的 私自身はこれまでCTF競技への参加経験はあるものの、問題を作る側の経験は多くありませんでした。一方で、日々さまざまなセキュリティ教材を学習する中で、インプットした知識や手法を「業務以外で試し、形にして残せるアウトプットの場」が欲しいと感じるようになりました。そこで、自分の理解を一段深める手段として、CTFの問題の作成に挑戦することにしました。 また当時、ペネトレーションテスト系資格の学習も進めており、知識が点ではなく線としてつながっていく感覚を得ていました。ペネトレーションテストでは、限られた手掛かりから段階的に侵入経路を組み立て、権限や到達範囲を広げていくことが多くあります。この侵入を積み上げていくような体験を、CTFでも無理なく再現できないかと考えたのが、今回の問題設計の出発点です。 その結果として、単一の脆弱性を解くだけで終わらず、複数の脆弱性を組み合わせて段階的にゴールへ近づく形式の問題の作成を意識しました。 2. CTFとは 2-1. CTFの概要 CTF(Capture The Flag)は、用意されたシステムやプログラムに潜む弱点を見つけ、条件を満たして「フラグ」と呼ばれる文字列を見つけ出す競技です。 2-2. 問題の種類 CTFの問題は、扱う技術領域ごとに複数のカテゴリがあります。代表例は次の通りです。 Web(認証、セッション、アクセス制御、入力検証など) Pwn(メモリ破壊、サンドボックス回避など) Crypto(暗号・署名・乱数の設計不備など) Forensics(ログ解析、ファイル復元、メモリ解析など) Reversing(バイナリ解析、難読化解除など) Misc(プロトコル、OSINT、自動化など) 2-3. 国内/海外の大会の例 海外では大規模なオンライン大会が年間を通じて多数開催され、難易度や形式も幅広いです。国内でも学生・社会人向けを含む大会や、コミュニティ主催のイベントが定期的に行われています。 SECCON :国内で代表的なCTFで、オンライン予選とオンサイト決勝があるのが特徴です。 picoCTF :学生・初心者にも学びやすいオンラインCTF(常設)として有名です。 2-4. 社内CTF大会の概要 社内CTF大会は、社員のセキュリティ技術の底上げと、これまでセキュリティに馴染みのなかった層にも興味を持ってもらうことを目的に開催しています。​ 本大会の特徴は、問題の企画・作成から競技環境の構築、当日の運営までをすべて社員が内製で行っている点です。今回はその大会に出題する問題を1問作成したので、どのようにテーマを決め、設計し、実装したのかを紹介します。 3. CTF問題作成について 3-1. 脆弱性の検討 CTF問題に  ペネトレーションテスト のようなストーリー性を取り入れる場合、最初に押さえるべき考慮ポイントの一つが「参加者がVPN接続できる前提かどうか」です。リバースシェルを張るような攻撃シナリオを軸にすると、どうしても到達性やネットワーク制約の都合でVPN環境が必要になりがちだからです。​ 今回はVPNを利用しないCTF環境において、ネットワーク前提に依存しない形で「段階的に侵入していく体験」を成立させる方針にしました。具体的には、1つのWebサイトに複数の脆弱性を用意し、それらを順に攻略していくことで、フロント(Web)からバックエンド(データベース)へと到達していくシナリオです。 採用した脆弱性は、参加者にとって親しみのあるWebアプリケーション領域に寄せることとし、 不備のあるJWT(JSON Web Token)認証を持つAPIエンドポイント API上のSQLインジェクション 認証情報を平文で保存していることによるリスク という3段階で構成しました。これらの脆弱性を選定した主な理由は、後述する、私が過去に参加した「Webアプリケーション開発研修」で開発したアプリケーションへ比較的容易に組み込めるためです。 3-2. 難易度の考慮 CTFでは、問題ごとに点数が設定され、その点数設計に合わせて難易度が調整されるのが一般的です。 今回は「600点問題」という大会内で高難度に位置づけられた600点枠の作成が求められており、参加者も“普段セキュリティに馴染みがない層”から“実務でセキュリティに携わる層”まで幅広い前提でした。そこで、全チームが解ける難易度ではなく、最終的に1〜2チームが解ければ十分というラインを目標に設定しました。​ また、複数脆弱性を段階的に攻略する形式は、入口でつまずくとその後の攻略が止まってしまうため、特に最初の脆弱性に気づくための足掛かりについては、問題文中にヒントとなるキーワードを意識的に含めました。参加者はここでAPIの存在に気づき、次にJWTの構造を調べることを期待しました。 APIエンドポイントがあることを誘うキーワード 難易度は問題レビューアーと綿密にすり合わせを行い、ヒント量と探索難度のバランスが崩れないように何度か調整しました。実際の競技では、多数のチームが参加する中で1チームが正解に到達し、狙い通りの難易度にできたと感じています。 4. Webアプリケーションの作成 4-1. ベースアプリケーションの流用 問題の作成にあたっては、別途受講していた「Webアプリケーション開発研修」で開発したアプリケーションを土台として利用しました。ゼロから新規開発するよりも、既存の画面・データベース・ログイン機能が揃っているため、CTF用の改修点(API追加や脆弱性の仕込み)に集中できると判断したためです。また、研修時には脆弱性ができないように開発をしていましたが、逆に脆弱性のあるアプリケーションに改修することで、セキュアなアプリケーション開発に対する理解を深めることができるのではと考えました。 4-2. 技術スタックと既存機能 アプリケーションは Python で実装しており、Webフレームワークに Flask、テンプレートに Jinja2 を利用しています(FlaskはJinja2をテンプレートエンジンとして利用する構成が標準です)。 バックエンドデータベースには MariaDB を採用し、ユーザー登録・ユーザーログインといった基本機能がすでに実装されている状態でした(MariaDBは一般的なオープンソースのRDBMS(Relational Database Management System)です)。 4-3. API機能の追加 「段階的に侵入していく体験」を作るため、既存のWeb画面に加えてAPI機能を追加し、さらにAPIを利用する認証機構も実装しました。新たに追加したエンドポイントは以下の2つです。 /api/v1/auth :Web画面で登録したユーザー名・パスワードで認証し、成功したらJWTトークンを発行 /api/v1/search :JWTトークンによる認証を前提に、検索機能を提供 ここでの狙いは「Web画面(ブラウザ操作)とAPI(プログラム的操作)」を行き来する導線を作り、CTFとしての探索の幅を広げることでした。 4-3-1. /api/v1/auth :JWT認証の実装と脆弱性 /api/v1/auth  では、ユーザー名・パスワードの検証が成功するとJWTを返すようにしました。JWTは「トークンが改ざんされていないこと(署名検証)」と「権限情報(roleなど)の扱い」をどう設計するかが肝になります。 このトークン検証箇所に意図した脆弱性を埋め込み、次の段階へ進むための足掛かりにしました。 実際に脆弱なコードを実装する際、該当する脆弱なパラメータは標準設定では無効化されていたため、そのままでは再現できず苦労しました。JWTトークンを扱う関数の挙動や設定項目をひとつひとつ調査したうえで、意図した脆弱性を成立させるために、明示的に脆弱なオプションを指定する必要がありました。 ※以下のコードはCTF教材用であり、実運用に転用しないでください。 def verify_admin_token (token): try : # トークンのヘッダーからアルゴリズムを取得 header = jwt.get_unverified_header(token) algorithm = header.get( 'alg' ) # アルゴリズムに応じてキーを選択 if algorithm == "none" : payload = jwt.decode(token, None , algorithms=[ "none" ], options={ "verify_signature" : False , # 署名検証無効化 "verify_exp" : False , # 有効期限検証無効化 "verify_iat" : False # 発行時刻検証無効化 }) elif algorithm == "HS256" : payload = jwt.decode(token, JWT_SECRET, algorithms=[ "HS256" ]) if payload.get( 'role' ) == 'admin' : return True , payload else : return False , "Insufficient privileges" except jwt.ExpiredSignatureError: return False , "Token expired" except jwt.InvalidTokenError as e: return False , f "Invalid token: {str(e)}" ​ 4-3-2. /api/v1/search :検索APIとSQLインジェクション   /api/v1/search  では、データベース検索を行うAPIとして実装し、ここにSQLインジェクションの脆弱性を仕込みました。600点(最難関)とはいえ「複数脆弱性を組み合わせる問題」であり、参加者層も幅広い想定だったため、ここを過度に難しくしすぎない方針にしました。 また、参加者が「脆弱性の存在に気づける」ことも重要だったため、エラーをあえてレスポンスに含める(=探索の手掛かりを残す)設計にしています。もちろん実運用のアプリでは推奨されない挙動ですが、CTF教材としては「気づき」のコストを下げるためそのような実装にしました。 ※以下のコードはCTF教材用であり、実運用に転用しないでください。 try : # 脆弱性: SQLインジェクション sql = f "SELECT username, category, ioc, score FROM history WHERE ioc LIKE '%{ioc_value}%'" # 脆弱なSQL実行 results = database.execute_sql_select_raw(sql) return jsonify({ "status" : "success" , "ioc" : ioc_value, "results" : results, "count" : len (results), "message" : f "Found {len(results)} results in flags database" }) except Exception as e: # エラー情報の漏洩 return jsonify({ "status" : "error" , "message" : f "Database error: {str(e)}" , "query" : sql if 'sql' in locals () else "N/A" , "ioc" : ioc_value }), 500 ※参考として、本来は「プレースホルダ(パラメータ化)を使い、SQL文字列を組み立てない」実装を行うことがセキュアなアプリケーションとして求められます。 ​ sql = "SELECT username, category, ioc, score FROM history WHERE ioc LIKE %s" params = (f "%{ioc_value}%" ,) results = database.execute_sql_select(sql, params) 4-3-3. 平文の認証情報を発見させる 最後の段階では、SQLインジェクションで取得できる情報の中に、管理者アカウントに関する「認証情報が危険な形で保存されている」状態を用意しました。具体的には、CTF用のダミーデータとして管理者のユーザー名とパスワードを平文で格納しておき、参加者がそれを発見したら、ブラウザでWebアプリのログイン画面へ戻って管理者としてログインし、フラグが表示される——という一連の流れです。 この構成にした理由は、単に「SQLインジェクションできた」で終わらせず、 情報の保存形式 が最終的な侵害に直結する、という実務寄りの学びまでつなげたかったからです。 本来のアプリケーション実装において、PythonであればBcryptなどを用いたハッシュ化を行い、ハッシュ値をデータベースに格納することで、仮にデータベースの内容が流出したとしても平文のパスワードは守られるという状態にしておかなければなりません。 5. 解法(WriteUp)の作成 CTFでは、問題の解き方をまとめた資料を一般にWriteUp(write-up)と呼びます。 今回のWriteUpは、わかりやすさを最優先し、「実施する操作内容」「使用するツール名」「実行結果の例」を淡々と並べるシンプルなテキスト形式で作成しました。​ 一方で、セキュリティ学習という観点では、なぜその操作に至るのか(観察ポイント、判断の根拠、つまずきやすい点、安全な実装のためのヒントなど)まで含めて整理したほうが、参加者にとっても後から読む自分にとっても価値が高いWriteUpになります。 そのため、この点は反省として残っており、次回は手順の列挙に加えて「どの挙動がヒントだったか」「本来の安全な実装ならどうなるか」「修正版ではどこを直すか」といった要素についても取り入れ、改善していくつもりです。 作成したWriteUp(一部) 6. まとめ 本記事では、社内CTF向けに「複数の脆弱性を連鎖させて解く」Web問題を1問作成した過程(設計・難易度調整・実装)を紹介しました。​ 複数脆弱性問題では、前提(VPN有無)と導線(入口・中間・ゴール)を先に固めるのが重要だと分かりました。WriteUpは手順中心で簡素だったため、次回は観察ポイントや安全な実装のためのヒントまで書いて教材としての価値を上げたいと思います。 執筆者 鴨下 将成(NTT西日本 セキュリティ&トラスト部所属) 社内セキュリティ業務に携わっています。 好きな食べ物はラーメンです。 OSCP、CISSP、GPEN、GREM、GCFE、CEH、情報処理安全確保支援士 商標 Python は Python Software Foundation の登録商標です。 MariaDB は MariaDB Corporation Ab の登録商標です。 その他、記載されている会社名・製品名は各社の商標または登録商標です。
はじめに NTT西日本の中川です。 先日、「Rustならこのロジック、どう書くんだっけ?」というちょっとした興味が湧きました。本来なら10分もあれば済む確認のはずでしたが、気づけばコンパイラのバージョン管理や依存ライブラリの衝突と格闘し、いつのまにか丸一日が溶けていました。 そんなとき、ふと「プレイグラウンド」の存在を思い出し、検索してみました。 すると、ありました。Rustのプレイグラウンド。 環境構築などは不要で、試したいロジックをコピペで張り付けるだけでもう動く環境があり、丸一日費やした後だったので、非常に感動しました。 そんな私と同じ後悔を皆さんにはしてほしくない、そしてできれば環境構築不要で試せる感動を是非他の人にも広めたいと思い、今回はブラウザだけでサクッとコードを実行できる環境を、主要言語ごとにご紹介します。 本記事は、2026年2月時点の情報に基づきます。 対象読者 本記事は、以下のような方を対象としています。 新しい言語に興味があるが、環境構築の「泥沼」で挫折したくない方 記事や本で見かけたコードを、その場ですぐに動かして「体感」したい方 10分の確認のために、PCのストレージや貴重な休日を消費したくない方 背景 現代のエンジニアにとって、環境構築は避けて通れない道ですが、 「本質的な学習」の前に立ちはだかる大きな壁 でもあります。 各言語の公式プレイグラウンドを知っておくことは、単なる時短術ではありません。「思考を即座にコードに変換できる自由」を手に入れるための、エンジニアの生存戦略だと私は考えています。 1. 動的型付け言語(スクリプト言語系) PHP: PHP Playground 「今のバージョンだとどう動く?」を確かめるのに最適です。 PHP Playground 推しポイント : WebAssembly(Wasm)技術が使われていて、サーバー不要でブラウザ内でPHPが高速に動作します。体感ではローカル環境とほぼ同等の速度です。 Python: Python.org Shell & Colab 公式が出してくれているので安心感もひときわです。 人気のpythonもサクっと試せます。 Python.org Interactive Shell 公式の簡易シェル。 import math など、importも利用できるので、基本挙動を確認するのに便利です。 Google Colaboratory プレイグラウンドの枠を超え、Jupyter Notebook環境を無料で利用できます。機械学習の学習にも非常に有用です。 ただし、Googleのアカウントが必要なので要注意。 2. 静的型付け・コンパイル言語系 型定義やビルドが必要な言語こそ、プレイグラウンドの恩恵が最大化されます。 TypeScript: TypeScript Playground TypeScript Playground おすすめ活用法 : 右側のパネルで「JavaScript」を選択してください。自分の書いた型定義が、実行時にどう消えるのかが視覚的に理解できます。 TypeScriptはコンパイルされるとJavaScriptに変換されますが、型付きで書いた内容がJavaScriptだとどう見えるのかを即座に確認できるので、めっちゃ勉強になります。 C#: SharpLab SharpLab C#エンジニアには定番の高機能デバッグツール。最新のC#機能がどうコンパイルされるか、IL(Intermediate Language: 中間言語)レベルで覗けます。 Rust: Rust Playground Rust Playground 私がこの記事を書こうと思った理由です(笑)。もっと前に存在を知っておきたかったです。 Rustは何でもできると言われますが、用途が広いほど迷うことも多いと思います。一旦プレイグラウンドで触ってみて、良いと思ったら深掘りしていくスタイルでもいいのかなと個人的には思っています。 コード例 fn main() { let s = String::from("hello"); let _s2 = s.clone(); println!("{}", s); } 3. 多言語対応サービス Wandbox Wandbox 日本発の誇るべきサービス。古いバージョンから最新まで揃っており、「あの頃の挙動」を確認する際の駆け込み寺です。 UIはシンプルですが、ほんとに沢山の言語がこれ一つで試せるので、めちゃくちゃ重宝します。 4. プレイグラウンドを「武器」にするための注意点 プレイグラウンドは非常に便利なツール群ですが、安全に使うために以下の2点は必ず守ってください。 機密情報は決して貼らない : これらは基本的に「公開環境」です。業務の秘匿コードや本番環境のAPIキーを入力するのは厳禁です。 「使い捨て」と割り切る : 凝ったコードを書いても、保存(URL発行)を忘れると一瞬で消えます。長くなるならローカルへ移行しましょう。 まとめ ちょっと試したいだけで環境構築に丸一日費やし、疲れ果ててPCを閉じるような経験はだれしも一度くらいはあるのではないでしょうか。 今後は、まずプレイグラウンドで10分、コードに触れてみてください。 今までの環境構築のストレスが大幅に軽減されます。 執筆者 中川 拓哉(NTT西日本所属) NTT西日本のWEBアプリケーションの開発・運営に従事。 好きな技術:TypeScript, Vue.js, Nuxt.js, GraphQL 商標 Python は Python Software Foundation の登録商標です。 PHP は PHP Group の登録商標です。 .NET、C#、Visual Studio Code は Microsoft Corporation の米国およびその他の国における登録商標です。 TypeScript は Microsoft Corporation の米国およびその他の国における商標または登録商標です。 Rust は Rust Foundation の登録商標です。 Jupyter は NumFOCUS の商標です。 WebAssembly は W3C の商標です。 Google Colaboratory は Google LLC の商標または登録商標です。 その他、記載されている会社名、製品名は各社の商標または登録商標です。
はじめに NTT西日本の中川です。 プログラム開発において、デバッグ(バグの修正)は避けて通れない工程ですよね。 どれほど丁寧に設計を練っても、不具合をゼロにするのは至難の業。僕も新人の頃は、画面の前で「なんで動かないんだ?」と頭を抱えたまま、気づけば外が暗くなっていた、なんて苦い経験が何度もあります。 当時は、闇雲にコードを書き換えてはブラウザをリロードする、俗にいう「お祈りデバッグ」を繰り返していました。しかし、それではいつまで経っても解決しないんですよね。今回は、そんなお祈りデバッグを卒業するための 「検証ツール(DevTools)」の活用法 と、デバッグの時間を効率よく短縮するための 「仮説検証」のアプローチ を、実体験を交えてご紹介できたらと思います。 対象読者 本記事が想定する対象読者は次の通りです。 「とりあえず値を確認したいから、まず console.log 」が口癖の方 エラーが出ると、どこから手を付けていいか分からずフリーズしてしまう方 検証ツールを「色をポチポチ変えるだけ」のツールから卒業させたい方 背景 開発時間の半分以上がデバッグに消えてしまう……。そんな悩みを抱えている方は多いはずです。 僕も以前は、「なんとなくここが怪しい」という直感だけでコードをいじり、別のバグを生むという悪循環に陥っていました。 ですが、大切なのは、ブラウザが標準で備えている強力な武器(検証ツール)を使いこなし、 「推測」を「事実」に置き換えていくプロセス です。今回は、僕が現場で「もっと早く知りたかった!」と感じたテクニックを厳選しました。 1. デバッグの基本は「期待」と「事実」のギャップを埋めること デバッグで最も大切なのは、実はツールの知識よりも 「思考のプロセス」 です。 不具合が起きているとき、そこには必ず 「期待している挙動(理想)」 と 「実際に起きている挙動(現実)」 のズレがあります。 最短で解決するための3ステップ 現象の観察 : 焦ってコードを直す前に、エラー文を「最後の一文字まで」じっくり読みます。 仮説の構築 : 「もしかして、APIから空のデータが返ってきているのでは?」と言葉にしてみます。 事実の確認 : ツールを使って、その瞬間のデータを「目視」し、裏付けをとります。 このステップを飛ばすと、偶然直ったとしても「なぜ直ったか分からない」ため、同じミスを繰り返してしまいます。「急がば回れやで」と先輩に幾度も口酸っぱく言われましたが、いまとなっては「デバッグにおいて最大の金言」だと思っています。 console.log は確かに万能感がありますが、大量のデータが出ると追いかけるのが大変です。そんな時に役立つのがこちら。 データの構造をパッと見抜く console.table() 「APIから返ってきたユーザーリスト、誰のroleが抜けてるんだ……?」と羅列された大量のログを追うのはもうやめましょう。 const users = [ { id : 1 , name : "中川" , role : "Developer" } , { id : 2 , name : "田中" , role : undefined } , // ここが原因か! { id : 3 , name : "佐藤" , role : "Manager" } ] ; // 表形式なら、特定の項目の欠落を1秒で見つけられます console . table ( users ) ; 「なんとなく重い」を数字にする console.time() 「この処理、ちょっとモッサリしてない?」という感覚をチームに伝える時、数値ほど強い味方はありません。 console . time ( "データ加工の計測" ) ; const result = heavyProcess ( hugeData ) ; // 重い処理 console . timeEnd ( "データ加工の計測" ) ; 3. プログラムの実行を制御する「ブレークポイント」 console.log が「過去の足跡」だとしたら、 「ブレークポイント」 は「現在進行形の時間を止める術」です。 やり方: 検証ツールの Sources タブで、気になる行番号をクリック。 青いマークがついたら、プログラムがその瞬間で「停止」します。 ここでの感動ポイントは、 「止まった時点での変数の値がすべて見える」 こと。 console.log を10個並べるより、1つのブレークポイントの方が圧倒的に情報量が多いです。 行番号をクリックすると画像のように青くマークされる 4. 呼び出しの経緯を遡る「コールスタック」 「エラーの場所は分かった。でも、 そもそも誰がこの関数を呼んだんだ? 」 そんなときは Call Stack(コールスタック) の出番です。 実行の歴史を「タイムスリップ」するように遡れます。 呼び出し元を順にクリックしていくと、どの時点でデータが壊れたのかを特定できます。 「関数Aが呼んだ関数Bの、さらに先の関数C」で起きたミスも、効率的に追跡できます。 赤枠の箇所がCallStackの出力内容 5. 境界線を引く「切り分け」の技術(ネットワークタブ) フロントエンド開発で一番悲しいのは、自分のコードが悪いと思って3時間調べた結果、実はAPIサーバー側が止まっていた……というパターンです。 調査の前に Network タブを開く癖をつけましょう。 Status : 500系なら、すぐにサーバー側を確認するようにしましょう。「フロントエンドの問題じゃない」と分かれば、無駄な調査を即座に終了できます。 Response : 期待通りのJSONが返ってきているか、真っ先に確認してください。 まとめ デバッグは、不具合の「正体」を一つずつ暴いていく探偵のような仕事です。 エラー文を友達にする : 解決のヒントは、常にエラー文が教えてくれます。楽しむくらいの気持ちで読んでみましょう。 仮説を独り言にしてみる : 「ここが怪しいんだよな」と呟くと、思考が整理されます。(結構大事です。) ツールで「事実」を見る : 自分の推測を疑い、ツールで証拠を掴みます。 検証ツールを使いこなせるようになると、デバッグは「苦痛な作業」から「知的な推理ゲーム」に変わります。明日からのデバッグが、ほんの少しだけ楽しみになれば幸いです。 今日から、お祈りデバッグを卒業して、スマートにバグを対処していきましょう! 執筆者 中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属) NTT西日本のWebアプリケーションの開発・運営に従事。 好きな技術スタック:TypeScript, Vue.js, GraphQL, Laravel 参考文献 MDN: デバッグの概要 Chrome DevTools 公式ドキュメント 商標 「Google Chrome」は、Google LLCまたはその関連会社の商標もしくは登録商標です その他、本記事に記載されている会社名、製品名、サービス名等は、各社の商標または登録商標です。
はじめに NTT西日本の中川です。 本記事では、オブジェクト指向設計の重要な考え方である 「SOLID(ソリッド)の原則」 を、JavaScriptのサンプルコードと共に解説したいと思います。 本記事は、2026年2月時点の情報に基づきます。 対象読者 本記事が想定する対象読者は以下の通りです。 もっと「保守性の高いコード」を書きたい方 過去の自分のコードを見て「修正が怖い」と感じた経験がある方 チーム開発で設計の指針を求めているエンジニアの方 目次 はじめに 対象読者 目次 1. 目的 1. S:単一責任の原則 (Single Responsibility Principle) なぜこの原則が必要か? 悪い例:複数の機能を持ったなんでも屋さんのクラス 良い例:責任を分ける 2. O:開放閉鎖(オープンクローズド)の原則 (Open/Closed Principle) なぜこの原則が必要か? 悪い例:条件分岐(if/switch)による判定 良い例:多態性(ポリモーフィズム)を活用 3. L:リスコフの置換原則 (Liskov Substitution Principle) なぜこの原則が必要か? 悪い例:期待を裏切る継承 良い例:親の期待に沿う継承 4. I:インターフェース分離の原則 (Interface Segregation Principle) なぜこの原則が必要か? 悪い例:使わないメソッドまで要求される 良い例:役割の細分化 5. D:依存性逆転の原則 (Dependency Inversion Principle) なぜこの原則が必要か? 悪い例:特定の道具に密結合 良い例:依存性の注入 (DI) 6. まとめ 執筆者 参考資料・出典 商標 1. 目的 プログラムは一度書いて終わりではありません。機能追加や仕様変更は多くのプロジェクトで発生します。設計が不十分だと「一箇所直すと別の場所が壊れる」「今回の修正範囲とは関係がないはずの機能に影響が出てしまう」などの負の連鎖に陥ることがあります。 本記事は、SOLIDの5つの原則を理解することで、 変化に強く、テストしやすく、そして何より「メンテナンスしやすい」コード を書けるようになるための指針を提示することを目的としています。 サンプルコードはブラウザのコンソールなどでそのまま実行できますので、試してみてください。 1. S:単一責任の原則 (Single Responsibility Principle) 「一つのクラス(または関数)は、一つのことだけを担当すべき」 という原則です。 この法則を意識するだけでもグッと良いコード構成になってきます。 なぜこの原則が必要か? 一つのクラスに複数の責任を持たせると、以下の問題が発生しやすいためです。 影響範囲の増大 : ある機能を修正した際、無関係なはずのもう一方の機能にバグが混入するリスクが高まる。 テストの難化 : 依存関係が複雑になり、特定の動作だけを検証する単体テストが書きづらくなる。 再利用性の低下 : 「ログ機能だけ使いたい」と思っても、ユーザー管理機能と密結合していると切り出せなくなる。 悪い例:複数の機能を持ったなんでも屋さんのクラス class User { constructor ( name ) { this. name = name ; } display () { console . log ( `User: ${ this. name } ` ) ; } // ユーザー情報とは無関係な「ログ保存」の責任まで持っている saveLog ( message ) { console . log ( `ログを保存しました: ${ message } ` ) ; } } 良い例:責任を分ける class User { constructor ( name ) { this. name = name ; } display () { console . log ( `User: ${ this. name } ` ) ; } } class Logger { saveLog ( message ) { console . log ( `ログを保存しました: ${ message } ` ) ; } } 2. O:開放閉鎖(オープンクローズド)の原則 (Open/Closed Principle) 「拡張に対しては開いていて、修正に対しては閉じているべき」 という原則です。 なぜこの原則が必要か? 既存機能の保護 : 新機能追加のたびに「既に動いているコード」を書き換えると、デグレード(先祖返り・品質悪化)のリスクが常に付きまとってしまう。 変更コストの削減 : 既存コードに手を加えずに「付け足すだけ」で機能が増やせる状態が、多くのプロジェクトにおいては安全で高速な開発となるため。 悪い例:条件分岐(if/switch)による判定 function calculateArea ( shape ) { if ( shape . type === 'square' ) { return shape . size ** 2 ; } else if ( shape . type === 'circle' ) { return Math . PI * ( shape . radius ** 2 ) ; } // 新しい形が増えるたびに、この既存関数を壊すリスクを負って修正が必要 } 良い例:多態性(ポリモーフィズム)を活用 class Square { constructor ( size ) { this. size = size ; } area () { return this. size ** 2 ; } } class Circle { constructor ( radius ) { this. radius = radius ; } area () { return Math . PI * ( this. radius ** 2 ) ; } } // 既存のコードを一切修正せず、新しいクラスを渡すだけで拡張可能 function calculateArea ( shape ) { return shape . area () ; } 3. L:リスコフの置換原則 (Liskov Substitution Principle) 「親クラスは、その子クラスでいつでも代用できなければならない」 という原則です。 なぜこの原則が必要か? 予測可能性の維持 : 親クラスを継承した子クラスが「親とは全く違う挙動(例外を投げるなど)」をした場合を想像してみてください。そのクラスを使う側は常に「これは特殊な子クラスではないか?」と疑ってコードを書かなければならなくなってしまい、バグの温床になってしまいます。 悪い例:期待を裏切る継承 class Bird { fly () { console . log ( "空を飛びます" ) ; } } class Ostrich extends Bird { fly () { throw new Error ( "ダチョウは飛べません" ) ; } // Birdとして扱った時にエラーになる } 良い例:親の期待に沿う継承 class Sparrow extends Bird { fly () { console . log ( "スズメが空を飛びます" ) ; } // Birdとして扱っても安全に動作する } // 呼び出し側は「Birdの子孫」として扱うだけでよく、中身がSparrowでもOstrichでも意識しなくてよい const bird = new Sparrow () ; bird . fly () ; // 期待通りの挙動 4. I:インターフェース分離の原則 (Interface Segregation Principle) 「利用しないメソッドを、クラスに無理やり実装させてはいけない」 という原則です。 なぜこの原則が必要か? 不要な依存の排除 : 使わない機能まで無理やり実装させられると、その機能に変更があった際、本来関係のないはずのクラスまで再コンパイルや修正の影響を受けてしまいます。 悪い例:使わないメソッドまで要求される // 「掃除も料理も何でもできる人」を要求してしまう設計 function useWorker ( worker ) { worker . clean () ; worker . cook () ; // 掃除だけしたい場合でも、cook() の実装を強制されてしまう } // 掃除だけしたいのに、cook() も実装しなければならない class Janitor { clean () { console . log ( "掃除しました" ) ; } cook () { throw new Error ( "担当外です" ) ; } // 使わないのに実装が強制される } 良い例:役割の細分化 ※ JavaScriptには厳密なInterface構文はありませんが、「必要な機能だけを要求する」設計を意識することで、依存関係をクリーンに保てます。 // 掃除担当。cleanメソッドさえ持っていれば、他の余計な機能は知らなくて良い function cleanRoom ( cleaner ) { cleaner . clean () ; } // 掃除だけできればよいので、clean() だけ持てばよい const myCleaner = { clean : () => console . log ( "掃除しました" ) } ; cleanRoom ( myCleaner ) ; 5. D:依存性逆転の原則 (Dependency Inversion Principle) 「具体的なものに依存せず、抽象的なものに依存せよ」 という原則です。 なぜこの原則が必要か? 交換可能性の確保 : 特定のデータベースや外部ツールに依存したコードを書くと、ツールの変更やバージョンアップの際にシステム全体を書き換える必要が出てきます。 テストの容易性 : 抽象に依存していれば、本物のデータベースの代わりに「テスト用のダミー(Mock)」を差し替えることが容易になります。 悪い例:特定の道具に密結合 // 仮に MySQL 専用のクラスがあるとする class MySQLDatabase { save ( data ) { console . log ( "MySQLに保存:" , data ) ; } } class UserStore { constructor () { this. db = new MySQLDatabase () ; // 常にMySQLに依存しきっている } addUser ( name ) { this. db . save ({ name }) ; } } // テストで偽のDBに差し替えたい、別のDBに変えたい、というときに書き換えが大変 良い例:依存性の注入 (DI) class UserStore { constructor ( database ) { this. database = database ; // 「保存機能」を持つ何かであれば、中身はMySQLでもPostgreSQLでも良い } addUser ( name ) { this. database . save ({ name }) ; } } // 本番では実装を、テストではMockを渡せる const realDb = { save : ( data ) => console . log ( "保存:" , data ) } ; const store = new UserStore ( realDb ) ; store . addUser ( "中川" ) ; 6. まとめ SOLID原則を意識することで、以下のようなメリットが得られます! 影響範囲の特定 : 修正時の「どこまで壊れるか」が明確になります。 テストコードの簡素化 : 各部品が独立しているため、検証が容易です。 チーム開発の円滑化 : 共通の指針があることで、コードレビューの質も向上します。 もし、試してみようと思っていただけたなら、まずは 「S(単一責任):この関数は欲張りすぎていないか?」 をチェックすることから始めてみてください。きっと新しい気づきがあるはずです。 執筆者 中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属) NTT西日本のWEBアプリの開発・運営をしています。 TypeScript, Vue.js, GraphQL, Laravelが好きです。 参考資料・出典 本記事を執筆するにあたり、以下のサイトを参考にしました。 原典となる論文: Design Principles and Design Patterns (PDF) 商標 JavaScript は、Oracle Corporation の米国およびその他の国における登録商標です。
はじめに NTT西日本の中川です。 本記事ではデザインパターンの一つである 「Observer(オブザーバー)パターン」 をJavaScriptを利用してご紹介します。本記事は、2026年2月時点の情報に基づきます。 対象読者 本記事が想定する対象読者は次の通りです。 フロントエンドエンジニア リアルタイム反映に興味があり、仕組みを理解したい人 手を動かしながら理解を進めたい人 背景 プログラミングを進めていくと、 「コードの複雑化(スパゲッティコード)」 が起こってしまうことも多いと思います。 「ボタンを押したら、ヘッダーの通知アイコンを変えて、サイドバーの数字も更新して、ログも表示して……」といったように、一つのアクションに対してあちこちを更新しようとすると、コードが複雑に絡み合い、どこを直せばいいか分からなくなることがあります。 今回は、そんな課題を整理するのに役立つデザインパターン 「Observer(オブザーバー)パターン」 を、ツール不要、コピー&ペーストだけで今すぐ試せるプログラムと共に紹介します。ぜひ体験してみてください。 1. Observerパターンは「YouTubeのチャンネル登録」をするイメージ Observerパターンを一言で言うと、 「状態が変わった時に、登録者に一斉にお知らせする仕組み」 です。 例えば... Subject(発信者) : YouTubeチャンネル。「動画を出したよ!」と知らせる役割。 Observer(受信者) : 視聴者。「動画が出たら教えてね」と登録している役割。 チャンネル側は、 誰が 登録しているかは詳しく知りません。 ただ「リストに載っている人全員に通知を送る」だけ。これによって、お互いのコードが過度に干渉しない状態を作れます。これを 「疎結合(そけつごう)」 と呼び、修正に強いコードの基本とされています。[1] [1] ※ 「疎結合(そけつごう)」 とは、各システムやモジュール間での依存関係(AがないとBが動かないなど)が弱く、変更や差し替えが容易な設計のことです。 2. コンソールで今すぐ試す! まずは、動くものを見てみる方が理解しやすいので、ブラウザの「検証ツール(コンソール)」に貼り付けるだけで動く、シンプルなコードを用意しました。 手順: このブラウザで F12キー (Macは Cmd + Option + I )を押してコンソールを開く。 以下のコードをコピーして貼り付け、 Enter を押す。 // --- 1. 司令塔(チャンネル)の仕組み --- class Subject { constructor () { this. observers = new Set () ; // 登録者リスト(重複を防ぐためSetを使用) } // 登録(購読) subscribe ( fn ) { this. observers . add ( fn ) ; console . log ( "新しい視聴者が登録されました。" ) ; } // 解除(購読解除) unsubscribe ( fn ) { this. observers . delete ( fn ) ; console . log ( "登録が解除されました。" ) ; } // 一斉通知 notify ( data ) { this. observers . forEach ( fn => fn ( data )) ; } } // --- 2. 実際に動かしてみる --- const youtubeChannel = new Subject () ; // 通知が来た時の処理を定義 const viewerA = ( title ) => console . log ( `視聴者A:「 ${ title } 」の通知が来た!` ) ; const viewerB = ( title ) => console . log ( `視聴者B:「 ${ title } 」のリアクション!` ) ; // AとBが登録 youtubeChannel . subscribe ( viewerA ) ; youtubeChannel . subscribe ( viewerB ) ; // 動画を投稿!(通知を実行) youtubeChannel . notify ( "JavaScript入門" ) ; // 視聴者Aが解除 youtubeChannel . unsubscribe ( viewerA ) ; // 次の動画投稿(Bだけに通知が届く) youtubeChannel . notify ( "デザインパターンの活用" ) ; 結果: 2回目の通知では、解除した視聴者Aにはメッセージが届かなくなります。このように「必要なときだけ通知を受け取る」制御が柔軟に行えます。 3. コードを読み解いてみよう 3-1. 司令塔(Subjectクラス)の役割 まず、通知を管理する「システム本体」を作ります。 class Subject { constructor () { this. observers = new Set () ; // 登録者リスト } class Subject : 通知システムの設計図となる部分です。 this.observers = new Set() : 重複登録を防ぐため、配列の代わりに Set オブジェクトを使用しています。ここが 「登録者名簿」 の役割を果たします。 3-2. 登録・解除・通知のメソッド 次に、名簿を操作する機能を作ります。 subscribe ( fn ) { this. observers . add ( fn ) ; } unsubscribe ( fn ) { this. observers . delete ( fn ) ; } subscribe / unsubscribe : 名簿に処理を追加したり、削除したりします。 解除の重要性 : 不要になった通知設定をそのままにすると、メモリの無駄遣いや予期せぬエラーの原因(メモリリーク)になる可能性があるため、解除機能は実務上の重要なポイントです。 notify ( data ) { this. observers . forEach ( fn => fn ( data )) ; } } notify(data) : 名簿に載っているすべての処理をループで実行します。 3-3. インスタンス化と実行 設計図から実体を作り、処理を「予約」します。 const youtubeChannel = new Subject () ; youtubeChannel . subscribe (( data ) => { ... }) ; アロー関数 : (data) => { ... } を渡すことで、「通知が来た時に実行してほしい内容」をあらかじめ登録しておきます。 4. 簡単なリアルタイム文字カウントWeb アプリ(解除機能付き)を作ってみよう! 「入力した文字数を複数の場所で表示する」アプリに、特定の通知をストップする機能を持たせてみましょう。 <!DOCTYPE html> < html lang = "ja" > < head > < meta charset = "UTF-8" > < title > Observerパターン実践 </ title > < style > body { font-family : sans-serif ; padding : 20px ; background : #f0f2f5 ; } .container { background : white ; padding : 30px ; border-radius : 15px ; box-shadow : 0 4px 15px rgba( 0 , 0 , 0 , 0.1 ) ; max-width : 700px ; margin : auto ; } .display-area { display : grid ; grid-template-columns : repeat ( 3 , 1fr ); gap: 15px ; margin-top : 20px ; } .box { padding : 15px ; border : 1px solid #eee ; border-radius : 8px ; text-align : center ; } input { width : 100% ; padding : 12px ; font-size : 18px ; border : 2px solid #ddd ; border-radius : 8px ; box-sizing : border-box ; } button { margin-top : 10px ; cursor : pointer ; padding : 5px 10px ; } .warning { color : red ; font-weight : bold ; } </ style > </ head > < body > < div class = "container" > < h2 > Observerパターン・文字数カウンター </ h2 > < input type = "text" id = "myInput" placeholder = "入力して連動を確認..." > < div class = "display-area" > < div class = "box" > < h4 > 文字数 </ h4 > < span id = "charCount" > 0 </ span > </ div > < div class = "box" > < h4 > 制限チェック </ h4 > < div id = "alertMsg" > OK </ div > < button id = "stopAlert" > 連動を止める </ button > </ div > < div class = "box" > < h4 > 逆さま表示 </ h4 > < div id = "reverseText" > - </ div > </ div > </ div > </ div > < script > class InputSubject { constructor () { this. observers = new Set () ; } subscribe ( fn ) { this. observers . add ( fn ) ; } unsubscribe ( fn ) { this. observers . delete ( fn ) ; } notify ( text ) { this. observers . forEach ( fn => fn ( text )) ; } } const inputSubject = new InputSubject () ; // 通知が来た時の処理 const updateCount = ( text ) => { document . getElementById ( 'charCount' ) . innerText = text . length ; } ; const updateAlert = ( text ) => { const msg = document . getElementById ( 'alertMsg' ) ; if ( text . length > 10 ) { msg . innerText = "10文字超過!" ; msg . className = "warning" ; } else { msg . innerText = "OK" ; msg . className = "" ; } } ; const updateReverse = ( text ) => { document . getElementById ( 'reverseText' ) . innerText = text . split ( '' ) . reverse () . join ( '' ) ; } ; // 登録 inputSubject . subscribe ( updateCount ) ; inputSubject . subscribe ( updateAlert ) ; inputSubject . subscribe ( updateReverse ) ; // イベント監視 document . getElementById ( 'myInput' ) . addEventListener ( 'input' , ( e ) => { inputSubject . notify ( e . target . value ) ; }) ; // 解除ボタン document . getElementById ( 'stopAlert' ) . addEventListener ( 'click' , () => { inputSubject . unsubscribe ( updateAlert ) ; alert ( "制限チェックの連動を停止しました。" ) ; }) ; </ script > </ body > </ html > 5. なぜこの書き方が「保守性の高いコード」に繋がるのか? 特徴 命令的な書き方(密結合) Observerパターン(疎結合) 拡張性 既存の関数を毎回書き換える必要がある 新しい処理を subscribe するだけで完了 影響範囲 1つの修正が全体に波及しやすい 各処理が独立しているため影響が限定的 リソース管理 処理の解除が難しくなりがち unsubscribe で柔軟に制御可能 まとめ 「知らせる側」と「やる側」を分ける : 役割を分離することで、コードの整理がしやすくなります。 登録と解除の管理 : 実務開発では、メモリ管理の観点から unsubscribe も意識しておくと安心です。 標準機能への理解 : addEventListener もこのパターンの考え方を応用したものです。 今回は仕組みを深く理解するために、JavaScriptのクラスを用いて独自のObserverシステムを構築しました。 最初は難しく感じるかもしれませんが、YouTubeのチャンネル登録など身近なサービスをイメージしながら取り入れてみてください。このパターンを意識することで、普段のコードをより拡張しやすく、メンテナンス性の高いものへと改善できるはずです。 さらに高度な監視を行いたい場合は、参考文献に挙げたブラウザ標準のAPIもぜひ活用してみてください。 執筆者 中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属) NTT西日本の法人向け顧客ポータルサイトの開発・運営に従事。 好きな技術スタック:TypeScript, Vue.js, GraphQL, Laravel 参考文献 MDN: MutationObserver MDN: Intersection Observer API 商標 YouTube は Google LLC の商標または登録商標です。 その他、本記事に記載されている会社名、製品名、サービス名等は、各社の商標または登録商標です。
はじめに NTTビジネスソリューションズの辻本です。 ブラウザだけでシミュレータ上のロボットを遠隔操作できるデモ の事例を紹介します。Azure VM の headless(GUI なし)環境で動かすために行った 公式クックブック(実装ガイド) からの構成変更や、複数カメラの同時配信といった独自の拡張を中心に解説します。 なお、本記事中で扱うサービス(SkyWayなど)に関する記載は2026年2月時点の情報に基づきます。また、動作結果は筆者の実行環境・設定に依存し、記事内で掲載しているコードは、理解しやすさを優先した簡略版(抜粋)です。 背景・目的 GitHubのSkyWay 関連のリポジトリを眺めていたら skyway_ros_bridge を見つけて、「なんだこれ?」と思いました。SkyWay はビデオ通話やチャット向けのプラットフォームと思っていましたが、それがロボットの世界のROSとブリッジ・・・?想定外で驚きました。SkyWayのREADME をたどると 公式クックブック があることを知り、「メッセンジャー向けの WebRTC 技術がロボットで何に使えるのか?」――その疑問から、クックブックをベースに Azure VM headless 環境での構築やオリジナルワールドの作成など、独自の要素を加えたデモを作りました。 対象読者 ROS 2 でロボットのリモート監視・操作に興味がある方 SkyWay(WebRTC)× ロボットの組み合わせに興味がある方 GUI なしの headless 環境で Gazebo シミュレータを動かしたい方 前提条件 項目 内容 VM Azure Standard_D4s_v3 (4 vCPU / 16 GiB RAM / GPU なし) OS Ubuntu 24.04 LTS SkyWay アカウント SkyWay 公式サイト でアカウント作成済み (詳細は ユーザーガイドの「はじめに」 を参照) 完成イメージ 操作デモ:ブラウザからロボットを遠隔操作している様子 ブラウザ上に 2 つのカメラ映像(ロボット視点 + 俯瞰)が表示され、矢印キーでロボットを操作できます。 ロボット視点のカメラでは、周りの光景が見えます。俯瞰カメラは上空から世界を見渡すイメージです。 遠隔操作ロボットが右下から動いてくる小さい点が見えます。 SkyWay とは SkyWay は NTT ドコモビジネスが提供する WebRTC プラットフォームです。WebRTC に必要なシグナリングや TURN(中継)/STUN(経路確認)サーバーをマネージドで提供しており、インフラを自前で構築する必要がありません。また、ROS 2 と連携するため開発ドキュメントとして 公式クックブック と skyway_ros_bridge (ROS 2 と SkyWay を接続するブリッジノード)が用意されています。 今回のデモで SkyWay を採用した理由を以下にまとめます。 項目 説明 NAT 越え TURN/STUN サーバーが利用できる ため、VM にポートを開放する必要がない ブラウザ完結 専用クライアント不要。Chrome があればどこからでもアクセスできる 映像 + 操作が同一基盤 VideoStream(カメラ映像)と DataStream(WebRTC の DataChannel に相当)を 1 つの Room でまとめて扱える マルチユーザー対応 Room 機能で複数人が同時に映像を視聴できる 今回の Azure VM ではポート開放を一切していませんが、SkyWay 経由で映像も操作コマンドも問題なく通りました。 デモの全体像 アーキテクチャ Docker Compose で 4 つのサービスを構成しています。 データフロー 映像と操作コマンドが SkyWay を介して双方向に流れます。 技術スタック カテゴリ 技術 VM Azure VM / Ubuntu 24.04 ロボット OS ROS 2 Jazzy シミュレータ Gazebo Harmonic(headless) WebRTC SkyWay (Linux SDK / JavaScript SDK) ROS 2 ↔ SkyWay ブリッジ skyway_ros_bridge コンテナ Docker Compose(4 サービス) クックブックから headless 環境への構成変更 公式クックブック は GUI ありの環境を前提としています。今回は Docker Compose による headless 環境で構築したため、いくつかの構成変更が必要でした。以下、クックブックとの主な構成差分と、それぞれの対応方法を紹介します。 項目 クックブック 本デモ コンテナ構成 1 コンテナ内で全て実行 4 コンテナに分離 Gazebo GUI あり headless( -s フラグ) カメラモデル デフォルト(wideanglecamera) 軽量版(camera, 160x120, 1 Hz) ipc: host 不要(単一コンテナ) 必須(Fast DDS 共有メモリ) DataStream 購読 ターミナルから手動でサービスコール シェルスクリプトで自動購読 起動方法 docker compose exec ros bash で中に入り手動実行 docker compose up で全自動起動 1. カメラセンサーの軽量化 今回使用した Azure VM には GPU が搭載されていないため、Gazebo のレンダリングは Ogre2 のソフトウェアレンダリングで処理されます。起動してみると Real-Time Factor(RTF)が 0.0015 (実時間の 0.15%)まで低下し、シミュレーションがほぼ停止しました。RTF 0.0015 を見たときは、カクカクとさえ動かず、GPU なしでのコンテナ化は難しいと感じました。 原因は TurtleBot3 デフォルトモデルの wideanglecamera センサーにありました。このセンサーは内部でキューブマップ(6 面レンダリング)を行うため、GPU なしの環境では処理が追いつきません。解像度だけを下げても RTF はほとんど改善しなかったため、カメラタイプ自体を camera (通常レンダリング)に変更しました。 軽量版のモデル SDF を作成し volume mount で差し替えました。SDF(Simulation Description Format)は Gazebo のロボットやワールドを記述する XML 形式です。 パラメータ デフォルト 軽量版 カメラタイプ wideanglecamera (キューブマップ) camera (通常レンダリング) 解像度 320x240 160x120 フレームレート 30 Hz 1 Hz LiDAR 5 Hz 1 Hz IMU 200 Hz 50 Hz SDF での変更箇所は主にカメラセンサーの定義部分です。 <!-- 変更前:wideanglecamera(キューブマップ) --> <sensor name = "camera" type = "wideanglecamera" > <camera> <image><width> 320 </width><height> 240 </height></image> </camera> <update_rate> 30 </update_rate> </sensor> <!-- 変更後:camera(通常レンダリング) --> <sensor name = "camera" type = "camera" > <camera> <image><width> 160 </width><height> 120 </height></image> </camera> <update_rate> 1 </update_rate> </sensor> この変更で RTF が 0.0015 → 0.978 に改善しました。GPU なしの headless 環境で Gazebo を動かす場合、VM スペックを上げる前に、まずセンサーの負荷を見直すのがお勧めです。 2. コンテナ間の DDS 通信設定 クックブックでは 1 つの Docker コンテナ内で全てを実行するため、DDS の通信設定を意識する必要がありません。今回は 4 コンテナに分離したことで、追加の設定が必要になりました。 ROS 2 Jazzy のデフォルト DDS 実装は Fast DDS です。Fast DDS は同一ホスト内の通信に 共有メモリ( /dev/shm ) を使います。Docker コンテナはデフォルトで /dev/shm が隔離されるため、DDS Discovery(UDP マルチキャスト)は成功しますが、データ転送(共有メモリ)が機能しない状態になります。 docker-compose.yml の全コンテナに ipc: host を追加しました。 services : gazebo : network_mode : host ipc : host # Fast DDS 共有メモリ通信に必要 ipc: host によりコンテナがホストの /dev/shm を共有し、Fast DDS の共有メモリ通信が正常に動作します。複数コンテナで ROS 2 を動かす場合に必要な設定です。 3. ICE 接続の設定 ブラウザ側で SkyWay Room を作成する際、 FindOrCreate() に type: 'p2p' を指定すると ICE candidate が生成されず、映像が届きません。 // NG: type を指定すると ICE candidate が生成されない room = await SkyWayRoom . FindOrCreate ( context , { type : 'p2p' , name : roomName , }) ; // OK: type を指定しない(クックブックと同じ) room = await SkyWayRoom . FindOrCreate ( context , { name : roomName , }) ; クックブックのコードでは type を指定していないため、クックブック通りに実装すれば問題ありません。独自に Room の type を指定する場合は注意が必要です。 複数カメラの同時配信 クックブックでは skyway_ros_bridge を 1 つ起動して 1 カメラを配信する構成です。今回は俯瞰カメラを追加し、2 つの映像を同時配信する拡張を行いました。 対応方法はシンプルで、 skyway_ros_bridge を名前空間を変えてもう 1 つ起動するだけ です。ビデオ通話に 2 人目が参加してカメラをオンにするのと同じ要領で、skyway_ros_bridge のソースコード改修は不要です。 # docker-compose.yml(抜粋) skyway-bridge-robot : environment : - CAMERA_TOPIC=/camera/image_raw command : ros2 run skyway_ros_bridge skyway skyway-bridge-overhead : environment : - CAMERA_TOPIC=/overhead/image_raw - MEMBER_NAME=ros_overhead command : ros2 run skyway_ros_bridge skyway --ros-args -r __ns:=/overhead 2 つのノードが同じ SkyWay Room に異なるメンバー名で参加し、それぞれのカメラ映像を VideoStream として配信します。ブラウザ側では配信元のメンバー名でどちらのカメラかを判別します。 async function subscribeIfVideo ( publication ) { if ( publication . contentType ! == 'video' ) return; const { stream } = await me . subscribe ( publication . id ) ; const name = publication . publisher . name || '' ; if ( name . includes ( 'overhead' )) { stream . attach ( overheadVideo ) ; // 俯瞰カメラ } else { stream . attach ( robotVideo ) ; // ロボットカメラ } } 操作コマンドの送受信 ブラウザからの操作コマンドは SkyWay の DataStream で送信します。DataStream は文字列しか送れないため、ROS 2 の geometry_msgs/Twist (前進速度と回転速度をフィールドに持つメッセージ型)を直接扱えません。そこで Gazebo の TriggeredPublisher プラグインで、 "forward" のような文字列を受け取ったら対応する Twist の値に変換しています。 <plugin filename = "gz-sim-triggered-publisher-system" name = "gz::sim::systems::TriggeredPublisher" > <input type = "gz.msgs.StringMsg" topic = "/cmd_vel_string" > <match field = "data" > "forward" </match> </input> <output type = "gz.msgs.Twist" topic = "/cmd_vel" > linear: {x: 0.5} angular: {z: 0.0} </output> </plugin> NTT ワールド 俯瞰カメラを追加したことで、上から見たときに面白いワールドを作りたいと思いました。デモ用のオリジナル Gazebo ワールドとして、「N」「T」「T」の形にボックスを配置した「NTT ワールド」を SDF で作成しました。なお、最初に生成した SDF では「N」の斜め線が水平になり「H」に見えてしまうアクシデントもありましたが、調整して「N」に近づけました。 俯瞰カメラ:NTT の文字 ロボット視点:色付きの壁が並ぶ迷路のように見える 俯瞰カメラはワールド上空 15m に固定し、真下を向いています。そのため「NTT」の文字配置を一望できます。一方、ロボットカメラは地上 0.16m(ロボット本体の高さ)にあり、目の前の壁しか映りません。ロボット視点だと色付きの壁が並ぶ迷路のように見えていますが、自分の位置が移動によって変化することが分かるように壁の色替えをしています。俯瞰カメラと組み合わせることで、ロボットの現在位置を把握しながら操作する「監視 + 操作」の構成にしました。 おわりに この記事では、SkyWay の 公式クックブック をベースに、Azure VM headless 環境でシミュレータ上のロボットをブラウザから操作するデモを構築した事例を紹介しました。 headless 環境への移行で最もインパクトが大きかったのはカメラセンサーの軽量化で、RTF が 0.0015 から 0.978 に大きく改善しました。 ipc: host の追加や ICE 接続の設定など、コンテナ分離に伴う落とし穴もありましたが、いずれも原因が分かれば数行の修正で解決できるものでした。クックブックの存在が大きく、ゼロからの構築と比べると少ない労力で動くデモを作ることができました。 SkyWay の NAT 越えとブラウザ完結という特徴は、ロボットのリモート監視・操作の用途でも有効です。今後は Nav2(Navigation2: ROS 2 の自律移動フレームワーク)などの自律移動と組み合わせて、ブラウザから走行状況を監視するような構成に発展させたいと考えています。 参考リンク SkyWay 公式サイト SkyWay × ROS 2 クックブック skyway_ros_bridge(GitHub) SkyWay JS SDK ドキュメント ROS 2 Jazzy ドキュメント Gazebo Harmonic ドキュメント TurtleBot3 Simulations(GitHub) 執筆者 辻本傑(NTTビジネスソリューションズ株式会社 バリューデザイン部 システム開発部門) コミュニケーションサービスの開発・運用に携わっています。C#やクラウドが好きで、最近は生成AIの活用にも関心があります。 認定スクラムマスター(CSM) 商標 SkyWay は NTT ドコモビジネス株式会社が提供するサービスです。 ROS 2 および Gazebo は、Open Robotics の商標または登録商標です。 Microsoft Azure は、米国 Microsoft Corporation の商標または登録商標です。 Docker は、Docker, Inc. の商標または登録商標です。 Ubuntu は、Canonical Ltd. の商標または登録商標です。 TurtleBot3 は、ROBOTIS Co., Ltd. の商標です。 Google Chrome は、Google LLC の商標です。 その他、本文中に記載されている会社名・製品名・サービス名等は、各社の商標または登録商標である場合があります。
はじめに NTTビジネスソリューションズの平田です。 AWSアカウントやIAMユーザーが増えると、「各AWSアカウントのIAMユーザー管理が大変」という課題にぶつかります。AWSアカウントごとにIAMユーザーを管理する運用は手間がかかるだけでなく、削除漏れや権限の棚卸しが困難になるなど、IDガバナンスの面でもリスクを抱えます。 本記事では複数AWSアカウントを保有する環境において、AWSマネジメントコンソールのユーザーを一元管理する方式の比較と、そのうちの一つ(外部IdP → IAM Identity Center連携)の導入手順を紹介します。 前半(1〜5章)は3方式の比較と選定基準、後半(6章)は方式③の導入手順です。比較だけ知りたい方は5章まで、手順を知りたい方は6章からお読みください。 本記事は2026年2月時点の情報に基づきます。 目次 はじめに 目次 対象読者 前半 認証方式の比較 1. マルチアカウント環境における認証方式 2. 方式①: 外部IdP → AWSアカウント(SAML連携) SAMLとは 方式①の仕組み 方式①のメリット 方式①のデメリット 方式①の設定例 3. 方式②: IAM Identity Center(組み込みディレクトリ)→ AWSアカウント IAM Identity Centerとは 方式②の仕組み 許可セットの仕組み 方式②のメリット 方式②のデメリット 方式②の設定例 4. 方式③: 外部IdP → IAM Identity Center → AWSアカウント SCIMとは 方式③の仕組み 方式③のメリット 方式③のデメリット 5. 方式選択の考え方 3方式の比較 「Identity Centerだけではダメなのか?」 判断フロー 後半 ハンズオン 6. 方式③のハンズオン(Okta → Identity Center → AWSアカウント) 6-1. Organizations セットアップ 6-2. IAM Identity Center 有効化 6-3. Okta側の準備 6-4. Okta → Identity Center 接続(SAML+SCIM) SAML連携設定 SCIMプロビジョニング設定 6-5. Oktaの情報をIdentity Centerに同期 6-6. 権限を割り当て 6-7. サインイン確認 6-8. トラブルシューティング Oktaのマイアプリ一覧から「AWS IAM Identity Center」をクリックするとエラーが表示される・アクセスできない SCIMユーザー同期が反映されない 認証成功してもAWSアカウントが表示されない 7. 設計・設定・運用で気をつけること CloudTrailでの追跡 許可セットの変更管理 SCIMトークンの有効期限 既存環境からの移行 IDソースは1つだけ IDソースの切り替えに伴う、登録済み情報の削除 方式③導入後の定期運用タスクの一覧(一例) まとめ 執筆者 参考資料・出典 商標 対象読者 複数のAWSアカウントを管理するシステム管理者 AWSのIAMとIdentity Centerについて概要的な知識をお持ちの方(AWS Certified Solutions Architect - Associateの学習経験がある方を想定) ID管理のガバナンスを検討中の方 前半 認証方式の比較 1. マルチアカウント環境における認証方式 AWSのベストプラクティスでは、ワークロードや環境(開発・ステージング・本番など)ごとにAWSアカウントを分離することが推奨されています。 docs.aws.amazon.com しかしAWSアカウントを分離すると、IAMの管理が煩雑になります。なぜかというと、AWSアカウントごとにIAMユーザーやIAMグループを個別に管理する必要があるためです。例えば管理者には以下のような不便があります。 卒業者が出たら、全AWSアカウントからIAMユーザーを削除して回る 「どのAWSアカウントの誰がどの権限を持っているか」を一覧で確認が困難 ユーザーにとっても、AWSアカウントごとに認証情報が分かれます。 AWSアカウントごとに異なるユーザー名・パスワード・MFAを管理する アクセスするAWSアカウントが変わるたびにサインインし直す AWSアカウントが増えるほど、これらの管理工数とミスのリスクが増えていきます。 ユーザーの一元管理を行うことで、このような管理工数の削減やリスクを低減でき、かつユーザーの利便性が向上します。 一元管理の方式として、大きく分類すると3つあります。 方式 概要 ① 外部IdP → AWSアカウント 外部のIdP(Okta、Keycloakなど)からSAMLで各AWSアカウントにログイン ② IAM Identity Center → AWSアカウント Identity Centerの組み込みディレクトリでユーザーを管理し、許可セット(Permission Set)で各AWSアカウントへのアクセスを制御 ③ 外部IdP → IAM Identity Center → AWSアカウント 外部IdPでユーザーを管理し、Identity Center経由で各AWSアカウントにアクセス これらの方式は、いずれもIAMユーザーを各AWSアカウントに登録する必要がなく、ユーザー情報を一元管理できます。次のセクションから、それぞれの仕組みとメリットデメリットを整理します。 2. 方式①: 外部IdP → AWSアカウント(SAML連携) SAMLとは SAML(Security Assertion Markup Language)2.0は、認証情報や属性情報をシステム間で授受するためのプロトコルで、シングルサインオン(SSO)に利用します。IdP(Identity Provider)がユーザーを認証し、「このユーザーは認証済みです」という情報をSP(Service Provider、ここではAWSマネジメントコンソール)に伝えます。ユーザーがIdPで一度ログインすれば、SP側で再度パスワードを入力する必要がありません。 方式①の仕組み この方式は、外部IdP(Okta、Keycloakなど)が各AWSアカウントに直接SAML連携します。 外部IdPのグループとAWSアカウントのIAMロールを1:1で紐づけます。具体的には、以下の設定を行います。 AWSアカウント側: IAMにIDプロバイダ(SAML)を作成し、IAMロールの信頼ポリシーでそのプロバイダを指定する 外部IdP側: SAMLアサーションに「どのIAMロールを引き受けるか」(ロールARN)を含める設定を行う ユーザーが外部IdPからログインすると、SAMLアサーションに含まれるロールARNに基づいて、対象AWSアカウントのIAMロールを引き受けます(AssumeRoleWithSAML)。 方式①のメリット 構成がシンプル: AWSアカウント側の設定はIAM SAMLプロバイダとIAMロールだけです。 プロビジョニング不要: AWS側にユーザー情報を事前登録する必要がありません。外部IdPで認証に成功すれば、そのままIAMロールを引き受けられます。 方式①のデメリット 設定がN×Mで膨張する: AWSアカウント数(N)×ロール種類数(M)分の設定が必要です。AWSアカウント2つ、ロール2種類なら4つの設定で済みますが、AWSアカウント10×ロール5種類で50の設定になります。 管理が分散する: IAMロールは各AWSアカウントで個別に作成・管理するため、「どのAWSアカウントにどのロールがあるか」の全体像を把握しにくくなります。 方式①の設定例 外部IdPとしてKeycloakを利用した場合の設定例です。(外部サイト) qiita.com 3. 方式②: IAM Identity Center(組み込みディレクトリ)→ AWSアカウント IAM Identity Centerとは IAM Identity Center(旧AWS SSO)は、複数のAWSアカウントへのアクセスを一元管理するサービスです。ユーザーはAccess Portalという単一のサインイン画面からサインインし、アクセス権のあるAWSアカウントを選んで操作します。以下、Identity Centerと略します。 方式②の仕組み この方式は、Identity Centerの組み込みディレクトリでユーザーとグループを管理し、許可セットによって各AWSアカウントへのアクセス権を定義します。 許可セットの仕組み 許可セットは「AWSアカウントで何ができるか」を定義するテンプレートです。IAMポリシーを含みますが、IAMロールそのものではありません。 許可セットの割り当ては3つの次元で行います。 グループ: 誰がアクセスするか AWSアカウント: どのAWSアカウントにアクセスするか 許可セット: どの権限でアクセスするか 例えば、「DevelopersグループにPowerUserAccess権限を、開発用AWSアカウントで割り当てる」のように指定します。 この割り当てを行うと、Identity Centerが対象のAWSアカウントにIAMロールを自動生成します。生成されるIAMロールの名前は AWSReservedSSO_{PermissionSet名}_{一意のサフィックス} という形式です。 ここで重要なのは、許可セットとIAMロールの関係が1:nであることです。1つの許可セットを3つのAWSアカウントに割り当てれば、3つのIAMロールが生成されます。管理者は許可セットだけを管理すればよく、各AWSアカウントのIAMロールを手動で作る必要はありません。 方式②のメリット AWS内で完結: 外部サービスに依存しません。AWSアカウントがあればすぐに始められます 一元管理: 許可セットを一度定義すれば、複数のAWSアカウントに一括適用できます。方式①のN×M問題が発生しません IAMロールの自動生成: AWSアカウント側のIAMロールの作成・削除をIdentity Centerが自動で行います 方式②のデメリット ID管理がAWSに閉じる: 1点目のメリットの裏返しですが、ユーザー・グループをAWS内で個別管理します。組織に既存のIdP(Okta、Keycloakなど)があると、ユーザー情報を二重管理することになります AWS以外のアプリケーションとの統合: Identity Centerは他のSaaSへのSAML IdPにもなれますが、外部SaaSへのSSO連携は主にSAML 2.0で行います(OAuth 2.0はTrusted Identity Propagation向け) 方式②の設定例 Identity Centerを利用した場合の設定方法です。(外部サイト) docs.aws.amazon.com この記事の後半で手順を紹介しますが、その中にも内包されています。 4. 方式③: 外部IdP → IAM Identity Center → AWSアカウント SCIMとは 方式③では外部IdPのユーザー情報をIdentity Centerに同期する必要があります。この同期に使うプロトコルがSCIMです。 SCIM(System for Cross-domain Identity Management)は、ユーザーやグループの情報をシステム間で自動同期するためのプロトコルです。IdPでユーザーを追加・変更・削除すると、その情報がREST API経由で連携先に自動反映されます。この記事では、外部IdP(Okta)からSCIMを使ってIdentity Centerに同期します。 方式③では、SAMLとSCIMの2つのプロトコルを使います。役割が異なるので、混同しないように整理しておきます。 SAML: 認証(ログイン時に「誰か」を伝える) SCIM: プロビジョニング(IdPのユーザー・グループ情報をIdentity Centerに同期する) SAMLだけでは「Identity Centerにそのユーザーが存在しない」ためログインできません。SCIMで事前にユーザーを同期しておく必要がある、というのが方式③のポイントです。 方式③の仕組み この方式では、外部IdPでユーザー・グループを管理し、SCIMでIdentity Centerに同期します。権限管理は方式②と同じく、Identity Centerの許可セットで行います。 方式②との違いはユーザーとグループの管理元がどこにあるか(プライマリがどこか)です。方式②ではIdentity Centerがユーザーとグループの管理元ですが、方式③では外部IdPが管理元です。Identity Center側のユーザーとグループは、IdPから同期されたコピーです。 なお、許可セット → IAMロール自動生成の仕組みは方式②と同じです。 また、Identity CenterはJIT(ジャストインタイム)プロビジョニングに対応していないため、ユーザー・グループの事前同期が必要です。SCIM対応のIdP(Okta、Entra IDなど)なら自動同期できますが、非対応のIdPの場合は手動でIdentity Centerにユーザー・グループを作成します。 方式③のメリット ID管理とアクセス管理の役割分担: 「誰がいるか」「誰がどのグループに所属するのか」は外部IdPが管理し、「どのAWSアカウントに、どの権限で」はIdentity Centerが管理します。責務が明確に分かれます(ID管理は外部IdP、アクセス管理はIdentity Center) 組織のID基盤と連携: 組織に既存のIdPがあれば、AWS用に別のユーザー管理を持つ必要がありません Access Portal: ユーザーは単一のサインイン画面から、アクセス権のあるAWSアカウントを選んで操作できます(このメリットは方式②にも共通) 方式③のデメリット プロビジョニングが必須: IdPのユーザー・グループ情報をIdentity Centerに同期する仕組み(SCIMまたは手動)が必要です 構成が最も複雑: 外部IdP、Identity Center、AWSアカウントの3レイヤーにまたがるため、設定・運用・トラブルシュートの範囲が広くなります 外部IdPの費用:AWSのコストに加え、外部IdPのコストが必要です。 正直なところ、方式③は3つの中で構成やしくみが最も複雑です。ただ、一度組み上げてしまえば「ユーザーの追加・削除はIdP側だけ」「権限の変更はIdentity Center側だけ」と作業の分担がはっきりするので、運用負担がぐっと下がります。 5. 方式選択の考え方 3方式の比較 観点 ①外部IdP→AWSアカウント ②Identity Center→AWSアカウント ③外部IdP→Identity Center→AWSアカウント ユーザー管理の場所 外部IdP Identity Center 外部IdP AWSアクセス管理(権限付与) 各AWSアカウントのIAM Identity Center Identity Center IAMロール 手動作成(N×M) 自動生成 自動生成 プロビジョニング 不要 ― 必須(SCIM/手動) 構成の複雑さ 低い 中程度 高い AWSアカウント増加時 設定が線形に増加 許可セットで吸収 許可セットで吸収 「Identity Centerだけではダメなのか?」 方式②と③を見ると、AWSアクセスの管理はどちらもIdentity Centerが担います。Identity Centerは他のSaaSへのSAML IdPにもなれるので、「方式②だけでAWSアクセスも他アプリのSSOも、全部まかなえるのでは?」というツッコミがあると思います。 判断のポイントは以下の3つです。 組織にすでにIdPがあるか: Okta、Entra IDなどのIdPをすでに運用しているなら、Identity Centerにもユーザーを作ると二重管理になります。外部IdPに接続する方がID管理を一元化できます SAML以外のプロトコルが必要か: Identity Centerの組み込みディレクトリをIdPとした場合、外部のSPと連携できるのはSAML 2.0が主です。OIDCで連携したいSPがある場合には、Identity Centerだけでは対応できません。 認証フローをカスタマイズしたいか: Identity Centerの認証フロー(MFAの種類、条件付きアクセスなど)はAWSの仕様に従います。細かい制御が必要なら、外部IdPの方が柔軟です 実際の企業環境では、すでにIdPを運用しているケースも多いことでしょう。その場合、IDの管理は外部IdPに任せ、Identity CenterはAWSへの入り口に徹する(方式③)のがシンプルになります。 一方、既存のIdPがなかったり(予算の都合で導入が難しかったり…)、AWSの利用規模が小さければ方式②で十分です。 判断フロー AWSアカウントが少数(2〜3)で、外部IdPがある → 方式①が手軽 AWSに閉じた環境で、外部IdPがない → 方式②で始める 組織にIdPがある、またはAWSアカウントが多い → 方式③ 目安として判断フローを提示しましたが、実際にはこのように単純ではなく、AWSアカウントの設定や運用状況などに影響されます。 現実的には、AWSを導入して間もない組織であれば方式②や方式③の複雑な方式には考えが及ばないでしょうし、AWSアカウントが増えてから問題に直面し方式③を検討するころにはすでに管理状況がバラバラかもしれません。 いずれにしても現状を十分に調査し、仕様を理解し、十分な検証を実施したうえで慎重に導入計画を立てることをお勧めします。 後半 ハンズオン 6. 方式③のハンズオン(Okta → Identity Center → AWSアカウント) ここからは方式③を実際に構築してみます。外部IdPとしてOktaを使います。 手順が多く、OktaとAWSを行ったり来たりして混乱しやすいので慎重に進めてください。 前提: AWSアカウントを2つ以上持っている(Organizations有効化済み) Okta Developerアカウントを持っている 6-1. Organizations セットアップ 方式②③ではIAM Identity Centerを使うため、AWS Organizationsが有効になっている必要があります。 マネジメントアカウントでAWS Organizationsコンソールを開く 「組織を作成」を選択 メンバーアカウントを招待または新規作成する すでにOrganizationsを使っている場合はこの手順はスキップしてください。 詳細な手順は以下をご参照ください。 docs.aws.amazon.com 6-2. IAM Identity Center 有効化 Identity Centerには「組織インスタンス」と「アカウントインスタンス」の2種類があります。マルチアカウントのアクセス管理にはOrganizations管理アカウントから有効化する「組織インスタンス」が必要です。メンバーアカウント単体で作成する「アカウントインスタンス」では許可セットによるマルチアカウント管理ができません。 マネジメントアカウント(または委任管理者アカウント)でIAM Identity Centerコンソールを開く 「有効にする」を選択 リージョンを選択する 有効化すると、デフォルトのIDソースとして「Identity Centerディレクトリ」が設定されます。次の手順でこれを外部IdPに切り替えます。 詳細な手順は以下をご参照ください。 docs.aws.amazon.com 6-3. Okta側の準備 今回はOktaが開発者用に提供している無償プラン(Integrator Free Plan)を利用します。Gmailなどのフリーメールでは登録できないのでご注意ください。 Oktaにユーザーとグループを作成します。ここで作成したユーザー・グループが、のちの設定によってSCIMでIdentity Centerに同期されます。 すでにOktaでユーザー・グループを管理している場合はこの手順はスキップしてください。 Okta Admin Consoleで「ディレクトリ」→「ユーザー」からユーザーを作成します。 名前やメールアドレスなどを入力して「保存」をクリックします。 「ユーザー」画面で、登録したユーザーが表示されることを確認します。 「ディレクトリ」→「グループ」からグループを作成します。(例: Developers、Admins) 作成したグループ(ここではDevelopers)にユーザーを割り当てます。 +ボタンを押して「割り当て済み」に変更後、「完了」をクリック 6-4. Okta → Identity Center 接続(SAML+SCIM) この手順はAWS公式ドキュメントに沿ったものです。 docs.aws.amazon.com 作業中、OktaとAWSの設定を行ったり来たりします。そのため、Okta Admin ConsoleとAWSマネジメントコンソールを横並びで作業することをお勧めします。手順の冒頭に(Okta)と記載したものがOkta Admin Consoleの作業、(AWS)と記載したものがAWSマネジメントコンソールでの作業です。 SAML連携設定 公式 のステップ1にあたる作業です。 (Okta)Oktaの管理コンソールで「アプリケーション」→「アプリカタログを参照」→「AWS Identity Center」を検索して選択します。 SAMLの設定は本来かなりややこしいのですが、Oktaは主要なSP(今回はAWS)とSAML連携しやすいようにあらかじめテンプレートを用意してくれています。今回はOktaさんの厚意に甘えます。 (Okta)「統合を追加」をクリックします。 (Okta)一般設定はそのままで「完了」をクリックします。 (Okta)「サインオン」→「編集」をクリックします。 (Okta)表示される「サインオンURL」と「発行者」を控えてください。このパラメータは後でAWS側に設定します。 (Okta)下にスクロールし、SAML証明書の「アクション」→「証明書をダウンロード」をクリックするとokta.certのダウンロード確認が表示されるので保存してください。このファイルも後でAWS側に設定します。 このOktaの証明書は、SAMLアサーションに付与された電子署名の検証に利用されます。OktaがSAMLアサーションに秘密鍵で署名し、AWS側がこの証明書(公開鍵)で署名を検証することで、アサーションが改ざんされていないことを確認します。 次は 公式 のステップ2にあたる作業です。 (AWS)Identity Centerコンソールで「設定」→「アイデンティティソース」→「アクション」→「アイデンティティソース」を選択します。 (AWS)「外部IDプロバイダー」を選択して「次へ」をクリックします。 (AWS)表示されるIAM Identity CenterのSAMLメタデータ(ACSのURL、発行者URL)を控えてください。このパラメータは後程Okta側に設定します。 ACSは、SAML認証フローにおいてIdP(Okta)から送られるSAMLアサーション(認証応答)を受け取るエンドポイントURLです。簡単に言うと、Oktaで認証が成功した後、「この人は認証OKですよ」という情報(SAMLアサーション)をどこに送り返すかを指定するURLがACSです。 方式①では、IdPから各AWSアカウントへ直接SAMLアサーションを送るため、AWSアカウントごとにACSエンドポイント( https://signin.aws.amazon.com/saml )を設定する必要があります。AWSアカウントが10個あれば、Okta側にも10個のSAMLアプリケーション(またはACS URL)を登録することになります。これが前述したN×M問題の一因です。 一方、方式③ではIdentity CenterがSAMLアサーションの受け口を一本化しています。Okta側で設定するACS URLは1つだけで、その先のAWSアカウントへの振り分けはIdentity Centerの許可セットが担います。つまり、AWSアカウントが増えてもOkta側のSAML設定は変更不要です。 この「ACS URLが1つで済む」という点が、方式③を選択する実務上の大きなメリットです。ACS URLを誤って設定した場合のトラブルシューティングについては6-8で後述します (AWS)「IdPサインインURL」に、先ほど控えた「OktaのサインオンURL」を入力してください。「IdP発行者URL」に先ほど控えた「Oktaの発行者(URL)」を入力してください。IdP証明書の「ファイルを選択」をクリックし、先ほど保存したokta.certをアップロードしてください。「次へ」をクリックしてください。 (Okta)「高度なサインオン設定」までスクロールします。「AWS SSO ACS URL」に先ほど控えたAWS側の「IAM Identity Center Assertion Consumer Service (ACS) の URL」の値をコピペします。「AWS SSO発行者URL」に先ほど控えたAWS側の「IAM Identity Center 発行者 URL」の値をコピペします。「保存」をクリックします。 (AWS)確認及び確定のフィールドに「承諾」を入力して「アイデンティティソースを変更」をクリック ここまでで認証(SAML)の設定が完了しました。次にプロビジョニング(SCIM)を設定します。 SCIMプロビジョニング設定 公式 のステップ3にあたる作業です。 ここからSCIMによる自動プロビジョニングを設定します。方式①(直接SAML連携)ではIdPとAWSアカウントが直接やり取りするため、SCIM設定は不要です。一方、方式③ではIdentity Centerが認証の中継役となるため、「Identity Center上にユーザーが事前に存在すること」が前提になります。しかしIdentity CenterにはSAML認証時にユーザーを自動作成する機能がないため、このSCIM自動同期で事前にユーザー・グループを同期しておくこと(プロビジョニング)が必要です。 (AWS)Identity Centerコンソールで「設定」→「自動プロビジョニング」→「有効にする」をクリックします。 (AWS)表示される「SCIMエンドポイント-IPv4のみ」のURLとアクセストークンを控えます。アクセストークンはこの画面を閉じると二度と表示されないので、一時的にテキストファイルに控えるなど確実にコピーしてください。 (Okta)「アプリケーション」→「AWS IAM Identity Center」→「プロビジョニング」タブ→「API統合を構成」をクリックしてください。 (Okta)「API統合を有効化」をチェックするとフォームが表示されます。「ベースURL」にAWS画面の「SCIMエンドポイント-IPv4のみ」のURLをコピペしてください。「APIトークン」にAWS画面の「アクセストークン」をコピペしてください。 (Okta)「API資格情報をテスト」をクリックしてください。設定が正しければ「AWS IAM アイデンティティセンター は正常に検証されました」 のメッセージが表示されます。テストが完了したら「保存」をクリックしてください。 エラーが出た場合コピペするパラメータが正しいことを再確認してください。 (Okta)「アプリにプロビジョニング」の編集をクリックしてください。   (Okta)「ユーザーを作成」「ユーザー属性」「ユーザーの非アクティブ化」の3箇所チェックボックスをチェックして「保存」をクリック 6-5. Oktaの情報をIdentity Centerに同期 公式 のステップ4にあたる作業です。 この作業では、OktaからIdentity Centerに同期するユーザーとグループを指定します。 (Okta) まずユーザーの同期を設定します。「割り当て」タブ→「割り当て」→「ユーザーに割り当て」をクリックします。 (Okta) Identity Centerに同期したいユーザーの「割り当て」をクリックします。 (Okta) 「AWS IAM Identity Centerを​ユーザーに​割り当てる」の画面で「保存して戻る」をクリックします。(属性値は特に変更しなくても大丈夫です) (Okta) 同期対象のユーザー全てで同じ作業を繰り返し「完了」をクリックします。 (Okta) 「割り当て」タブのユーザー画面で、同期対象のユーザーが表示されることを確認します。 (AWS) Identity Centerのユーザー一覧を確認すると、Oktaのユーザーが同期されていることが確認できます。ここまでがユーザーの割り当て作業でした。 (Okta)次にグループの同期を設定します。「割り当て」タブ→「割り当て」→「グループに割り当て」をクリックします。 (Okta) Identity Centerに同期したいグループの「割り当て」をクリックします(今回はDeveloperグループ) (Okta) 「AWS IAM Identity Centerをグループに​割り当てる」の画面で「保存して戻る」をクリックします。(属性値は特に変更しなくても大丈夫です) (Okta) 同期対象のグループ全てで同じ作業を繰り返し「完了」をクリックします。 (Okta)「プッシュグループ」タブ→「プッシュグループ」→「名前でグループを検索」で、グループ名を検索します(今回はAdminsグループを検索)。グループ名が検索できたら「保存」をクリックします。 (Okta)対象グループのプッシュステータスが「プッシュ中」から「アクティブ」に変化することを確認します。 同期対象のグループ全てで同じ作業を繰り返します。 (AWS) Identity Centerのグループ一覧を確認すると、Oktaのグループが同期されていることが確認できます。作成者が SCIM になっており、SCIMで連携されたことがわかります。ここまでがグループの割り当て作業でした。 6-6. 権限を割り当て Identity Centerのグループやユーザーに対し、AWSアカウントへ権限を割り当てるための作業です。(方式②も同じ作業で割当てできます) (AWS)Identity Centerコンソールで「AWSアカウント」を選択し、権限を割り当てたいAWSアカウントにチェックを入れ、「ユーザー又はグループを割り当て」をクリックします。 (AWS)「ユーザーとグループの選択」で、権限を割り当てたいグループ(Oktaから連携されたグループ)にチェックを入れ「次へ」をクリックします。 (AWS)「許可セットを選択」画面で、グループに割り当てたい許可セット(権限)にチェックを入れ、「次へ」をクリックします。 (AWS) 設定内容を確認し「送信」をクリックします。 設定はここでおしまいです。おつかれさまでした。次は動作確認です。 6-7. サインイン確認 AWSマネジメントコンソール、Okta Admin Consoleからサインオフしておきます。 Identity CenterのAccess Portal URL( https://<インスタンスID>.awsapps.com/start )にアクセスします。 Oktaの認証画面にリダイレクトされるので、Oktaのユーザー(6-3で登録したユーザー)でログインします。 初回ログインの際にOkta Verifyの設定を求められることがあるので画面の指示に従ってよしなに登録します。 Oktaダッシュボードが表示され、割り当てられたアプリケーション一覧が表示されます。「AWS IAM Identity Center」をクリックします。 Identity Centerで割り当てたAWSアカウントの選択画面が表示されるので、操作対象のAWSアカウントを選択します。 6-8. トラブルシューティング Oktaのマイアプリ一覧から「AWS IAM Identity Center」をクリックするとエラーが表示される・アクセスできない Oktaのアプリケーション設定の「サインオン」タブの「AWS SSO ACS URL」を誤っている可能性があります。 SCIMユーザー同期が反映されない SCIMトークンの有効期限(1年)が切れているかもしれません。Identity Center側で新しいトークンを発行し、Okta側に再設定してください。 認証成功してもAWSアカウントが表示されない ユーザーまたはグループに許可セットが割り当てられていません。Identity Centerの「AWSアカウント」画面で、対象ユーザー/グループへの許可セット割り当てを確認してください。 7. 設計・設定・運用で気をつけること CloudTrailでの追跡 Identity Center経由のサインインは、CloudTrailの管理イベントにFederateやAuthenticateといったイベント名で記録されます。詳細な監査ログの設計については以下の公式ドキュメントをご参照ください。 docs.aws.amazon.com aws.amazon.com 許可セットの変更管理 許可セットを変更すると、その許可セットが割り当てられている全AWSアカウントのIAMロールに反映されます。意図しない権限変更を防ぐため、変更前に影響範囲(どのAWSアカウント・グループに割り当てられているか)を確認してください。 SCIMトークンの有効期限 SCIMアクセストークンの有効期限は1年です。期限が切れるとIdPからIdentity Centerへのユーザー・グループ同期が停止します。SCIMトークンが失効してもSAML認証フロー自体には直接影響しませんが、外部IdP側の変更(ユーザー無効化、グループ変更など)がIdentity Centerに反映されなくなるため、間接的にアクセスに影響が出る可能性があります。 期限切れ前にIdentity Centerコンソールで新しいトークンを生成し、IdP側の設定を更新してください。 docs.aws.amazon.com 既存環境からの移行 すでにIAMユーザーやIAMロール(方式①など)で運用している環境にIdentity Centerを導入する場合、既存のIAMロールをそのまま許可セットに取り込む機能はありません。許可セットを新たに設計し、既存の権限を再現する必要があります。 ただし、Identity Centerの導入と既存のIAMロールの廃止は同時に行う必要はありません。並行運用しながら段階的に移行できます。なお、移行対象はユーザがログインに使うIAMロールのみで、Lambda実行ロールやEC2インスタンスプロファイルなどのサービスロールは対象外です。 IDソースは1つだけ Identity CenterのIDソース(Identity Source)は、1つの組織につき1つしか設定できません。選択肢は以下の3つのうちいずれかです。 Identity Centerディレクトリ(組み込み、デフォルト) Active Directory(AWS Managed Microsoft ADまたはAD Connector) 外部IdP(Okta、Entra IDなど) つまり、方式②(組み込みディレクトリ)と方式③(外部IdP)は併用できません。 IDソースの切り替えに伴う、登録済み情報の削除 IDソースを切り替えると、切り替え元のユーザー・グループが削除されることがあります。割り当て(許可セットとユーザー/グループの紐づけ)が保持されるかどうかは切り替えパターンによります。詳細は公式ドキュメントを参照してください。 docs.aws.amazon.com 方式③導入後の定期運用タスクの一覧(一例) タスク 頻度 対応しなかった場合の影響 SCIMアクセストークンの更新 年1回(有効期限: 1年) ユーザー・グループの同期が停止する。新規ユーザーがログイン不可になる・無効化ユーザが反映されない 許可セットの棚卸し 半年〜年1回(推奨) 不要な権限が残存し、最小権限の原則から逸脱する 退職者のIdP側アカウント無効化 発生都度 SCIM同期によりIdentity Center側も無効化されるが、反映までのタイムラグに注意 SAML証明書の更新 IdP側の証明書有効期限による(今回取得した証明書(okta.cert)は10年でしたが、環境により異なります。) SAML認証に影響が発生する恐れ このほかにも、組織によってはCloudTrailログでの利用状況(サインイン状況)を監査することもあるでしょう。 まとめ マルチアカウント環境の認証方式を3つ比較し、方式③(外部IdP → Identity Center → AWSアカウント)の導入手順を紹介しました。 方式①(外部IdP→AWSアカウント): 構成がシンプルだが、AWSアカウント数に比例して設定が増える 方式②(Identity Center→AWSアカウント): AWS内で完結し、許可セットで一元管理できる。外部IdPがない場合に適している 方式③(外部IdP → Identity Center → AWSアカウント): ID管理とアクセス管理を分離できる。組織にIdPがある場合に適している 余談ですが、このような導入手順を確認したり検証したりすると、MFAデバイス(スマホのAuthenticatorアプリ)にアカウントが増殖してしまいます。手順の確認が終わったらもちろん削除するのですが、実運用で使っているエントリを誤削除してしまわないかいつもひやひやします。このようなひやひや感をユーザーに感じさせないためにも認証の一元化はおすすめです。 執筆者 平田賀一(NTTビジネスソリューションズ(株)バリューデザイン部 システム開発部門) PaaS/SaaSの開発・運用、社内案件のコンサル、エンジニア育成、NTT WEST Engineers' Blog運営などに携わっています。 技術士(情報工学部門)/CISSP/2025 Japan All AWS Certifications Engineers 参考資料・出典 本記事を執筆するにあたり、以下のサイトを参考にしました。 docs.aws.amazon.com docs.aws.amazon.com docs.aws.amazon.com docs.aws.amazon.com docs.aws.amazon.com qiita.com 商標 Amazon Web Services、AWS、AWS Identity and Access Management (IAM)、AWS IAM Identity Center、AWS Single Sign-On、AWS Organizations、AWS CloudTrail、AWS Lambda、Amazon EC2、AWS Managed Microsoft AD、AD Connector、AWS Certified Solutions Architectなどは、Amazon.com, Inc. またはその関連会社の商標です。 Active Directory、Entra IDはMicrosoft Corporationまたはその関連会社の商標です。 KeycloakはThe Linux Foundationの商標です。 Okta、Okta VerifyはOkta, Inc.の登録商標です。