🗂

MinIOによるローカルS3環境の構築

2025/02/10に公開

はじめに

S3互換ストレージであるMinIOを使用して、ローカル環境でS3の開発環境を構築する方法を解説します。
S3(MinIO)への接続はAWS SDK for Go V2を使用します。MinIO SDKは使用しません。

MinIOとは

MinIOは、オープンソースのS3互換オブジェクトストレージサーバーです。
AWS S3と互換性のあるAPIを提供しており、開発環境やテスト環境での利用に適しています。

利点として以下があります:

  • AWS S3と同じAPIインターフェースを使用可能
  • ローカル環境で完結するため、費用がかからない
  • Dockerで簡単に環境構築が可能
  • 本番環境との切り替えが容易

別サービスとして、S3だけでなく様々なAWSサービスをエミュレーション可能なlocalstackがあります。
localstackは、コンテナを再起動するとアップロードしていたファイルが消えてしまいます。有料化することで永続化できますが、現状ではコスト面から採用しませんでした。

環境構築

動作環境

  • Go 1.22.5
  • Docker
  • Docker Compose

プロジェクトのセットアップ

まずは新しいプロジェクトディレクトリを作成し、必要なパッケージをインストールします。

mkdir minio-golang-demo
cd minio-golang-demo
go mod init minio-golang-demo

AWS SDK for Go V2の必要なパッケージをインストールします:

go get github.com/aws/aws-sdk-go-v2
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/s3
go get github.com/aws/aws-sdk-go-v2/credentials

MinIOのセットアップ

Docker Composeを使ってMinIOを起動します。以下の内容でdocker-compose.ymlを作成します:

version: '3'
services:
  minio:
    image: minio/minio
    ports:
      - "9000:9000"
      - "9001:9001"
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin
    command: server /data --console-address ":9001"
    volumes:
      - minio_data:/data

volumes:
  minio_data:

MINIO_ROOT_USERMINIO_ROOT_PASSWORDは任意の値ですが、8文字以上である必要があります。

MinIOを起動します:

docker-compose up -d

GoコードでS3を操作する

基本的な接続設定

まず、main.goを作成し、MinIOへの接続設定を実装します。
接続方法はAWS SDK for Go V2で接続します。

公式サイトにサンプルがあるのでそちらをベースに実装します。
https://docs.aws.amazon.com/ja_jp/code-library/latest/ug/go_2_s3_code_examples.html

公式のサンプルコード

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/smithy-go"
)

// main uses the AWS SDK for Go V2 to create an Amazon Simple Storage Service
// (Amazon S3) client and list up to 10 buckets in your account.
// This example uses the default settings specified in your shared credentials
// and config files.
func main() {
	ctx := context.Background()
	sdkConfig, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		fmt.Println("Couldn't load default configuration. Have you set up your AWS account?")
		fmt.Println(err)
		return
	}
	s3Client := s3.NewFromConfig(sdkConfig)
	count := 10
	fmt.Printf("Let's list up to %v buckets for your account.\n", count)
	result, err := s3Client.ListBuckets(ctx, &s3.ListBucketsInput{})
	if err != nil {
		var ae smithy.APIError
		if errors.As(err, &ae) && ae.ErrorCode() == "AccessDenied" {
			fmt.Println("You don't have permission to list buckets for this account.")
		} else {
			fmt.Printf("Couldn't list buckets for your account. Here's why: %v\n", err)
		}
		return
	}
	if len(result.Buckets) == 0 {
		fmt.Println("You don't have any buckets!")
	} else {
		if count > len(result.Buckets) {
			count = len(result.Buckets)
		}
		for _, bucket := range result.Buckets[:count] {
			fmt.Printf("\t%v\n", *bucket.Name)
		}
	}
}

