Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

CloudFront署名付きURLを用いたS3コンテンツの配信をGoで実装する

サーバーサイドエンジニアの森谷です。

画像ファイルなどS3に置いてあるコンテンツの配信の仕方として、S3バケットは非公開にしておきつつも許可されたユーザーに対しては署名付きURLを用いて限定的なアクセスを許可する方法がよく用いられているかと思います。

これはS3単体で署名付きURLを発行することもできますが、CloudFront経由でアクセスさせるURLを発行する方法も用意されています。

今回はその実装をGoで行ったコードの紹介記事となります。

前提知識

CloudFront + S3での配信の仕組みやAWS側の設定についての解説は、すでに世の中に素晴らしい記事がたくさんありますので説明を割愛させていただきます。

導入にあたり参考にした記事を載せますので、上記の理解から深めたい方は以下を読んでいただけますと幸いです。

docs.aws.amazon.com

docs.aws.amazon.com

dev.classmethod.jp

前提環境

S3

example-bucket バケットを作成し、その中に配置した example/key/sample.png という画像を配信したいものとします。

CloudFront

経由させたいCloudFrontのドメイン名を example.com として説明を進めます。
必要に応じてBehavior設定からS3バケットとの関連付けを行うなどを忘れずに実施してください。
(自分は最初ここでハマりました。。。)

またCloudFrontのキーペアを発行しておきます(詳しくは上述のリンク等を参考にしてください)。
発行されたPrivate Key Fileはひとまずローカル環境に置いてあるものとして解説します。
このpemファイルとキーペアIDが用意されている前提でコードを記述します。
pk-APKAXXXXXXXXXXXXXXX.pem のようなpemファイルがダウンロードされると思いますが、この APKAXXXXXXXXXXXXXXX 部分がキーペアIDとなります。)

Goで実装してみる

github.com/aws/aws-sdk-go/service/cloudfront/sign をimportしてください。
その他は標準パッケージのimportのみとなります。

func main() {
    adaptor := &CloudFrontAdaptor{
        Pem:       os.Getenv("CLOUD_FRONT_PEM_FILE"),    // pemファイルのパス
        KeyPairID: os.Getenv("CLOUD_FRONT_KEY_PAIR_ID"), // キーペアID
    }

    // 配信URL
    contentURL := url.URL{
        Scheme: "https",
        // CloudFrontのドメイン、S3の画像のキーを指定
        Path: path.Join("example.com", "example/key/sample.png"),
    }

    // 署名付きURLの有効期限
    // ここでは仮に1時間とします
    expire := time.Now().Add(1 * time.Hour) 

    signedURL, _ := adaptor.Sign(contentURL.String(), expire)

    // https://example.com/example/key/sample.png?Expires=1656683790&Signature=(略)&Key-Pair-Id=APKAXXXXXXXXXXXXXXX
    fmt.Println(signedURL)
}

type CloudFrontAdaptor struct {
    Pem, KeyPairID string

    mutex      sync.RWMutex
    privateKey *rsa.PrivateKey
}

func (a *CloudFrontAdaptor) Sign(url string, expire time.Time) (string, error) {
    signer, err := a.NewURLSigner()
    if err != nil {
        return "", err
    }
    return signer.Sign(url, expire)
}

func (a *CloudFrontAdaptor) NewURLSigner() (*sign.URLSigner, error) {
    // pemファイルからrsa.PrivateKeyを生成
    //
    // privateKeyの生成処理を無駄に何度も行いたくないため、初回のみこの処理が走るようにします
    // mutexを用いたロックなどは本題に関わる話ではありませんので、mutex周りは読み飛ばしても構いません
    a.mutex.RLock()
    pk := a.privateKey
    a.mutex.RUnlock()
    if pk == nil {
        // pemファイルの中身の読み込み
        // 今回はとりあえずローカルに置いたのでos.Openで読み取っていますが、
        // io.Readerで取得できればどのような方法で保管・取得しても構いません
        file, err := os.Open(a.Pem)
        if err != nil {
            return nil, err
        }
        fBytes, err := io.ReadAll(file)
        if err != nil {
            return nil, err
        }

        // pemの中身からrsa.PrivateKeyを生成
        // Decodeの第2戻り値はエラーではなく残りバイト列であることに注意してください
        pemKey, _ := pem.Decode(fBytes)
        if pemKey == nil {
            return nil, errors.New("nil pemKey")
        }
        privateKey, err := x509.ParsePKCS1PrivateKey(pemKey.Bytes)
        if err != nil {
            return nil, err
        }

        // 同時並列でNewURLSignerが叩かれても問題ないようロックしつつ代入
        a.mutex.Lock()
        a.privateKey = privateKey
        pk = privateKey
        a.mutex.Unlock()
    }

    return sign.NewURLSigner(a.KeyPairID, pk), nil
}

感想

割と何年も前からある技術なので今更記事を書くのもどうかと思いましたが、社内ドキュメントとして書いたれ!の気持ちで出してみました。

(本当はローカル環境でも検証できるようLocalStackを用いるケースも書こうと思ったのですが、有料版でないとCloudFrontが使えなかったという罠……)

しかしAWSで新しい仕組みを導入しようとすると毎回権限や環境変数周りでハマってしまいますね。

最後に

ヤプリではGoに関心の高いサーバーサイドエンジニアを募集しています!
もしご興味がありましたら是非カジュアル面談でお話しましょう!! open.talentio.com