×
Community Blog 手動運用から自動オーケストレーション、そしてAI活用へ:ECSディスク管理における効率化の進化

手動運用から自動オーケストレーション、そしてAI活用へ:ECSディスク管理における効率化の進化

手動運用から自動オーケストレーション、そしてAI活用へ:ECSディスク管理における効率化の進化

Section 1:今回の検証を行った理由

TAMチームとお客様との週次ミーティングでの議論を通じて、私たちはお客様側の運用上の課題を知りました。お客様のECSクラスターは規模が大きく、ECSにアタッチされているディスクの構成を変更する場合(例えば、PL0>PL1)、1台ずつ操作する必要があり、時間がかかるうえに誤操作も起こりやすく、使い勝手がよくありません。この課題に対して、私たちはVOCを製品側にフィードバックし、製品の改善を働きかけ始めました。しかし、プロダクトマネージャーは通常、多くのお客様からの要望を抱えており、優先順位に基づいて順次スケジュールを組む必要があるため、新機能を迅速に実装してお客様の課題を解消することは、多くの場合困難です。そこで私たちは、こうしたシナリオに迅速に対応するため、OOSなどの運用自動化ソリューションを活用することをお客様に提案しました。ただ、お客様はAlibaba Cloudの機能の仕組みについての理解が限られており、その機能を具体的にどのように使ってソリューションを実現すればよいか分からないとのことでした。

このようなときに最適なのは、検証可能な最小限のテストシナリオを提供し、ハンズオンを通じてお客様がAlibaba Cloudの製品機能に素早く慣れ、お客様自身でソリューションを反復的に改善できるよう支援することです。従来のようにゼロから手作業で作り上げる方法と比べ、AI Agentの支援があれば、私たちはこの目標を効率よく迅速に達成できます。

ここからは、今回私がAI Agentをエンドツーエンドで活用してお客様の課題を解決した実践について簡単に共有します。皆さんの参考になれば幸いです。

手順を簡素化するため、私たちはお客様の課題を、受け入れ確認が可能な2つのシナリオに絞り込みました。

    ● 1つ目のシナリオでは、2台のECSのインスタンスIDだけを入力し、プログラムがこの2台のインスタンスにアタッチされているすべてのディスクを自動で選択する必要があります。

    ● 2つ目のシナリオでは、インスタンスIDとディスクIDを同時に入力し、プログラムは明示的に指定され、かつこれらのインスタンスに実際にアタッチされているディスクだけを処理する必要があります。

    ● どちらのシナリオでも、まず事前チェックを行い、その後で実行を明示的に確認し、ディスクごとのJSON形式のエビデンスを出力する必要があります。

この記事で確かめたいのは「APIを呼び出せるか」ではなく、以下の一連の流れを最後まで完結できるかどうかです。

繰り返しテストできる環境を作成
  → インスタンスとディスクの所属関係を解析
  → 適格性チェックと ModifyDiskSpec DryRun
  → 人による確認 --execute
  → OOS による一括実行
  → ディスクごとのタスク監査
  → 選択したディスクを PL1 にアップグレードし、選択していないディスクは変更しない
  → JSON によるエビデンス保存と完全なクリーンアップ

Section 2:ソリューションの概要とアーキテクチャ

免責事項:本ソリューションはテスト環境での学習を目的としたものであり、変更を加えずに本番環境のベストプラクティスに適合することを保証するものではありません。

TerraformとPythonはどちらもローカルのターミナルで実行するため、テスト用ECSにログインする必要はなく、ゲストOSのスクリプトも実行しません。TerraformはVPC、VSwitch、セキュリティグループ、2台のECS、2個の追加データディスク、OOSサービスロール、およびディスク単位の権限の作成とクリーンアップを担当します。Pythonはディスクの検出、事前チェック、OOSの起動、待機、監査、およびエビデンスの出力を担当します。実際のディスク仕様の変更は、クラウド上のOOSパブリックテンプレートACS-ECS-BulkyModifyDiskSpecがModifyDiskSpecを呼び出して行います。

権限は2つの層に分かれています。操作者は信頼できるRAM管理者を使用して実験を行い、OOSは実行時にTerraformが作成したサービスロールを引き受けます。このサービスロールに許可されるのは、今回Terraformが作成した4個のディスクARNに対するecs:ModifyDiskSpecの実行だけです。操作者の権限が広いからといって、OOSがアカウント内のすべてのディスクを変更する権限を得ることはありません。

ここでAI Agentを導入する役割は、要件の分解、IaCとPythonコードの生成、オフライン回帰テストの設計、公式ドキュメントの確認、実際に発生したエラーの原因特定、および最終的な技術記事への取りまとめを支援することです。

ソリューションのアーキテクチャ概要については、以下の図をご参照ください。

ソリューションのアーキテクチャ概要については、以下の図をご参照ください。

Section 3:前提条件と環境の準備

3.1 テストの対象範囲

この記事では、Alibaba Cloud国際サイトの日本(東京)リージョンを例に説明します。リージョンIDはap-northeast-1です。

実験の対象は、通常のECS、単一アタッチのESSD、およびPL0→PL1の変更だけです。PL0は1–65536 GiB、PL1は20–65536 GiBに対応しているため、20 GiBがこの実験で両方の条件を満たす最小容量となります。