このままですと、AWSの方に接続しようとするため、クライアント生成部分を変更します。

	creds := credentials.NewStaticCredentialsProvider("minioadmin", "minioadmin", "")
	sdkConfig, err = config.LoadDefaultConfig(context.Background(),
		config.WithRegion("us-east-1"),
		config.WithBaseEndpoint("http://localhost:9000"),
		config.WithCredentialsProvider(creds),
	)

	s3Client := s3.NewFromConfig(sdkConfig, func(o *s3.Options) {
		o.UsePathStyle = true
	})
  • creds: MinIO接続用のクレデンシャル(dockerコンテナに指定したMINIO_ROOT_USER, MINIO_ROOT_PASSWORDをそれぞれ指定します。
  • config.WithRegion:リージョンはus-east-1固定
  • config.WithBaseEndpoint:エンドポイント。MinIOのエンドポイントを指定
  • config.WithCredentialsProvider(creds):MinIO用のクレデンシャルを指定
  • s3Client:UsePathStyleのオプションをtrueにします。これはS3のエンドポイントの構成を変更するもので、デフォルトのままですとエンドポイントが、http://{バケット名}.localhost:9000となってしまいます。UsePathStyletrueにすることで、http://localhost:9000/{バケット名}となります。

実際に使ってみる

上記の接続情報を用いて、実際にS3の操作してみましょう:

実装

main.goと同じディレクトリに、bucketBasics.goとして
「基本を学ぶ」のBucketBasicsのコードをコピー
packageの指定だけ追加しました。ロジックは特に修正していません。

S3操作部分

package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"os"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/service/s3/types"
	"github.com/aws/smithy-go"
)

// BucketBasics encapsulates the Amazon Simple Storage Service (Amazon S3) actions
// used in the examples.
// It contains S3Client, an Amazon S3 service client that is used to perform bucket
// and object actions.
type BucketBasics struct {
	S3Client *s3.Client
}

・・・略・・・

S3のデモ呼び出し部分
main.goに処理を追加します。

  • mainメソッドの下に「基本を学ぶ」のRunGetStartedScenarioをコピーします。
  • mainメソッドからRunGetStartedScenarioを呼び出します。
func main() {
    // 前述の接続設定コード...

	RunGetStartedScenario(ctx, sdkConfig, demotools.NewQuestioner())
}


// RunGetStartedScenario is an interactive example that shows you how to use Amazon
// Simple Storage Service (Amazon S3) to create an S3 bucket and use it to store objects.
//
// 1. Create a bucket.
// 2. Upload a local file to the bucket.
// 3. Download an object to a local file.
// 4. Copy an object to a different folder in the bucket.
// 5. List objects in the bucket.
// 6. Delete all objects in the bucket.
// 7. Delete the bucket.
//
// This example creates an Amazon S3 service client from the specified sdkConfig so that
// you can replace it with a mocked or stubbed config for unit testing.
//
// It uses a questioner from the `demotools` package to get input during the example.
// This package can be found in the ..\..\demotools folder of this repo.
func RunGetStartedScenario(ctx context.Context, sdkConfig aws.Config, questioner demotools.IQuestioner) {
	defer func() {
		if r := recover(); r != nil {
			log.Println("Something went wrong with the demo.")
			_, isMock := questioner.(*demotools.MockQuestioner)
			if isMock || questioner.AskBool("Do you want to see the full error message (y/n)?", "y") {
				log.Println(r)
			}
		}
	}()

	log.Println(strings.Repeat("-", 88))
	log.Println("Welcome to the Amazon S3 getting started demo.")
	log.Println(strings.Repeat("-", 88))

	// s3Client := s3.NewFromConfig(sdkConfig)
	s3Client := s3.NewFromConfig(sdkConfig, func(o *s3.Options) {
		o.UsePathStyle = true
	})

	bucketBasics := actions.BucketBasics{S3Client: s3Client}

	count := 10
	log.Printf("Let's list up to %v buckets for your account:", count)
	buckets, err := bucketBasics.ListBuckets(ctx)
	if err != nil {
		panic(err)
	}
	if len(buckets) == 0 {
		log.Println("You don't have any buckets!")
	} else {
		if count > len(buckets) {
			count = len(buckets)
		}
		for _, bucket := range buckets[:count] {
			log.Printf("\t%v\n", *bucket.Name)
		}
	}

	bucketName := questioner.Ask("Let's create a bucket. Enter a name for your bucket:",
		demotools.NotEmpty{})
	bucketExists, err := bucketBasics.BucketExists(ctx, bucketName)
	if err != nil {
		panic(err)
	}
	if !bucketExists {
		err = bucketBasics.CreateBucket(ctx, bucketName, sdkConfig.Region)
		if err != nil {
			panic(err)
		} else {
			log.Println("Bucket created.")
		}
	}
	log.Println(strings.Repeat("-", 88))

	fmt.Println("Let's upload a file to your bucket.")
	smallFile := questioner.Ask("Enter the path to a file you want to upload:",
		demotools.NotEmpty{})
	const smallKey = "doc-example-key"
	err = bucketBasics.UploadFile(ctx, bucketName, smallKey, smallFile)
	if err != nil {
		panic(err)
	}
	log.Printf("Uploaded %v as %v.\n", smallFile, smallKey)
	log.Println(strings.Repeat("-", 88))

	log.Printf("Let's download %v to a file.", smallKey)
	downloadFileName := questioner.Ask("Enter a name for the downloaded file:", demotools.NotEmpty{})
	err = bucketBasics.DownloadFile(ctx, bucketName, smallKey, downloadFileName)
	if err != nil {
		panic(err)
	}
	log.Printf("File %v downloaded.", downloadFileName)
	log.Println(strings.Repeat("-", 88))

	log.Printf("Let's copy %v to a folder in the same bucket.", smallKey)
	folderName := questioner.Ask("Enter a folder name: ", demotools.NotEmpty{})
	err = bucketBasics.CopyToFolder(ctx, bucketName, smallKey, folderName)
	if err != nil {
		panic(err)
	}
	log.Printf("Copied %v to %v/%v.\n", smallKey, folderName, smallKey)
	log.Println(strings.Repeat("-", 88))

	log.Println("Let's list the objects in your bucket.")
	questioner.Ask("Press Enter when you're ready.")
	objects, err := bucketBasics.ListObjects(ctx, bucketName)
	if err != nil {
		panic(err)
	}
	log.Printf("Found %v objects.\n", len(objects))
	var objKeys []string
	for _, object := range objects {
		objKeys = append(objKeys, *object.Key)
		log.Printf("\t%v\n", *object.Key)
	}
	log.Println(strings.Repeat("-", 88))

	if questioner.AskBool("Do you want to delete your bucket and all of its "+
		"contents? (y/n)", "y") {
		log.Println("Deleting objects.")
		err = bucketBasics.DeleteObjects(ctx, bucketName, objKeys)
		if err != nil {
			panic(err)
		}
		log.Println("Deleting bucket.")
		err = bucketBasics.DeleteBucket(ctx, bucketName)
		if err != nil {
			panic(err)
		}
		log.Printf("Deleting downloaded file %v.\n", downloadFileName)
		err = os.Remove(downloadFileName)
		if err != nil {
			panic(err)
		}
	} else {
		log.Println("Okay. Don't forget to delete objects from your bucket to avoid charges.")
	}
	log.Println(strings.Repeat("-", 88))

	log.Println("Thanks for watching!")
	log.Println(strings.Repeat("-", 88))
}

