[R2] S3 -> R2 마이그레이션 with S3 API

루나·2022년 10월 20일
0

회사에서 S3에 에셋을 담아두고 있었는데 요금 관련해서 R2가 더 저렴하다고 판단되어 이동하게 됐다.
S3에 이미 파일이 n백개 있었으며 지정되어 있는 헤더 등을 온전히 옮기기 위해 R2에서 제공해주는 S3 API를 사용하기로 했다.

R2에서의 S3 API는 현재 구현중이며 아직 지원하지 않는 부분이 굉장히 많았는데 특히 이쪽 서비스에서 필요한 업로드 진행도를 뽑아내는데에 고생했다. 특히 aws-sdk-js-v3을 사용했을 때 제대로 동작하지 않는 부분이 많았으며 R2에서는 v2를 사용하기로 했다.


시나리오는 S3 버킷 내의 모든 파일들의 키를 뽑고, 키로 파일을 찾아와 R2에 업로드한다.
우선 의존 라이브러리를 설치한다.

yarn add aws-sdk @aws-sdk/client-s3

S3 관련은 v3(@aws-sdk/client-s3)을 사용하고 R2에 업로드 하는 것은 v2(aws-sdk)를 사용할 것이다.

! 아래 코드는 글을 작성했을 당시(돌아가는 로직) 기준이며 최신 코드는 Github을 참조해주세요.
https://github.com/Luna-Runa/s3-to-r2

S3

// ./s3Client.ts
import { S3Client } from '@aws-sdk/client-s3';

export const s3Client = new S3Client({
  region: 'ap-northeast-2', // your region
  endpoint: `https://s3.ap-northeast-2.amazonaws.com`, // your endpoint
  credentials: {
    accessKeyId: {accessKeyId},
    secretAccessKey: {secretAccessKey},
  },
});

export const S3_BUCKET = {bucketName} as const;
// ./index.ts
import { ListObjectsCommand } from '@aws-sdk/client-s3';
import { s3Client, S3_BUCKET } from './s3Client.js';

export const getKeysByBucket = async () => {
  try {
    const files = await s3Client.send(
      new ListObjectsCommand({ Bucket: S3_BUCKET }),
    );

    const keys = files.Contents.map((item) => item.Key);

    return keys; // ['key1', 'key2', 'key3']
  } catch (err) {
    console.log('Error', err);
  }
};

export const getFileByKey = async (getBucketParams: GetObjectCommandInput) => {
  try {
    const data = await s3Client.send(new GetObjectCommand(getBucketParams));

    return data;
  } catch (err) {
    console.log('getFileByKey Error : ', err);
  }
};

getKeysByBucket으로 버킷 내의 모든 키를 불러온 뒤 getFileByKey에 하나씩 넣어주면 된다.

R2

// ./r2Client.ts
import { S3Client } from '@aws-sdk/client-s3';
import S3 from 'aws-sdk/clients/s3.js';

export const r2ClientV2 = new S3({
  endpoint: `https://{accountId}.r2.cloudflarestorage.com`,
  accessKeyId: {accessKeyId},
  secretAccessKey: {secretAccessKey},
  signatureVersion: 'v4',
});

