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?

ECSの組み込みBlue/Greenデプロイに人間承認の仕組みを組み込んだ(SSM Automation + Slack通知)

0
Last updated at Posted at 2026-05-13

0. はじめに

ECSのBlue/Greenデプロイは便利です。現行バージョン(Blue)を残しながら新バージョン(Green)をこっそり起動し、テスト完了後にトラフィックを切り替える。ロールバックも簡単。

ただ、標準の動作ではGreenのタスクが正常に起動したら自動で本番切り替えまで走ります。
起動できたかどうかは確認できても、実際にちゃんと動いているかは別の話です。
切り替え前に人間が目視で確認して承認する仕組みは自分で作る必要があります。

本番への切り替えだけは人間にボタンを押させたい、Slackで承認依頼が来て、コンソールでApprove/Denyを選ぶだけで、あとはシステムが全部やってくれる。そんな仕組みが欲しくて、実際に作ってみました。

既存のAWSマネージドサービスだけで完結。追加の承認基盤は一切作りません。

実現したこと:

  • 承認依頼をSlackで通知
  • Greenのテスト結果に問題がなければ、承認後、デプロイは本番切り替えへ
  • 問題があれば承認を拒否 → デプロイ中止・ロールバック
  • 誰がいつ承認・却下したかはSSM Automationの実行履歴に自動記録(承認者の承認/拒否時のコメントも可能)
  • 承認者の管理はParameter Storeで動的に変更可能(CloudFormation更新不要)

そもそもBlue/Greenデプロイとは

ECSのBlue/Greenデプロイは、本番環境を2つの環境に分けて段階的にトラフィックを切り替える仕組みです。

  • Blue:現行バージョン。本番トラフィックを処理中
  • Green:新バージョン。テスト中

新しいコードをデプロイするときは、Blueを動かしたままでGreenを起動します。
Greenの動作を十分に確認してから、本番トラフィックをBlueからGreenへ切り替えます。
問題が見つかればすぐにBlueへ戻せるので、ロールバックが簡単です。

image.png

出典:Amazon ECS ブルー/グリーンサービスのデプロイワークフロー

デプロイのライフサイクルステージ

デプロイは以下のライフサイクルステージを順番に進みます。

ステージ トラフィック状況 ライフサイクルフック呼び出し
スケールアップ前 Blueが本番トラフィック100% を処理。Green未起動。テストトラフィックなし。
スケールアップ Greenを起動中。Greenはトラフィックをまだ処理していない。 不可
スケールアップ後 Greenが起動完了。Blueが本番トラフィック100% を処理。テストトラフィックなし。
テストトラフィック移行 Blueが本番トラフィック100%。Greenがテストトラフィック0→100% に移行中。
テストトラフィック移行後 Greenがテストトラフィック100% を処理。Blueは本番トラフィック100% を維持。
ここで人間承認を挟む
本番トラフィック移行 Blueの本番トラフィックが100→0%、Greenの本番トラフィックが0→100% に移行中。
本番トラフィック移行後 Greenが本番トラフィック100% を処理。Blueはすぐに終了。
ベイクタイム BlueとGreenの両方が同時に実行される期間。Greenの安定性を確認。ロールバック可能 不可
クリーンアップ Blueがタスクへスケールダウン。Greenが本番サービスリビジョンになる。 不可

参考:Amazon ECS ブルー/グリーンサービスのデプロイワークフロー

ライフサイクルフックとは

ライフサイクルステージでLambdaを呼び出す仕組みがライフサイクルフックです。
Lambdaの返却値でデプロイの進行を制御できます。

返却値 意味
SUCCEEDED 次のライフサイクルステージへ進む
FAILED ロールバック
IN_PROGRESS 次のライフサイクルステージへは進まず、指定秒数後に再度同じLambdaを呼び出す

たとえば、テストトラフィック移行後ステージで、IN_PROGRESS を返し続けることで、
本番トラフィック切り替え前にGreenの動作確認が実施できます。

参考:Amazon ECS サービスデプロイのライフサイクルフック


1. 公式サンプルでは足りなかった理由

ECS組み込みBlue/Greenデプロイに承認を組み込むサンプルは、AWS公式の sample-amazon-ecs-blue-green-deployment-patterns に存在します。

しかし、この公式サンプルの仕組みはすこし使いにくいと感じました。

1.1. 公式サンプルの仕組み

  1. Lambdaが テストトラフィック移行後 で呼び出される
  2. S3バケットに特定のファイル(ファイル名 = リビジョン番号)が存在するかチェック
  3. ファイルがあれば SUCCEEDED(それにより本番トラフィック切り替え)、なければ IN_PROGRESS を返して30秒後に再確認

1.2. 何が足りないか

問題 詳細
操作が直感的でない S3にファイルを手動で配置するだけなので、「これが承認操作」と認識しにくい
承認手順の手動操作が多い リビジョン番号を手動で確認して、ファイル名にしてS3バケットにアップロードが必要
承認履歴の管理が面倒 S3にファイルを配置した事実だけで、誰がいつ承認したか確認が面倒(CloudTrailでの確認になる)
承認者の管理が面倒 対象のS3バケットにアクセスできれば誰でも承認できてしまう
通知が別途必要 Slack 通知など、承認依頼の通知は自分で実装する必要がある

1.3. 今回の解決策

SSM Automationの aws:approve アクションを使うことで、上記の問題をすべて解決できます。

項目 公式サンプル 今回の実装
承認方法 S3バケットへのファイルアップロード AWSコンソールのApproveボタン
履歴 CloudTrail SSM Automationに自動記録
承認者制限 S3アクセス権限 IAMロール・ユーザーで厳密に制限
通知 なし Slackに通知
承認者管理 S3アクセス権限編集 Parameter Storeで動的に変更可能

2. 全体フロー

2.1 インフラ全体構成