S3のクライアント生成部分だけ以下のように修正します。

	s3Client := s3.NewFromConfig(sdkConfig, func(o *s3.Options) {
		o.UsePathStyle = true
	})

パッケージが不足しているので、インストールします。

go mod tidy

実行
実行してみます。

go run main.go

サンプルコードを実行すると、対話形式で入力を求められます。
バケットの作成

Let's list up to 10 buckets for your account.
You don't have any buckets!
2025/02/09 17:50:01 ----------------------------------------------------------------------------------------
2025/02/09 17:50:01 Welcome to the Amazon S3 getting started demo.
2025/02/09 17:50:01 ----------------------------------------------------------------------------------------
2025/02/09 17:50:01 Let's list up to 10 buckets for your account:
2025/02/09 17:50:01 You don't have any buckets!
Let's create a bucket. Enter a name for your bucket:
test-bucket
2025/02/09 17:50:11 Bucket test-bucket is available.
2025/02/09 17:50:11 Bucket created.
2025/02/09 17:50:11 ----------------------------------------------------------------------------------------

バケットを作るためにバケット名を入力します。
test-bucketとしました。

ファイルアップロード
ローカルにあるファイルのパスを指定します。

Let's upload a file to your bucket.
Enter the path to a file you want to upload:
/Users/myuser/Downloads/sample.csv
2025/02/09 17:51:27 Uploaded /Users/myuser/Downloads/sample.csv as doc-example-key.
2025/02/09 17:51:27 ----------------------------------------------------------------------------------------

アップロードされたファイルはdoc-example-keyというキー名としてアップロードされます。サンプルソースで固定で指定されているので、変更は可能。

URL的には以下
http://localhost:9000/test-bucket/doc-example-key

ファイルダウンロード
ダウンロードする際のファイル名を指定します。
ダウンロード先はmain.goのディレクトリです。

2025/02/09 17:51:27 Let's download doc-example-key to a file.
Enter a name for the downloaded file:
dl-sample.csv
2025/02/09 17:51:45 File dl-sample.csv downloaded.
2025/02/09 17:51:45 ----------------------------------------------------------------------------------------