export const r2ClientV3 = new S3Client({
  region: 'auto',
  endpoint: `https://{accountId}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: {accessKeyId},
    secretAccessKey: {secretAccessKey},
  },
});

export const R2_BUCKET = {bucketName} as const;
// ./index.ts
import { r2ClientV2, r2ClientV3, R2_BUCKET } from './r2Client.js';

// R2의 버킷 CORS는 대쉬보드에서 설정이 불가능하기 때문에 여기서 설정해줘야된다.
export const putBucketCors = async () => {
  try {
    const data = await r2ClientV3.send(
      new PutBucketCorsCommand({
        Bucket: R2_BUCKET,
        CORSConfiguration: {
          CORSRules: [
            {
              AllowedHeaders: ['*'],
              AllowedMethods: ['HEAD', 'GET', 'PUT', 'POST', 'DELETE'],
              AllowedOrigins: ['*'],
              ExposeHeaders: ['ETag'],
            },
          ],
        },
      }),
    );
  } catch (err) {
    console.log('putBucketCors Error : ', err);
  }
};

await putBucketCors();

let i = 1;

export const uploadFile = async (uploadBucketParams: PutObjectRequest) => {
  try {
    const fileName =
      uploadBucketParams.ContentDisposition.split('filename=')[1];

    const upload = r2ClientV2.upload(uploadBucketParams, (err, data) => {
      console.log(`${i} / ${keys.length} Complete!`);
      fs.appendFileSync(
        'log.txt',
        `${fileName.padEnd(50, ' ')} ${i++} / ${keys.length} Complete!\n`,
      );
    });

    // file upload progress, progress.total is not working
    upload.on('httpUploadProgress', (progress) => {
      console.log(
        `${fileName.padEnd(50, ' ')} progress : ${
          (progress.loaded / uploadBucketParams.ContentLength) * 100
        }`,
      );
    });
  } catch (err) {
    console.log('uploadFile Error : ', err);
  }
};

먼저 Bucket의 CORS 설정을 해주기 위한 putBucketCors를 실행시킨다.
S3는 대쉬보드에서 만들면서 설정 가능하지만 R2에서는 API를 이용해서 설정해야한다.
여기선 굳이 v2를 사용할 이유는 없으니 v3를 사용했다.

uploadFile 함수로 PutObjectRequest를 받아 실제 업로드를 처리한다.

이쪽 시나리오에서는 버킷에 저장하는 파일의 키는 uuid로 저장하고 ContentDisposition에 filename= 뒤에 원본 파일 이름이 들어있었기 때문에 fileName을 저렇게 설정했다.

업로드 진행도를 확인하기 위해 AWS.S3.upload (v2)를 사용했다.
upload 함수의 콜백으로 어떤 파일이 완료되었고 n개중 i번째인지를 로그 파일로 추출한다.
만약 파일 이름을 버킷에 들어가는 파일 키 그 자체로 지정하고 있다면 fileName 대신에 이 함수의 콜백 인자에서 data.Key를 이용해 추출하면 된다.

upload.on을 이용해 progress를 추출한다.
는 progress.loaded는 다행히 정상적으로 제공해주지만 progress.total은 undefined로 넘어오기 때문에 param으로 줬던 ContentLength를 사용한다.

이제 필요한 함수는 모두 구현했다.

전체 코드

// ./index.ts
import {
  GetObjectCommand,
  GetObjectCommandInput,
  ListObjectsCommand,
  PutBucketCorsCommand,
} from '@aws-sdk/client-s3';
import { s3Client, S3_BUCKET } from './s3Client.js';
import { PutObjectRequest } from 'aws-sdk/clients/s3.js';
import { r2ClientV2, r2ClientV3, R2_BUCKET } from './r2Client.js';
import fs from 'fs';

// run this once
// export const putBucketCors = async () => {
//   try {
//     const data = await r2ClientV3.send(
//       new PutBucketCorsCommand({
//         Bucket: R2_BUCKET,
//         CORSConfiguration: {
//           CORSRules: [
//             {
//               AllowedHeaders: ['*'],
//               AllowedMethods: ['HEAD', 'GET', 'PUT', 'POST', 'DELETE'],
//               AllowedOrigins: ['*'],
//               ExposeHeaders: ['ETag'],
//             },
//           ],
//         },
//       }),
//     );
//   } catch (err) {
//     console.log('Error', err);
//   }
// };

// putBucketCors();

export const getKeysByBucket = async () => {
  try {
    const files = await s3Client.send(
      new ListObjectsCommand({ Bucket: S3_BUCKET }),
    );

    const keys = files.Contents.map((item) => item.Key);

    return keys;
  } catch (err) {
    console.log('getKeysByBucket Error : ', err);
  }
};

export const getFileByKey = async (getBucketParams: GetObjectCommandInput) => {
  try {
    const data = await s3Client.send(new GetObjectCommand(getBucketParams));

    return data;
  } catch (err) {
    console.log('getFileByKey Error : ', err);
  }
};

export const uploadFile = async (uploadBucketParams: PutObjectRequest) => {
  try {
    const fileName =
      uploadBucketParams.ContentDisposition.split('filename=')[1];

    const upload = r2ClientV2.upload(uploadBucketParams, (err, data) => {
      console.log(`${i} / ${keys.length} Complete!`);
      fs.appendFileSync(
        'log.txt',
        `${fileName.padEnd(50, ' ')} ${i++} / ${keys.length} Complete!\n`,
      );
    });

    // file upload progress, progress.total is not working
    upload.on('httpUploadProgress', (progress) => {
      console.log(
        `${fileName.padEnd(50, ' ')} progress : ${
          (progress.loaded / uploadBucketParams.ContentLength) * 100
        }`,
      );
    });
  } catch (err) {
    console.log('uploadFile Error : ', err);
  }
};

const keys = await getKeysByBucket();

let i = 1;

keys.forEach(async (Key) => {
  const getBucketParams = {
    Bucket: S3_BUCKET,
    Key,
  };

  const { ContentLength, ContentDisposition, Body, ContentType } =
    await getFileByKey(getBucketParams);

  const uploadBucketParams: PutObjectRequest = {
    Bucket: R2_BUCKET,
    Key,
    ContentLength,
    ContentDisposition,
    Body,
    ContentType,
  };

  uploadFile(uploadBucketParams);
});

알려진 에러

A client error (RequestTimeTooSkewed) occurred when calling the ListObjects operation: The difference between the request time and the current time is too large.

50개의 파일을 병렬로 업로드하던 중 이 에러가 발생했으며 원인은 적혀있듯이 요청을 보낸 시간과 현재 시간의 차이가 15분(900000ms)보다 커지면 발생한다.
50개의 업로드 요청을 한번에 병렬로 보냈지만 인터넷 문제, 최대 업로드 처리가 가능한 파일 수 문제 등이 원인일 것이라 예상했으며 병렬처리로 보낼 파일의 갯수를 적당히 조절하는 것으로 해결했다.


MalformedXML: The XML you provided was not well formed or did not validate against our published schema.

사실 업로드 자체는 v3의 putObjectCommand를 사용하면 말끔히 할 수 있지만 이쪽 시나리오에서는 업로드 진행도를 클라이언트에게 보내야했고 R2에서 v3의 upload는 요청이 제대로 이루어지지 않아 400코드를 뱉으며 업로드에 실패했다.
R2에선 아직 S3 API를 전부 완벽히 구현해내지 못했으며 특히 v3의 upload는 작동을 아예 안했고 v2의 upload에선 upload.on에서 주는 progress.total이 작동하지 않았다.
추후엔 사라질 문제일 것이라고 생각되지만 현시점의 해결 방법을 기록하기 위해 이 글을 작성했다.

profile
백엔드 개발자

1개의 댓글

comment-user-thumbnail
2023년 3월 9일

안녕하세요. 당신이 절 살렸습니다. 감사합니다

답글 달기