ぐるなびにあった2億ファイルをAWSにデータ移行しました

ぐるなびにあった2億ファイルをAWSに移行しました

こんにちは!店舗開発チームの滝口です。

ぐるなびでは、認証・認可のプラットフォーム開発に携わったのち、現在はレストランデータの運用をしつつ、ぐるなび掲載ページや、店舗向け管理画面の開発をしています。

はじめに

このたび、オンプレで稼働していた「非構造化データストレージ(通称:UDS)」をAWSに移行しました。
UDS は NAS に保存されているファイルを REST API を介して CRUD 操作できるシステムで、ぐるなびで掲載している店舗の画像や CSS 、Javascript 等の保存に利用されています。
この記事では NAS に保存されたファイルをどのようにして AWS に移行したのか、その移行方式や AWS アーキテクチャを紹介します。

目次

UDS 基本情報

アクセス数 データ参照 約 900 万/日
(CDN 経由を除くオリジンリクエストのみ)
データ登録・更新・削除 約 100 万/日
データ量 データ数 約 2 億
(ぐるなびで掲載している店舗の画像や javascript 、 css はほぼここに入っています)
データサイズ合計 約 20 TB

今回使った主な AWS

サービス名 用途
CloudFront CDN サービスで、S3のオリジナルキャッシュとして利用しました。
CloudFormation AWS を自動構築するためのサービスで、本記事では S3 の自動構築を紹介します。
CloudWatch データ移行中のエラー検知仕組みとして利用しました。
S3 NAS のデータ移行先として利用しました。
Lambda オンプレ-AWS 間のデータ同期を実現するために利用しました。

AWS を活用して実現したいこと

UDS のサーバが保守切れを迎えるため、その対応を検討していました。
以下がその候補です。

  • 保守延長
  • 単純リプレイス(新しいサーバへの乗せかえ)
  • 新アーキテクチャ

UDS は物理サーバ上で稼働していたのですが、サーバが古かっため、保守延長は今回除外しました。
単純リプレイスでも良かったのですが、サーバリソースの最適化やコストの可視化および削減をしたいと思い、新アーキテクチャも検討することにしました。ぐるなびではクラウドとして AWS の利用が推奨されているため、新アーキテクチャとしては、AWS への移行を前提に構成を検討しました。
総合的に判断して、新アーキテクチャを採用することになりました。
他にも色々あると思いますが、クラウドのメリットをあげておきます。

  • サービスに対して最適なアーキテクチャを考えて適応できる
    • 特にAWSはサービスが豊富で選択肢が多いところが魅力
  • サーバリソース最適化
    • ガラガラなCPUやメモリが最適化される
  • マシンリソースの増減を調整できる
    • 繁忙期だけマシンを増設することも容易に可能
  • コストが可視化されることでROIが明確になる
  • サービスのクローズが容易
    • サーバは一度購入してしまうとしばらく運用し続けなければならないが、クラウドであればいつでもやめられる
  • コピー環境を容易に作れる
    • 本番とは切り離した検証環境の構築が容易になる、Bule/Green デプロイがし易くなる

参考までに、AWS導入検討時にコスト見積もりで使用したツールを紹介します。

AWS 導入におけるアーキテクチャ

UDS と同じ機能を実現するために「CloudFront + S3」の構成にしました。S3 は、NAS に API が追加されたようなサービスで、UDS が提供していたようなファイルの CRUD 操作ができました。

●各 UDS のサーバ説明

CDN UDSが持っているオリジナルファイルのキャッシュ
リサイズサーバ ファイル(画像)のリサイズ
APIサーバ ファイル操作のCRUDを提供
データストア ファイルのパス情報を管理
(ファイルがNASのどこにあるかを管理)
NAS オリジナルファイルを格納

UDSアーキテクチャ
UDSアーキテクチャ

上図は、UDS アーキテクチャを AWS 移行した場合の比較です。
画像の最適化(リサイズ、クロップ、圧縮等)のために Lambda@Edge を使用しています。

AWS への移行方式

これまでは UDS がファイルを NAS で一元管理していましたが、移行後はファイルを登録したチームが直接ファイルを管理できるようにするため、移行時に各チームが保持している AWS アカウントにファイルを配布していきました。
また、並行稼働中に新旧ドメインでアクセス可能なように逆同期の仕組みを用意しました。

データ移行アーキテクチャ
データ移行アーキテクチャ

スケジュール

