Article

Infrastructure Drift検出と自動修正の仕組み作り

高田晃太郎
Infrastructure Drift検出と自動修正の仕組み作り

統計や事故調査の蓄積は、Infrastructure Drift(設定ドリフト)を軽視することがどれほど高くつくかを物語ります。Gartnerは2020年までにクラウドのセキュリティ失敗の95%が顧客側に起因すると予測し¹、HashiCorp関連の調査でもマルチクラウド採用は一般化し、回答者の76%が導入済みと報告されています²。手作業の変更やツール間の差異が重なると、期待した状態と実際のインフラが乖離するのは必然です。現場では、変更申請を経ない緊急対応やコンソール操作が累積して、リリース直前にIaCとの差が一気に顕在化するパターンが繰り返し観測されます。だからこそ「検出の高速化」と「自動修正という安全弁」を両輪で設計し、検出遅延(MTTD)を2分程度まで縮め、平均復旧時間(MTTR)を約半減させることを狙う現実的な仕組みが、エンジニアリング生産性とガバナンスの両立に直結します。

インフラDriftの正体とビジネス影響

Infrastructure Drift(ドリフト)とは、定義された望ましい状態と実インフラの差分が継続的に発生する現象を指します³。IaC(Infrastructure as Code: コードでインフラを宣言管理)の宣言と実環境の差、KubernetesのDesired/Currentの差、クラウドアカウント内の手動変更や外部API連携の副作用など、入口は複数あります。重要なのは、差が発生した瞬間に気付ける(イベント駆動で検知できる)仕掛けがあるか、そして差が発生しても自律的に安全な状態へ戻る(オートリメディエーション)仕組みを設けているかです。検出までの遅延が長いほど、差分は連鎖し、評価・再現・ロールバックのコストが指数的に膨らみます。

ビジネスへの影響は可用性、セキュリティ、コストで明確に観測できます。セキュリティでは意図しない広範な通信許可や公開設定が脆弱性の窓を開きます。可用性ではオートスケールやヘルスチェックの閾値逸脱がMTTR(Mean Time To Recovery)を押し上げます。コストでは無効化されたライフサイクルポリシーや過剰なスループット設定が月次請求を押し上げます。定量的なSLO(Service Level Objective)として、検出遅延、自己修復成功率、手動介入比率、望ましい状態への収束時間などをモニタリングすれば、経営と現場が同じダッシュボードで健全性を議論できるようになります。イベント駆動の検出に切り替えるだけで平均検出時間が十数分から1~2分台に短縮し、合わせて週次の手動修正件数が大幅に減少する、といった報告も一般に見られます。

Driftが起きる主因を技術的に分解する

原因は三層に整理できます。まずプロセス面では緊急時の例外運用と権限制御の甘さが、変更の非同期化を招きます。次にツール面ではIaC、構成管理、Kubernetes、クラウドマネージドの四象限で責務境界が曖昧になり、どこがソースオブトゥルース(唯一の正)かが崩れます。最後にアーキテクチャ面ではイベントの可観測性が低く、CloudTrail(AWSの操作監査ログ)やAudit LogからのフィードがCIに連結されないことで、差分が放置されます⁸。これらを逆手に取れば、ソースオブトゥルースの一本化、イベント駆動の検出、低リスクな自動修正の三点でアーキテクチャを再構成すればよいと分かります³。

ROIをどう説明するか

投資対効果は人的コストの削減だけでなく、インシデント回避による機会損失の抑制で評価します。例えば、月間で高優先度の設定逸脱が二十件、解析と修正に一件あたり二時間、エンジニアの実コストが時給一万円だとすると、直接コストだけで月四百万円規模です。検出をイベント駆動化し、標準化した自動修正プレイブックで五割を自己修復できれば、単純計算でも月二百万円規模の節約が見込めます。加えてSLA逸脱の回避は売上保全に効くため、インフラ投資として説明が立ちます。

検出と自動修正のアーキテクチャ設計

まずソースオブトゥルースを明示します。クラウドとKubernetesの宣言はGit(バージョン管理)で一元管理し、TerraformやPulumiなどのIaCがクラウドの望ましい状態を、HelmやKustomizeがKubernetesの望ましい状態を担います。実環境の変更イベントはCloudTrailやAudit Log、KubernetesのAdmission Webhook(リクエストの事前検査フック)で受け、イベントブリッジ(AWS EventBridge等)やメッセージングを経由して検出パイプラインに流します⁸。検出は二系統を併用すると堅牢です。ひとつはスケジュールでのリフレッシュ計画差分、もうひとつはイベント起点の差分計算です。二段構えにすることで、ログ欠落や一時的なAPI障害に対する冗長性を確保します。

