プロフィールアイコン

LocalStack S3 の仮想ホスト形式で DNS 解決エラーが発生する仕組みと解決策

Tech
#AWS#Go

内容

Webアプリケーションで、画像のアップロード機能を実装している際に遭遇したエラーについて記録しておきたいと思います。

前提として、今回私が触っていたアプリケーションは以下のような構成で画像のアップロードを行っています。

APIサーバ (Go) よりS3の署名付きURLを生成し、クライアントではそのURLを使用してアップロードするようなよくある構成かと思います

01_infrastructure_overview.png

開発中に起きたこと

AWS SDK Go v2 から LocalStack の S3 にアクセスした際、DNS 解決エラーが発生しました。

dial tcp: lookup dev-bucket.localstack on 127.0.0.11:53: no such host

原因としては、AWS SDK のデフォルト動作である「仮想ホスト形式」と Docker ネットワーク内の DNS 解決の仕組みにあるようでした。

元々は以下のような設定を compose.yaml に定義して、開発環境を動作させていました。

services:
  localstack:
    image: localstack/localstack:latest

    // 省略

    networks:
      default:
        aliases:
          - localhost.localstack.cloud

そもそも理解のところで、S3のアドレッシングスタイルとしては、大きく以下2つのURL形式があります。

形式URL 例
仮想ホスト形式{bucket}.s3.{region}.amazonaws.com
パス形式s3.{region}.amazonaws.com/{bucket}

現在、AWSは以下のような理由で仮想ホスト形式を推奨しているようです。

  • DNS ベースのルーティング: バケット名がホスト名に含まれるため、DNS レベルでリクエストを適切なリージョンにルーティングできる点
  • スケーラビリティ: パス形式では単一のエンドポイントに負荷が集中するが、仮想ホスト形式ではバケット単位で分散できる点
  • SSL 証明書: ワイルドカード証明書により、全バケットで HTTPS を効率的に提供できる点

参考: Amazon S3 パス廃止計画 – 今後の展開

この辺りの経緯があるため、APIサーバ側では AWS SDK Go v2 を利用しているのですが、こちらのライブラリはデフォルトで 仮想ホスト形式 の方を使用しているようです。

localstack 側のドキュメントにも解説されている箇所があるようです。

参考: Path-Style and Virtual Hosted-Style Requests

type Options struct {
    ....
	// Allows you to enable the client to use path-style addressing, i.e.,
	// https://s3.amazonaws.com/BUCKET/KEY . By default, the S3 client will use virtual
	// hosted bucket addressing when possible( https://BUCKET.s3.amazonaws.com/KEY ).
	UsePathStyle bool
...

https://github.com/aws/aws-sdk-go-v2/blob/dcbed91b6c6235022f15eda6ea526dbb91e1cb81/service/s3/options.go#L164

おそらく以下の関数で仮想ホスト形式に変換されているのだと認識しています。※ UsePathStyle を true にしている場合は何もせずそのまま返しているようですので

func (u updateEndpoint) updateEndpointFromConfig(req *smithyhttp.Request, bucket string, region string) error {
	// do nothing if path style is enforced
	if u.usePathStyle {
		return nil
	}
    ...
	// move bucket to follow virtual host style
	moveBucketNameToHost(req.URL, bucket)
	return nil
}

// updates endpoint to use virtual host styling
func moveBucketNameToHost(u *url.URL, bucket string) {
	u.Host = bucket + "." + u.Host
	removeBucketFromPath(u, bucket)
}

https://github.com/aws/aws-sdk-go-v2/blob/dcbed91b6c6235022f15eda6ea526dbb91e1cb81/service/s3/internal/customizations/update_endpoint.go#L237-L239

ポイントとして、SDK の挙動としては localstack というホスト名の前にバケット名を付加するだけ。ということだと認識しています。

BaseEndpoint: http://localstack:4566
↓
http://dev-bucket.localstack:4566/key # バケット名 + "." + localstack の形式になっている

Docker の DNS 明示的に定義されたコンテナ名やサービス名などは解決してくれるため、 localstack に関しては解決できるものの、*.localstack のようなパターンマッチングによる解決はデフォルトではしてくれないので、こちらが原因で処理が失敗していたようでした。

解決策

調べてみると諸々解法はあるようなのですが、より手間もお金もかけずにやるとなると compose.yaml でネットワークエイリアスを設定することで解決できます。

services:
  localstack:
    image: localstack/localstack:latest

    ...

    networks:
      default:
        aliases:
          - localhost.localstack.cloud
          # S3 仮想ホスト形式用エイリアスを使用するバケット毎に追加
          - dev-bucket.s3.localhost.localstack.cloud
const localstackS3Endpoint = "http://s3.localhost.localstack.cloud:4566"

func InitStorageClient(ctx context.Context, cfg *cfg.Config) *s3.Client {
    opts := []func(*s3.Options){}
    if cfg.IsDevelopment() {
        opts = append(opts, func(o *s3.Options) {
            o.BaseEndpoint = aws.String(localstackS3Endpoint)
        })
    }
    return s3.NewFromConfig(cfg.AWSConfig, opts...)
}

このように、エンドポイントを s3.localhost.localstack.cloud 形式にすることで、SDK が生成する URL が {bucket}.s3.localhost.localstack.cloud となるため、通信できるかたちになります。

エラーが出た場合、AI等にヒアリングすることで即時解決ができるかもしれませんが、 知識の定着を意識する場合は、SDKの実装等追ってみるとなんとなく実態がつかめるので、おすすめです。

今回の件についてまとめると、

  1. AWS SDK Go v2 は仮想ホスト形式がデフォルト。UsePathStyle を明示的に設定しない限り、バケット名がサブドメインとして付加される点に注意すること
  2. Docker 内部 DNS はサービス名を解決するが、ワイルドカードサブドメインは解決できない点について注意。※Pro版だともしかしたらできるかもしれない?
  3. AWS側はパス形式よりも仮想ホスト形式を推奨しているため、可能な限りはその実装に合わせる。
  4. s3.localhost.localstack.cloud 形式のエンドポイントを使用することで、SDK が期待する URL 形式と Docker DNS の解決を両立できる