Article

クラウド移行事例:オンプレミスからAWSへ移行し運用コストを30%削減

高田晃太郎
クラウド移行事例:オンプレミスからAWSへ移行し運用コストを30%削減

統計が示す傾向は明確だ。複数の第三者調査ではクラウド支出の約3割が浪費されているとの推定もある¹。FlexeraのState of the Cloud 2024では、企業のクラウド支出の約28%が未利用や過剰プロビジョニングに起因する無駄とされ、Gartnerも非効率なリソース運用が30%の超過支出を生むと報告している。裏を返せば、設計と運用の見直しだけで二桁の最適化余地があるということだ。また、オンプレミスからAWSへの移行がコスト削減につながるとする独立調査もある²。本稿では、老朽化したオンプレミスを抱えた中堅規模のEC事業者を想定した一般的な移行シナリオを題材に、AWSへ移行して「約30%の削減」を狙うための設計判断、実装、ベンチマーク、運用の定常化までを連続したストーリーとして解剖する。数値は公開統計や一般的な相場感に基づく目標値・試算として提示し、再現可能な根拠と実装を示す。

ビジネス要件と移行の設計原則

移行の起点はビジネス要件の明確化にある。季節波動の大きいECでは、ピークトラフィックに合わせた固定設備は恒常的な遊休資産を生みやすい。狙いは、年間の平準期に近いベースラインへリソースを合わせ、ピークはオートスケール(負荷に応じた自動増減)で弾性吸収する構えだ。対応方針は三段構えが実務的で、まずはリフト&シフト(最小変更での移行)に準じてダウンタイム最小でAWSへ退避し、同時にコンテナ化とIaC(Infrastructure as Code)で運用の可塑性を確保する。続いて、計測に基づくリサイズと購入戦略でランレートを押し下げ、最後にアプリのI/Oボトルネックとクエリプランを最適化して性能あたりのコストをさらに圧縮する。

成功のための制約条件も初期に固定する。例として、RTO(目標復旧時間)は30分、RPO(目標復旧時点)は5分、セキュリティはゼロトラスト原則とIAM境界で統制し、全ての変更はGitOpsで追跡可能にする。可用性はマルチAZ、性能はP95(95%のリクエストがこの時間以内)の応答時間を420msから300ms台へ、運用目標はデプロイ頻度を週1から日次へ、MTTR(平均復旧時間)は2時間から30分へ短縮する、といった目標例を数値で握る。KPIが具体化されていれば、以降の設計判断は迷いにくい。

アーキテクチャ選定の妥当性

アプリは状態を外出しできる前提とし、ステートレス層はAmazon ECS on Fargateへ、セッションはAmazon ElastiCache for Redis、データはAmazon Aurora PostgreSQLへ載せ替える。ネットワークは三つのAZにプライベートサブネットを分け、踏み台なしでAWS Systems Manager Session Managerを用いる運用とする。トラフィックはAmazon CloudFrontとALBの多段で捌く。調達は1年のCompute Savings Plansを30%カバレッジで開始し、実測に合わせて段階的に引き上げる方式が現実的だ。初期から全面を固定化しないことで、学習サイクルに投資判断を同期できる³。

設計と実装の要点:ネットワーク、データ移行、アプリの青緑化

基盤はTerraformで一貫管理する。VPC、サブネット、ルーティング、ECSクラスター、ALB、セキュリティグループに至るまで、差分適用とコードレビューに統一することで、人手作業のばらつきを排せる。以下はVPCとECSの最小構成例であり、環境差異はtfvarsで吸収している。

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.region
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  name    = var.name
  cidr    = var.cidr
  azs     = var.azs
  private_subnets = var.private_subnets
  public_subnets  = var.public_subnets
  enable_nat_gateway = true
}

resource "aws_ecs_cluster" "app" {
  name = "${var.name}-cluster"
}

resource "aws_lb" "public" {
  name               = "${var.name}-alb"
  internal           = false
  load_balancer_type = "application"
  subnets            = module.vpc.public_subnets
}

データ移行はAWS Database Migration Serviceを主軸に設計する。初期ロードはスナップショット、差分はCDC(変更データキャプチャ)で追随させ、アプリの書き込みを段階的に新系へ誘導する。boto3でのタスク起動は例外処理とリトライを備える。ログはCloudWatchに集約し、失敗時はEventBridgeで通知する。