以下のスケジュールで AWS 移行を実施しました。

データ移行スケジュール
データ移行スケジュール

① 本番データ転送

NAS のファイルを S3 へデータ転送する期間です。初期データ転送が終わったあとは、差分データを転送し続けていました。

転送方法

S3 Sync でデータ転送をしました。S3 Sync は rsync のようなコマンドで、ローカルファイルと S3 を同期してくれます。

aws s3 sync 同期元のディレクトリパス s3://同期先のS3バケット名/同期先のディレクリパス --exact-timestamps --delete
 
例)
aws s3 sync /home/nas01/ s3://hoge-s3sync-bucket/home/nas01/ --exact-timestamps --delete

オプション説明

--exact-timestamps 同じサイズのファイルで、タイムスタンプが違ければ同期する

--delete 同期元に存在しない、同期先のファイルが削除される

詳細はこちら
ただし、NAS をまるごとそのまま S3 Sync で同期すると、ファイル数が多すぎるため、途方もない時間がかかってしまいます。そこで、NAS のディレクトリをある程度の単位に分割し、並列で転送するようにしました。
以下は NAS のディレクトリ階層例です。

.
└── home
    └── nas01
        ├── hoge01
        │   ├── 001.jpg
        │   ├── 002.jpg
        │   ├── 003.jpg
        │   ├── :
        │   ├── :
        :   :
        ├── hoge02
        │   ├── 001.jpg
        │   ├── 002.jpg
        │   ├── 003.jpg
        │   ├── :
        │   ├── :
        :   :
        ├── hoge03
        │   ├── 001.jpg
        │   ├── 002.jpg
        │   ├── 003.jpg
        │   ├── :
        │   ├── :
        :   :

以下のようにディレクトリを分けて、複数サーバからコマンドを実行しました。

aws s3 sync /home/nas01/hoge01/ s3://hoge-s3sync-bucket/home/nas01/hoge01/ --exact-timestamps --delete
aws s3 sync /home/nas01/hoge02/ s3://hoge-s3sync-bucket/home/nas01/hoge02/ --exact-timestamps --delete
aws s3 sync /home/nas01/hoge03/ s3://hoge-s3sync-bucket/home/nas01/hoge03/ --exact-timestamps --delete
:

データ転送イメージ
データ転送イメージ

ディレクトリ変換

UDS を参照していたときと同じ URL でファイルを閲覧できるように、NAS のディレクトリ構造を変換しながらデータ転送をしました。 具体的には以下のようなディレクトリ変換をしました。

NAS /rest/img/AA/ZZ/店舗ID/◯◯.jpg
ハッシュ文字列を削除(赤字部分)
S3 /rest/img/店舗ID/◯◯.jpg

上記の変換を実現するために、テンポラリーの S3 バケットにデータを入れ、それをトリガーに Lambda を実行させ変換処理をしました。

クロスアカウント

今回、複数の AWS アカウントに対して、ファイルを振り分けながらデータ転送しました。クロスアカウントは S3 のバケットポリシーで実現しました。(クロスアカウントするサービスが、S3 のみであれば AssumeRole は使わずとも実現ができます。) 以下はバケットポリシーの例となります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::移行用AWSアカウントのID:root"
            },
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::S3バケット名",
                "arn:aws:s3:::S3バケット名/*"
            ]
        }
    ]
}

クロスアカウントイメージ
クロスアカウントイメージ

権限(ACL)変更

Lambda から、S3 に配置したファイルの権限を変更することで以下を実現しました。

  • CloudFront から S3 参照(公開権限)
  • S3 のクロスリージョンレプリケーション(オーナー権限)

レプリケーションするにはファイルにオーナー権限が必要なため、別AWSアカウントから登録したファイルには、オーナー権限を付与する必要があります。そのため、同期先にファイルを更新後、以下のように権限を変更しました。 以下はそのときのサンプルコードです。(nodejs)

(プリセットのACLにpublic-readがありますが、これだと公開権限しか付かないため、別途オーナー権限を付与する必要があります。)

const aws = require('aws-sdk');
const s3 = new aws.S3();
 
// S3バケットACLを取得
var bucketAcl = s3.getBucketAcl({
    Bucket: 転送先のS3バケット
});
 
// ACL変更
s3.putObjectAcl({
    Bucket: 転送先のS3バケット,
    Key: S3のディレクトリパス,
    GrantFullControl: 'id=' + bucketAcl.Owner.ID,  // オーナー権限付与
    GrantRead: 'uri=http://acs.amazonaws.com/groups/global/AllUsers',  // 公開権限
})

