AWS Service Catalogを1年半使って分かった失敗と今の構成【実運用レポート】

「古いテンプレートを本番適用してSOC2審査で詰められた」——そんな修羅場からService Catalogを本格導入。1年半で踏んだ失敗と現在の安定構成を正直に書きます。

はじめに:テンプレート管理が「地獄」になるまで

正直に言う。Service Catalogを導入するまで、うちのチームのCloudFormationテンプレート管理は壊滅的だった。

S3の適当なバケットにテンプレートが散乱して、誰が何を使っているか分からない。開発者がEC2を立てるたびにインフラチームにSlackが飛んでくる。承認フローはスプレッドシート管理。そしてある日、開発者が古いテンプレート(セキュリティグループが0.0.0.0/0で空いてるやつ)をそのまま本番に適用して、SOC2の審査担当者に詰められた。

その事件を機に「セルフサービス化しながらガバナンスを保つ」仕組みとしてService Catalogを本格導入したのが、2025年1月のこと。以来1年半、今の2026年8月時点でようやく「安定して運用できている」と言えるフェーズになった。

この記事では、その1年半で踏んだ失敗と、今の構成を正直に書く。Service Catalogを検討中の人にとって「あるある」として刺されば嬉しい。


Service Catalogの基本構造と、うちが選んだ設計方針

改めてService Catalogの構造を整理しておく。知ってる人は飛ばして良い。

graph TB
    subgraph "管理者アカウント(共有サービスアカウント)"
        SC[Service Catalog 管理ポートフォリオ]
        CF[CloudFormationテンプレート]
        CDK[CDK Constructs]
        SC --> CF
        SC --> CDK
    end

    subgraph "AWS Organizations"
        subgraph "開発アカウント"
            DEV_SC[ポートフォリオ共有]
            DEV_USR[開発者]
            DEV_USR --> DEV_SC
        end

        subgraph "ステージングアカウント"
            STG_SC[ポートフォリオ共有]
            STG_USR[QAエンジニア]
            STG_USR --> STG_SC
        end

        subgraph "本番アカウント"
            PRD_SC[ポートフォリオ共有]
            PRD_USR[SRE]
            PRD_USR --> PRD_SC
        end
    end

    SC -->|OrganizationsでポートフォリオをShare| DEV_SC
    SC -->|OrganizationsでポートフォリオをShare| STG_SC
    SC -->|OrganizationsでポートフォリオをShare| PRD_SC

大きく分けると「ポートフォリオ」「プロダクト」「プロビジョニングされた製品」の3層構造になっている。管理者がポートフォリオにプロダクト(CloudFormationテンプレートや最近だとCDKベース)を登録して、Organizations経由でアカウントに共有する流れだ。

うちが選んだ設計方針は3つ。最初からこれを決めておけば、後半の苦労がだいぶ減ったと思う。

  1. テンプレートはCDKで管理、CloudFormation合成物をService Catalogに登録
  2. ポートフォリオはドメイン単位で分割(インフラ基盤/データ基盤/アプリケーション)
  3. 本番へのプロビジョニングにはService Catalog Enginesの承認ステップを必須化

CDKベースのテンプレート管理についてはCDK v2のカスタムコンストラクト、1年運用して設計を2回壊した話でも書いたけど、Service Catalogとの組み合わせでさらに「型」が効いてくる。


最初の3ヶ月で盛大に失敗したこと

最初はやる気満々で「全部Service Catalogに移行するぞ」とやった。これが失敗の原因だった。

失敗1: テンプレートのバージョン管理を甘く見た

Service Catalogは製品に「バージョン」という概念がある。v1.0.0を登録して、修正したらv1.1.0を追加する。一見きれいに聞こえるが、プロビジョニング済みの製品はデフォルトで古いバージョンのまま動き続ける。

問題は「誰がどのバージョンを使っているか」のトラッキングだ。最初は何も仕組みを作らなかったので、3ヶ月後には以下の状況になった。

$ aws servicecatalog list-provisioned-products --query 'ProvisionedProducts[*].{Name:Name,ProductId:ProductId,Version:LastProvisioningRecordId}'

