AWS Service Catalog 12ヶ月運用して気づいたセルフサービス化の現実

「誰が作ったかわからないEC2で月200万溶けた」事件がきっかけでService Catalogを導入。テンプレート設計の失敗・承認フローの迷走・Terraform連携の罠、正直に書きます。

Service Catalog本番導入12ヶ月で気づいたセルフサービス化の現実と設計パターン

うちのチームがAWS Service Catalogを本格導入したのは去年の夏ごろ。きっかけは「誰が作ったかわからないEC2インスタンスで月200万円溶けてた」という事件で、その後からインフラのガバナンスを一気に締め直した流れの中にあった。最初は「プロダクトカタログ管理なんて大企業がやるやつでしょ」と半信半疑だったけど、12ヶ月運用してみて、正直これなしでは戻れない体になってしまった。

ただ、導入がスムーズだったかというと全然そんなことはなくて、テンプレート設計の失敗・承認フローの見直し・Terraform連携の罠など、地味に痛い思いをたくさんした。今回はそういう「実際に手を動かしてわかったこと」を中心に書いていく。


Service Catalogを導入する前の状況と、導入を決めた理由

導入前のうちのチームは、開発者が直接AWS ConsoleやTerraformで好き勝手にリソースを作れる状態だった。もちろん一定のIAMポリシーはかけていたけど、それも年月とともにカオスになっていて「このロールなぜかS3の全権限ある」みたいな状態が散見されていた。

典型的な問題は3つあって、どれも「なんとなくわかってたけど見て見ぬふりしてた」系のやつだった。

  • タグ付けが属人化していて、コスト配賦が機能しない — プロジェクトコードのタグを付け忘れる開発者が後を絶たず、月末のコスト集計で担当者が泣く光景が定番になっていた。
  • 承認なしで本番相当のインスタンスが立ち上がる — 開発環境のつもりでr7g.4xlargeを作って放置、みたいなケースが月に数件起きていた。
  • セキュリティ設定のバラつき — S3バケットのパブリックアクセスブロック、暗号化設定、VPCエンドポイント経由かどうか、人によってバラバラ。SOC2対応の文脈でも「同じ種類のリソースなのに設定が違いすぎる」と指摘を受けていた。

Service Catalogの導入はこれらを一気に解決する賭けだったわけだけど、最初の3ヶ月は「本当にこれで解決できるのか?」と思いながら作業していた。


アーキテクチャ全体像:マルチアカウント構成でどう組んだか

うちはAWS Organizationsでアカウントを分けていて、Service Catalogのポートフォリオは管理アカウントから共有する構成にした。以下が実際の構成図。

graph TB
    subgraph Management["管理アカウント (Org Management)"]
        SC_HUB["Service Catalog Hub<br/>ポートフォリオ管理"]
        SSM_PARAM["SSM Parameter Store<br/>共有設定値"]
        CFN_TEMPLATE["CloudFormation Templates<br/>S3バケット"]
    end

    subgraph SharedServices["Shared Services アカウント"]
        TF_BACKEND["Terraform Backend<br/>S3 + DynamoDB"]
        CODEPIPELINE["CodePipeline<br/>テンプレートCI/CD"]
        SNS_NOTIFY["SNS<br/>承認通知"]
    end

    subgraph ProdAccount["本番アカウント"]
        subgraph VPC_PROD["VPC (10.0.0.0/16)"]
            subgraph AZ_A["AZ-a"]
                EC2_PROD["EC2 (Catalog経由)"]
                RDS_PROD["RDS (Catalog経由)"]
            end
            subgraph AZ_B["AZ-b"]
                EC2_PROD_B["EC2 (Catalog経由)"]
                RDS_PROD_B["RDS (Standby)"]
            end
            ENDPOINT["VPC Endpoint<br/>Service Catalog API"]
        end
        SC_LOCAL["Service Catalog<br/>ローカルポートフォリオ"]
    end

    subgraph DevAccount["開発アカウント"]
        subgraph VPC_DEV["VPC (10.1.0.0/16)"]
            EC2_DEV["EC2 (Catalog経由)"]
            S3_DEV["S3 (Catalog経由)"]
        end
        SC_DEV["Service Catalog<br/>ローカルポートフォリオ"]
    end

    SC_HUB -->|"ポートフォリオ共有<br/>(Org共有)"| SC_LOCAL
    SC_HUB -->|"ポートフォリオ共有<br/>(Org共有)"| SC_DEV
    CFN_TEMPLATE -->|"テンプレート参照"| SC_HUB
    CODEPIPELINE -->|"テンプレート更新"| CFN_TEMPLATE
    SC_LOCAL --> ENDPOINT
    SC_DEV -->|"リソース作成"| EC2_DEV
    SC_DEV -->|"リソース作成"| S3_DEV
    SC_LOCAL -->|"リソース作成"| EC2_PROD
    SC_LOCAL -->|"リソース作成"| RDS_PROD
    SNS_NOTIFY -->|"Slack通知"| CODEPIPELINE
    SSM_PARAM -->|"パラメータ注入"| SC_HUB