テストでは従量課金のECSとESSDを作成し、ディスクをPL0からPL1にアップグレードします。OOSのオーケストレーション自体は無料ですが、ECS、クラウドディスク、およびOOSが呼び出すクラウドサービスは通常どおり課金され、アップグレード後のクラウドディスクは新しい性能レベルに基づいて課金されます。実際の費用は、その時点のリージョンにおけるインスタンス仕様、クラウドディスクの価格、および稼働時間によって異なります。この記事では、価格照会による確認を行っていない固定金額は提示しません。独立したテスト用アカウントで実行し、あらかじめ残高、クォータ、および組織のセキュリティ規定を確認してください。

3.2 RAMユーザー、権限、およびAccessKey

Alibaba Cloudのベストプラクティスに従い、本ソリューションではRoot AccessKeyを使用しません。信頼できるRAM管理者がすでに存在する場合は、その管理者を再利用できます。存在しない場合は、既存の管理者がRAMコンソールのIdentities > Users > Create Userに移動し、この実験専用のRAMユーザーを作成します。例えば、pl-upgrade-adminというユーザーです。プログラムからのアクセス方式にはPermanent AccessKeyを選択し、AccessKeyの作成が必要であることを確認します。ユーザーの作成時にシステムがキーペアを生成するので、その場で安全に保存してください。作成後、ユーザーのPermissions > Add Permissionsに移動して権限を付与します。

このサンプルでは、内部テストの手順を短縮するため、同じRAM管理者がTerraform、Python/OOS、およびクリーンアップの操作を行います。操作者にはAliyunECSFullAccess、AliyunVPCFullAccess、AliyunRAMFullAccess、AliyunOOSFullAccess、または同等のカスタム権限が必要です。

Terraformのインスタンス自動選定では、BSS OpenAPIのGetPayAsYouGoPriceを呼び出します。対応するRAM Actionはbss:DescribePriceです。条件を満たす最も安価なECSインスタンスを検索し、今回のテスト用に作成します。そのため、ECS製品の価格照会だけに限定した以下のカスタムポリシーを追加する必要があります。

{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "bss:DescribePrice",
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "bssapi:ProductCode": "ecs"
        }
      }
    }
  ]
}

ユーザーの作成時にすでにキーを生成して保存している場合は、重複して作成しないでください。既存のRAMユーザーに利用可能なキーがない場合は、RAM管理者がそのユーザーのCredential > AccessKey > Create AccessKeyに移動し、用途とセキュリティに関する注意事項を読み、セキュリティ認証を完了してからキーを作成します。

注意:AccessKey Secretは一度しか表示されません。コード、記事、スクリーンショット、Git、またはチャットに記載しないでください。

参考画像1:RAMアカウントの作成とAccessKeyの取得

参考画像1:RAMアカウントの作成とAccessKeyの取得

参考画像1:RAMアカウントの作成とAccessKeyの取得

参考画像1:RAMアカウントの作成とAccessKeyの取得

参考画像2:権限の作成と付与

参考画像2:権限の作成と付与

参考画像2:権限の作成と付与

参考画像2:権限の作成と付与

3.3 ローカルのTerraformとPython

開始する前に、まずSection 8を参照して、ローカルの実行ディレクトリと実行用ファイル(3個)を準備してください。その後、実行ディレクトリに移動し、以下の操作を順番に行います。

この記事では、macOSのzshターミナルを使用してエンドツーエンドのテストを行います。ほかのOSでは、同等の操作で確認してください。

以下のコードでbrewのインストール状況を確認します。

brew --version

Homebrewがまだインストールされていない場合は、Homebrewの公式インストールページに従ってインストールしてください。

以下のコードでTerraformのインストール状況を確認します。

terraform version

Terraformがまだインストールされていない場合は、以下のコードでインストールします。

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

以下のコードでPythonのインストール状況を確認します。

python3 --version

Pythonがまだインストールされていない場合は、以下のコードを参考にインストールします。

brew install python@3.11

Python 3.11を現在のターミナルのPATHに追加します。

export PATH="$(brew --prefix python@3.11)/bin:$PATH"
python3.11 --version

Pythonの実行環境と依存ライブラリを設定します。

python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python run_upgrade.py --help

同じzshターミナルで、キーを対話形式で入力します。以下の方法では、Secretがコマンド履歴に直接記録されることはありませんが、現在のプロセスの環境には存在します。CLIを閉じて再起動した場合は、キーを再入力する必要があります。

set +x
read -r "ALICLOUD_ACCESS_KEY?RAM AccessKey ID: "
read -r -s "ALICLOUD_SECRET_KEY?RAM AccessKey Secret: "
printf '\n'
export ALICLOUD_ACCESS_KEY ALICLOUD_SECRET_KEY

export ALIBABA_CLOUD_ACCESS_KEY_ID="$ALICLOUD_ACCESS_KEY"
export ALIBABA_CLOUD_ACCESS_KEY_SECRET="$ALICLOUD_SECRET_KEY"
unset ALICLOUD_SECURITY_TOKEN ALIBABA_CLOUD_SECURITY_TOKEN

export TF_VAR_region_id="ap-northeast-1"
export TF_VAR_name_prefix="pl-upgrade-blog"

Section 4:操作手順と受け入れ確認

4.1 2台のECSと4個のPL0ディスクを作成

terraform init
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
terraform output -json test | tee terraform-output.json

planには、PayAsYouGoかつNoSpotのECSが2台、追加データディスクが2個、ならびにVPC、VSwitch、セキュリティグループ、OOSロール、ポリシー、および関連付けが表示される必要があります。インスタンスにはインターネット向けの送信帯域幅を設定せず、セキュリティグループには受信ルールを追加しません。費用面の安全策として、ECSは約4時間後に自動リリースされるように設定しますが、これはterraform destroyの代わりにはなりません。