自動修正はリスクとインパクトで階層化します。高リスクなネットワークやアイデンティティに関しては、ブロックリスト方式の即時ロールバックに限定し、監査証跡を残して担当者への承認通知を飛ばします。低リスクなタグ、暗号化、バージョン管理、S3の公開ブロックなどは安全なプリミティブに閉じるため、完全自動での収束を許容します⁴。KubernetesではGitOps(宣言と実環境の自動同期)のAuto-Syncを許可した上で、保護対象のネームスペースは強制化し、変更はPull Request経由のみに絞ります⁷。これらは運用ルールではなくシステムの制約として実装することがポイントです。

セキュリティと可観測性も設計段階から織り込みます。検出と修正のすべてのアクションは監査ログと相関できるようにトレースIDを付与し、メトリクスは検出件数、修正成功率、リトライ回数、承認待ち時間を時系列で蓄積します。ダッシュボードはSLOベースで、検出遅延のp50(中央値)とp95、収束時間のp50とp95、自己修復率の移動平均を第一画面に配置します。通知は即時のアラートだけではなく、週次で逸脱の根本原因カテゴリをレポートし、権限過大やレビュー未経由といったプロセス改善に結びつけます。

実装ガイドとコード例

ここからは、中規模以上のインフラを想定し、イベント駆動のドリフト検出と安全な自動修正(オートリメディエーション)を組み合わせた具体例を示します。いずれも本番適用前にステージングでのゲームデイ(意図的障害演習)を推奨します。

AWS S3の公開リスクを即時収束するLambda(Python)

S3のパブリックアクセスブロックとバケットポリシーを強制する例です。CloudTrailのPutBucketPolicyイベントをトリガーにして、望ましくない公開を見つけ次第、ブロックとポリシー修正を行います⁴。

import json
import os
import logging
from typing import Any, Dict

import boto3
from botocore.exceptions import ClientError, EndpointConnectionError

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

s3 = boto3.client("s3")

PUBLIC_DENY_STATEMENT = {
    "Sid": "DenyPublicReadWrite",
    "Effect": "Deny",
    "Principal": "*",
    "Action": ["s3:GetObject", "s3:PutObject"],
    "Resource": []
}


def ensure_public_access_block(bucket: str) -> None:
    try:
        s3.put_public_access_block(
            Bucket=bucket,
            PublicAccessBlockConfiguration={
                "BlockPublicAcls": True,
                "IgnorePublicAcls": True,
                "BlockPublicPolicy": True,
                "RestrictPublicBuckets": True,
            },
        )
        logger.info("Public access block enforced for %s", bucket)
    except ClientError as e:
        if e.response.get("Error", {}).get("Code") == "NoSuchBucket":
            logger.warning("Bucket %s not found", bucket)
        else:
            raise


def patch_bucket_policy(bucket: str) -> None:
    try:
        policy_doc = {"Version": "2012-10-17", "Statement": []}
        try:
            current = s3.get_bucket_policy(Bucket=bucket)
            policy_doc = json.loads(current["Policy"])  # type: ignore
        except ClientError as e:
            if e.response["Error"]["Code"] != "NoSuchBucketPolicy":
                raise
        PUBLIC_DENY_STATEMENT["Resource"] = [f"arn:aws:s3:::{bucket}/*"]
        statements = [s for s in policy_doc.get("Statement", []) if s.get("Sid") != "DenyPublicReadWrite"]
        statements.append(PUBLIC_DENY_STATEMENT)
        policy_doc["Statement"] = statements
        s3.put_bucket_policy(Bucket=bucket, Policy=json.dumps(policy_doc))
        logger.info("Policy patched for %s", bucket)
    except (ClientError, EndpointConnectionError) as e:
        logger.error("Policy patch failed for %s: %s", bucket, e)
        raise


def handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    try:
        detail = event.get("detail", {})
        bucket = detail.get("requestParameters", {}).get("bucketName")
        if not bucket:
            logger.warning("No bucket in event")
            return {"status": "ignored"}
        ensure_public_access_block(bucket)
        patch_bucket_policy(bucket)
        return {"status": "remediated", "bucket": bucket}
    except Exception as e:
        logger.exception("Unhandled error: %s", e)
        return {"status": "error", "message": str(e)}

この関数は二段階で安全側に倒します。まず強制的にパブリックアクセスブロックを有効化し、その後にバケットポリシーへ明示的なDenyを注入します。冪等性を保っているため、再実行されても副作用が増幅しません。運用環境やリージョン構成にもよりますが、イベント受領から収束までの目標値は1~2分台に置くと実務的です。