[
  {"Name": "app-server-prod-01", "ProductId": "prod-xxx", "Version": "1.0.0"},
  {"Name": "app-server-prod-02", "ProductId": "prod-xxx", "Version": "1.2.3"},
  {"Name": "app-server-prod-03", "ProductId": "prod-xxx", "Version": "1.0.0"},
  {"Name": "data-pipeline-01",  "ProductId": "prod-yyy", "Version": "2.1.0"},
  ...(40件以上)
]

バージョンがバラバラ。古いバージョンにはセキュリティパッチが当たっていない。これはまずい。

対策として、AWS Configカスタムルールを書いてバージョン検知を自動化した:

import boto3
import json

def evaluate_compliance(configuration_item):
    sc = boto3.client('servicecatalog')
    
    # プロビジョニング済み製品の詳細取得
    pp_id = configuration_item['resourceId']
    try:
        response = sc.describe_provisioned_product(Id=pp_id)
        pp = response['ProvisionedProductDetail']
        product_id = pp['ProductId']
        current_artifact = pp['ProvisioningArtifactId']
    except Exception:
        return 'NOT_APPLICABLE'
    
    # 最新バージョンのArtifactIDを取得
    artifacts = sc.list_provisioning_artifacts(
        ProductId=product_id
    )['ProvisioningArtifactDetails']
    
    # アクティブな最新バージョンを特定
    active_artifacts = [
        a for a in artifacts 
        if a.get('Active', False) and not a.get('Guidance') == 'DEPRECATED'
    ]
    
    if not active_artifacts:
        return 'NOT_APPLICABLE'
    
    # 作成日時でソートして最新を取得
    latest = sorted(active_artifacts, key=lambda x: x['CreatedTime'], reverse=True)[0]
    
    if current_artifact == latest['Id']:
        return 'COMPLIANT'
    else:
        return 'NON_COMPLIANT'

def lambda_handler(event, context):
    invoking_event = json.loads(event['invokingEvent'])
    configuration_item = invoking_event.get('configurationItem', {})
    
    compliance = evaluate_compliance(configuration_item)
    
    config = boto3.client('config')
    config.put_evaluations(
        Evaluations=[
            {
                'ComplianceResourceType': configuration_item['resourceType'],
                'ComplianceResourceId': configuration_item['resourceId'],
                'ComplianceType': compliance,
                'OrderingTimestamp': configuration_item['configurationItemCaptureTime']
            }
        ],
        ResultToken=event['resultToken']
    )

これで古いバージョンのプロビジョニング済み製品が即座に検出できるようになった。次のステップとして、NON_COMPLIANTをトリガーにEventBridge → SNS → Slackに通知するフローを組んでいる。

失敗2: ポートフォリオを1個にまとめた

最初は「管理を楽にしたい」と全プロダクトを1つのポートフォリオに入れた。結果、150個以上のプロダクトが1ポートフォリオに混在して、検索性がゼロになった。後から分割する作業がまた地獄で、正直これが一番後悔している判断かもしれない。

今はドメイン単位で分割している:

ポートフォリオ名含むプロダクト数主な対象者
infra-foundation12SRE、インフラエンジニア
data-platform18データエンジニア
application-common25全開発者
security-compliance8セキュリティエンジニア
sandbox30全エンジニア(制限緩め)

sandboxポートフォリオは意外と重要で、「試したいけど本番用は使いたくない」というニーズを吸収している。コスト上限をService Catalogのタグポリシーと組み合わせて設定し、自動削除のライフサイクルポリシーも入れた。個人的にはこのsandbox分離が、開発者の「とりあえず試してみよう」を引き出す上でかなり効いていると思う。


今の構成:CDK × Service Catalog × Enginesの組み合わせ

1年半かけて辿り着いた構成がこれだ。