今回実装したECS組み込みBlue/Greenデプロイにおける全体のインフラ構成は以下の通りです。
ライフサイクルフックによるSlack 通知~承認処理の部分をCloudFormationで作成しました。
ECSのライフサイクルステージ(テストトラフィック移行後)でLambdaが呼び出され、
SSM Automationを経由してSlackに承認依頼が届き、承認者の判断でデプロイが続行またはロールバックされるフローを構築しています。

image.png

2.2 使用サービス

サービス 役割
ECS Blue/Greenデプロイとライフサイクルフック
Lambda フック受信・SSM Automation 起動・ステータスポーリング
SSM Automation 承認ワークフロー(aws:approve)・Slack 通知
SSM Parameter Store 承認者 ARN・最小承認数の管理
SNS SSM AutomationからSlackへのルーティング
Amazon Q Developer in chat applications Slackへのカスタム通知配信

2.3 本実装でのALB・CloudFront構成

ECS組み込みBlue/Greenデプロイでは、デプロイ中に2つのTargetGroupが動作します。

  • TargetGroup 1(Blue) → ALBのポート80に割り当て(本番ルール)
  • TargetGroup 2(Green) → ALBのポート8080に割り当て(テストルール)

デプロイ中、本番トラフィックはポート80を通じてBlueへ、テストトラフィックはポート8080を通じてGreenへ自動的に送られます。
今回は動作確認用にCloudFrontを使って以下のようにマッピングすることで、URLによってBlueとGreenを区別して確認できるようにしました。

  • /(本番) → ALB本番リスナー(ポート80) → TargetGroup 1(Blue)
  • /test/(テスト) → ALBテストリスナー(ポート8080) → TargetGroup 2(Green)

2.4ライフサイクルフロー

LambdaがECSのライフサイクルフックで繰り返し呼び出されます。
LambdaはSSM Automationのステータスをポーリングして、承認完了までECSに IN_PROGRESS を返し続けます。

各ステージでのトラフィック流れを以下に示します。

①スケールアップ前

Blueが本番トラフィック100% を処理。Greenは未起動。

②スケールアップ

Greenを起動中。トラフィックはまだBlueへのみ。

③スケールアップ後

Greenが起動完了。トラフィックはまだBlueへのみ。

④テストトラフィック移行

Blueが本番トラフィック100% を処理。Greenがテストトラフィック0→100% に移行中。

⑤テストトラフィック移行後

ここでライフサイクルフックのLambdaがSSM Automationを起動。
Blueが100% を処理。Greenがテストトラフィック100% を処理。

  • Slackに承認依頼が届く
  • 承認完了までライフサイクルフックのLambdaはIN_PROGRESSを返し続ける

⑥本番トラフィック移行

本番トラフィックがBlueからGreenへ移行中。Blueは100→0%、Greenは0→100%。

⑦本番トラフィック移行後

本番切り替え完了。Greenが本番トラフィック100% を処理。

ベイクタイム

BlueとGreenが同時に実行される期間。この期間にロールバック可能。

クリーンアップ

Blueがスケールダウン。Greenが本番サービスリビジョンになる。


3. 動作の流れ

(1) デプロイがテストトラフィック移行後に到達

ECSが テストトラフィック移行後 ステージに進むと、ライフサイクルフックでLambdaが呼び出されます。LambdaはSSM Automationを起動し、承認ワークフローが開始されます。

(2) Slackに承認依頼が届く

SSM Automationが実行され、SNS経由でSlackに通知が届きます。
通知には以下の情報と2つのリンクが含まれます。

  • SSM実行 ID・クラスター ARN・サービスARN・リビジョン番号・ベイクタイム
  • :link: デプロイ詳細」へのリンク(ECSサービスのデプロイ詳細画面)
  • :white_check_mark: 承認または却下」へのリンク(SSM Automationの承認/拒否画面)
  • :mag: オートメーション実行の詳細」へのリンク(SSM Automationの実行詳細画面)

image.png

(3) SSMコンソールで承認または拒否

承認者がSlackの「:white_check_mark: 承認または却下」リンクをクリックすると、SSM Automationコンソールの承認画面へ飛びます。

承認または拒否を選択します。コメントの入力も可能です(任意)。

image.png

(4-A) 承認した場合

Lambdaが SUCCEEDED を返し、ECSデプロイが 本番トライフィック移行 へ進んで本番トラフィックがGreenに切り替わります。

Slackには、「承認されました」通知が届きます。

image.png

(4-B) 拒否した場合

Lambdaが Failed を返し、ECSデプロイがロールバックすることで、Blueがそのまま本番トラフィックを処理し続けます。
Slackには、「却下されました」通知が届きます。

image.png

(4-C) タイムアウトした場合

Slackに「タイムアウト」通知が届き、Lambdaは FAILED を返してロールバックします。

image.png


4. 実際にやってみた

今回の動作確認には、Nginxで index.html を返すかわいいアプリをつくりました。version-badge の数字でバージョンを識別できるようにしています。

4.1. 初期状態

CloudFrontのURLで本番トラフィック/ にアクセスするとv1.0.0が表示されました。
これがBlue(現行バージョン)です。

image.png

なお、CloudFrontのURLでテストトラフィック/test にアクセスしても、v1.0.0が表示されました。
本番リスナーもテストリスナーもBlue(現行バージョン)を向いていることがわかります。

image.png

4.2. 新環境をデプロイ開始

index.html のバージョン表示をv2.0.0に更新してイメージをビルドし、ECSサービスを更新します。デプロイが開始されると、Greenとしてv2.0.0のタスクが起動し始めます。

テストトラフィック移行後に到達した時点でライフサイクルフックが呼び出され、Slackに承認依頼が届きます。

image.png

:link: デプロイ詳細」リンクをクリックすることで、デプロイの状態を確認できます。
ライフサイクルステージがテストトラフィック移行後になっていることがわかります。

ライフサイクルフックLambdaがIN_PROGRESSを返し続けるので、ライフサイクルステージはテストトラフィック移行後で一時停止します。
本番トラフィックはBlue環境のまま、影響を与えずにGreenを安全にテストトラフィック経由で動作確認することができます。

image.png

4.3.テストトラフィック(Green)で動作確認