自動選定では、各ゾーンで小規模なECSインスタンスタイプを検索し、カタログ価格が最も安いものを選んで作成します。NO_TYPEが返された場合や、注文時に在庫がない場合は、コンソールで現在利用可能なゾーンとインスタンス仕様を確認し、明示的に指定してから再度planを行ってください。

正常に実行されると、以下の出力結果が得られます。ここにある情報は後で使用するので、メモしておいてください。

test = {
  "account_id" = "5851XXXXXXXXX374"
  "data_disk_ids" = [
    "d-6we74XXXXXXXXXiw",
    "d-6we1zXXXXXXXXXyhft",
  ]
  "instance_ids" = [
    "i-6weXXXXXXXXX8ltny",
    "i-6weXXXXXXXXXjdu5v",
  ]
  "instance_type" = "ecs.e-c1m1.large"
  "oos_role" = "pl-upgrade-blog-oos-role"
  "region_id" = "ap-northeast-1"
  "system_disk_ids" = [
    "d-6we1XXXXXXXXX1gywo",
    "d-6we5XXXXXXXXX3dandy",
  ]
  "zone_id" = "ap-northeast-1a"
}

参考画像3:以下の図のように2台のECSが作成され、それぞれにPL0のシステムディスク1個とPL0のデータディスク1個がアタッチされています。

参考画像3:以下の図のように2台のECSが作成され、それぞれにPL0のシステムディスク1個とPL0のデータディスク1個がアタッチされています。

参考画像3:以下の図のように2台のECSが作成され、それぞれにPL0のシステムディスク1個とPL0のデータディスク1個がアタッチされています。

4.2 実験A:インスタンスIDだけを指定し、アタッチされたすべてのディスクをアップグレード

まず、--executeを付けずに事前チェックを実行します。(太字の部分を置き換えてください)

python run_upgrade.py --region "$REGION" \
  --instance-ids "$I1" "$I2" \
  --oos-role "$ROLE" \
  --output all-preview.json

期待される結果はresult=PRECHECK_ONLY、precheckPassed=trueで、selectedDiskIdsに4個のディスクが含まれることです。DryRunに成功すると、APIは公式の規定に従ってDryRunOperationを返し、ディスクは変更されません。

選択されたディスクに間違いがないことを確認してから、明示的に実行します。(太字の部分を置き換え、executeパラメーターを追加してください)

python run_upgrade.py --region "$REGION" \
  --instance-ids "$I1" "$I2" \
  --oos-role "$ROLE" \
  --execute \
  --output all-execute.json

プログラムはOOSが最終状態に達するまで待機し、ディスクごとのループタスクを監査した後、4個のディスクの情報を再取得します。受け入れ確認では、以下の条件をすべて同時に満たす必要があります。

result == SUCCESS
oosStatus == Success
taskAudit.passed == true
verification.passed == true

参考画像4:以下の図のように、2台のECSのシステムディスクとデータディスクがすべてPL1にアップグレードされています。

参考画像4:以下の図のように、2台のECSのシステムディスクとデータディスクがすべてPL1にアップグレードされています。

参考画像4:以下の図のように、2台のECSのシステムディスクとデータディスクがすべてPL1にアップグレードされています。

4.3 環境を破棄し、新しいPL0環境を再構築

従量課金のESSDはPL0にダウングレードできません。実験Bでは、まず実験Aの環境をクリーンアップしてから、まったく新しいインスタンスとディスクを作成する必要があります。

terraform plan -destroy -out=destroy-a
terraform apply destroy-a
terraform state list

実験B用のテスト環境を再作成します。

terraform plan -out=tfplan-b
terraform apply tfplan-b
terraform output -json test | tee terraform-output-b.json

4.4 実験B:ECS1のシステムディスクとECS2のデータディスクだけをアップグレード

事前チェックと実行を行います。(太字の部分を置き換えてください)

python run_upgrade.py --region "$REGION" \
  --instance-ids "$I1" "$I2" \
  --disk-ids "$S1" "$D2" \
  --oos-role "$ROLE" \
  --output selected-preview.json

python run_upgrade.py --region "$REGION" \
  --instance-ids "$I1" "$I2" \
  --disk-ids "$S1" "$D2" \
  --oos-role "$ROLE" \
  --execute \
  --output selected-execute.json

私のテストで期待する結果は、ECS1のシステムディスクとECS2のデータディスクがPL1になり、ECS1のデータディスクとECS2のシステムディスクはPL0のままであることです。

注意:実行するたびに新しい--outputパスを使用してください。プログラムはファイルがすでに存在することを検出すると、エビデンスの上書きを防ぐために起動を拒否します。

参考画像5:以下の図のように、ECS1のシステムディスクとECS2のデータディスクがPL1にアップグレードされています。

参考画像5:以下の図のように、ECS1のシステムディスクとECS2のデータディスクがPL1にアップグレードされています。

参考画像5:以下の図のように、ECS1のシステムディスクとECS2のデータディスクがPL1にアップグレードされています。

Section 5:テスト環境と認証情報のクリーンアップ

OOSが最終状態に達していることを確認した後、元のディレクトリで以下を実行します。

terraform plan -destroy -out=destroy-plan
terraform apply destroy-plan
terraform state list

完了後、コンソールでECS、データディスク、VPC、VSwitch、セキュリティグループ、OOSサービスロール、およびカスタムポリシーが残っていないことを確認します。また、必要に応じて、実験で使用したRAMアカウント、権限ポリシー、またはAccessKeyの情報をコンソールで削除します。