必要な権限イメージ
必要な権限イメージ

プロキシサーバ 高負荷問題

S3 Sync を本番環境で試したところ社内のプロキシサーバが高負荷になり、ぐるなびサービスのレスポンスが遅延する問題がありました。原因はサービスと同じプロキシサーバを使っていたためでした。S3 Sync ように一度に大量データを同期する場合、ネットワーク帯域を制限するか、サービスとは別のプロキシサーバを構築することが必要です。今回のデータ移行では、後者で対応しました。

プロキシサーバ高負荷問題
プロキシサーバ高負荷問題

② 更新の切り替え

データ更新は、UDS が用意した SDK を使っています。SDK は、今回のデータ移行で、S3 を更新するように改修しました。更新はデータ不整合が発生しないようにメンテナンス日にサービスを停止して切り替えを実施しました。

SDK切り替えイメージ
SDK切り替えイメージ

パフォーマンス改善

AWS に限った話ではありませんが、クラウド化することでオンプレに比べ、物理的に距離の遠くなるクラウドは、レスポンス性能が劣化します。オンプレのときは、以下の処理のようにファイル更新をファイル数分ループするような処理でもパフォーマンスはそこまで問題にはなりませんでした。

for ( $items as $item) {
    putObject($item);
}

ところが、クラウド化することで、一件あたりのレスポンス性能が少しずつ遅くなることにより、大量更新時にちりつもで、サービスのパフォーマンスが下がってしまう問題がありました。そこで、大量更新にも耐えられるように、一件ずつループするのではなく、一括で処理できるようにSDKを改修しました。
以下はそのときのサンプルコードです。(PHP)
aws-sdk-php2.8を使っています。

// 一度に一括更新するのは600件まで(それ以上の場合、分割)
// ※ あまりに大量だとメモリが逼迫してしまうため
$objectsChunk = array_chunk($objects, 600);
 
$putBatch = array();
$putAclBatch = array();
 
// 600件ごとにループ
foreach($objectsChunk as $chunk) {
 
    // 更新対象をオブジェクト型にひとまとめする
    foreach($chunk as $object) {

        $putBatch[] = $this->awsS3Client->getCommand('PutObject', array(
            'Bucket'     => $bucket,
            'Key'        => $object['Key'],
            'SourceFile' => $object['SourceFilePath'],
        ));
    }
 
    // ひとまとめにしたオブジェクトをまとめて実行
    $putResults = $this->awsS3Client->execute($putBatch); 
 
    // 処理結果をハンドリング
    foreach($putResults as $result) {
        if ($result instanceof S3Exception) {
            // error handle...
        }
    }
}

③ 参照の切り替えと並行稼働

AWS に移行したことでこれまで参照に使っていたドメインが変更になりました。

●旧ドメイン https://uds.gnst.jp/rest/img/店舗ID/◯◯.jpg

●新ドメイン https://rimage.gnst.jp/rest/img/店舗ID/◯◯.jpg

UDSの参照は、利用者が多いため以下の方針で切り替えを実施しました。

  • 社内チームからの参照
    →並行稼働期間内に新ドメインへの切り替え対応を実施

  • 社外からの参照(例えば個人ブログに店舗の画像をリンクしている等)
    →旧ドメインへのアクセスがあった場合、新ドメインへリダイレクトさせる(ドメイン救済措置)

並行稼働について

並行稼働中は UDS と S3 両方への参照があるため、S3 への更新を UDS 側(NAS)にも反映させる仕組みを作りました。S3 への更新をトリガーに Lambda を実行させ、UDS API を経由してファイルを NAS に保存しました。

並行稼働イメージ
並行稼働イメージ

ドメイン救済措置

UDSのファイル(主に画像)は、個人ブログ等にリンクが貼られていたり、検索エンジン等のサムネイルで参照されていたりと、こちらからはコントロールできない部分のアクセスがそこそこあります。そのため、旧ドメインへのアクセスをしばらくの間、新ドメインへリダイレクトさせる救済措置を行いました。

参照救済イメージ
参照救済イメージ

バックアップ

これまでは NAS の snapshot 機能でデータの断面を定期的に取得していましたが、S3 にその機能はありません。 代替として、S3 のバージョニング機能を使いました。 バージョニングは、有効化することで、ファイルに更新があるたびに履歴を持ってくれます。これにより、誤ってファイルを削除してしまっても、履歴から復元することができるようになります。