graph TB
    subgraph "CI/CDパイプライン"
        GH[GitHub Repository]
        CP[CodePipeline]
        CB[CodeBuild]
        GH --> CP --> CB
    end

    subgraph "管理アカウント"
        subgraph "Service Catalog管理"
            PF_INFRA[ポートフォリオ: infra-foundation]
            PF_DATA[ポートフォリオ: data-platform]
            PF_APP[ポートフォリオ: application-common]
        end
        
        subgraph "テンプレートストレージ"
            S3[S3 Bucket\ntemplates-master]
            SSM[SSM Parameter Store\nバージョン情報管理]
        end
        
        subgraph "承認・通知"
            SFN[Step Functions\n承認ワークフロー]
            SNS[SNS Topic]
        end
        
        CB -->|CloudFormation合成| S3
        CB -->|バージョン更新| SSM
        S3 --> PF_INFRA
        S3 --> PF_DATA
        S3 --> PF_APP
    end

    subgraph "AWS Organizations"
        subgraph "開発アカウント dev-xxxxx"
            DEV_PORT[共有ポートフォリオ]
            DEV_ENG[開発者]
            DEV_ENG -->|セルフサービスプロビジョニング| DEV_PORT
        end
        
        subgraph "本番アカウント prd-xxxxx"
            PRD_PORT[共有ポートフォリオ]
            PRD_ENG[SRE]
            PRD_ENG -->|承認必須プロビジョニング| PRD_PORT
            PRD_PORT --> SFN
            SFN -->|承認通知| SNS
        end
    end

    PF_INFRA -->|Organizations Share| DEV_PORT
    PF_APP -->|Organizations Share| DEV_PORT
    PF_INFRA -->|Organizations Share| PRD_PORT
    PF_APP -->|Organizations Share| PRD_PORT

CDKでService Catalogプロダクトを定義する

CDK v2でService Catalogプロダクトを作る場合、aws-cdk-lib/aws-servicecatalogを使う。2026年時点でCDK Service Catalogの機能がだいぶ整ってきて、以前より素直に書けるようになった:

import * as cdk from 'aws-cdk-lib';
import * as servicecatalog from 'aws-cdk-lib/aws-servicecatalog';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';

// プロダクト定義(CDK Stackとして定義)
export class StandardEc2ProductStack extends servicecatalog.ProductStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    // パラメータ定義
    const instanceType = new cdk.CfnParameter(this, 'InstanceType', {
      type: 'String',
      default: 't3.medium',
      allowedValues: ['t3.small', 't3.medium', 't3.large', 'm6i.large'],
      description: 'EC2インスタンスタイプ(大きいものは申請が必要)',
    });

    const environment = new cdk.CfnParameter(this, 'Environment', {
      type: 'String',
      allowedValues: ['dev', 'stg', 'prd'],
      description: '環境名',
    });

    // セキュリティグループ(最小権限で定義済み)
    const sg = new cdk.aws_ec2.SecurityGroup(this, 'InstanceSG', {
      vpc: cdk.aws_ec2.Vpc.fromLookup(this, 'VPC', {
        vpcId: cdk.aws_ssm.StringParameter.valueForStringParameter(
          this, `/foundation/${environment.valueAsString}/vpc-id`
        ),
      }),
      description: 'Standard EC2 Security Group - managed by Service Catalog',
      allowAllOutbound: false, // 0.0.0.0/0は禁止
    });

    // 必須タグをサービスカタログ側で強制
    cdk.Tags.of(this).add('ManagedBy', 'ServiceCatalog');
    cdk.Tags.of(this).add('Environment', environment.valueAsString);
    cdk.Tags.of(this).add('CostCenter', '${CostCenter}'); // プロビジョニング時に入力必須
  }
}

// ポートフォリオ定義
export class AppFoundationPortfolioStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const portfolio = new servicecatalog.Portfolio(this, 'AppPortfolio', {
      displayName: 'Application Common - Standard Products',
      providerName: 'Platform Engineering Team',
      description: 'セキュリティ・コスト最適化済みの標準プロダクト群',
      messageLanguage: servicecatalog.MessageLanguage.JP,
    });

    // EC2プロダクトを登録
    const ec2Product = new servicecatalog.CloudFormationProduct(this, 'StandardEc2Product', {
      productName: 'Standard EC2 Instance',
      owner: 'Platform Engineering',
      description: 'セキュリティグループ・タグが標準化されたEC2インスタンス',
      productVersions: [
        {
          productVersionName: 'v2.1.0',
          description: 'IMDSv2必須化、gp3デフォルト化',
          cloudFormationTemplate: servicecatalog.CloudFormationTemplate.fromProductStack(
            new StandardEc2ProductStack(this, 'EC2Template')
          ),
        },
      ],
    });

    portfolio.addProduct(ec2Product);

    // 開発者ロールにアクセス権限付与
    portfolio.giveAccessToRole(
      iam.Role.fromRoleName(this, 'DevRole', 'developer-role')
    );

    // Organizations経由でアカウントに共有
    portfolio.shareWithAccount('123456789012'); // 開発アカウント
    portfolio.shareWithAccount('234567890123'); // ステージングアカウント
  }
}