参考画像6:以下の図のように、ECSなどのテストリソースはすべて削除されています。

参考画像6:以下の図のように、ECSなどのテストリソースはすべて削除されています。

最後に、現在のターミナルの変数(AccessKeyなど)をクリアします。

unset ALICLOUD_ACCESS_KEY ALICLOUD_SECRET_KEY ALICLOUD_SECURITY_TOKEN
unset ALIBABA_CLOUD_ACCESS_KEY_ID ALIBABA_CLOUD_ACCESS_KEY_SECRET
unset ALIBABA_CLOUD_SECURITY_TOKEN
unset TF_VAR_region_id TF_VAR_name_prefix TF_VAR_zone_id
unset TF_VAR_instance_type TF_VAR_image_id

Section 6:今回の取り組みで得られた成果

今回の実践の価値は、ECSディスクの性能レベルを一括で変更する方法を見つけたことだけではありません。さらに重要なのは、技術サポートの提供方法を一歩前進させたことです。これまでは、同様の要望に対して、私たちは通常、TerraformやOOSなどの選択肢となるツールを紹介し、調査、組み合わせ、および検証はお客様自身に行っていただいていました。今回はさらに踏み込み、お客様の課題を、テスト環境の構築、権限の準備、リソースのデプロイから、変更前のチェック、一括実行、結果の検証、トラブルシューティング、環境のクリーンアップまでを含む、完全なエンドツーエンドのテストソリューションへと具体化しました。同時に、直接参考にできるTerraform設定、Python実行プログラム、および操作ドキュメントも提供しました。

このような提供方法によって、お客様が「製品の機能を理解する」段階から「実際にソリューションを検証する」段階へ進むまでの距離が短くなりました。お客様は、異なる製品間の関係をゼロから調べる必要がなく、明確な操作手順に沿って素早く検証を完了し、ソリューションの実装の仕組み、適用範囲、およびリスクを管理する方法を理解できます。そのうえで、お客様自身の環境に合わせて、リソース選択、承認フロー、同時実行制御、監視とアラート、監査記録の保存などの機能をさらに拡張することもできます。こうして、今回のサンプルを、今後の運用自動化体制の構築に向けた出発点にできます。

AI Agentは、この過程全体で重要な役割を果たしました。要件の分解、アーキテクチャの設計、TerraformとPythonの参考コードの生成、テストシナリオの設計、エラーの原因特定、および技術記事の整理を支援しました。もともとは複数の製品ドキュメント、コードのバージョン、および検証結果を何度も行き来する必要があった作業が、一続きの提供プロセスに統合され、エンジニアは事実の確認、リスクの判断、およびお客様とのコミュニケーションにより多くの時間を使えるようになりました。

このような効率向上は、必要な人による判断を省略することを意味しません。むしろ、AI Agentが大量の反復的な検索、整理、および初期生成作業を担う一方で、重要な権限の境界、実行リスク、クラウド上でのテスト結果、およびお客様への適合性は、引き続きエンジニア(TAM)がレビューして確認します。人とAIの協働によって、提供のスピードと内容の網羅性が同時に向上し、単発の技術相談も、再利用可能な実践資産として蓄積しやすくなりました。

今回の実践は、技術的な成果物の提供そのものを超える価値ももたらしました。お客様はソリューションが形になるまでの過程を知った後、Alibaba CloudのAI Agentの活用方法に強い関心を持ち、利用の詳細や関連する見積もりについて理解を深めるため、テーマを絞った意見交換の場を設けたいと希望されました。これは、ディスクの運用自動化という1つの要望が、さらに新たなAI Agentの活用シナリオや潜在的なビジネス機会へと広がったことを意味します。

社内チームにとっても、この成果はTAMチームと営業チームの間の信頼を強めました。TAMはお客様が現在抱える技術的な問題を解決しただけでなく、実行可能で、検証可能かつ拡張可能なソリューションを通じて、営業チームが新たなお客様のニーズを見いだすことも支援しました。一方、営業チームは実際の実践に基づいて、お客様と、よりニーズに即した今後の協力について話し合うことができます。

このように、今回の取り組みは複数の成果をもたらしました。お客様は、より効率的で実行に移しやすい技術サポートを得ました。TAMチームは、再利用可能なソリューション資産を蓄積しました。営業チームは、新たな協力の機会を見いだしました。一見すると個別の運用要望だったものが、最終的にはカスタマーサクセス、チーム間の連携、および事業拡大をつなぎ、手動運用から運用自動化、そしてAI活用に至る、効率化の進化の全体像を示しました。


英語版はこちらをご参照ください:From Manual Operations to Automated Orchestration and AI Empowerment: The Evolution of Efficiency in ECS Disk Management

Section 7:完全な参考コード

最小限の再現用ディレクトリに含まれるファイルは、次の3個だけです。

.
├── main.tf
├── requirements.txt
└── run_upgrade.py

    ● main.tfは、テストリソースとOOSロールを作成します。

    ● requirements.txtは、ECS/OOS SDKのバージョンを固定し、ほかの依存ライブラリにはバージョンの範囲を設定します。

    ● run_upgrade.pyは、事前チェック、実行、監査、およびエビデンスの出力を行います。

    ● 以下の3つのコードブロックを、それぞれ対応するファイルとして実行ディレクトリに保存してください。

7.1 main.tf

terraform {
  required_version = ">= 1.5.0, < 2.0.0"
  required_providers {
    alicloud = {
      source  = "aliyun/alicloud"
      version = "= 1.294.0"
    }
  }
}