Security Groupの逸脱を検出し収束するGo実装

高リスクなネットワーク設定は即時ロールバックが基本です。以下は過剰な0.0.0.0/0の入出力ルールを検出し、所定のポート以外は削除する例です⁵。

package main

import (
    "context"
    "log"
    "os"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    ec2 "github.com/aws/aws-sdk-go-v2/service/ec2"
    "github.com/aws/aws-sdk-go-v2/service/ec2/types"
)

func main() {
    groupID := os.Getenv("SECURITY_GROUP_ID")
    allowedPort := int32(443)

    ctx := context.Background()
    cfg, err := config.LoadDefaultConfig(ctx)
    if err != nil { log.Fatal(err) }
    client := ec2.NewFromConfig(cfg)

    desc, err := client.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{
        GroupIds: []string{groupID},
    })
    if err != nil { log.Fatal(err) }

    if len(desc.SecurityGroups) == 0 { log.Fatal("sg not found") }
    sg := desc.SecurityGroups[0]

    var revokeIn []types.IpPermission
    for _, p := range sg.IpPermissions {
        for _, r := range p.IpRanges {
            if aws.ToString(r.CidrIp) == "0.0.0.0/0" && aws.ToInt32(p.FromPort) != allowedPort {
                revokeIn = append(revokeIn, p)
                break
            }
        }
    }

    if len(revokeIn) > 0 {
        _, err = client.RevokeSecurityGroupIngress(ctx, &ec2.RevokeSecurityGroupIngressInput{
            GroupId:       aws.String(groupID),
            IpPermissions: revokeIn,
        })
        if err != nil { log.Fatal(err) }
        log.Printf("revoked %d ingress rules", len(revokeIn))
    }
}

本番では許可リストの管理を外部化し、意図しない削除を避けます。処理の一貫性確保のため、CloudWatch Logsで実行結果を相関できるリクエストIDを必ず付けます。

Pulumi TypeScriptでタグ準拠を強制する

コストや責任の可視化にはタグ準拠が効きます。Pulumiで必須タグを付与し、欠落時はデプロイを失敗させて収束させます⁶。

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

const cfg = new pulumi.Config();
const env = cfg.require("env");

function withRequiredTags(tags: Record<string, string>) {
  const base = { "owner": cfg.require("owner"), "env": env };
  return { ...base, ...tags };
}

const bucket = new aws.s3.Bucket("logs", {
  bucket: pulumi.interpolate`my-logs-${env}`,
  tags: withRequiredTags({ "cost-center": cfg.require("costCenter") }),
});

export const bucketName = bucket.bucket;

CIではpulumi previewの結果を必ず記録し、差分に必須タグが含まれない変更は承認を保留します。ドリフトしてタグが外れた場合も、次の適用で自動的に再付与されます。

Terraformのrefresh-only計画を自動実行するPython

手動変更の検出にはrefresh-onlyが有効です(実インフラの状態を取り込み差分のみを表示)。以下はリポジトリ内のTerraformワークスペースごとに計画を実行し、差分を検出したらSlackへ通知するスニペットです。

import subprocess
import json
import os
import sys
from typing import Tuple

def run(cmd: list[str]) -> Tuple[int, str]:
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    out, _ = proc.communicate()
    return proc.returncode, out

workspace = os.getenv("TF_WORKSPACE", "default")
code, _ = run(["terraform", "init", "-input=false"])
if code != 0:
    sys.exit("terraform init failed")

code, out = run(["terraform", "plan", "-refresh-only", "-no-color", "-out=plan.out"])

has_changes = "No changes. Your infrastructure matches" not in out
print(out)

if has_changes:
    # TODO: send to Slack or create GitHub issue
    print("Drift detected in workspace:", workspace)
    sys.exit(2)
else:
    print("No drift detected")

定期実行だけに頼らず、CloudTrailから該当ディレクトリの計画に絞って起動すれば、検出遅延を大幅に削減できます⁸。適用は人手の承認を求めるか、低リスクの変更に限って自動化します。

Kubernetesの設定ドリフトを修復するPythonクライアント

GitOpsに加え、最小限の自己修復を備えると強い場面があります。ConfigMapの重要キーが逸脱したら望ましい内容で上書きする例です。

from kubernetes import client, config
from kubernetes.client.exceptions import ApiException

DESIRED = {"featureFlag": "enabled"}

config.load_incluster_config()
api = client.CoreV1Api()

namespace = "app"
name = "settings"