承認前の段階で、テストトラフィック/test/ にアクセスすると、ALBのテストリスナー経由でGreenタスクに接続されるので、Green環境に動作確認することができます。

image.png
→ただしくv2.0.0が表示されることが確認できました!

なお、このとき、本番トラフィック(/)はまだBlueタスクに接続され、v1.0.0のままです。

image.png

4.4. 承認画面

Greenの動作に問題がなければ、Slackの「:white_check_mark: 承認または却下」リンクから以下承認画面に飛びます。
[決定事項]で、承認/拒否を選択し、[コメント]でコメントを記録できます。
image.png

4.5. 承認後

「承認」を選択すると、ライフサイクルステージが進み、本番トラフィック/ がGreenタスクに切り替わります。
Slackの同一スレッドに「承認されました」通知が届きます。

image.png

:mag: オートメーション実行の詳細」リンクから、オートメーション実行履歴のステップ 2の詳細を確認することで、承認者(Approver)やコメントが確認できます

image.png

4.6. アプリの確認

CloudFrontのURLで本番トラフィック/ にアクセスするとv2.0.0が表示されました。
Greenに完全に切り替わっていることがわかります。

image.png

CloudFrontのURLでテストトラフィック/test にアクセスしても、v2.0.0が表示されました。
本番リスナーもテストリスナーもGreen(新バージョン)を向いていることがわかります。

image.png

4.7. ロールバック(承認を拒否した場合)

Denyを押すと、/ はv1.0.0のまま継続されます。Greenは終了され、Slackに「却下されました」通知が届きます。

image.png

ECSサービスのデプロイ詳細を見ると、ロールバックしていることがわかります。
image.png

オートメーションの詳細画面で、拒否したときのコメントが確認できます。
ステータスが失敗になるのは仕様で、aws:approveは拒否を選択すると、ステータスが「失敗」になるようになっています。
なので、処理でなにか不具合が発生したわけではありません。

image.png


5. 実装の詳細

5.1. Lambda:フック受信・SSM 起動・ポーリング

LambdaはECSから繰り返し呼び出されます。初回と2回目以降で動作が異なります。

IN_PROGRESS を返す際は、次の呼び出しで参照できるよう実行IDを hookDetails に保存します。この関数は初回も2回目以降も使います。

def hook_in_progress(execution_id: str) -> dict:
    return {
        "hookStatus": "IN_PROGRESS",
        "callBackDelay": 30,         # 30秒後に再呼び出し
        "hookDetails": {
            "SSM_EXECUTION_ID": execution_id,
        },
    }

**初回の動作**

サービスを新規作成したときにもライフサイクルフックが発火します初回デプロイまで承認を求めるのは煩わしいため`list_service_deployments`でデプロイ履歴が1件以下のときはスキップしています