# Credentials are supplied by the RAM user, never stored in this file.
provider "alicloud" {
  region = var.region_id
}

variable "region_id" {
  description = "Test region, for example ap-northeast-1. Do not use a zone ID such as ap-northeast-1a."
  type        = string
}
variable "name_prefix" {
  description = "Unique resource prefix; keep it within the administrator-approved RAM policy scope."
  type        = string
  default     = "pl-upgrade-blog"
  validation {
    condition     = can(regex("^pl-upgrade-blog(-[a-z0-9]+)?$", var.name_prefix)) && length(var.name_prefix) <= 32
    error_message = "Use pl-upgrade-blog or pl-upgrade-blog-<lowercase-alphanumeric-suffix>, up to 32 characters."
  }
}
variable "zone_id" {
  description = "Optional availability zone; required with an explicit instance type."
  type        = string
  default     = ""
}
variable "instance_type" {
  description = "Optional ESSD-compatible instance type; set zone_id together with this value."
  type        = string
  default     = ""
}
variable "image_id" {
  description = "Optional x86_64 Linux image ID; otherwise use the latest Alibaba Cloud Linux 3 image."
  type        = string
  default     = ""
}

data "alicloud_account" "current" {}
data "alicloud_zones" "eligible" {
  available_resource_creation = "VSwitch"
  available_disk_category     = "cloud_essd"
}
data "alicloud_images" "linux" {
  count       = var.image_id == "" ? 1 : 0
  owners      = "system"
  name_regex  = "^aliyun_3_x64_20G_alibase_.*"
  most_recent = true
}

# Compare catalog prices, not real-time inventory, across five small shapes.
locals {
  image = var.image_id != "" ? var.image_id : data.alicloud_images.linux[0].images[0].id
  zones = var.zone_id != "" ? [var.zone_id] : [for z in data.alicloud_zones.eligible.zones : z.id]
  queries = {
    for q in flatten([
      for z in local.zones : [
        for shape in [[1, 1], [1, 2], [2, 2], [2, 4], [2, 8]] : {
          key = "${z}-${shape[0]}-${shape[1]}", zone = z, cpu = shape[0], memory = shape[1]
        }
      ]
    ]) : q.key => q
  }
  tags = { ManagedBy = "Terraform", Purpose = "essd-pl0-to-pl1", TestGroup = var.name_prefix }
}
data "alicloud_instance_types" "small" {
  for_each             = var.instance_type == "" ? local.queries : {}
  availability_zone    = each.value.zone
  cpu_core_count       = each.value.cpu
  memory_size          = each.value.memory
  instance_charge_type = "PostPaid"
  system_disk_category = "cloud_essd"
  image_id             = local.image
  sorted_by            = "Price"
}
locals {
  prices = sort(flatten([
    for key, result in data.alicloud_instance_types.small : [
      for item in result.instance_types :
      format("%020.8f|%s|%s", tonumber(item.price), local.queries[key].zone, item.id)
    ]
  ]))
  cheapest = split("|", try(local.prices[0], "0|NO_ZONE|NO_TYPE"))
  zone     = var.instance_type != "" ? var.zone_id : local.cheapest[1]
  type     = var.instance_type != "" ? var.instance_type : local.cheapest[2]
}

# Exactly two PayAsYouGo instances; each starts with two 20-GiB PL0 disks.
resource "alicloud_vpc" "test" {
  vpc_name   = "${var.name_prefix}-vpc"
  cidr_block = "172.31.0.0/16"
  tags       = local.tags
}
resource "alicloud_vswitch" "test" {
  vswitch_name = "${var.name_prefix}-vsw"
  vpc_id       = alicloud_vpc.test.id
  zone_id      = local.zone
  cidr_block   = "172.31.1.0/24"
  tags         = local.tags
}
resource "alicloud_security_group" "test" {
  security_group_name = "${var.name_prefix}-sg"
  description         = "Isolated disk-upgrade test; no inbound rules"
  vpc_id              = alicloud_vpc.test.id
  security_group_type = "normal"
  tags                = local.tags
}
resource "alicloud_instance" "test" {
  count                         = 2
  instance_name                 = "${var.name_prefix}-ecs-${count.index + 1}"
  availability_zone             = local.zone
  image_id                      = local.image
  instance_type                 = local.type
  instance_charge_type          = "PostPaid"
  spot_strategy                 = "NoSpot"
  status                        = "Running"
  vswitch_id                    = alicloud_vswitch.test.id
  security_groups               = [alicloud_security_group.test.id]
  internet_max_bandwidth_out    = 0
  system_disk_category          = "cloud_essd"
  system_disk_performance_level = "PL0"
  system_disk_size              = 20
  auto_release_time             = timeadd(timestamp(), "4h")
  tags                          = local.tags
  volume_tags                   = local.tags
  lifecycle {
    # OOS owns the PL change; do not try to downgrade it on a later apply.
    ignore_changes = [auto_release_time, system_disk_performance_level]
    precondition {
      condition     = local.type != "NO_TYPE" && (var.instance_type == "" || var.zone_id != "")
      error_message = "No small ESSD-compatible candidate found, or zone_id is missing. Set zone_id and instance_type explicitly."
    }
  }
}
resource "alicloud_ecs_disk" "data" {
  count                = 2
  disk_name            = "${var.name_prefix}-data-${count.index + 1}"
  zone_id              = local.zone
  category             = "cloud_essd"
  performance_level    = "PL0"
  size                 = 20
  payment_type         = "PayAsYouGo"
  delete_with_instance = true
  multi_attach         = "Disabled"
  tags                 = local.tags
  lifecycle {
    ignore_changes = [performance_level]
  }
}
resource "alicloud_ecs_disk_attachment" "data" {
  count                = 2
  disk_id              = alicloud_ecs_disk.data[count.index].id
  instance_id          = alicloud_instance.test[count.index].id
  delete_with_instance = true
}

