0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CDKでSSM Command Documentの実行完了を待つ方法(カスタムリソース)

Last updated at Posted at 2025-08-25

はじめに

仕事で,Active Directoryを作り,ドメイン参加済み・設定済みのWindowsを大量に立てる仕組みを作る必要がありました。
まったく同じ構成で何度も作成→破棄を繰り返す必要があるので,CDKでManaged Microsoft ADとEC2 インスタンスを作り,SSMのCommand Documentでインスタンスの初期設定をします。

CDKでSSMのCommand Documentを作成・実行するためには,以下のL1 Construct が利用できます:

  • CfnDocument:Command Document作成
  • CfnAssociation:インスタンスとCommand Documentの関連付け(これでCommand Documentが実行される)

とても便利なのですが,これだけだと以下のことができません:

  • あるインスタンスでCommand Documentが実行されたことを待機したのち,別のインスタンスで別のCommand Documentを実行したい

たとえば,あるインスタンスをOUを指定して Active Directory に参加させたいとします。このためには事前にOUを作成している必要があります。
ドメインの管理を行うために建てた管理インスタンスでドメインにOUを作成する設計が多いと思いますが,CDKで Command DocumentとAssociationを作るだけでは,Command Documentの実行完了を待機できず,存在しないOUに参加しようとすることになってしまいます。

この記事は,SSM のAssociationの実行が成功したかどうかポーリングするカスタムリソースを作ることで,これを実現するためのものです。

うごくのですが,スマートではないと思います。もっと良い方法をご存知のかたいらっしゃったらぜひ教えてください...

(追記)
この記事を書いていて見つけたのですが,CfnWaitConditionを使うのがベターですね。

  • CfnWaitConditionHandle を作る
  • CfnWaitConditionを作る
  • SSMのCommand Document で cfn-signal する

カスタムリソースの作成

カスタムリソースの作り方を分かりやすくまとめてくださっているので,参考にします。

カスタムリソースの定義はこんな感じです。
lambdaでAssociationの状態をポーリングするので,タイムアウトは最大の900秒(15分)にしておきます。
ポーリングではSSMの DescribeAssociation APIを使うので,ポリシーステートメントで有効にしておきます。

construct/ssm-wait-association-execution.ts
import { CustomResource, Duration } from "aws-cdk-lib";
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Provider } from "aws-cdk-lib/custom-resources";
import { Construct } from "constructs";
import path from "path";

export interface WaitAssociationExecutionProps {
    readonly associationId: string
}

export class WaitAssociationExecution extends Construct {
    constructor(scope: Construct, id: string, props: WaitAssociationExecutionProps) {
        super(scope, id);

        const policy = new PolicyStatement({
            effect: Effect.ALLOW,
            actions: [
                "ssm:DescribeAssociation",
            ],
            resources: ["*"],
        });

        const lambda = new NodejsFunction(this, "EventHandler", {
            runtime: Runtime.NODEJS_22_X,
            entry: path.join(__dirname, "./src/index.ts"),
            depsLockFilePath: path.resolve(__dirname, "./src/package-lock.json"),
            handler: "handler",
            initialPolicy: [policy],
            timeout: Duration.seconds(900),
        });

        const provider = new Provider(this, "Provider", {
            onEventHandler: lambda,
        });

        new CustomResource(this, "CustomResource", {
            serviceToken: provider.serviceToken,
            properties: props,
        });
    }
}

lambdaの実装はこんな感じで,リソースがCreateされる時だけAssociationの状態をポーリングします。

900秒を超えて動き続ける実装にすると,CDK側ではCREATE_IN_PROGRESSで止まり,lambdaはkillされる挙動になったので,ここでは840秒で失敗するようにしています。

construct/src/index.ts
import { CdkCustomResourceHandler } from "aws-lambda";
import { DescribeAssociationCommand, SSMClient } from "@aws-sdk/client-ssm";

export interface WaitAssociationExecutionProps {
    readonly associationId: string
}

const client = new SSMClient();
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));

export const handler: CdkCustomResourceHandler = async (event) => {
    console.log(event);
    if (event.RequestType != "Create") {
        return {};
    }

    const props: WaitAssociationExecutionProps = {
        associationId: event.ResourceProperties.associationId,
    };

    const command = new DescribeAssociationCommand({
        AssociationId: props.associationId,
    });

    var timeLeft = 840000;
    while (true) {
        if (timeLeft <= 0) {
            throw new Error("Failed")
        }

        const result = await client.send(command);

        const status = result.AssociationDescription?.Overview?.Status ?? "None";
        console.log("status: " + status);
        if (status == "Success") {
            break;
        } else if (status == "Failed") {
            throw new Error("Failed")
        }
        
        await sleep(10000);
        timeLeft -= 10000;
    }

    return {};
};

CDKで使うときはこんな感じになります。
他リソースは,waiterdependsOn()することでドキュメント実行を待てるようになります。

const document = new cdk.aws_ssm.CfnDocument(this, "Document", {
    ...
});

const association = new cdk.aws_ssm.CfnAssociation(this, "Association", {
    name: document.ref,

    ...
});

const waiter = new WaitAssociationExecution(this, "Waiter", {
  associationId: assoc.ref
});

おわりに

手元では,一応ちゃんと動いているようです。
ここまでできれば,cdk-skylight のように抽象化してきれいにまとめられたらいいなと思います。

没ネタ1

SSMではなくcfn-initを使って初期設定することでも同じことが実現できます。ただcfn-initで色々な処理をするのはなかなかつらい... です。

  • ドメイン参加用のクレデンシャルを各インスタンスに引き渡す部分を作りこむ必要がある
  • cfn-initのログは各インスタンスに残るので,デバッグが大変(SSMだとCloudWatchに残せる)

没ネタ2

以下の方法でも同じことが実現できます:

  • Step FunctionsでCreateAssociationアクションをwaitForTaskToken付きで実行
  • Command Document側ではAWS CLIを使い aws stepfunctions send-task-success --task token {taskToken} してタスク完了を通知

この方法だと15分以上かかるCommand Documentを待機できますが,さらに複雑になります。

没ネタ3

SSM Automation Runbookでも似たことが 実現できるようです
この方法の場合,以下の点が使いづらいです:

  • ドメイン参加用のクレデンシャルを各インスタンスに引き渡す部分を作りこむ必要がある
  • SSMでのインスタンス設定実行とCDKコンストラクトを混ぜられない。たとえば,インスタンスを全部作ってからRunbookでドメイン参加させることはできるが,管理インスタンスをドメイン参加させてから他のインスタンスを作る... みたいなことができない(やりづらい)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?