はじめに
S3でユーザーが投稿した画像を管理している場合、ユーザーがアプリを退会した際にユーザーに関する情報、S3からもユーザーのファイルを削除する必要性があります。
S3上のプレフィックスは userID/
となっており、全体のキーはbucket/userID/fileName.jpg
となっています。
問題点
AWSの制約上、bucket/userID
以下にファイルが1つでもあると、
いきなりbucket/userID
を削除することはできません。
加えて、リストを取得するListObjects
では1度に1000個のキーか取得することができず、
オブジェクトの削除を行うDeleteObject
も1度に1000個のキーまでしか指定できません。
もしユーザーが1000個以上のファイルをアップロードしている場合、
一筋縄ではいかないため工夫する必要性があります。
コード
解説
ユーザーが投稿したファイルを全て取得する
func getAllObject(userID string) ([]string, error) { var objects []string var result *s3.ListObjectsOutput svc := initS3() config := model.NewConfig() isTruncated := true // for getting more 1000 keys in loop input := &s3.ListObjectsInput{ Bucket: aws.String(config.AWSS3Bucket), Prefix: aws.String(userID + "/"), Delimiter: aws.String(userID + "/"), MaxKeys: aws.Int64(1000), // max: 1000 } result, err := svc.ListObjects(input) if err != nil { return objects, err } for _, v := range result.Contents { objects = append(objects, *v.Key) } // If isTruncated is true, User has more 1000 keys. if bool(*result.IsTruncated) { for { if isTruncated { input = &s3.ListObjectsInput{ Bucket: aws.String(config.AWSS3Bucket), Prefix: aws.String(userID + "/"), Delimiter: aws.String(userID + "/"), Marker: result.NextMarker, MaxKeys: aws.Int64(1000), // max: 1000 } result, err = svc.ListObjects(input) if err != nil { return objects, err } for _, v := range result.Contents { objects = append(objects, *v.Key) } isTruncated = *result.IsTruncated } else { break } } } return objects, nil }
最初のリクエストをする際に、Delimiter: aws.String(userID + "/"),
をつけてリクエストをすることで、レスポンスにIsTruncated
が含まれています。
IsTruncated
がtrue
だと全てを取得できず、まだ残りのオブジェクトがある状態
false
だと全てのオブジェクトを取得できた意味をします
if bool(*result.IsTruncated) { for { if isTruncated { input = &s3.ListObjectsInput{ Bucket: aws.String(config.AWSS3Bucket), Prefix: aws.String(userID + "/"), Delimiter: aws.String(userID + "/"), Marker: result.NextMarker, MaxKeys: aws.Int64(1000), // max: 1000 } result, err = svc.ListObjects(input) if err != nil { return objects, err } for _, v := range result.Contents { objects = append(objects, *v.Key) } isTruncated = *result.IsTruncated } else { break } } }
最初のリクエストでIsTruncated
がtrue
の場合、
NextMarker
がレスポンスに含まれているのでこれを次のリクエストに含めておきます。
ループの中でこれらを繰り返しておき、
IsTruncated
がfalse
になった時点で終了。この関数では[]string
を返します。
オブジェクトを削除する
func DeletePhotosFromS3(userID string, wg *sync.WaitGroup) { defer wg.Done() var delObj []*s3.ObjectIdentifier svc := initS3() config := model.NewConfig() objects, err := getAllObject(userID) if err != nil { Slack("DeletePhotosFromS3", "getAllObject", "", userID) return } if len(objects) > 1000 { // delete method can delete objects up to 1000 key. // So I need divided objects. divided := chunk(objects) for _, v := range divided { // &s3.Delete.Objects required []*s3.ObjectIdentifier // insert key(string) into deleteObjects for _, vv := range v { vvcopy := vv delObj = append(delObj, &s3.ObjectIdentifier{Key: &vvcopy}) } // Delete files under the bucket/user_id input := &s3.DeleteObjectsInput{ Bucket: aws.String(config.AWSS3Bucket), Delete: &s3.Delete{ Objects: delObj, Quiet: aws.Bool(false), }, } _, err := svc.DeleteObjects(input) if err != nil { Slack("DeletePhotosFromS3", "svc.DeleteObjects(input)", "more 1000 keys", userID) return } delObj = nil } } else { for _, v := range objects { delObj = append(delObj, &s3.ObjectIdentifier{Key: &v}) } // Delete files under the bucket/user_id input := &s3.DeleteObjectsInput{ Bucket: aws.String(config.AWSS3Bucket), Delete: &s3.Delete{ Objects: delObj, Quiet: aws.Bool(false), }, } _, err = svc.DeleteObjects(input) if err != nil { Slack("DeletePhotosFromS3", "svc.DeleteObjects(input)", "less than 1000", userID) return } } // Delete bucket/user_id input1 := &s3.DeleteObjectInput{ Bucket: aws.String(config.AWSS3Bucket), Key: aws.String(userID), } _, err = svc.DeleteObject(input1) if err != nil { Slack("DeletePhotosFromS3", "svc.DeleteObjects(input)", "delete bucket/"+userID, userID) return } }
関数全体↑
if len(objects) > 1000 { // delete method can delete objects up to 1000 key. // So I need divided objects. divided := chunk(objects) for _, v := range divided { // &s3.Delete.Objects required []*s3.ObjectIdentifier // insert key(string) into deleteObjects for _, vv := range v { vvcopy := vv delObj = append(delObj, &s3.ObjectIdentifier{Key: &vvcopy}) } // Delete files under the bucket/user_id input := &s3.DeleteObjectsInput{ Bucket: aws.String(config.AWSS3Bucket), Delete: &s3.Delete{ Objects: delObj, Quiet: aws.Bool(false), }, } _, err := svc.DeleteObjects(input) if err != nil { Slack("DeletePhotosFromS3", "svc.DeleteObjects(input)", "more 1000 keys", userID) return } delObj = nil } } else { for _, v := range objects { delObj = append(delObj, &s3.ObjectIdentifier{Key: &v}) } // Delete files under the bucket/user_id input := &s3.DeleteObjectsInput{ Bucket: aws.String(config.AWSS3Bucket), Delete: &s3.Delete{ Objects: delObj, Quiet: aws.Bool(false), }, } _, err = svc.DeleteObjects(input) if err != nil { Slack("DeletePhotosFromS3", "svc.DeleteObjects(input)", "less than 1000", userID) return } }
getAllObject
で取得したキーが入った配列の長さが1000以上だった場合、
先程述べた様に、1度のリクエストで削除できるキーが最大1000のため配列を分割する必要性があります。
// Get key size that is to make array which has 1000 keys from the array have more 1000 keys. // for s3 delete method.(It's up to 1000 keys once.) func size(s int) (int) { i := s / 1000 f := float32(s) / 1000 if f > float32(i) { i++ } return i } func chunk(logs []string) ([][]string) { var div [][]string size := size(len(logs)) cSize := (len(logs) + size - 1) / size for i := 0; i < len(logs); i += cSize { end := i + cSize if end > len(logs) { end = len(logs) } div = append(div, logs[i:end]) } return div } divided := chunk(objects)
func chunk()
では1000要素以上の配列に入ったキーから1つの配列が1000個未満のキーとなるように複数の配列に分割して[][]string
を返します。
for _, v: = range divided { // &s3.Delete.Objects required []*s3.ObjectIdentifier // insert key(string) into deleteObjects for _, vv: = range v { vvcopy: = vv delObj = append(delObj, &s3.ObjectIdentifier { Key: &vvcopy }) } // Delete files under the bucket/user_id input: = &s3.DeleteObjectsInput { Bucket: aws.String(config.AWSS3Bucket), Delete: &s3.Delete { Objects: delObj, Quiet: aws.Bool(false), }, } _, err: = svc.DeleteObjects(input) if err != nil { Slack("DeletePhotosFromS3", "svc.DeleteObjects(input)", "more 1000 keys", userID) return } delObj = nil }
この分割された[][]string
をfor-range
でループさせ、画像を削除していきます。
// Delete bucket/user_id input1 := &s3.DeleteObjectInput{ Bucket: aws.String(config.AWSS3Bucket), Key: aws.String(userID), } _, err = svc.DeleteObject(input1) if err != nil { Slack("DeletePhotosFromS3", "svc.DeleteObjects(input)", "delete bucket/"+userID, userID) return }
bucket/userID
以下のファイルが0になったら、bucket/userID
を削除します
使い方
ちなみに画像数が5,000枚レベルになると、マシンのスペックにもよりますがAPIサーバーからアプリへレスポンスを返すまでに1分程度、かかってしまいとても使えたものではありません。
なので、今回はDBからユーザーの情報を削除する部分はGoroutineで制御し、
このS3から画像を削除する部分はレスポンスを返した後に実行されるようにしてみました。
func destroyUser(wDB, rDB *gorm.DB, userID string) (error) { var wg sync.WaitGroup Destroy := func(ctx context.Context) (error) { g, ctx := errgroup.WithContext(ctx) g.Go(func() error { err := deleteOneday(&userID, wDB) if err != nil { return err } return nil }) g.Go(func() error { err := deletePhoto(&onedayIDs, wDB) if err != nil { return err } return nil }) g.Go(func() error { err := deleteTag(&onedayIDs, wDB) if err != nil { return err } return nil }) g.Go(func() error { err := deleteUser(userID, wDB) if err != nil { return err } return nil }) if err := g.Wait(); err != nil { return err } return nil } err = Destroy(context.Background()) if err != nil { return err } // This func is very heavy // So it goes to goroutine and don't wait. wg.Add(1) go lib.DeletePhotosFromS3(userID, &wg) return nil }
DBからユーザー情報を削除する部分はerrgroup(goroutine)で制御を行って、
エラー処理ができるように。全ての処理が終わったあとに、
wg.Add(1) go lib.DeletePhotosFromS3(userID, &wg) return nil
WaitGroup
をインクリメントしておき、wg.Wait()
をせずにそのままreturn nil
でレスポンスを返します。
ただ、これを行うとS3から画像を削除する部分でエラーを起こるとユーザーへ通知できなくなってしまうので、Slackへ通知するようにしてます。
(そして手作業で該当ユーザーのファイルを削除していく…)
おわり
Go言語に自信が全く無い素人のコードを晒して大変恐縮していますが
もし間違いや、こうした方がいいのご指摘がありましたらぜひコメントしていただけると本当に幸いです。