ポイントは「テンプレートのCI/CDパイプラインを別途持つ」こと。最初はS3に手動でZIPを上げていたんだけど、テンプレートが増えるにつれてバージョン管理が破綻した。CodePipelineでGitHub → S3 → Service Catalog更新を自動化したのが大きな転換点だった。あの手動アップロード地獄には二度と戻りたくない。


テンプレート設計で失敗したこと3つ

失敗1:パラメータを増やしすぎた

最初の設計では「柔軟性を持たせよう」という意図で、EC2プロビジョニングのテンプレートに30個以上のパラメータを持たせた。インスタンスタイプ、AMI ID、サブネット、セキュリティグループ、EBSサイズ、暗号化有無、タグ5種類…… 全部入力可能にしてしまった。

結果として、開発者から「項目が多すぎてどれを入力すればいいかわからない」というフィードバックが来た。セルフサービス化のはずが、逆に使いにくいフォームになってしまった。個人的にはこれが一番の失敗だったと思っている。

正解は「ユースケース別に分割する」こと。EC2テンプレートを「開発用Web Server」「バッチ処理用」「踏み台サーバー」の3つに分けて、パラメータを5〜8個に絞った。AMI IDは最新のAmazon Linux 2023を自動参照するSSMパラメータに差し替えた。

# 改善後のAMI ID参照方法
Parameters:
  LatestAmiId:
    Type: 'AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>'
    Default: '/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64'
    Description: "最新Amazon Linux 2023(自動更新)"

これで開発者はAMI IDを調べる必要がなくなった。地味に便利。

失敗2:承認フローを全種類に入れた

最初は「リソース作成には全部承認が必要」というルールにした。セキュリティ的には正しいんだけど、開発アカウントのt3.micro 1台にも承認が飛んでくる状態になってしまって、承認者(シニアエンジニア)が1日に20件以上の承認リクエストを処理する羽目になった。当然ながら「こんなの意味ないよ」という不満が噴出した。

見直し後は環境とコストのしきい値で段階化した。

環境コスト推定承認フロー
dev月$100未満自動承認
dev月$100以上TL承認
stg月$300未満TL承認
stg月$300以上Manager承認
prodすべてManager + Security承認

コスト推定はService NotificationsのSNSトピックにLambdaを繋げて、プロビジョニング前にAWS Pricing APIで概算を出す仕組みを作った。正直まだ精度は80%くらいで、Spot使うケースだと外れることもあるけど、承認するかどうかの判断材料としては十分だった。

失敗3:タグを強制したつもりが抜け道があった

CloudFormationのConstraintでタグを必須にしていたんだけど、AllowedValuesを設定していないタグキーは空文字を入れられてしまうことに気づかなかった。CostCenterに空白を入れてプロビジョニングしていた人が何人かいた。これは盲点だった。

対策としてSCP(Service Control Policy)と二重のチェックを入れた。Service Catalogでの入力バリデーションと、CloudFormation Hooksで最終確認する2段構えにした。CloudFormation Hooksの実装はこちらの記事が参考になる

# タグのAllowedPatternで空白を拒否
Parameters:
  CostCenter:
    Type: String
    Description: "コストセンターコード (例: CC-12345)"
    AllowedPattern: "CC-[0-9]{5}"
    ConstraintDescription: "CC-XXXXX形式で入力してください"

  ProjectCode:
    Type: String
    Description: "プロジェクトコード"
    AllowedPattern: "[A-Z]{2,5}-[0-9]{4}"
    ConstraintDescription: "XX-YYYY形式で入力してください"

TerraformベースのService Catalog製品を作る話

Service CatalogというとCloudFormationのイメージが強いけど、2024年後半からTerraform Open Source/Terraform Cloud連携が実用的になってきていて、うちのチームは一部のプロダクトをTerraformベースに移行した。正直Terraformに慣れているチームにとっては、こっちの方が書きやすいのでかなり助かっている。

構成はこんな感じ。