try:
    cm = api.read_namespaced_config_map(name, namespace)
    if cm.data != DESIRED:
        cm.data = DESIRED
        api.replace_namespaced_config_map(name, namespace, cm)
        print("configmap reconciled")
    else:
        print("no drift")
except ApiException as e:
    if e.status == 404:
        body = client.V1ConfigMap(metadata=client.V1ObjectMeta(name=name), data=DESIRED)
        api.create_namespaced_config_map(namespace, body)
        print("configmap created")
    else:
        raise

Admissionで禁止できる変更は入口で止め、やむを得ないケースのみ自己修復に委ねます。Argo CDのPruneとSelfHealを有効化した上で、基盤系ネームスペースは強制同期にしておくと安定します⁷。

イベント駆動とスケジュールの併用で盲点を潰す

イベント駆動のみだとAuditログ欠損や一部サービスの非記録イベントで取りこぼしが発生し、スケジュールのみだと検出遅延が伸びることがあります。両者を併用し、イベントでは即時検出、スケジュールでは完全性検査という役割分担にすると、p95の検出遅延を2分弱、取りこぼし率を1%未満といった実務的な水準を目標にしやすくなります。さらに、修正の自動化を低リスク項目へ限定することで、誤修正リスクを抑制できます。

運用、計測、そして継続改善

運用においては四つの観点が肝心です。まずリリースフローの前後でドリフトを見つける前提の運用設計にし、期待と実体が一時的に離れる時間を最小化します。次に人手が触る必要がある領域は最小化し、承認フローと変更の可観測性を高めます。三点目としてメトリクスの連続監視を行い、SLOの逸脱をチームのOKRと紐づけて改善の意思決定を迅速化します。最後にゲームデイを定例化し、意図的に逸脱を起こして検出と修正の経路を踏み抜き、ドキュメントの更新と自動化のカバレッジ拡大に繋げます。

具体的な指標としては、検出遅延の中央値と上位パーセンタイル、自己修復成功率、承認待ちの平均時間、リトライ回数、そしてDrift件数のカテゴリ別内訳が有用です。実務の現場では、S3公開設定の自動修正導入後にセキュリティレビューの指摘が減少したり、夜間のインシデント呼び出しが大幅に減ったりする、といった効果が報告されることもあります。コスト面でも、タグ準拠の強制によって未割り当てコストが減り、部門別のコスト配賦が進むことで、スループット見直しやReserved/Spot最適化に踏み出しやすくなります⁶。

継続改善では、ドリフトの多発領域を地図化して責務の境界を見直します。ネットワークやIAMのような高リスク領域は宣言の厳格さを上げ、デプロイの自動化比率を下げても安全側へ倒します。逆にストレージ、メタデータ、監視設定は自動化のカバレッジを広げ、誤修正の余地を狭めます。標準化の成果物であるモジュールやテンプレートを社内レジストリで配布し、逸脱の余地そのものを消していくと、検出や修正の負担も相対的に下がっていきます。

関連トピックとして、GitOpsの設計指針、クラウド監査基盤、SREのSLO設計は今回の仕組みと無理なく接続できます。

まとめ:小さく始めて、検出遅延をまず潰す

Infrastructure Driftは完全に消す対象ではなく、発生しても速やかに安全側へ戻る設計へと視点を切り替えるのが現実的です。ソースオブトゥルースを明示し、イベント駆動検出で遅延を縮め、低リスクから自動修正を有効化していくという順序なら、チームの負荷を上げずに成果を狙えます。まずは一つの高頻度・低リスク領域に絞り、検出から収束までの道筋を通し、メトリクスで効果を可視化してください。次にどの領域を広げるかはデータが教えてくれます。あなたのインフラで、明日から「2分以内の検出と自動収束」を一つだけでも目標にするとしたら、まずどの設定から着手しますか。

参考文献

  1. WALLIX Blog. Gartner: By 2020, 95% of cloud security failures will be the customer’s fault.
  2. PR TIMES. クラウド導入の実態:ほとんどの企業はすでにマルチクラウドを採用済み(調査回答者の76%)。
  3. Spacelift Blog. Infrastructure Drift Detection: what it is and how to detect it.
  4. AWS Storage Blog. Understanding S3 Block Public Access.
  5. Wiz Academy. AWS Security Groups Best Practices.
  6. Infracost Docs. Tagging policies: why tagging matters for cost allocation and showback.
  7. Argo CD Docs. Auto-Sync and Self Heal.
  8. AWS Security Blog. Using CloudTrail to identify unexpected behaviors in individual workloads.