承認ワークフローの実装

本番アカウントへのプロビジョニングには承認ステップを入れた。Service Catalog Enginesを使うと、プロビジョニング前にStep Functionsを発火させて承認フローを走らせられる。これが地味に効いていて、「誰が何を本番に作ったか」が全部Slackのスレッドに残るようになった:

# Step Functions承認ワークフローのLambda(承認リクエスト送信)
import boto3
import json
import os

slack_webhook_url = os.environ['SLACK_WEBHOOK_URL']

def handler(event, context):
    # Service Catalogからのイベント情報
    product_name = event['productName']
    requester = event['requester']
    parameters = event['parameters']
    task_token = event['taskToken']  # Step Functionsの待機トークン
    
    # SSMに承認待ちトークンを保存
    ssm = boto3.client('ssm')
    request_id = f"sc-approval-{context.aws_request_id[:8]}"
    ssm.put_parameter(
        Name=f"/service-catalog/approvals/{request_id}",
        Value=json.dumps({
            'taskToken': task_token,
            'productName': product_name,
            'requester': requester,
        }),
        Type='SecureString',
        Overwrite=True,
    )
    
    # Slackに承認リクエストを通知
    import urllib.request
    message = {
        "blocks": [
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*🔔 本番プロビジョニング承認リクエスト*\n" +
                            f"製品: `{product_name}`\n" +
                            f"申請者: {requester}\n" +
                            f"パラメータ: {json.dumps(parameters, ensure_ascii=False, indent=2)}"
                }
            },
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "✅ 承認"},
                        "style": "primary",
                        "url": f"https://approval.internal.example.com/approve/{request_id}"
                    },
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "❌ 却下"},
                        "style": "danger",
                        "url": f"https://approval.internal.example.com/reject/{request_id}"
                    }
                ]
            }
        ]
    }
    
    req = urllib.request.Request(
        slack_webhook_url,
        data=json.dumps(message).encode(),
        headers={'Content-Type': 'application/json'}
    )
    urllib.request.urlopen(req)
    
    return {'status': 'approval_requested', 'requestId': request_id}

監査ログとしても機能しているので、SOC2対応的にも助かっている。ガバナンス強化が目的の人はSOC2審査でCloudTrail・Config設計が半年泥沼になった実録も合わせて読んでほしい。


1年半運用してわかった、本当の課題

テンプレートの「腐敗」問題

これは正直まだ解決しきれていない。Service Catalogに登録したテンプレートは、AWS側のサービスアップデートで徐々に「古くなる」。例えば、EKS 1.32が出てテンプレートがまだ1.29を指定していたり、EBSのデフォルトがgp2からgp3になったのにテンプレートが更新されていなかったり。誰も気づかないまま古い設定で動き続けるのが一番怖い。

対策として、月次で「テンプレート健康診断」のバッチを走らせている:

#!/bin/bash
# template-health-check.sh
# 登録済みプロダクトのCloudFormationテンプレートをlint + 既知の非推奨設定をチェック

aws servicecatalog search-products-as-admin \
  --filters '{"Owner":["Platform Engineering"]}' \
  --query 'ProductViewDetails[*].ProductViewSummary.ProductId' \
  --output text | tr '\t' '\n' | while read product_id; do
  
  # 最新バージョンのテンプレートURLを取得
  template_url=$(aws servicecatalog describe-provisioning-artifact \
    --product-id "$product_id" \
    --provisioning-artifact-id $(aws servicecatalog list-provisioning-artifacts \
      --product-id "$product_id" \
      --query 'ProvisioningArtifactDetails[-1].Id' \
      --output text) \
    --query 'Info.TemplateUrl' \
    --output text)
  
  # cfn-lintで検査
  aws s3 cp "$template_url" /tmp/template.yaml --quiet
  cfn-lint /tmp/template.yaml --format json 2>/dev/null | \
    jq -r '.[] | select(.Level == "Error") | "[ERROR] ProductID: '"$product_id"' - " + .Message'