import os
import time
import logging
from botocore.exceptions import ClientError
import boto3

logging.basicConfig(level=logging.INFO)
dms = boto3.client('dms')

def start_replication(task_arn: str) -> None:
    try:
        resp = dms.start_replication_task(
            ReplicationTaskArn=task_arn,
            StartReplicationTaskType='reload-target'
        )
        logging.info("task started: %s", resp['ReplicationTask']['Status'])
    except ClientError as e:
        logging.exception("failed to start task: %s", e)
        raise

if __name__ == '__main__':
    task_arn = os.environ.get('TASK_ARN')
    if not task_arn:
        raise SystemExit("TASK_ARN is required")
    start_replication(task_arn)
    while True:
        desc = dms.describe_replication_tasks(Filters=[{"Name":"replication-task-arn","Values":[task_arn]}])
        status = desc['ReplicationTasks'][0]['Status']
        logging.info("status=%s", status)
        if status in ("stopped", "failed", "ready"):
            break
        time.sleep(15)

アプリ層の移行ではダウンタイムを抑えるため、青緑デプロイを前提にパイプラインを組む。GitHub ActionsからECRへイメージをプッシュし、ECSのBlue/GreenをCodeDeployで切り替える。ヘルスチェックはP95を監視するカナリアで守り、エラー率の微増でも自動ロールバックがかかるよう設計する⁴。

name: deploy-app
on:
  push:
    branches: [ main ]
jobs:
  build-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
      - uses: aws-actions/amazon-ecr-login@v2
      - name: Build
        run: |
          docker build -t $IMAGE_NAME:$GIT_SHA .
          docker tag $IMAGE_NAME:$GIT_SHA $ECR/$IMAGE_NAME:$GIT_SHA
      - name: Push
        run: docker push $ECR/$IMAGE_NAME:$GIT_SHA
      - name: Deploy
        run: |
          aws ecs update-service \
            --cluster $CLUSTER \
            --service $SERVICE \
            --force-new-deployment

性能検証はk6での事前ロードテストを繰り返し、閾値違反のパスを洗い出す。下のスクリプトはカート操作とチェックアウトの複合トランザクションを模すもので、P95の遷移とエラー率をSLO(合意したサービス品質目標)と一致させて可視化できる。

import http from 'k6/http';
import { sleep, check } from 'k6';
export const options = {
  vus: 200,
  duration: '10m',
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<300'],
  },
};
export default function () {
  let res = http.get(`${__ENV.BASE_URL}/products`);
  check(res, { 'list ok': (r) => r.status === 200 });
  res = http.post(`${__ENV.BASE_URL}/cart`, JSON.stringify({ sku: 'abc', qty: 1 }));
  check(res, { 'cart ok': (r) => r.status === 200 });
  res = http.post(`${__ENV.BASE_URL}/checkout`, JSON.stringify({ method: 'card' }));
  check(res, { 'checkout ok': (r) => r.status === 200 });
  sleep(1);
}

ゼロダウンタイム切替の実務

本番切替は段階的に行う。最初は読み込みトラフィックの一部を新系に流し、キャッシュのホット化とALBターゲットのウォームアップを待つ。その後、書き込みのシャドウテストを行い、DMSのラグが5分以内で安定したことを確認してから書き込みを新系に集約する。DNSのTTLは前日から短縮し、Route 53ヘルスチェックの閾値は厳しめに設定して可逆性を担保する。こうした手順を踏めば、実運用でもダウンタイムをゼロ〜数分に抑えやすい。カート内の二重書き込みなどは監査ログで全件追跡できるように仕立てておくと安全だ。

可観測性とコスト最適化:SLO準拠の運用とFinOps

移行後の運用は、可観測性とFinOps(クラウド費用の継続改善)を中核に据えて定常化させる。SLOはリクエストの可用性99.95%、P95の応答時間300ms未満、エラー率1%未満とし、SLO違反予算の消費速度を週次でレビューする。メトリクスはCloudWatch、分散トレースはAWS Distro for OpenTelemetry経由でX-Rayへ、ログは統一スキーマでCloudWatch Logsに集約し、アラートは多段で抑制する。タグ運用を厳格化し、チーム別、環境別、機能別にコストを割り振ることで、責任と改善の単位を一致させる⁵。

