マルチアカウント Transit Gateway 環境を1コマンドで棚卸ししてみる
はじめに
かつまたです。
最近は、AWS 環境調査や棚卸しの際に AI によって調査、または調査コマンドを出力してもらい、環境把握をしていく場面が多くなってきていると思います。
今回は、複数アカウントにまたがる Transit Gateway とそこに接続されている VPC などのリソース詳細を一括で取得し、マークダウンでリソース台帳を作成するコマンドを作成し、マルチアカウントで実際に試してみました。
対象 TGW と各アカウントのプロファイルを指定することで、汎用的に利用が可能です。
やりたいこと
- マルチアカウント TGW 環境の構成情報を1コマンドで収集する
- TGW ルートテーブルの
static/blackholeルートを自動検出する - VPC ルートテーブルの TGW 向けルート本数・デフォルトルートの有無を俯瞰する
- セキュリティグループのルールから VPC 間通信パターンを一覧化する
- 出力を Markdown のリソース台帳として出力する
前提条件
調査対象となるマルチアカウント TGW 環境は、以下の構成で事前作成しています。

環境前提
- AWS CLI v2
- bash 4以上
- jq
- AWS アカウントプロファイル設定済み
アカウント・VPC 構成
| アカウント | VPC 名 | CIDR |
|---|---|---|
| Account A | Vpc-App | 10.1.0.0/16 |
| Account A | Vpc-Shared | 10.2.0.0/16 |
| Account B | Vpc-Db | 10.3.0.0/16 |
TGW 共有
- Account A で TGWを作成
- AWS RAM 経由で Account B に TGW を共有
- 各 VPC を TGW にアタッチ(計3アタッチメント)
やってみた
スクリプトの全体像
調査スクリプトは大きく4つのフェーズに分かれています。
- TGW アタッチメント取得:
describe-transit-gateway-attachmentsで全アタッチメントを収集 - TGW ルートテーブル取得: 各 RT のルートを
search-transit-gateway-routesで収集 - VPC ルートテーブル取得: Attachment の
ResourceOwnerIdでアカウントを特定し、各プロファイルでdescribe-route-tablesを実行 - セキュリティグループ取得:
describe-security-groupsでインバウンド・アウトバウンドルールを取得
収集したデータは Markdown テーブルに整形して1ファイルにまとめます。
やってみる
スクリプトは以下を利用しました。
使用スクリプト
#!/bin/bash
set -euo pipefail
TGW_ID=""
PROFILES=""
OUTPUT="tgw-inventory.md"
REGION="${AWS_DEFAULT_REGION:-ap-northeast-1}"
usage() {
echo "Usage: $0 [--tgw TGW_ID] --profiles PROFILE1,PROFILE2 [--output FILE] [--region REGION]"
echo ""
echo "Options:"
echo " --tgw Transit Gateway ID(省略時は自動検出)"
echo " --profiles AWS CLI プロファイル(カンマ区切り)"
echo " --output 出力ファイル名(デフォルト: tgw-inventory.md)"
echo " --region リージョン(デフォルト: ap-northeast-1)"
exit 1
}
while [[ $# -gt 0 ]]; do
case $1 in
--tgw) TGW_ID="$2"; shift 2 ;;
--profiles) PROFILES="$2"; shift 2 ;;
--output) OUTPUT="$2"; shift 2 ;;
--region) REGION="$2"; shift 2 ;;
*) usage ;;
esac
done
[[ -z "$PROFILES" ]] && usage
# プロファイルを配列化
IFS=',' read -ra PROFILE_LIST <<< "$PROFILES"
PRIMARY_PROFILE="${PROFILE_LIST[0]}"
aws_cmd() {
local profile="$1"; shift
aws --profile "$profile" --region "$REGION" "$@"
}
log() {
echo "[$(date '+%H:%M:%S')] $*" >&2
}
# ===================== TGW 検出 =====================
if [[ -z "$TGW_ID" ]]; then
log "TGW ID 未指定 — 自動検出中..."
TGW_LIST=$(aws_cmd "$PRIMARY_PROFILE" ec2 describe-transit-gateways \
--filters "Name=state,Values=available" \
--query 'TransitGateways[*].[TransitGatewayId,Tags[?Key==`Name`].Value|[0]]' \
--output text)
TGW_COUNT=$(echo "$TGW_LIST" | wc -l | tr -d ' ')
if [[ "$TGW_COUNT" -eq 0 ]]; then
echo "ERROR: TGW が見つかりません" >&2; exit 1
elif [[ "$TGW_COUNT" -eq 1 ]]; then
TGW_ID=$(echo "$TGW_LIST" | awk '{print $1}')
TGW_NAME=$(echo "$TGW_LIST" | awk '{print $2}')
log "TGW 検出: $TGW_ID ($TGW_NAME)"
else
echo "複数の TGW が見つかりました。--tgw で指定してください:" >&2
echo "$TGW_LIST" >&2
exit 1
fi
else
TGW_NAME=$(aws_cmd "$PRIMARY_PROFILE" ec2 describe-transit-gateways \
--transit-gateway-ids "$TGW_ID" \
--query 'TransitGateways[0].Tags[?Key==`Name`].Value|[0]' \
--output text 2>/dev/null || echo "N/A")
fi
# ===================== データ収集 =====================
log "=== 1/4: TGW アタッチメント取得 ==="
ATTACHMENTS_JSON=$(aws_cmd "$PRIMARY_PROFILE" ec2 describe-transit-gateway-attachments \
--filters "Name=transit-gateway-id,Values=$TGW_ID" \
--query 'TransitGatewayAttachments[*].{AttachmentId:TransitGatewayAttachmentId,ResourceId:ResourceId,ResourceType:ResourceType,OwnerId:ResourceOwnerId,State:State,Name:Tags[?Key==`Name`].Value|[0]}' \
--output json)
log "=== 2/4: TGW ルートテーブル取得 ==="
TGW_RTS=$(aws_cmd "$PRIMARY_PROFILE" ec2 describe-transit-gateway-route-tables \
--filters "Name=transit-gateway-id,Values=$TGW_ID" \
--query 'TransitGatewayRouteTables[*].{RtId:TransitGatewayRouteTableId,Name:Tags[?Key==`Name`].Value|[0],Default:DefaultAssociationRouteTable}' \
--output json)
# 各 RT のルートを取得
declare -A RT_ROUTES
for RT_ID in $(echo "$TGW_RTS" | jq -r '.[].RtId'); do
log " ルート取得: $RT_ID"
RT_ROUTES[$RT_ID]=$(aws_cmd "$PRIMARY_PROFILE" ec2 search-transit-gateway-routes \
--transit-gateway-route-table-id "$RT_ID" \
--filters "Name=type,Values=static,propagated" \
--output json)
done
log "=== 3/4: VPC ルートテーブル取得 ==="
# Attachment から VPC ID を抽出し、プロファイルごとに VPC RT を取得
declare -A VPC_ROUTES
declare -A VPC_NAMES
for PROFILE in "${PROFILE_LIST[@]}"; do
ACCOUNT_ID=$(aws_cmd "$PROFILE" sts get-caller-identity --query Account --output text 2>/dev/null || echo "unknown")
log " プロファイル: $PROFILE (Account: $ACCOUNT_ID)"
# このアカウントが所有する VPC Attachment を抽出
VPC_IDS=$(echo "$ATTACHMENTS_JSON" | jq -r \
--arg owner "$ACCOUNT_ID" \
'.[] | select(.OwnerId == $owner and .ResourceType == "vpc") | .ResourceId')
for VPC_ID in $VPC_IDS; do
VPC_NAME=$(aws_cmd "$PROFILE" ec2 describe-vpcs --vpc-ids "$VPC_ID" \
--query 'Vpcs[0].{Name:Tags[?Key==`Name`].Value|[0],Cidr:CidrBlock}' \
--output json 2>/dev/null || echo '{"Name":"N/A","Cidr":"N/A"}')
VPC_NAMES[$VPC_ID]="$VPC_NAME"
RT_JSON=$(aws_cmd "$PROFILE" ec2 describe-route-tables \
--filters "Name=vpc-id,Values=$VPC_ID" \
--query 'RouteTables[*].{RtId:RouteTableId,Name:Tags[?Key==`Name`].Value|[0],DefaultRoute:Routes[?DestinationCidrBlock==`0.0.0.0/0`]|[0],TgwRoutes:Routes[?TransitGatewayId!=`null`]}' \
--output json 2>/dev/null || echo '[]')
VPC_ROUTES[$VPC_ID]="$RT_JSON"
done
done
log "=== 4/4: セキュリティグループ取得 ==="
declare -A VPC_SGS
for PROFILE in "${PROFILE_LIST[@]}"; do
ACCOUNT_ID=$(aws_cmd "$PROFILE" sts get-caller-identity --query Account --output text 2>/dev/null || echo "unknown")
VPC_IDS=$(echo "$ATTACHMENTS_JSON" | jq -r \
--arg owner "$ACCOUNT_ID" \
'.[] | select(.OwnerId == $owner and .ResourceType == "vpc") | .ResourceId')
for VPC_ID in $VPC_IDS; do
SG_JSON=$(aws_cmd "$PROFILE" ec2 describe-security-groups \
--filters "Name=vpc-id,Values=$VPC_ID" \
--query 'SecurityGroups[?GroupName!=`default`].{SgId:GroupId,Name:GroupName,Ingress:IpPermissions[*].{Proto:IpProtocol,FromPort:FromPort,ToPort:ToPort,Cidrs:IpRanges[*].CidrIp},Egress:IpPermissionsEgress[*].{Proto:IpProtocol,FromPort:FromPort,ToPort:ToPort,Cidrs:IpRanges[*].CidrIp}}' \
--output json 2>/dev/null || echo '[]')
VPC_SGS[$VPC_ID]="$SG_JSON"
done
done
# ===================== Markdown 生成 =====================
log "=== Markdown 生成中 ==="
{
DATE=$(date '+%Y-%m-%d %H:%M')
echo "# TGW 棚卸し台帳"
echo ""
echo "**生成日時**: $DATE "
echo "**TGW**: $TGW_ID ($TGW_NAME) "
echo "**リージョン**: $REGION "
echo "**プロファイル**: ${PROFILES}"
echo ""
echo "---"
echo ""
# --- 1. アタッチメント一覧 ---
echo "## 1. TGW アタッチメント一覧"
echo ""
echo "| Attachment ID | リソース | 種別 | Owner Account | State | Name |"
echo "|---|---|---|---|---|---|"
echo "$ATTACHMENTS_JSON" | jq -r '.[] | "| \(.AttachmentId) | \(.ResourceId) | \(.ResourceType) | \(.OwnerId) | \(.State) | \(.Name // "—") |"'
echo ""
# --- 2. TGW ルートテーブル ---
echo "## 2. TGW ルートテーブル"
echo ""
STATIC_COUNT=0
BLACKHOLE_COUNT=0
for RT_ID in $(echo "$TGW_RTS" | jq -r '.[].RtId'); do
RT_NAME=$(echo "$TGW_RTS" | jq -r --arg id "$RT_ID" '.[] | select(.RtId == $id) | .Name // "—"')
IS_DEFAULT=$(echo "$TGW_RTS" | jq -r --arg id "$RT_ID" '.[] | select(.RtId == $id) | .Default')
echo "### $RT_ID ($RT_NAME)"
[[ "$IS_DEFAULT" == "true" ]] && echo "> デフォルトルートテーブル"
echo ""
echo "| 宛先 CIDR | 種別 | 転送先 Attachment | State |"
echo "|---|---|---|---|"
ROUTES_DATA="${RT_ROUTES[$RT_ID]}"
echo "$ROUTES_DATA" | jq -r '.Routes[] | "| \(.DestinationCidrBlock) | \(.Type) | \(.TransitGatewayAttachments[0].TransitGatewayAttachmentId // "—") | \(.State) |"' 2>/dev/null || true
# static / blackhole カウント
RT_STATIC=$(echo "$ROUTES_DATA" | jq '[.Routes[] | select(.Type == "static")] | length' 2>/dev/null || echo 0)
RT_BLACKHOLE=$(echo "$ROUTES_DATA" | jq '[.Routes[] | select(.State == "blackhole")] | length' 2>/dev/null || echo 0)
STATIC_COUNT=$((STATIC_COUNT + RT_STATIC))
BLACKHOLE_COUNT=$((BLACKHOLE_COUNT + RT_BLACKHOLE))
echo ""
done
if [[ $STATIC_COUNT -gt 0 || $BLACKHOLE_COUNT -gt 0 ]]; then
echo "> **検出事項**: "
[[ $STATIC_COUNT -gt 0 ]] && echo "> - static ルート ${STATIC_COUNT} 件 — 手動追加されたルートです。用途を確認してください "
[[ $BLACKHOLE_COUNT -gt 0 ]] && echo "> - blackhole ルート ${BLACKHOLE_COUNT} 件 — 転送先が無効です。削除を検討してください "
echo ""
fi
# --- 3. VPC ルートテーブル ---
echo "## 3. VPC ルートテーブル"
echo ""
echo "| VPC | VPC Name | CIDR | RT ID | 0.0.0.0/0 → | TGW 向けルート数 |"
echo "|---|---|---|---|---|---|"
for VPC_ID in $(echo "$ATTACHMENTS_JSON" | jq -r '.[] | select(.ResourceType == "vpc") | .ResourceId' | sort -u); do
VPC_INFO="${VPC_NAMES[$VPC_ID]:-"{}"}"
V_NAME=$(echo "$VPC_INFO" | jq -r '.Name // "—"')
V_CIDR=$(echo "$VPC_INFO" | jq -r '.Cidr // "—"')
RT_DATA="${VPC_ROUTES[$VPC_ID]:-[]}"
if [[ "$RT_DATA" == "[]" || -z "$RT_DATA" ]]; then
echo "| $VPC_ID | $V_NAME | $V_CIDR | — | ⚠ アクセス不可 | — |"
else
echo "$RT_DATA" | jq -r --arg vpc "$VPC_ID" --arg vname "$V_NAME" --arg vcidr "$V_CIDR" \
'.[] | "| \($vpc) | \($vname) | \($vcidr) | \(.RtId) | \(if .DefaultRoute == null then "なし" else (.DefaultRoute.GatewayId // .DefaultRoute.NatGatewayId // .DefaultRoute.TransitGatewayId // "なし") end) | \(.TgwRoutes | length) |"' 2>/dev/null || true
fi
done
echo ""
# --- 4. SG 通信パターン ---
echo "## 4. VPC 間通信パターン(SG Ingress / Egress)"
echo ""
echo "| 対象 VPC | SG 名 | 方向 | プロトコル | ポート | 相手 CIDR |"
echo "|---|---|---|---|---|---|"
for VPC_ID in $(echo "$ATTACHMENTS_JSON" | jq -r '.[] | select(.ResourceType == "vpc") | .ResourceId' | sort -u); do
SG_DATA="${VPC_SGS[$VPC_ID]:-[]}"
V_NAME=$(echo "${VPC_NAMES[$VPC_ID]:-"{}"}" | jq -r '.Name // "—"')
if [[ "$SG_DATA" == "[]" || -z "$SG_DATA" ]]; then
echo "| $V_NAME ($VPC_ID) | ⚠ アクセス不可 | — | — | — | — |"
else
echo "$SG_DATA" | jq -r --arg vname "$V_NAME" --arg vpc "$VPC_ID" '
def proto: if . == "-1" then "all" else . end;
def port: if . == null then "—" else tostring end;
def portrange($f;$t):
if $f == null and $t == null then "—"
elif $f == $t then ($f | port)
else "\($f | port)–\($t | port)" end;
.[] | .Name as $sg | (
(.Ingress[]? | "| \($vname) (\($vpc)) | \($sg) | ingress | \(.Proto | proto) | \(portrange(.FromPort;.ToPort)) | \(.Cidrs[]?) |"),
(.Egress[]? | "| \($vname) (\($vpc)) | \($sg) | egress | \(.Proto | proto) | \(portrange(.FromPort;.ToPort)) | \(.Cidrs[]?) |")
)' 2>/dev/null || true
fi
done
echo ""
echo "---"
echo ""
echo "*Generated by tgw-inventory.sh*"
} > "$OUTPUT"
log "=== 完了: $OUTPUT ==="
echo ""
echo "台帳を生成しました: $OUTPUT"
echo ""
echo "=== サマリー ==="
echo " TGW: $TGW_ID ($TGW_NAME)"
echo " Attachments: $(echo "$ATTACHMENTS_JSON" | jq length)"
echo " Route Tables: $(echo "$TGW_RTS" | jq length)"
echo " Static Routes: $STATIC_COUNT"
echo " Blackholes: $BLACKHOLE_COUNT"
echo " VPCs surveyed: ${#VPC_ROUTES[@]}"
echo " SGs surveyed: ${#VPC_SGS[@]}"
実行してみます。
./tgw-inventory.sh \
--tgw tgw-xxxxxxxxxxx \
--profiles "account-a,account-b" \
--output tgw-inventory.md
実行時出力例
[10:32:01] === 1/4: TGW アタッチメント取得 ===
[10:32:02] === 2/4: TGW ルートテーブル取得 ===
[10:32:02] ルート取得: tgw-rtb-0abc...
[10:32:03] === 3/4: VPC ルートテーブル取得 ===
[10:32:03] プロファイル: account-a (Account: 111111111111)
[10:32:05] プロファイル: account-b (Account: 222222222222)
[10:32:06] === 4/4: セキュリティグループ取得 ===
[10:32:07] === Markdown 生成中 ===
[10:32:07] === 完了: tgw-inventory.md ===
台帳を生成しました: tgw-inventory.md
=== サマリー ===
TGW: tgw-xxxxxxxxxxx
Attachments: 3
Route Tables: 1
Static Routes: 2
Blackholes: 0
VPCs surveyed: 3
SGs surveyed: 3
結果
出力された tgw-inventory.md は以下のような内容になりました。
- リソース台帳例
# TGW 棚卸し台帳
**生成日時**: 2026-04-21 10:47
**TGW**: tgw-018179f857379293f (Blog-TGW)
**リージョン**: ap-northeast-1
**プロファイル**: blog-a,blog-b
---
## 1. TGW アタッチメント一覧
| Attachment ID | リソース | 種別 | Owner Account | State | Name |
|---|---|---|---|---|---|
| tgw-attach-05a032f7428143419 | vpc-028c0c38f23e9614c | vpc | 222222222222 | available | — |
| tgw-attach-0115dba74bf788131 | vpc-0855875fba21385d4 | vpc | 111111111111 | available | Attach-App |
| tgw-attach-0c267c7614b84a844 | vpc-0b6734af602e80d1d | vpc | 111111111111 | available | Attach-Shared |
## 2. TGW ルートテーブル
### tgw-rtb-088fad148de33be1d (—)
> デフォルトルートテーブル
| 宛先 CIDR | 種別 | 転送先 Attachment | State |
|---|---|---|---|
| 10.1.0.0/16 | propagated | tgw-attach-0115dba74bf788131 | active |
| 10.2.0.0/16 | propagated | tgw-attach-0c267c7614b84a844 | active |
| 10.3.0.0/16 | propagated | tgw-attach-05a032f7428143419 | active |
| 172.16.0.0/24 | static | tgw-attach-0115dba74bf788131 | active |
| 192.168.100.0/24 | static | tgw-attach-0c267c7614b84a844 | active |
> **検出事項**:
> - static ルート 2 件 — 手動追加されたルートです。用途を確認してください
## 3. VPC ルートテーブル
| VPC | VPC Name | CIDR | RT ID | 0.0.0.0/0 → | TGW 向けルート数 |
|---|---|---|---|---|---|
| vpc-028c0c38f23e9614c | Vpc-Db | 10.3.0.0/16 | rtb-0c69020a785287ce3 | なし | 2 |
| vpc-0855875fba21385d4 | Vpc-App | 10.1.0.0/16 | rtb-0749e1db835c7d1de | なし | 2 |
| vpc-0b6734af602e80d1d | Vpc-Shared | 10.2.0.0/16 | rtb-0d8d98fd7b96a7046 | なし | 2 |
## 4. VPC 間通信パターン(SG Ingress / Egress)
| 対象 VPC | SG 名 | 方向 | プロトコル | ポート | 相手 CIDR |
|---|---|---|---|---|---|
| Vpc-Db (vpc-028c0c38f23e9614c) | Blog-Db-SG | ingress | tcp | 5432 | 10.1.0.0/16 |
| Vpc-Db (vpc-028c0c38f23e9614c) | Blog-Db-SG | ingress | tcp | 3306 | 10.1.0.0/16 |
| Vpc-Db (vpc-028c0c38f23e9614c) | Blog-Db-SG | ingress | icmp | -1 | 10.0.0.0/8 |
| Vpc-Db (vpc-028c0c38f23e9614c) | Blog-Db-SG | ingress | tcp | 9100 | 10.2.0.0/16 |
| Vpc-Db (vpc-028c0c38f23e9614c) | Blog-Db-SG | egress | all | — | 0.0.0.0/0 |
| Vpc-App (vpc-0855875fba21385d4) | Blog-App-SG | ingress | udp | 53 | 10.2.0.0/16 |
| Vpc-App (vpc-0855875fba21385d4) | Blog-App-SG | ingress | tcp | 53 | 10.2.0.0/16 |
| Vpc-App (vpc-0855875fba21385d4) | Blog-App-SG | ingress | tcp | 389 | 10.2.0.0/16 |
| Vpc-App (vpc-0855875fba21385d4) | Blog-App-SG | ingress | icmp | -1 | 10.0.0.0/8 |
| Vpc-App (vpc-0855875fba21385d4) | Blog-App-SG | ingress | tcp | 443 | 10.3.0.0/16 |
| Vpc-App (vpc-0855875fba21385d4) | Blog-App-SG | egress | all | — | 0.0.0.0/0 |
| Vpc-Shared (vpc-0b6734af602e80d1d) | Blog-Shared-SG | ingress | udp | 53 | 10.0.0.0/8 |
| Vpc-Shared (vpc-0b6734af602e80d1d) | Blog-Shared-SG | ingress | tcp | 53 | 10.0.0.0/8 |
| Vpc-Shared (vpc-0b6734af602e80d1d) | Blog-Shared-SG | ingress | tcp | 389 | 10.0.0.0/8 |
| Vpc-Shared (vpc-0b6734af602e80d1d) | Blog-Shared-SG | ingress | icmp | -1 | 10.0.0.0/8 |
| Vpc-Shared (vpc-0b6734af602e80d1d) | Blog-Shared-SG | ingress | tcp | 3389 | 10.1.1.100/32 |
| Vpc-Shared (vpc-0b6734af602e80d1d) | Blog-Shared-SG | egress | all | — | 0.0.0.0/0 |
---
読み取れることの例
- TGW ルートテーブル:
propagatedの3本は各 VPC アタッチメントから自動伝播された正常なルート。static2本は誰かが手動で追加したもので、用途や構築背景を確認する必要がある、と検出事項として明示されます。 - VPC ルートテーブル: どの VPC もデフォルトルート(
0.0.0.0/0)を持たず、TGW 向けルートを2本ずつ持っているので、閉域で VPC 間通信のみが許可されている構成だと確認できます。 - セキュリティグループの通信パターン: 「Vpc-App は Vpc-Shared から tcp/389・tcp,udp/53、Vpc-Db から tcp/443 を受ける」「Vpc-Db は Vpc-App から tcp/3306・tcp/5432、Vpc-Shared から tcp/9100 を受ける」といった誰からどのポートを受け付けているかを台帳として残せます。
おわりに
ご覧いただきありがとうございました。
今回のコマンドにより、定期棚卸しや、引き継ぎ時に簡単にリソース台帳の作成が実施でき、環境把握につながると思います。
AI 活用する場合でも、トークン節約にもなると思いますので、同様の環境に対する調査が必要になった場合は利用してみてください。
参考資料
クラスメソッドオペレーションズ株式会社について
クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AIをフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。
※2026年1月 アノテーション㈱から社名変更しました