```python
def is_create_service(service_arn: str) -> bool:
    ecs = boto3.client("ecs")
    response = ecs.list_service_deployments(service=service_arn)
    deployments = response.get("serviceDeployments", [])
    return len(deployments) <= 1

2回目以降の動作(ポーリング):

hookDetails に保存したSSM実行 IDでステータスを確認し、結果を返します。

SSM Automationステータス Lambdaの返却値
Success SUCCEEDED(デプロイ続行)
InProgress / Waiting / PendingApproval / Approved IN_PROGRESS(待機継続)
Failed / TimedOut / Cancelled / Rejected FAILED(ロールバック)

5.2. SSM Automation Documentの構成

承認の核となるのが aws:approve アクションです。指定したIAMプリンシパルがAWSコンソールから承認/拒否を押すまで実行を一時停止します。

approve:
  action: aws:approve
  timeoutSeconds: !Ref ApprovalTimeoutSeconds
  onFailure: step:checkApprovalStatus
  nextStep: notifyApproved
  inputs:
    NotificationArn: "{{ NotificationArn }}"
    Message: "ECS Blue/Green デプロイの承認をお願いします。サービス: {{ ServiceArn }}"
    MinRequiredApprovals: "{{ MinRequiredApprovals }}"
    Approvers: "{{ ApproverArns }}"
  • nextStep: notifyApproved → 承認時は通知ステップへ
  • onFailure: step:checkApprovalStatus → 拒否・タイムアウト時は分岐ステップへ

5.3. Slackへのカスタム通知

Amazon Q Developer in chat applicationsは特定のJSON形式を受け取るとリッチな通知を表示します。metadata.threadId に同じ値を設定することで、複数の通知を同一スレッドにまとめられます。

{
  "version": "1.0",
  "source": "custom",
  "content": {
    "textType": "client-markdown",
    "title": ":bell: ECSデプロイ承認リクエスト",
    "description": "ECS Blue/Green デプロイの承認をお願いします。\n\n*オートメーション実行ID:* {{ automation:EXECUTION_ID }}\n*クラスター:* `{{ ClusterArn }}`\n*サービス:* `{{ ServiceArn }}`\n*リビジョン:* {{ RevisionNumber }}\n*ベイクタイム:* {{ BakeTimeInMinutes }} 分\n\n<{{ DeploymentUrl }}|🔗 デプロイ詳細>\n<https://{{ Region }}.console.aws.amazon.com/systems-manager/automation/execution/{{ automation:EXECUTION_ID }}|🔍 オートメーション実行の詳細>\n<https://{{ Region }}.console.aws.amazon.com/systems-manager/automation/execution/{{ automation:EXECUTION_ID }}/approval|✅ 承認または却下>",
    "keywords": ["承認待ち", "ECS", "Blue/Green"]
  },
  "metadata": {
    "threadId": "{{ automation:EXECUTION_ID }}",
    "enableCustomActions": false
  }
}

5.4. 承認者・最小承認数の管理

承認者 ARN・最小承認数はSSM Parameter Storeで管理します。
Lambdaが起動時に取得してSSM Automationに渡すため、CloudFormationを更新せずに承認者を変更できます。

def get_ssm_parameters() -> dict:
    ssm = boto3.client("ssm")
    response = ssm.get_parameters(
        Names=[
            "/ecs-approval/approver-arns",
            "/ecs-approval/min-required-approvals",
        ]
    )
    params = {p["Name"]: p["Value"] for p in response["Parameters"]}
    approver_arns = [a.strip() for a in params["/ecs-approval/approver-arns"].split(",")]
    return {
        "approver_arns":          approver_arns,
        "min_required_approvals": int(params["/ecs-approval/min-required-approvals"]),
    }

承認者はIAM ARNで指定します。AWS SSO(Identity Center)を使っている場合、許可セット全体または個人で指定できます。

パターンA:許可セット単位(その許可セット内の全ユーザーが承認可能)

arn:aws:iam::<account-id>:role/aws-reserved/sso.amazonaws.com/<region>/AWSReservedSSO_<permission-set>_<suffix>

パターンB:個人単位(特定ユーザーのみ承認可能)

arn:aws:sts::<account-id>:assumed-role/AWSReservedSSO_<permission-set>_<suffix>/<IdCのusername>

複数指定する場合はカンマ区切りで:

arn:aws:sts::<account-id>:assumed-role/AWSReservedSSO_<permission-set>_<suffix>/<username1>,arn:aws:sts::<account-id>:assumed-role/AWSReservedSSO_<permission-set>_<suffix>/<username2>

6. CloudFormationテンプレート

ライフサイクルフックによるSlack 通知~承認処理の部分をCloudFormationで作成しました。

6.1.パラメータ

パラメータ デフォルト 説明
ApprovalTimeoutSeconds 84600(23.5時間) aws:approve のタイムアウト秒数(最大84600 = 23.5時間)
ApproverArns 承認者のIAM ARN(カンマ区切り)。SSM Parameter Storeの初期値として使用
MinRequiredApprovals 1 最小承認数。SSM Parameter Storeの初期値として使用
SlackWorkspaceId SlackワークスペースID(Amazon Q Developerコンソールで確認)
SlackChannelId 通知先SlackチャンネルID

ApproverArnsMinRequiredApprovals は、デプロイ後にParameter Storeを直接更新することで、スタック更新なしに変更できます。

タイムアウトの設計: ECSライフサイクルステージのタイムアウトは24時間です。SSM Automationのタイムアウトをこれより短い23.5時間(84600秒)に設定することで、ECSが無通知でロールバックするより先にSlackへ通知できます。

6.2. 作成されるリソース

リソース種別 リソース名
SNS Topic Automation-ecs-approval-notification
Chatbot SlackChannelConfiguration ecs-approval-slack
SSM Automation Document ecs-approval
Lambda ecs-approval-lifecycle-hook
IAM Role(Amazon Q Developer in chat applications用) ecs-approval-chatbot
IAM Role(SSM Automation用) ecs-approval-ssm
IAM Role(Lambda用) ecs-approval-lambda
IAM Role(ECSライフサイクルフック用) ecs-approval-lifecycle-hook-invoke
SSM Parameter /ecs-approval/approver-arns
SSM Parameter /ecs-approval/min-required-approvals
テンプレート全文(クリックで展開)
AWSTemplateFormatVersion: "2010-09-09"
Description: >
  ECS Blue/Green Lifecycle Hook with Human Approval via SSM Automation aws:approve.
  POST_TEST_TRAFFIC_SHIFT hook triggers SSM Automation.
  Approvers authenticate to AWS Console to Approve/Deny.
  Approval request and result notifications are sent to Slack via Amazon Q Developer
  in chat applications. Approval records are retained in SSM Automation execution history.

Parameters:
  ApprovalTimeoutSeconds:
    Type: Number
    Default: 84600
    MinValue: 60
    MaxValue: 84600
    Description: >
      Seconds before the aws:approve step times out (default 23.5 hours).
      Must be 23.5 hours (84600 seconds) or less to ensure SSM times out
      before the ECS lifecycle stage timeout of 24 hours (86400 seconds).

  ApproverArns:
    Type: String
    Description: >
      Comma-separated list of IAM ARNs allowed to approve/deny deployments.
      Examples:
        - Single user: arn:aws:sts::123456789012:assumed-role/RoleName/username
        - Permission set: arn:aws:iam::123456789012:role/aws-reserved/sso.amazonaws.com/region/AWSReservedSSO_PermissionSetName_suffix

  MinRequiredApprovals:
    Type: Number
    Default: 1
    MinValue: 1
    Description: Minimum number of approvals required before deployment can proceed.

  SlackWorkspaceId:
    Type: String
    Description: >
      Slack workspace ID authorized with Amazon Q Developer in chat applications.
      Obtain this from the Amazon Q Developer console after completing the initial
      Slack authorization flow.
    AllowedPattern: "^[0-9A-Z]{1,255}$"

  SlackChannelId:
    Type: String
    Description: >
      Slack channel ID to receive notifications.
      Right-click the channel name in Slack and choose Copy Link.
      The channel ID is the string at the end of the URL (e.g. ABCBBLZZZ).
    AllowedPattern: "^[A-Za-z0-9]+$"

Resources:

  # -----------------------------------------------------------------------
  # SSM Parameter Store
  # -----------------------------------------------------------------------
  ParamApproverArns:
    Type: AWS::SSM::Parameter
    Properties:
      Name: /ecs-approval/approver-arns
      Type: String
      Value: !Ref ApproverArns
      Description: Comma-separated list of IAM ARNs allowed to approve/deny

  ParamMinRequiredApprovals:
    Type: AWS::SSM::Parameter
    Properties:
      Name: /ecs-approval/min-required-approvals
      Type: String
      Value: !Ref MinRequiredApprovals
      Description: Minimum number of approvals required

  # -----------------------------------------------------------------------
  # SNS Topic
  # aws:approve の NotificationArn に使用するためトピック名は "Automation" プレフィックス必須
  # 承認依頼・結果通知の両方をこのトピック経由で送信する
  # ref: https://docs.aws.amazon.com/systems-manager/latest/userguide/automation-action-approve.html
  # -----------------------------------------------------------------------
  NotificationTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: Automation-ecs-approval-notification

  # -----------------------------------------------------------------------
  # Amazon Q Developer in chat applications - Slack Channel Configuration
  # 注意: Slackワークスペースの初回認証はコンソールで事前に完了している必要がある
  # ref: https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-resource-chatbot-slackchannelconfiguration.html
  # -----------------------------------------------------------------------
  SlackChannelConfig:
    Type: AWS::Chatbot::SlackChannelConfiguration
    Properties:
      ConfigurationName: ecs-approval-slack
      SlackWorkspaceId: !Ref SlackWorkspaceId
      SlackChannelId: !Ref SlackChannelId
      IamRoleArn: !GetAtt ChatbotRole.Arn
      SnsTopicArns:
        - !Ref NotificationTopic
      LoggingLevel: ERROR
      GuardrailPolicies:
        - arn:aws:iam::aws:policy/ReadOnlyAccess

  # -----------------------------------------------------------------------
  # IAM Role for Amazon Q Developer in chat applications
  # -----------------------------------------------------------------------
  ChatbotRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: ecs-approval-chatbot
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: chatbot.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/ReadOnlyAccess

  # -----------------------------------------------------------------------
  # SSM Automation Document
  # ステップ構成:
  #   1. approve             : aws:approve で承認待ち
  #                            承認 → notifyApproved へ
  #                            拒否/タイムアウト → checkApprovalStatus へ (onFailure)
  #   2. notifyApproved      : SNS Publish で承認通知 (カスタムフォーマット)
  #   3. checkApprovalStatus : ApprovalStatus を確認して拒否かタイムアウトかを判断
  #   4. notifyRejected      : SNS Publish で拒否通知 (カスタムフォーマット)
  #   5. notifyTimeout       : SNS Publish でタイムアウト通知 (カスタムフォーマット)
  #
  # 通知はすべて同一 threadId (automation:EXECUTION_ID) でスレッドにまとめられる
  # -----------------------------------------------------------------------
  ApprovalDocument:
    Type: AWS::SSM::Document
    Properties:
      Name: ecs-approval
      UpdateMethod: NewVersion
      DocumentType: Automation
      DocumentFormat: YAML
      Content:
        schemaVersion: "0.3"
        description: "ECS Blue/Green deployment human approval via aws:approve"
        assumeRole: !GetAtt SsmAutomationRole.Arn
        parameters:
          ServiceArn:
            type: String
            description: ECS service ARN
          ClusterArn:
            type: String
            description: ECS cluster ARN
          RevisionNumber:
            type: String
            description: ECS service revision number
          BakeTimeInMinutes:
            type: String
            description: Bake time in minutes after production traffic shift
          DeploymentUrl:
            type: String
            description: ECS deployment tab URL
          NotificationArn:
            type: String
            description: SNS topic ARN for all notifications (must start with Automation)
          Region:
            type: String
            description: AWS region for console URLs
          ApproverArns:
            type: StringList
            description: IAM ARNs of the approvers
          MinRequiredApprovals:
            type: Integer
            description: Minimum number of approvals required
        mainSteps:
          - name: notifyRequest
            action: aws:executeAwsApi
            nextStep: approve
            inputs:
              Service: sns
              Api: Publish
              TopicArn: "{{ NotificationArn }}"
              Message: >-
                {"version":"1.0","source":"custom","content":{"textType":"client-markdown","title":":bell: ECSデプロイ承認リクエスト","description":"ECS Blue/Green デプロイの承認をお願いします。\n\n*オートメーション実行ID:* {{ automation:EXECUTION_ID }}\n*クラスター:* `{{ ClusterArn }}`\n*サービス:* `{{ ServiceArn }}`\n*リビジョン:* {{ RevisionNumber }}\n*ベイクタイム:* {{ BakeTimeInMinutes }} 分\n\n<{{ DeploymentUrl }}|🔗 デプロイ詳細>\n<https://{{ Region }}.console.aws.amazon.com/systems-manager/automation/execution/{{ automation:EXECUTION_ID }}|🔍 オートメーション実行の詳細>\n<https://{{ Region }}.console.aws.amazon.com/systems-manager/automation/execution/{{ automation:EXECUTION_ID }}/approval|✅ 承認または却下>","keywords":["承認待ち","ECS","Blue/Green"]},"metadata":{"threadId":"{{ automation:EXECUTION_ID }}","enableCustomActions":false}}

          - name: approve
            action: aws:approve
            timeoutSeconds: !Ref ApprovalTimeoutSeconds
            onFailure: step:checkApprovalStatus
            nextStep: notifyApproved
            inputs:
              NotificationArn: "{{ NotificationArn }}"
              Message: "ECS Blue/Green デプロイの承認をお願いします。サービス: {{ ServiceArn }}"
              MinRequiredApprovals: "{{ MinRequiredApprovals }}"
              Approvers: "{{ ApproverArns }}"

          - name: notifyApproved
            action: aws:executeAwsApi
            isEnd: true
            inputs:
              Service: sns
              Api: Publish
              TopicArn: "{{ NotificationArn }}"
              Message: >-
                {"version":"1.0","source":"custom","content":{"textType":"client-markdown","title":":white_check_mark: ECSデプロイ承認","description":"ECS Blue/Green デプロイが *承認* されました。デプロイを続行します。\n\n*オートメーション実行ID:* {{ automation:EXECUTION_ID }}\n*クラスター:* `{{ ClusterArn }}`\n*サービス:* `{{ ServiceArn }}`\n*リビジョン:* {{ RevisionNumber }}\n*ベイクタイム:* {{ BakeTimeInMinutes }} 分\n\n<{{ DeploymentUrl }}|🔗 デプロイ詳細>\n<https://{{ Region }}.console.aws.amazon.com/systems-manager/automation/execution/{{ automation:EXECUTION_ID }}|🔍 オートメーション実行の詳細>","keywords":["承認","ECS","Blue/Green"]},"metadata":{"threadId":"{{ automation:EXECUTION_ID }}","enableCustomActions":false}}

          - name: checkApprovalStatus
            action: aws:branch
            inputs:
              Choices:
                - NextStep: notifyRejected
                  Variable: "{{ approve.ApprovalStatus }}"
                  StringEquals: Rejected
              Default: notifyTimeout

          - name: notifyRejected
            action: aws:executeAwsApi
            isEnd: true
            inputs:
              Service: sns
              Api: Publish
              TopicArn: "{{ NotificationArn }}"
              Message: >-
                {"version":"1.0","source":"custom","content":{"textType":"client-markdown","title":":x: ECSデプロイ却下","description":"ECS Blue/Green デプロイが *却下* されました。デプロイはロールバックされます。\n\n*オートメーション実行ID:* {{ automation:EXECUTION_ID }}\n*クラスター:* `{{ ClusterArn }}`\n*サービス:* `{{ ServiceArn }}`\n*リビジョン:* {{ RevisionNumber }}\n\n<{{ DeploymentUrl }}|🔗 デプロイ詳細>\n<https://{{ Region }}.console.aws.amazon.com/systems-manager/automation/execution/{{ automation:EXECUTION_ID }}|🔍 オートメーション実行の詳細>","keywords":["却下","ECS","Blue/Green"]},"metadata":{"threadId":"{{ automation:EXECUTION_ID }}","enableCustomActions":false}}

          - name: notifyTimeout
            action: aws:executeAwsApi
            isEnd: true
            inputs:
              Service: sns
              Api: Publish
              TopicArn: "{{ NotificationArn }}"
              Message: >-
                {"version":"1.0","source":"custom","content":{"textType":"client-markdown","title":":hourglass_flowing_sand: ECSデプロイ承認タイムアウト","description":"ECS Blue/Green デプロイの承認がタイムアウトしました。デプロイはロールバックされます。\n\n*オートメーション実行ID:* {{ automation:EXECUTION_ID }}\n*クラスター:* `{{ ClusterArn }}`\n*サービス:* `{{ ServiceArn }}`\n*リビジョン:* {{ RevisionNumber }}\n\n<{{ DeploymentUrl }}|🔗 デプロイ詳細>\n<https://{{ Region }}.console.aws.amazon.com/systems-manager/automation/execution/{{ automation:EXECUTION_ID }}|🔍 オートメーション実行の詳細>","keywords":["タイムアウト","ECS","Blue/Green"]},"metadata":{"threadId":"{{ automation:EXECUTION_ID }}","enableCustomActions":false}}

  # -----------------------------------------------------------------------
  # IAM Role for SSM Automation
  # -----------------------------------------------------------------------
  SsmAutomationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: ecs-approval-ssm
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ssm.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: SnsPublish
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: sns:Publish
                Resource: !Ref NotificationTopic

  # -----------------------------------------------------------------------
  # Lambda: Lifecycle Hook Handler (called repeatedly by ECS)
  # 初回: SSM Automation起動 → IN_PROGRESS
  # 2回目以降: SSM Automationステータスポーリング
  # -----------------------------------------------------------------------
  LifecycleHookFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: ecs-approval-lifecycle-hook
      Description: ECS POST_TEST_TRAFFIC_SHIFT lifecycle hook - triggers SSM Automation approval
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt LifecycleHookFunctionRole.Arn
      Timeout: 30
      Environment:
        Variables:
          SSM_DOCUMENT_NAME: !Ref ApprovalDocument
          NOTIFICATION_ARN: !Ref NotificationTopic
      Code:
        ZipFile: |
          import json
          import logging
          import os

          import boto3
          from botocore.exceptions import ClientError

          logger = logging.getLogger()
          logger.setLevel(logging.INFO)

          SSM_DOCUMENT_NAME = os.environ["SSM_DOCUMENT_NAME"]
          NOTIFICATION_ARN  = os.environ["NOTIFICATION_ARN"]
          AWS_REGION        = os.environ["AWS_REGION"]
          CALLBACK_DELAY    = 30
          SSM_EXEC_ID_KEY   = "SSM_EXECUTION_ID"

          # パラメータストアのパラメータ名
          PARAM_APPROVER_ARNS       = "/ecs-approval/approver-arns"
          PARAM_MIN_APPROVALS       = "/ecs-approval/min-required-approvals"


          def get_ssm_parameters() -> dict:
              ssm = boto3.client("ssm")
              response = ssm.get_parameters(
                  Names=[
                      PARAM_APPROVER_ARNS,
                      PARAM_MIN_APPROVALS,
                  ]
              )
              params = {p["Name"]: p["Value"] for p in response["Parameters"]}
              approver_arns = [a.strip() for a in params[PARAM_APPROVER_ARNS].split(",")]
              return {
                  "approver_arns":         approver_arns,
                  "min_required_approvals": int(params[PARAM_MIN_APPROVALS]),
              }


          def hook_succeeded():
              return {"hookStatus": "SUCCEEDED"}


          def hook_failed():
              return {"hookStatus": "FAILED"}


          def hook_in_progress(execution_id: str) -> dict:
              return {
                  "hookStatus": "IN_PROGRESS",
                  "callBackDelay": CALLBACK_DELAY,
                  "hookDetails": {
                      SSM_EXEC_ID_KEY: execution_id,
                  },
              }


          def is_create_service(service_arn: str) -> bool:
              ecs = boto3.client("ecs")
              response = ecs.list_service_deployments(service=service_arn)
              deployments = response.get("serviceDeployments", [])
              return len(deployments) <= 1


          def get_service_info(service_arn: str) -> dict:
              ecs = boto3.client("ecs")
              cluster_name = service_arn.split("/")[-2]
              response = ecs.describe_services(cluster=cluster_name, services=[service_arn])
              service = response["services"][0]
              cluster_arn = service.get("clusterArn", "")
              bake_time = service.get("deploymentConfiguration", {}).get("bakeTimeInMinutes", 0)
              return {
                  "cluster_arn": cluster_arn,
                  "bake_time_in_minutes": str(bake_time),
              }


          def build_deployment_url(service_arn: str) -> str:
              cluster_name = service_arn.split("/")[-2]
              service_name = service_arn.split("/")[-1]
              return (
                  f"https://{AWS_REGION}.console.aws.amazon.com/ecs/v2/clusters"
                  f"/{cluster_name}/services/{service_name}/deployments"
              )


          def start_approval_workflow(
              service_arn: str,
              revision_arn: str,
              cluster_arn: str,
              bake_time_in_minutes: str,
              deployment_url: str,
              approver_arns: list,
              min_required_approvals: int,
          ) -> str:
              revision_number = revision_arn.split("/")[-1]
              ssm = boto3.client("ssm")
              response = ssm.start_automation_execution(
                  DocumentName=SSM_DOCUMENT_NAME,
                  Parameters={
                      "ServiceArn":           [service_arn],
                      "ClusterArn":           [cluster_arn],
                      "RevisionNumber":       [revision_number],
                      "BakeTimeInMinutes":    [bake_time_in_minutes],
                      "DeploymentUrl":        [deployment_url],
                      "NotificationArn":      [NOTIFICATION_ARN],
                      "Region":               [AWS_REGION],
                      "ApproverArns":         approver_arns,
                      "MinRequiredApprovals": [str(min_required_approvals)],
                  },
              )
              return response["AutomationExecutionId"]


          def get_execution_status(execution_id: str) -> str:
              ssm = boto3.client("ssm")
              response = ssm.get_automation_execution(
                  AutomationExecutionId=execution_id
              )
              return response["AutomationExecution"]["AutomationExecutionStatus"]


          def lambda_handler(event, context):
              logger.info(json.dumps(event))

              execution_details = event.get("executionDetails", {})
              service_arn  = execution_details.get("serviceArn")
              revision_arn = execution_details.get("targetServiceRevisionArn")
              hook_details = event.get("hookDetails", {})

              if not service_arn or not revision_arn:
                  logger.error("Missing serviceArn or targetServiceRevisionArn in executionDetails")
                  return hook_failed()

              # 2回目以降: hookDetails に SSM 実行 ID があればステータス確認
              if SSM_EXEC_ID_KEY in hook_details:
                  execution_id = hook_details[SSM_EXEC_ID_KEY]
                  logger.info(f"Checking SSM Automation execution: {execution_id}")
                  try:
                      status = get_execution_status(execution_id)
                  except ClientError as e:
                      logger.error(f"get_automation_execution failed: {e}")
                      return hook_failed()

                  logger.info(f"SSM Automation status: {status}")
                  if status == "Success":
                      return hook_succeeded()
                  elif status in ("InProgress", "Waiting", "PendingApproval", "Approved"):
                      return hook_in_progress(execution_id)
                  else:
                      logger.warning(f"SSM Automation ended with status={status}. Returning FAILED.")
                      return hook_failed()

              # 初回: createService かどうか確認
              logger.info("First invocation. Checking if this is a createService.")
              try:
                  if is_create_service(service_arn):
                      logger.info("createService detected. Skipping approval.")
                      return hook_succeeded()
              except ClientError as e:
                  logger.error(f"list_service_deployments failed: {e}")
                  return hook_failed()

              # パラメータストアから設定を取得
              try:
                  params = get_ssm_parameters()
              except ClientError as e:
                  logger.error(f"get_parameters failed: {e}")
                  return hook_failed()

              # describe_services でクラスターARNとベイクタイムを取得
              try:
                  service_info = get_service_info(service_arn)
              except ClientError as e:
                  logger.error(f"describe_services failed: {e}")
                  return hook_failed()

              deployment_url = build_deployment_url(service_arn)

              logger.info("updateService detected. Starting SSM Automation approval workflow.")
              try:
                  execution_id = start_approval_workflow(
                      service_arn           = service_arn,
                      revision_arn          = revision_arn,
                      cluster_arn           = service_info["cluster_arn"],
                      bake_time_in_minutes  = service_info["bake_time_in_minutes"],
                      deployment_url        = deployment_url,
                      approver_arns         = params["approver_arns"],
                      min_required_approvals= params["min_required_approvals"],
                  )
              except ClientError as e:
                  logger.error(f"start_automation_execution failed: {e}")
                  return hook_failed()

              logger.info(f"Started SSM Automation execution: {execution_id}")
              return hook_in_progress(execution_id)

  LifecycleHookFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: ecs-approval-lambda
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ECSListDeployments
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - ecs:ListServiceDeployments
                  - ecs:DescribeServices
                Resource: "*"
        - PolicyName: SSMGetParameters
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: ssm:GetParameters
                Resource:
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/ecs-approval/*"
        - PolicyName: SSMAutomation
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: ssm:StartAutomationExecution
                Resource:
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:document/${ApprovalDocument}"
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:automation-execution/*"
              - Effect: Allow
                Action: ssm:GetAutomationExecution
                Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:automation-execution/*"

  LifecycleHookInvokeRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: ecs-approval-lifecycle-hook-invoke
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InvokeLambda
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource: !GetAtt LifecycleHookFunction.Arn

Outputs:
  LifecycleHookFunctionArn:
    Description: >
      ARN of the ECS lifecycle hook Lambda.
      Register this in your ECS service deployment configuration under
      lifecycleHooks with lifecycleStages: [POST_TEST_TRAFFIC_SHIFT].
    Value: !GetAtt LifecycleHookFunction.Arn
    Export:
      Name: ecs-approval-LifecycleHookFunctionArn

  LifecycleHookInvokeRoleArn:
    Description: >
      ARN of the IAM role that allows ECS to invoke the lifecycle hook Lambda.
      Register this in your ECS service deployment configuration under
      lifecycleHooks as roleArn.
    Value: !GetAtt LifecycleHookInvokeRole.Arn
    Export:
      Name: ecs-approval-LifecycleHookInvokeRoleArn

  SsmDocumentName:
    Description: SSM Automation Document name
    Value: !Ref ApprovalDocument

  NotificationTopicArn:
    Description: >
      SNS topic ARN for all notifications (approval requests and results).
      Automation prefix required by aws:approve.
    Value: !Ref NotificationTopic

  SlackChannelConfigArn:
    Description: Amazon Q Developer Slack channel configuration ARN
    Value: !Ref SlackChannelConfig


7. デプロイ手順

Step 1: Amazon Q DeveloperとSlackの連携

Amazon Q Developer in chat applicationsコンソール でSlackワークスペースを認証し、SlackWorkspaceIdT から始まる文字列)を取得します。

image.png

通知を受け取りたいチャンネルで /invite @Amazon Q Developer を実行します。チャンネル詳細の下部に表示されるチャンネルID(C から始まる文字列)を確認します。

Step 2: CloudFormationデプロイ

aws cloudformation deploy \
  --template-file ecs-lifecycle-hook-ssm-slack.yaml \
  --stack-name ecs-approval \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
    SlackWorkspaceId=T07XXXXXXX \
    SlackChannelId=C08XXXXXXX \
    ApprovalTimeoutSeconds=84600 \
    ApproverArns=arn:aws:sts::123456789012:assumed-role/RoleName/username \
    MinRequiredApprovals=1

SlackWorkspaceId: Amazon Q Developer in chat applicationsに設定したワークスペースID
SlackChannelId: SlackのチャネルID
ApprovalTimeoutSeconds: 承認待ちのタイムアウト秒数。デフォルト84600秒(23.5時間)ECSライフサイクルステージの上限24時間より短くする必要があります。
ApproverArns: 承認者のIAM ARN。カンマ区切りで複数指定可能。デプロイ後にParameter Storeで変更できます。
MinRequiredApprovals: 承認に必要な最小人数。複数の承認者を指定する場合に有効です。

Step 3: ECSサービスにフックを登録

デプロイオプションの設定で、
CloudFormationで作成したLambda関数と、ロールを指定する。
それぞれ、CloudFormationのOutput LifecycleHookFunctionArnLifecycleHookInvokeRoleArnの値です。

image.png


8. ハマりポイント

8.1. SNSトピック名は Automation で始まらないといけない

aws:approveNotificationArn に指定するSNSトピック名は、Automation で始まる必要があります。公式ドキュメントに明記されています。

The title of the Amazon SNS topic must be prefixed with "Automation".

出典: aws:approve – Pause an automation for manual approval

8.2. ssm:StartAutomationExecution のIAMリソース指定

LambdaのIAMポリシーには document/automation-execution/* の両方が必要です。automation-definition/ を使うと動きません。実際のエラーメッセージに document/ のARNが出てくるので、そこから気づけます。

# 誤り
Resource: "arn:aws:ssm:....:automation-definition/ecs-approval:*"

# 正しい
Resource:
  - "arn:aws:ssm:....:document/ecs-approval"
  - "arn:aws:ssm:....:automation-execution/*"

参考: Setting up identity based policies examples

8.3. カスタム名付きSSMドキュメントの更新エラー

Name を明示した AWS::SSM::Document をCloudFormationで更新しようとすると、以下のエラーが出ることがあります。

CloudFormation cannot update a stack when a custom-named resource requires replacing.
Rename ecs-approval and update the stack again.

SSMドキュメントはデフォルトで UpdateMethod: Replace(削除→再作成)が使われます。カスタム名付きリソースの置換はCloudFormationに禁止されているため、ドキュメントの内容を変更するとこのエラーになります。

解決策:UpdateMethod: NewVersion を追加する

ApprovalDocument:
  Type: AWS::SSM::Document
  Properties:
    Name: ecs-approval
    UpdateMethod: NewVersion  # 既存ドキュメントに新バージョンを追加する
    DocumentType: Automation

NewVersion にすることで、内容変更時に削除・再作成ではなく新バージョンの追加として処理されます。カスタム名付きのSSMドキュメントをCloudFormationで管理する場合は最初から指定しておくことをお勧めします。

8.4. aws:approvetimeoutSeconds にパラメータ参照が使えない

MinRequiredApprovals(商人に必要な最小人数)はParameter Store経由で動的に値を変更できますが、timeoutSeconds(承認待ちのタイムアウト秒数)はできません。
その理由は、ステップ定義レベルのフィールドであるかどうかの違いです。

timeoutSeconds:ステップ定義レベルのフィールド:

timeoutSeconds はSSM Automationステップの構造を定義するフィールドです。実際にコンソールで試すと、パラメータ参照({{ TimeoutSeconds }})を使うと以下のバリデーションエラーが発生します:

timeoutSeconds must be number and minimal value is 1

つまり、ステップ定義レベルのフィールドは、リテラル値(具体的な数値)のみ受け入れ、パラメータ参照は使用できません。

MinRequiredApprovals:inputsフィールド内のパラメータ:

一方、MinRequiredApprovalsinputs フィールド内のパラメータであり、パラメータ参照が使えます:

inputs:
  MinRequiredApprovals: "{{ MinRequiredApprovals }}"
  Approvers: "{{ ApproverArns }}"

LambdaがParameter Storeから取得した値をSSMに渡すことで、動的に変更できます:

"MinRequiredApprovals": [str(min_required_approvals)]

結論:

承認待ちのタイムアウト秒数は、CloudFormationパラメータApprovalTimeoutSecondsで管理し、変更時はスタック更新で対応してます。timeoutSecondsの動的管理は仕様上できないため、この制約は避けられませんでした。


9. まとめ

ECSライフサイクルフック × SSM Automation × Amazon Q Developer in chat applicationsの組み合わせで、人間承認フローを実現できました。

  • 承認者はAWSコンソールにログインするだけで承認・却下できる。専用ツールが不要
  • Slackに通知が届き、同一スレッドに結果もまとまるので見やすい
  • 承認履歴はSSM Automationの実行履歴として自動的に残るため監査ログにもなる
  • 承認者・最小承認数はParameter Storeで管理できるため、CFnを更新せずに変更できる
  • タイムアウト秒数はCloudFormationパラメータで管理しており、変更時はスタック更新で対応する

ハマりポイントがいくつかあったので、同じ構成を試す方の参考になれば幸いです。

参考

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?