コスト可視化はCost Explorer APIを用いて日次のランレートを取得し、Savings Plansのカバレッジと利用率を監視する。下記のPythonは当月のサービス別コストを取得し、しきい値を超えた場合にエラーを投げる単純な例だが、Slack通知やJIRA発券に接続すれば毎日の健全性チェックになる。

import datetime as dt
import logging
import os

import boto3
from botocore.exceptions import ClientError

ce = boto3.client('ce')
logging.basicConfig(level=logging.INFO)

def month_to_date_cost() -> float:
    start = dt.date.today().replace(day=1).isoformat()
    end = dt.date.today().isoformat()
    try:
        resp = ce.get_cost_and_usage(
            TimePeriod={'Start': start, 'End': end},
            Granularity='DAILY',
            Metrics=['UnblendedCost'],
            GroupBy=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}]
        )
        total = sum(float(day['Metrics']['UnblendedCost']['Amount'])
                    for day in resp['ResultsByTime'])
        return total
    except ClientError:
        logging.exception("cost query failed")
        raise

if __name__ == '__main__':
    budget = float(os.getenv('BUDGET', '1000'))
    cost = month_to_date_cost()
    logging.info("MTD cost=%.2f", cost)
    if cost > budget:
        raise SystemExit(f"budget exceeded: {cost} > {budget}")

タグ準拠の徹底には、Create/Updateイベントに対する検査と是正の自動化が効く。以下はNode.jsのLambdaで、必須タグが欠けたリソースを検出して通知する簡易版だ。実運用ではOrganizationsのService Control Policyと組み合わせ、逸脱をそもそも作らない構えに寄せる⁵。

import { CloudWatchLogsClient, PutLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs';
import { EC2Client, DescribeTagsCommand } from '@aws-sdk/client-ec2';

const required = ['Owner', 'CostCenter', 'Env'];
const ec2 = new EC2Client({ region: process.env.AWS_REGION });
const logs = new CloudWatchLogsClient({ region: process.env.AWS_REGION });

export const handler = async (event) => {
  const id = event.detail?.responseElements?.instanceId;
  if (!id) return;
  const desc = await ec2.send(new DescribeTagsCommand({
    Filters: [{ Name: 'resource-id', Values: [id] }]
  }));
  const keys = new Set(desc.Tags.map(t => t.Key));
  const missing = required.filter(k => !keys.has(k));
  if (missing.length === 0) return;
  const msg = `Instance ${id} missing tags: ${missing.join(',')}`;
  await logs.send(new PutLogEventsCommand({
    logGroupName: process.env.GROUP,
    logStreamName: process.env.STREAM,
    logEvents: [{ message: msg, timestamp: Date.now() }]
  }));
};

購入戦略と権限管理の実装ディテール

購入戦略は実測値に追随させるのが要諦だ。初月はオンデマンド中心とし、安定稼働しているコンピュートのフットプリントを週次で計測してから、1年のCompute Savings Plansを徐々に積み上げる³。FargateとLambdaの混在でもCompute SPが横断的に効き、カバレッジの弾力性が高い³。Auroraの読み取りはリードレプリカでスケールし、ピークの短時間帯はAuto Scalingで吸収する。権限はIAMロールとSCPで境界を切り、開発チームはアカウント単位でサンドボックスを持ち、本番はChange Manager経由での変更に限定する。これにより、事故のblast radius(影響範囲)を最小化し、運用の心理的安全性も高めやすい。

成果、ベンチマーク、学び:30%削減の内訳

結論から言えば、一般的なECワークロードでは、設計・運用の見直しだけで約20〜30%のコスト最適化を狙える余地がある¹²。例えば、年間ランレートが3,000万円規模の構成であれば、以下のような内訳で削減が見込まれるケースがある(試算例)。権限委譲とリサイズで約10〜15%、Savings Plansで約8〜12%、GravitonベースのFargate採用で約5〜8%、ストレージ階層化とライフサイクル管理で約2〜5%。性能面の改善例としては、P95の応答時間が420msから270〜300ms台、P99が820msから500ms前後へ短縮されることがある。可用性は99.90%から99.95%前後に向上し、エラー率は1%台から1%未満へ低下する、といったベンチマーク結果も再現されやすい。デプロイは日次の自動化が定着し、MTTRは30分前後までの短縮が期待できる。これらはk6とCloudWatch Syntheticsの併用で週次トレンドとして可視化し、四半期ごとにSLOを見直すことで維持しやすい。

移行のタイムラインは、目安として合計16週間程度が現実的だ。初期4週間でディスカバリとTCO見積もり、次の6週間で基盤のIaC整備とステージングへのリハース、残る期間でデータの増分同期と青緑切替の反復検証を行う。落とし穴になりやすい論点もある。たとえば、オンプレ時代のスロークエリがEBSのスループット制限に触れて増幅される事象は起きがちで、Aurora移行と同時にクエリプランの見直しとインデックス最適化を並走させるのが有効だ。また、Fargateのタスク起動時間がピーク帯でばらつく問題は、キャパシティプロバイダとウォームプールのチューニングで解消しやすい。ログコストの増加は構造化とサンプリング、保存期間の短縮で抑制し、必要な監査ログはS3 Glacier Flexible Retrievalへ退避するのが定石だ。

最後に、開発体験の向上が運用コストの低減に直結する点を強調したい。プラットフォームチームがテンプレート化したサービスブループリントを配布し、開発者は数行の設定で新サービスを立ち上げられるようにする。これにより、標準から外れた構成の持ち込みが減り、運用手順も画一化される。結果として、アラート疲れの解消とインシデントの早期鎮火が進み、人的コストも含めたTCOの削減につながる。

付録:アプリの優雅な終了と接続ドレイン

ALB配下のコンテナは接続ドレインと優雅な終了を実装しておくことで、青緑切替時のエラーを顕著に減らせる。GoのHTTPサーバでの実装例を載せる。SIGTERMを捕捉して、新規受付停止、存続接続のタイムアウト待機、タイムアウト後の強制終了という順で畳む。

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    srv := &http.Server{Addr: ":8080"}
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("graceful shutdown failed: %v", err)
    }
}