オブジェクトのコピー
先ほど作成したキーをコピーします。
コピー先のkeyを指定します。copy-keyとしました。

2025/02/09 17:51:45 Let's copy doc-example-key to a folder in the same bucket.
Enter a folder name: 
copy-key
2025/02/09 17:52:07 Copied doc-example-key to copy-key/doc-example-key.
2025/02/09 17:52:07 ----------------------------------------------------------------------------------------

URL的には以下
http://localhost:9000/test-bucket/copy-key/doc-example-key

バケット内のオブジェクトリスト取得

2025/02/09 17:52:07 Let's list the objects in your bucket.
Press Enter when you're ready.

2025/02/09 17:52:17 Found 2 objects.
2025/02/09 17:52:17     copy-key/doc-example-key
2025/02/09 17:52:17     doc-example-key
2025/02/09 17:52:17 ----------------------------------------------------------------------------------------

バケットやファイルの削除確認
すべて削除するかを確認されます。
内容を確認したいのでnにします。

Do you want to delete your bucket and all of its contents? (y/n)
n
2025/02/09 17:52:23 Okay. Don't forget to delete objects from your bucket to avoid charges.
2025/02/09 17:52:23 ----------------------------------------------------------------------------------------
2025/02/09 17:52:23 Thanks for watching!
2025/02/09 17:52:23 ----------------------------------------------------------------------------------------

GUIで確認

実際にバケットやファイルが作成されているか確認してみましょう。

ログイン
以下ページにアクセスします。
http://localhost:9001

username、passwordは、dockerコンテナに指定したMINIO_ROOT_USER, MINIO_ROOT_PASSWORDをそれぞれ指定します。

バケット一覧
先ほど作成したバケットが確認できます。

クリックでバケットの中身が確認できます。

オブジェクト一覧

無事確認ができました。

作成されたバケットやファイルの実体
docker-compose.yamlで指定したvolumeに作成されます。

ディレクトリやファイルを直接操作するのではなく、GUIやプログラムから操作するようにしましょう。
一応コピーやリネームは動作はしました。

ローカル環境と本番環境との接続切り替え

エンドポイントの指定があるかどうかで判断するのが良いと思います。

環境変数等にローカル接続用のURLを設定

S3_ENDPOINT=http://localhost:9000

環境変数の値有無によって分岐


import (
	"context"
	"os"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/credentials"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type s3Service struct {
	Client *s3.Client
}

func NewS3Service() S3Service {
	region := os.Getenv("REGION")

	var cfg aws.Config
	var err error
	if endpoint := os.Getenv("S3_ENDPOINT"); endpoint != "" {
		creds := credentials.NewStaticCredentialsProvider("minioadmin", "minioadmin", "")
		cfg, err = config.LoadDefaultConfig(context.Background(),
			config.WithRegion(region),
			config.WithBaseEndpoint(endpoint),
			config.WithCredentialsProvider(creds),
		)

		client := s3.NewFromConfig(cfg, func(o *s3.Options) {
			o.UsePathStyle = true
		})

		return &s3Service{Client: client}
	}

	cfg, err = config.LoadDefaultConfig(context.Background(),
		config.WithRegion(region),
	)
	if err != nil {
		panic("unable to load SDK config, " + err.Error())
	}

	client := s3.NewFromConfig(cfg)

    return &s3Service{Client: client}
}

この実装によって、エンドポイントの環境変数自体をなくすか、値を空にしておくことで、S3に対して接続を試みます。

まとめ

今回の記事では、以下の内容を解説しました。

  1. MinIOのDockerを使ったセットアップ方法
  2. AWS SDK for Go V2を使ったMinIOへの接続設定
  3. 基本的なS3操作(バケット作成、オブジェクトのアップロード/ダウンロード)の実装

MinIOを使うことで、ローカル環境でS3の動作確認が簡単にできます。
本番環境へ移行する際も、エンドポイントの設定を変更するだけで、ほぼ同じコードが使えるのが大きな利点です。

発展的な使い方

  • プレサインドURL生成
  • マルチパートアップロード
  • バケットポリシーの設定
  • バージョニング機能の利用

これらの機能も、AWS SDK for Go V2で同様に実装できます。必要に応じて、公式ドキュメントを参照してください。

関連リンク

レスキューナウテックブログ

Discussion