# OOS may assume this service role. The role may modify only the four test disks.
resource "alicloud_ram_role" "oos" {
  role_name = "${var.name_prefix}-oos-role"
  assume_role_policy_document = jsonencode({
    Version   = "1"
    Statement = [{ Effect = "Allow", Action = "sts:AssumeRole", Principal = { Service = ["oos.aliyuncs.com"] } }]
  })
  description = "Temporary service role for the ESSD PL test"
}
resource "alicloud_ram_policy" "oos" {
  policy_name = "${var.name_prefix}-oos-disk-upgrade"
  policy_document = jsonencode({
    Version = "1"
    Statement = [{
      Effect = "Allow", Action = ["ecs:ModifyDiskSpec"]
      Resource = [for id in concat(alicloud_instance.test[*].system_disk_id, alicloud_ecs_disk.data[*].id) :
      "acs:ecs:${var.region_id}:${data.alicloud_account.current.id}:disk/${id}"]
    }]
  })
  description = "ModifyDiskSpec on the four Terraform-managed disks only"
}
resource "alicloud_ram_role_policy_attachment" "oos" {
  role_name   = alicloud_ram_role.oos.role_name
  policy_name = alicloud_ram_policy.oos.policy_name
  policy_type = alicloud_ram_policy.oos.type
}

# One JSON output supplies every launcher argument without console lookups.
output "test" {
  value = {
    account_id      = data.alicloud_account.current.id
    region_id       = var.region_id
    zone_id         = local.zone
    instance_type   = local.type
    instance_ids    = alicloud_instance.test[*].id
    system_disk_ids = alicloud_instance.test[*].system_disk_id
    data_disk_ids   = alicloud_ecs_disk.data[*].id
    oos_role        = alicloud_ram_role.oos.role_name
  }
  depends_on = [alicloud_ecs_disk_attachment.data, alicloud_ram_role_policy_attachment.oos]
}

7.2 requirements.txt

alibabacloud-credentials>=1.0.2,<2.0.0
alibabacloud-ecs20140526==7.11.6
alibabacloud-oos20190601==3.7.0
alibabacloud-tea-openapi>=0.3.12,<1.0.0
alibabacloud-tea-util>=0.3.13,<1.0.0
urllib3>=1.26,<2.0

7.3 run_upgrade.py

#!/usr/bin/env python3
"""Batch-upgrade attached ESSD PL0 disks through OOS; preview is the default.

Use RAM credentials. Instance IDs select all attached disks unless --disk-ids
is supplied. No local configuration file is required. Every --execute run
waits, audits the modification tasks, and verifies selected/unselected disks.
"""
from __future__ import annotations

import argparse
import json
import sys
import time
import uuid
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path

from Tea.exceptions import TeaException
from alibabacloud_credentials.client import Client as Credentials
from alibabacloud_ecs20140526.client import Client as ECS
from alibabacloud_ecs20140526 import models as em
from alibabacloud_oos20190601.client import Client as OOS
from alibabacloud_oos20190601 import models as om
from alibabacloud_tea_openapi import models as api
from alibabacloud_tea_util.models import RuntimeOptions

TEMPLATE = "ACS-ECS-BulkyModifyDiskSpec"
TERMINAL = {"Success", "Failed", "Cancelled", "Terminated", "TimedOut"}
FIELDS = ("disk_id", "instance_id", "type", "category", "performance_level",
          "size", "status", "multi_attach")


def client(cls, service, region):
    """Use the default Credentials chain, with bounded network timeouts."""
    cfg = api.Config(credential=Credentials(), connect_timeout=10000, read_timeout=30000)
    cfg.endpoint = f"{service}.{region}.aliyuncs.com"
    return cls(cfg)


def error(exc):
    data = getattr(exc, "data", None) or {}
    code = getattr(exc, "code", None) or getattr(exc, "error_code", None)
    if not code and isinstance(data, dict):
        code = data.get("Code") or data.get("code")
    return {"code": str(code or type(exc).__name__), "message": str(getattr(exc, "message", None) or exc)}


def facts(disk):
    return {key: getattr(disk, key, None) for key in FIELDS}


def attached_disks(ecs, region, instances):
    """Read every disk page; truncation would violate all-attached selection."""
    result = {}
    for instance in instances:
        page = 1
        while True:
            req = em.DescribeDisksRequest(region_id=region, instance_id=instance, page_number=page, page_size=100)
            body = ecs.describe_disks_with_options(req, RuntimeOptions()).body
            rows = getattr(getattr(body, "disks", None), "disk", None) or []
            for row in rows:
                if row.disk_id in result:
                    raise RuntimeError("Duplicate disk in discovery; selection is ambiguous")
                result[row.disk_id] = facts(row)
            if len(rows) < 100:
                break
            page += 1
    return result


def select(disks, requested):
    """Fail closed: explicit disks must belong to one of the supplied instances."""
    missing = sorted(set(requested) - set(disks))
    if missing:
        raise ValueError(f"Specified disks are not attached to the supplied instances: {missing}")
    selected = list(dict.fromkeys(requested)) if requested else sorted(disks)
    if not selected:
        raise ValueError("No attached disks found")
    return selected