flowchart TD
    USER(["開発者"]) -->|"カタログからプロビジョニング"| SC["Service Catalog"]
    SC -->|"External Product Type"| CONNECTOR["ServiceCatalog-Terraform-OSS<br/>CloudFormation Stack"]
    CONNECTOR -->|"Terraform Plan/Apply"| TERRAFORM_LAMBDA["Lambda<br/>(Terraform実行エンジン)"]
    TERRAFORM_LAMBDA -->|"State管理"| S3_STATE["S3 Terraform State"]
    TERRAFORM_LAMBDA -->|"ロック"| DYNAMO_LOCK["DynamoDB<br/>State Lock"]
    TERRAFORM_LAMBDA -->|"リソース作成"| AWS_RESOURCES["AWSリソース群"]
    SC -->|"イベント通知"| EVENTBRIDGE["EventBridge"]
    EVENTBRIDGE -->|"Webhook"| SLACK["Slack<br/>承認・通知"]
    TERRAFORM_LAMBDA -->|"実行ログ"| CLOUDWATCH["CloudWatch Logs"]

この構成で一番ハマったのはStateファイルの管理。Service Catalogの製品ごとにStateが分かれるのはいいんだけど、製品を削除したときにStateが残る問題があった。Lambdaにクリーンアップ処理を組み込んで、EventBridgeのSERVICE_CATALOG_PRODUCT_DELETEDイベントをトリガーにして自動削除するようにした。これを忘れると孤立したStateファイルがじわじわ溜まっていくので要注意。

実際のTerraformテンプレートのディレクトリ構成はこんな感じにした。

service-catalog-products/
├── ec2-web-server/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── README.md
├── rds-aurora/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── README.md
└── s3-data-bucket/
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    └── README.md

variables.tfはService Catalogのパラメータと1:1で対応するように設計した。

# rds-aurora/variables.tf の一部
variable "environment" {
  description = "環境名 (dev/stg/prod)"
  type        = string
  validation {
    condition     = contains(["dev", "stg", "prod"], var.environment)
    error_message = "環境はdev、stg、prodのいずれかを指定してください。"
  }
}

variable "db_instance_class" {
  description = "DBインスタンスクラス"
  type        = string
  default     = "db.r7g.large"
  validation {
    condition     = can(regex("^db\\.(r7g|r8g)\\.(large|xlarge|2xlarge)$", var.db_instance_class))
    error_message = "承認されているインスタンスクラスを指定してください。"
  }
}

variable "cost_center" {
  description = "コストセンターコード"
  type        = string
  validation {
    condition     = can(regex("^CC-[0-9]{5}$", var.cost_center))
    error_message = "CC-XXXXX形式で入力してください。"
  }
}

Terraformのvalidationブロックでも入力値チェックを二重にかけておくと、CloudFormation Constraintと合わせて安心感がある。


12ヶ月運用してわかった効果と現実的な課題

導入前後で数字がどう変わったかをまとめると、こんな感じになった。

xychart-beta
    title "Service Catalog導入前後の月次比較(左:導入前 / 右:導入後)"
    x-axis ["タグ漏れ件数", "未承認リソース件数", "セキュリティ指摘件数", "コスト超過アラート"]
    y-axis "件数" 0 --> 50
    bar [42, 18, 23, 31]
    bar [3, 1, 4, 8]

タグ漏れは42件→3件、未承認リソース作成は18件→1件まで落ちた。正直ここまで改善するとは思っていなかった。数字で見るとインパクトが大きいけど、地味に効いているのはチームの「あれ、このリソース誰が作ったんだっけ」という会話がほぼなくなったこと。

一方で、現実的な課題もある。

テンプレートのメンテナンスコストが地味に重い。AWSのサービスアップデートや社内セキュリティポリシーの変更があるたびにテンプレートを更新する必要があって、現在30個以上のプロダクトを管理している。CI/CDパイプラインで自動テストを組んでいるものの、全製品のテストサイクルに30分以上かかるようになってきた。

バージョン管理の扱いも悩ましい。Service CatalogはテンプレートのVersioningができるけど、既存の製品インスタンスをどのタイミングで新バージョンに更新するかのポリシーが難しい。今は「新バージョンリリース後30日以内に移行必須」というルールにしているけど、移行時にわずかなダウンタイムが発生するケースがあって、まだ完全な解決策が見えていない。

開発者への周知も継続的な課題。特に外部の協力会社から参加するメンバーへのオンボーディングが大変で、「なぜService Catalog経由でしか作れないのか」の説明コストがそれなりにある。ここはインシデント対応の仕組みと絡めてドキュメントを整備しているところ。