done

これを毎月第1月曜日のAM 9時にEventBridge Schedulerで走らせて、エラーがあったらSlackに通知している。地味だけどこれがないとテンプレートが腐り続ける。仕組みとして回すまでが大変で、最初の半年は手動でやっていたのが今思うと恥ずかしい。

導入効果を数値で見てみると

導入前後のインフラチームへの問い合わせ件数とプロビジョニング所要時間を比較した。

xychart-beta
    title "月次インフラ問い合わせ件数の変化"
    x-axis ["2025/1", "2025/4", "2025/7", "2025/10", "2026/1", "2026/4", "2026/7"]
    y-axis "件数" 0 --> 120
    bar [98, 85, 52, 41, 28, 22, 19]
xychart-beta
    title "プロビジョニング平均所要時間(分)"
    x-axis ["導入前", "3ヶ月後", "6ヶ月後", "12ヶ月後", "18ヶ月後"]
    y-axis "分" 0 --> 180
    line [150, 90, 45, 30, 25]

問い合わせ件数が月98件→19件まで落ちた。プロビジョニング時間も150分→25分と1/6になっている。数字で見ると改善幅が大きいが、体感的には最初の6ヶ月は「本当に楽になった感じがしない」時期があった。テンプレートの整備と開発者への教育に時間がかかったからだ。数値が動き始めたのは7ヶ月目以降で、それまではひたすら種まきをしている感覚だった。

マルチアカウントのIaC全般的な設計についてはマルチアカウントIaC、単一アカウントからの3年の失敗と工夫も参考になる。

Service Catalog Enginesで変わったこと

2025年後半からService Catalog EnginesがTerraformネイティブに対応したのはかなり助かった。CloudFormation一本だったところにTerraformチームのテンプレートも取り込めるようになって、組織内の「どっちを使うか論争」が少し落ち着いた。

ただし、Terraform Engineのプロビジョニング状態とCloudFormation側のドリフト検知が統一されていないのは今も課題だ。別々に監視しないといけないのが正直しんどい。この辺はAWSに早く統一してほしいところ。


実際に困ったこと・皆さんはどうしてますか?

地味に今も悩んでいることがある。プロダクトの命名規則と検索性だ。

プロダクト数が50を超えてくると、「あのECSのテンプレートどこ?」が分からなくなる。Service Catalogの検索機能は正直弱くて、タグやDescriptionを充実させても開発者が見つけられない。社内ドキュメント(Confluenceとか)と手動で連携させているが、これはシステム的に解決したい。

AWSのConsoleに「Service Catalog検索」としてAI補完が入ってくるみたいな話があったが、2026年8月時点ではまだ限定的。Amazon Q Developerとの統合が進んでくれると助かるんだけどな、と思っている。皆さんはどうやって社内プロダクトの発見性を高めていますか?


まとめ

1年半Service Catalogを本番運用してわかったことを整理する。

  1. 最初からポートフォリオをドメイン分割する:後から分割するのはかなり辛い。最初に設計を固めるべきだった
  2. バージョン管理の仕組みは必須:自然放置するとバージョン混在地獄になる。Configカスタムルールで自動検知を入れるのが現実的
  3. CDK × Service Catalogの組み合わせは相性が良い:型安全なテンプレート管理と標準化が同時に実現できる。CDK Pipelines の使い方と組み合わせるとさらに良い
  4. 本番への承認フローはStep Functions × Slackが現実解:Service Catalog単体の承認機能は弱いので外部ワークフローと組み合わせる
  5. 「テンプレートの腐敗」は放置厳禁:月次健康診断バッチを入れて定期的にlint + 非推奨設定チェックを自動化する

次のアクション案:

  • まだService Catalogを使っていない人:まず「開発環境のEC2/RDS立て起動」だけService Catalog化してみる
  • 既に導入済みの人:バージョン混在状況をまず棚卸しして、Configカスタムルールを1本書いてみる
  • 問い合わせ削減を測定していない人:導入前後の数値を取ることで投資対効果が明確になる

正直まだ「完璧な運用」には程遠い部分もあるけれど、インフラチームへのSlackが月19件になっただけで十分元は取れていると思っている。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事