るいすのブログ

オタクエンジニアの雑記

AWS-SDK-Goを使って、ユーザーが投稿したファイルをS3から削除してみる


はじめに

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が含まれています。

IsTruncatedtrueだと全てを取得できず、まだ残りのオブジェクトがある状態 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
            }
        }
    }

最初のリクエストでIsTruncatedtrueの場合、 NextMarkerがレスポンスに含まれているのでこれを次のリクエストに含めておきます。

ループの中でこれらを繰り返しておき、 IsTruncatedfalseになった時点で終了。この関数では[]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
        }

この分割された[][]stringfor-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言語に自信が全く無い素人のコードを晒して大変恐縮していますが もし間違いや、こうした方がいいのご指摘がありましたらぜひコメントしていただけると本当に幸いです。