バージョニング設定
バージョニング設定

災害対策

S3 は東京リージョンを使っているのですが、万が一東京リージョンがダウンした場合に備えて、ソウルにも同じファイルをレプリケーションするようにしました。ソウルとマルチリージョン化することで可用性を向上させました。レプリケーションルールは以下となります。

レプリケーションルール設定
レプリケーションルール設定

移行中にエラーが発生した場合のエラー通知

Lambda でエラーが発生した場合に、Slack 通知するような仕組みを作りました。細かい部分は割愛しますが、エラー通知は以下の流れで設定できます。

1. CloudWatch Logs にメトリクスフィルターを追加
フィルターパターンは"ERROR"とする

2. アラームを作成

3. トピックを作成

4. サブスクリプションを作成

Slack通知はLambdaでします。以下はLambdaのサンプルコードです。 (nodejs)

const https = require ('https');
const url = require('url');
const moment = require('moment');

const ENDPOINT = process.env.SLACK_ENDPOINT_URL;
const PAYLOAD  = {
  "channel": process.env.SLACK_CHANNEL,
  "username": process.env.SLACK_USERNAME,
  "text": "",
  "icon_url": process.env.SLACK_ICON_URL,
};

exports.handler = async (event) => {
  // slack 通知
  let result = await postSlack(event.Records[0].Sns);
  return result;  
};

const postSlack = function requestToAPI(data) {
  console.log('■□■□ Slackへの投稿処理 ■□■□');
  return new Promise(
    (resolve, reject) => { 

      PAYLOAD.text = 'ここにエラーメッセージ';

      const parser = url.parse(ENDPOINT);
      let req, body = '';

      let options = {
        host : parser.host,
        port : 443,
        path : parser.path,
        method : 'POST',
        headers : {
          'Content-Type' : 'application/json',
          'Authorization': `Bearer ${process.env.SLACK_API_TOKEN}`, 
        }
      };

      // slack へのリクエスト
      req = https.request(options, (res) => {
        res.setEncoding('utf8');
        res.on('data', (chunk) => {
          body += chunk;
        });
        res.on('end', () => {
          if(res.statusCode == 200) {
            console.log('■□■□ レスポンス情報 ');
            console.log(body);
            resolve(body);
          } else {
            console.log('■□■□ レスポンス情報なし ');
          }
        });
      });

      req.write(JSON.stringify(PAYLOAD)); 
      req.on('error', (e) => {
        console.error(`problem with request: ${e.message}`);
        reject(e);
      });
      req.end();
  });
};

エラー発生時のリトライ

データ移行中のエラーは、バッチオペレーションでリトライしました。CloudWatch Logs Insights でエラーリストを作成し、それをバッチオペレーション経由で Lambda を実行させました。エラーリストは以下のクエリ結果となります。

fields Bucket, Key
| filter @message like /ERROR/
| filter ispresent(Key)
| sort @timestamp desc

S3 の構築方法

AWS には CloudFormation というアーキテクチャを自動構築するためのサービスがあります。

ここでは、S3 構築時の設定ファイルを紹介します。
CloudFormation は json or yaml で記述ができますが、コメントが入れられるので yaml での記述がおすすめです。

Parameters

S3 バケット名は、世界一意にする必要があるため、環境名をパラメータとして受け取るようにしました。

Parameters:
  # テスト環境 or 本番環境
  ENV:
    Type: String
    Description: (Required) "test" or "prod"
    AllowedValues:
      - test
      - prod
    ConstraintDescription: must specify prod, or test.
  # S3バケット名
  UdsBucketGroup:
    Type: String
    Description: (Required) enter the UDS bucket group. For example, rest
    AllowedPattern: ^[a-z-]+$
    ConstraintDescription: ^[a-z-]+$ only

Mappings

Mappings を設定することで CloudFormation 内で定数のような使い方ができます。

Mappings:
  Map01:
    # 移行用AWSアカウントのID
    # 以下は、UDSのAWSアカウントIDが設定です。
    UdsAwsAccountId:
      prod: 111111111111
      test: 222222222222

Resources

サービスの構築内容を Resources に記述していきます。