まとめ:測る、直す、固定しすぎない

この移行シナリオが示すのは、測定に基づく段階的な最適化と、固定しすぎない調達戦略の組み合わせが、無駄の多い運用コストに最短距離で切り込むという事実だ。設計段階でSLOを合意し、IaCと青緑デプロイで変更の摩擦を小さくし、実測に応じて権限委譲と購入を進める。この素朴な原則を愚直に回すだけで、移行の学習曲線はビジネス価値へ素早く変換される。もし次の四半期に同様の移行を検討しているなら、まずは現行のP95、エラー率、デプロイ頻度とMTTRを週次のダッシュボードに載せ、Savings Plansの初期カバレッジを小さく始めて、効果の出るところから確実に削るのがいい。あなたの環境で何を最初に測り、どこから直すのか。その問いに答える準備ができたとき、30%削減は着地点ではなく通過点になりうる。

参考文献

  1. IBM Blog: How to limit cloud cost waste with FinOps, accountability and automation. https://www.ibm.com/blog/how-to-limit-cloud-cost-waste-with-finops-accountability-and-automation/
  2. AWS Insights Blog: Moving from on-premises to the cloud with AWS delivers significant cost savings, report finds. https://aws.amazon.com/blogs/aws-insights/moving-from-on-premises-to-the-cloud-with-aws-delivers-significant-cost-savings-report-finds/
  3. AWS Savings Plans: Compute Pricing. https://aws.amazon.com/savingsplans/compute-pricing/?nc1=h_ls
  4. AWS DevOps Blog: Blue/Green deployments to Amazon ECS using AWS CloudFormation and AWS CodeDeploy. https://aws.amazon.com/blogs/devops/blue-green-deployments-to-amazon-ecs-using-aws-cloudformation-and-aws-codedeploy/
  5. AWS Cloud Financial Management Blog: Create and enforce your tagging strategy for more granular cost visibility. https://aws.amazon.com/blogs/aws-cloud-financial-management/gs-create-and-enforce-your-tagging-strategy-for-more-granular-cost-visibility/