def eligibility(disk):
    checks = {
        "type must be system or data": disk["type"] in ("system", "data"),
        "category must be cloud_essd": disk["category"] == "cloud_essd",
        "performance level must be PL0": disk["performance_level"] == "PL0",
        "capacity must be at least 20 GiB": isinstance(disk["size"], (int, float)) and disk["size"] >= 20,
        "disk must be In_use": disk["status"] == "In_use",
        "multi-attach must be disabled": disk["multi_attach"] in ("Disabled", "disabled", False),
    }
    return [message for message, passed in checks.items() if not passed]


def dry_run(ecs, disk_id):
    """DryRunOperation is the expected success exception, not a normal 2xx."""
    req = em.ModifyDiskSpecRequest(disk_id=disk_id, disk_category="cloud_essd", performance_level="PL1", dry_run=True)
    try:
        ecs.modify_disk_spec_with_options(req, RuntimeOptions())
        return {"passed": False, "code": "UnexpectedSuccess"}
    except TeaException as exc:
        detail = error(exc)
        return {"passed": detail["code"] == "DryRunOperation", **detail}
    except Exception as exc:
        return {"passed": False, **error(exc)}


def loop_item(value):
    if value is None or not str(value).strip():
        return None
    text = str(value).strip()
    try:
        decoded = json.loads(text)
        return decoded if isinstance(decoded, str) else text
    except json.JSONDecodeError:
        return text


def task_rows(oos, region, execution):
    """Retain child tasks and follow every token without accepting token cycles."""
    rows, token, seen = [], None, set()
    while True:
        req = om.ListTaskExecutionsRequest(region_id=region, execution_id=execution,
                                          include_child_task_execution=True, max_results=100, next_token=token)
        body = oos.list_task_executions(req).body
        rows.extend({"taskName": row.task_name, "taskAction": row.task_action,
                     "loopItem": loop_item(row.loop_item), "status": row.status,
                     "statusMessage": row.status_message}
                    for row in (body.task_executions or []))
        token = body.next_token
        if not token:
            return rows
        if token in seen:
            raise RuntimeError("Repeated OOS task pagination token")
        seen.add(token)


def audit(rows, selected):
    """The public template has one modifyDiskSpec/ACS::ExecuteApi loop per disk."""
    iterations = [r for r in rows if r["loopItem"] and r["taskName"] == "modifyDiskSpec"
                  and r["taskAction"] == "ACS::ExecuteApi"]
    counts = Counter(r["loopItem"] for r in iterations)
    expected = set(selected)
    missing, unexpected = sorted(expected - set(counts)), sorted(set(counts) - expected)
    duplicates = {key: count for key, count in counts.items() if count != 1}
    failed = [r for r in iterations if r["status"] != "Success"]
    return {"passed": not (missing or unexpected or duplicates or failed), "expectedDiskIds": selected,
            "missingDiskIds": missing, "unexpectedDiskIds": unexpected, "duplicates": duplicates,
            "failedTasks": failed, "observedLoopItems": [r["loopItem"] for r in iterations]}


def poll(fetch, ready, timeout, interval):
    """Bounded polling; incomplete API visibility never counts as success."""
    deadline = time.monotonic() + timeout
    while True:
        value = fetch()
        if ready(value):
            return value
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            return value
        time.sleep(min(interval, remaining))


def verification(current, baseline, selected):
    """Check PL1 and unchanged identity/category/size/attachment for every disk."""
    target, unselected = [], []
    selected = set(selected)
    for disk_id, before in baseline.items():
        after = current.get(disk_id)
        desired = "PL1" if disk_id in selected else before["performance_level"]
        stable = bool(after and all(after[k] == before[k] for k in FIELDS if k != "performance_level"))
        row = {"diskId": disk_id, "expectedLevel": desired, "before": before, "after": after,
               "passed": bool(stable and after["performance_level"] == desired)}
        (target if disk_id in selected else unselected).append(row)
    unexpected = sorted(set(current) - set(baseline))
    missing = sorted(set(baseline) - set(current))
    return {"selected": target, "unselected": unselected,
            "unexpectedDiskIds": unexpected, "missingDiskIds": missing,
            "passed": not (unexpected or missing) and all(r["passed"] for r in target + unselected)}


def save(report, path):
    if path:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")