このあたりの「カタログ製品の品質管理」については正直まだ試行錯誤中で、半年後にはまた違う答えを出しているかもしれない。皆さんのチームではどうやって管理していますか?


実際に動かしているSlack通知の仕組み

承認フローをSlackに統合するのが使い勝手の観点でかなり重要だった。コンソールを開かずに承認の流れを把握できるだけで、承認者の負担がだいぶ減った。Service CatalogのイベントはEventBridgeに飛ぶので、そこからLambda → Slack Incoming Webhookという構成。

import json
import os
import boto3
import urllib.request
from datetime import datetime, timezone

def lambda_handler(event, context):
    """
    Service Catalog承認リクエストをSlackに通知する
    EventBridgeのRULE_SATISFIED / PROVISION_PRODUCTイベントを処理
    """
    detail = event.get('detail', {})
    event_type = event.get('detail-type', '')
    
    if 'PROVISION_PRODUCT' in event_type:
        message = build_provision_message(detail)
    elif 'APPROVAL' in event_type:
        message = build_approval_message(detail)
    else:
        return {'statusCode': 200, 'body': 'Unsupported event type'}
    
    send_to_slack(message)
    return {'statusCode': 200, 'body': 'Notification sent'}


def build_approval_message(detail: dict) -> dict:
    """承認リクエストのSlackメッセージを構築"""
    product_name = detail.get('productName', 'Unknown')
    requester = detail.get('provisioningArtifactName', 'Unknown')
    token = detail.get('approvalToken', '')
    
    # 承認/却下URLを生成
    approve_url = f"https://console.aws.amazon.com/servicecatalog/home?region=ap-northeast-1#/approval"
    
    return {
        "blocks": [
            {
                "type": "header",
                "text": {
                    "type": "plain_text",
                    "text": "⚠️ Service Catalog 承認リクエスト"
                }
            },
            {
                "type": "section",
                "fields": [
                    {"type": "mrkdwn", "text": f"*製品名:*\n{product_name}"},
                    {"type": "mrkdwn", "text": f"*申請者:*\n{detail.get('principal', 'Unknown')}"},
                    {"type": "mrkdwn", "text": f"*環境:*\n{detail.get('environment', 'Unknown')}"},
                    {"type": "mrkdwn", "text": f"*コスト推定:*\n${detail.get('estimatedCost', 'N/A')}/月"}
                ]
            },
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {"type": "plain_text", "text": "承認する"},
                        "style": "primary",
                        "url": approve_url
                    }
                ]
            }
        ]
    }


def send_to_slack(message: dict):
    webhook_url = os.environ['SLACK_WEBHOOK_URL']
    data = json.dumps(message).encode('utf-8')
    req = urllib.request.Request(
        webhook_url,
        data=data,
        headers={'Content-Type': 'application/json'}
    )
    with urllib.request.urlopen(req) as response:
        return response.read()

承認ボタンをSlackに直接出すと、コンソールに飛ばずに処理できるようにしたかったけど、AWS側がSlack Actionsからのトークン処理に対応していないため、現状はコンソールへのリンクを踏む方式になっている。ここは今後改善したい箇所の一つ。


まとめ

Service Catalog導入12ヶ月で得た知見をまとめると、以下の5つに集約される。

  1. テンプレートはユースケース別に分割し、パラメータは最小限に絞る — 柔軟性より使いやすさを優先した方が定着率が上がる
  2. 承認フローはコストと環境で段階化する — 全部に承認を入れると承認者が疲弊して形骸化する
  3. Terraform連携は使えるが、StateファイルのライフサイクルはLambdaで自動管理が必須 — 削除時の残骸を放置するとどんどん増える
  4. タグ強制はService Catalog + SCP + CloudFormation Hooksの3層構造が安定 — 単体では必ず抜け道ができる
  5. テンプレートCI/CDパイプラインは最初から作る — あとから追加すると既存テンプレートの整理が大変

次のアクションとしては、まずテンプレートが少ないチームなら2〜3製品でパイロット導入してみることをおすすめする。EC2とS3の2種類から始めれば、承認フローとタグ強制の感覚がつかめる。Terraform連携はCloudFormationに慣れてからでも遅くない。

まだ解決できていない課題(既存製品インスタンスのバージョン更新戦略)については引き続き試行錯誤中なので、知見がある方はコメントかTwitter/Xで教えてもらえると嬉しい。

U

Untanbaby

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

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

関連記事