Resources:
  # -.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.
  # S3
  # -.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.-.
  ContentS3Bucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    Properties:
      # S3バケット名
      BucketName: !Sub '${ENV}-${UdsBucketGroup}'
      # パブリックアクセス定義
      PublicAccessBlockConfiguration:
        BlockPublicAcls: false
        BlockPublicPolicy: false
        IgnorePublicAcls: false
        RestrictPublicBuckets: false
      # バージョニング
      VersioningConfiguration:
        Status: Enabled
      # ライフサイクル
      LifecycleConfiguration:
        Rules:
          - Id: NoncurrentVersionExpirationRule
            Status: Enabled
            # 以前のバージョンの移行先
            NoncurrentVersionTransitions:
              - StorageClass: 'STANDARD_IA'
                TransitionInDays: 30
            # 以前のバージョンの有効期限(無期限としたいため設定なし)
            # NoncurrentVersionExpirationInDays: 30
      # クロスリージョンレプリケーション
      ReplicationConfiguration:
        Role: !Sub 'arn:aws:iam::${AWS::AccountId}:role/ContentS3ReplicationBucketRole-${ENV}-${UdsBucketGroup}'
        Rules:
          - Id: ReplicationRule
            Status: Enabled
            Prefix: ''
            Destination:
              Bucket:
                !Join [
                  '',
                  ['arn:aws:s3:::', !Sub '${ENV}-${UdsBucketGroup}-replica'],
                ]
              StorageClass: STANDARD_IA
      # 通知設定
      # UDS移行のためのLambdaに対してトリガーを設定します。
      # UDS移行後に、設定は削除するため、ドリフトとして検出されます。
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: s3:ObjectCreated:*
            Function: !Join
              - ''
              - - 'arn:aws:lambda:ap-northeast-1:'
                - !FindInMap [Map01, UdsAwsAccountId, !Ref ENV]
                - ':function:uds-sync'
          - Event: s3:ObjectRemoved:*
            Function: !Join
              - ''
              - - 'arn:aws:lambda:ap-northeast-1:'
                - !FindInMap [Map01, UdsAwsAccountId, !Ref ENV]
                - ':function:uds-sync'
  # バケットポリシー
  ContentS3BucketPolicy:
    Type: AWS::S3::BucketPolicy
    DeletionPolicy: Retain
    Properties:
      Bucket: !Ref ContentS3Bucket
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          # S3同期Lambda(UDS→S3)からのアクセスを許可
          - Action:
              - s3:*
            Effect: Allow
            Resource:
              - !Join ['', ['arn:aws:s3:::', !Ref ContentS3Bucket]]
              - !Join ['', ['arn:aws:s3:::', !Ref ContentS3Bucket, '/*']]
            Principal:
              AWS: !Join
                - ''
                - - 'arn:aws:iam::'
                  - !FindInMap [Map01, UdsAwsAccountId, !Ref ENV]
                  - ':root'
          # S3バケットの削除を制限
          - Action:
              - s3:DeleteBucket
            Effect: Deny
            Resource:
              - !Join ['', ['arn:aws:s3:::', !Ref ContentS3Bucket]]
            Principal: '*'
          # CloudFrontからのアクセスを許可
          - Action:
              - s3:GetObject
              - s3:PutObject
            Effect: Allow
            Resource:
              - !Join ['', ['arn:aws:s3:::', !Ref ContentS3Bucket, '/*']]
            Principal:
              CanonicalUser:
                - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          - Action:
              - s3:ListBucket
            Effect: Allow
            Resource:
              - !Join ['', ['arn:aws:s3:::', !Ref ContentS3Bucket]]
            Principal:
              CanonicalUser:
                - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

Outputs

構築内容を参照して CloudFormation の画面に表示させることができます。

Outputs:
  S3BucketSecureURL:
    Value: !Join ['', ['https://', !GetAtt [ContentS3Bucket, DomainName]]]
    Description: Name of S3 bucket to hold website content

さいごに

今回のデータ移行は多くの方にご協力いただきました。関係者のみなさまありがとうございました。本記事のノウハウがどなたかの一助になれば幸いです。

Special Thanks

アマゾン ウェブ サービス ジャパン株式会社
・武市 慶太郎 さん
・桑野 章弘 さん


滝口
趣味はバスケとスノボ。英会話とピアノを習いたいと思ってかれこれ1年以上経過。
好きな言葉は「Done is better than perfect.完璧を目指すよりまず終わらせろ。」
現在『キングダム』からチームビルディングを勉強中。