def run(args, report):
    # Validate instances and capture the complete baseline before any mutation.
    instances = list(dict.fromkeys(args.instance_ids))
    ecs = client(ECS, "ecs", args.region)
    req = em.DescribeInstancesRequest(region_id=args.region, instance_ids=json.dumps(instances), page_size=100)
    body = ecs.describe_instances_with_options(req, RuntimeOptions()).body
    found = {r.instance_id: r.status for r in (getattr(getattr(body, "instances", None), "instance", None) or [])}
    if set(found) != set(instances) or any(s not in ("Running", "Stopped") for s in found.values()):
        raise ValueError("An instance is missing, unauthorized, or not Running/Stopped")
    baseline = attached_disks(ecs, args.region, instances)
    selected = select(baseline, args.disk_ids or [])
    report.update(instanceIds=instances, selectedDiskIds=selected, baseline=baseline,
                  selectionMode="explicit-disks" if args.disk_ids else "all-attached-disks")
    checks = []
    for disk_id in selected:
        failures = eligibility(baseline[disk_id])
        check = {"diskId": disk_id, "eligibilityFailures": failures,
                 "dryRun": None if failures else dry_run(ecs, disk_id)}
        checks.append(check)
    report["prechecks"] = checks
    report["precheckPassed"] = all(not c["eligibilityFailures"] and c["dryRun"]["passed"] for c in checks)
    if not report["precheckPassed"]:
        report["result"] = "BLOCKED_BY_PRECHECK"
        return 2
    if not args.execute:
        report["result"] = "PRECHECK_ONLY"
        return 0

    # Protect the precheck/submit gap and record the idempotency token before start.
    if attached_disks(ecs, args.region, instances) != baseline:
        raise RuntimeError("Disk inventory changed after prechecks; execution blocked")
    oos = client(OOS, "oos", args.region)
    token = str(uuid.uuid4())
    parameters = {"regionId": args.region, "diskIds": selected, "performanceLevel": "PL1",
                  "diskCategory": "cloud_essd", "OOSAssumeRole": args.oos_role,
                  "rateControl": {"Mode": "Concurrency", "MaxErrors": 0,
                                  "Concurrency": min(args.concurrency, len(selected))}}
    report.update(stage="START_OOS", clientToken=token, parameters=parameters, result="START_PENDING")
    save(report, args.output)
    req = om.StartExecutionRequest(region_id=args.region, template_name=TEMPLATE, mode="Automatic",
                                   loop_mode="Automatic", safety_check="Skip", client_token=token,
                                   parameters=json.dumps(parameters))
    started = oos.start_execution(req).body.execution
    execution_id = getattr(started, "execution_id", None)
    if not execution_id:
        raise RuntimeError("StartExecution returned no execution ID; inspect OOS before retrying")
    report.update(executionId=execution_id, stage="WAIT_OOS", result="OOS_STARTED")
    save(report, args.output)

    def status():
        # ListExecutions requires MaxResults between 10 and 100, even with an execution ID filter.
        request = om.ListExecutionsRequest(region_id=args.region, execution_id=execution_id, max_results=10)
        rows = oos.list_executions(request).body.executions or []
        return {"status": rows[0].status, "message": rows[0].status_message} if rows else {"status": "NotVisible"}

    state = poll(status, lambda s: s["status"] in TERMINAL, args.timeout, args.interval)
    report.update(oosStatus=state["status"], oosMessage=state.get("message"), stage="AUDIT_AND_VERIFY")
    # Allow completed task evidence to become visible, but keep strict auditing.
    rows = poll(lambda: task_rows(oos, args.region, execution_id),
                lambda r: audit(r, selected)["passed"], min(60, args.timeout), args.interval)
    report.update(taskExecutions=rows, taskAudit=audit(rows, selected))
    final = poll(lambda: verification(attached_disks(ecs, args.region, instances), baseline, selected),
                 lambda v: v["passed"], args.timeout if state["status"] == "Success" else 0, args.interval)
    report["verification"] = final
    if state["status"] != "Success":
        report["result"] = "OOS_FAILED" if state["status"] in TERMINAL else "OOS_WAIT_TIMEOUT"
        return 3
    if not report["taskAudit"]["passed"]:
        report["result"] = "TASK_AUDIT_FAILED"
        return 4
    if not final["passed"]:
        report["result"] = "DISK_VERIFICATION_FAILED"
        return 5
    report.update(result="SUCCESS", stage="COMPLETED")
    return 0


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--region", required=True)
    parser.add_argument("--instance-ids", nargs="+", required=True)
    parser.add_argument("--disk-ids", nargs="+", help="Only these attached disks; otherwise all attached disks")
    parser.add_argument("--oos-role", required=True, help="Terraform-created OOS service role name")
    parser.add_argument("--execute", action="store_true", help="Modify, wait, audit, and verify")
    parser.add_argument("--concurrency", type=int, default=2)
    parser.add_argument("--timeout", type=int, default=1800)
    parser.add_argument("--interval", type=int, default=10)
    parser.add_argument("--output", type=Path, help="Optional JSON evidence path")
    args = parser.parse_args()
    if min(args.concurrency, args.timeout, args.interval) < 1 or len(set(args.instance_ids)) > 100:
        parser.error("Positive concurrency/timeout/interval and at most 100 unique instance IDs are required")
    if args.output and args.output.exists():
        parser.error("Evidence file already exists; choose a new --output path")
    report = {"generatedAt": datetime.now(timezone.utc).isoformat(), "regionId": args.region,
              "templateName": TEMPLATE, "executeRequested": args.execute, "stage": "PRECHECK"}
    try:
        code = run(args, report)
    except Exception as exc:
        report.update(result="ERROR", error=error(exc))
        code = 1
    try:
        save(report, args.output)
    except Exception as exc:
        # Preserve stdout evidence even when the destination is not writable.
        report.update(result="ERROR", evidenceSaveError=error(exc))
        code = 1
    print(json.dumps(report, indent=2))
    return code


if __name__ == "__main__":
    sys.exit(main())
0 0 0
Share on

Wei Ying

4 posts | 0 followers

You may also like

Comments

Wei Ying

4 posts | 0 followers

Related Products

  • QwenWork

    QwenWork is dedicated to helping employees strengthen their professional competitiveness in the AI era and to enabling enterprises to improve organizational effectiveness.

    Learn More
  • AgentCore

    AgentCore unifies agent construction, governance, and execution capabilities so enterprises can deploy diverse agents with confidence.

    Learn More
  • Qoder

    From Assisted to Autonomous — Adapting to Every Programming Need

    Learn More
  • AgentBay

    Multimodal cloud-based operating environment and expert agent platform, supporting automation and remote control across browsers, desktops, mobile devices, and code.

    Learn More