Private Hosted Zoneへのアクセスをローカルで再現して爆速開発を実現する

この記事は、[Japan AWS Jr. Champions Advent Calendar 2024](https://qiita.com/advent-calendar/2024/japan-aws-jr-champions)の14日目の記事です。


# はじめに

株式会社Finatextでサーバーサイド・AWSエンジニアをしている [@takuma5884rbb](https://x.com/takuma5884rbb)です。

私が普段触れているシステムは、AWS上に構築されたマイクロサービスアーキテクチャで運用されています。機能ごとにモジュールを分けて開発・デプロイを行うマイクロサービスアーキテクチャは、開発サイクルを高速化し、メンテナンスやスケーリングを簡素化します。一方で、モジュール間のインターフェースや接続方法について考慮する必要があり、開発手法にある種の制約を与えることもあります。本記事では、そういったケースへの対処法の一つをご紹介します。


# AWSを用いたソフトウェア開発とその課題

AWS上にデプロイされた複数のコンテナ（サーバー）を接続する際に考慮する点として、ネットワークアクセスの制限は外せないでしょう。

この課題に対するソリューションの一つが、Amazon Route53における[Private Hosted Zone](https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/hosted-zones-private.html)です。

Private Hosted Zoneは設定したVPCのみからアクセスできるHosted Zoneです。


```plantuml
@startuml
<style>
root {
  Padding 0
  Margin 0
}
caption {
  FontSize 30
}
</style>
title <img:https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601748/1080c392-a904-b325-b53b-d3db8cd6cb4a.png>
caption private hosted zoneでは紐付けるVPCを設定できる
@enduml
```


つまり、

1. アーキテクチャ内に存在するモジュールを特定のVPCに配置する
1. そのVPCを関連づけたHosted Zoneを作成
1. そのHosted Zoneを用いてDNSレコードを作成
1. それらのDNSレコードを用いて、各種モジュールへアクセスできるようにルーティングを設定

という手順を踏むことで、システム内部の安全な通信を確立させることができます。

```plantuml
@startuml
!define AWSPuml https://raw.githubusercontent.com/awslabs/aws-icons-for-plantuml/v14.0/dist

!include AWSPuml/AWSCommon.puml
!include AWSPuml/AWSSimplified.puml

!include AWSPuml/Groups/AWSCloud.puml

!include AWSPuml/Groups/VPC.puml
!include AWSPuml/Compute/EC2.puml
!include AWSPuml/NetworkingContentDelivery/Route53.puml
!include AWSPuml/NetworkingContentDelivery/ElasticLoadBalancingApplicationLoadBalancer.puml

title private hosted zoneを用いたモジュール間接続の例

AWSCloudGroup(cloud){
    Route53(zone,private.net,)
    VPCGroup(vpc,Service VPC) {
        EC2(server1,server1,)
        EC2(server2,server2,)
        ElasticLoadBalancingApplicationLoadBalancer(alb,internal ALB,)
    }
}

zone->alb

server1-d->zone
server2-d->zone

alb-l->server1
alb-r->server2

@enduml
```

一方で、アプリケーション開発のサイクルにおいて全ての作業をAWS上で行うわけではありません。例えば開発用EC2インスタンスを建ててその中で開発するとなると追加のコストがかかりますし、ローカルで開発したコードをAWS環境にデプロイして動作確認を行うにしても、CDパイプラインを用意したとしても十数分のリードタイムが生じてしまうでしょう。

したがって、

**ローカル環境にてコーディングから動作確認まで完結する**

状態を作ることができれば、ソフトウェアの開発サイクルを高速に回すことができます。

次のセクションから、そのための方法の一つをご紹介します。


# 本編

## AWS Systems Manager Session Managerを用いた接続方法

まずは、ローカルからPrivate Hosted Zoneへの接続を実現します。

Private Hosted Zoneが特定のVPCからしかアクセスできない以上、そのVPC上に起動しているインスタンスに接続することを考えます。ここでは、[AWS Systems Manager Session Manager](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager.html)を用います。

AWS CLIを用いる場合はこんな感じ
```shell
aws ssm start-session --target {インスタンスID} --document-name \
    AWS-StartPortForwardingSessionToRemoteHost \
    --parameters {endpoint},portNumber={リモート側のポート番号},localPortNumber={ローカルで使用したいポート番号} --region {region}
```

Goでスクリプトを書く場合はこんな感じ
```go
func (c *ssmClient) StartSession(ctx context.Context, target, host, port string) (string, error) {
	input := &ssm.StartSessionInput{
		Target: aws.String(target),
		Parameters: map[string][]string{
			"host":            {host},
			"portNumber":      {"3306"},
			"localPortNumber": {port},
		},
		DocumentName: aws.String("AWS-StartPortForwardingSessionToRemoteHost"),
	}

	output, err := c.svc.StartSession(ctx, input)
	if err != nil {
		return "", err
	}

	return *output.SessionId, nil
}
```

余談ですが、`start-session`の際のtargetにはFargateタスクを指定することができます。このときの指定方法は以下となります。

```
ecs:{ECSクラスタ名}_{ECSタスクID}_{ランタイムID}
```

ランタイムIDは[DescribeTasksAPI](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/APIReference/API_DescribeTasks.html)から取得することができます。

```json
{
...
   "tasks": [ 
      { 
         ...
         "availabilityZone": "string",
         "capacityProviderName": "string",
         "clusterArn": "string",
         "connectivity": "string",
         "connectivityAt": number,
         "containerInstanceArn": "string",
         "containers": [ 
            { 
               ...
               "runtimeId": "string", // これ
               "taskArn": "string"
            }
         ],
         "cpu": "string",
         "createdAt": number,
         "desiredStatus": "string",
        ...
      }
   ]
}
```

こうすることで、ローカルのポートに対するネットワークアクセスがEC2インスタンスの特定ポートにフォワードされるようになります。

## Dockerからの接続

ローカルでの開発環境構築にDockerを用いている方は多いと思います。ローカルから先ほどのポートフォワードを使用するためには一度localhostにルーティングする必要がありますが、先ほどの図のように、各種マイクロサービスへのアクセスをALBでルーティングすることを考えると、ホストヘッダーはAWS環境の設定に合わせておく必要があります。

:::note info
例）ALBのListener Ruleで「ホストヘッダーが`hoge.private.net`の場合にマイクロサービスAにルーティングする」という設定がある場合、ローカルからのアクセスでhostを`localhost`にしてしまうと、ポートフォワードでEC2インスタンスにアクセスするまではいいものの、Listener Ruleに適合できずマイクロサービスにアクセスできなくなってしまいます。
:::

ここでは、Dockerコンテナ内の`/etc/hosts`を書き換えることを考えます。

Dockerコンテナでは`host.docker.internal`というDNSを用いてホストへの名前解決を行う仕組みがあり、

:::note warn
`host.docker.internal`は開発用途のみの利用が推奨されています
https://docs.docker.jp/v19.03/docker-for-mac/networking.html#mac-i-want-to-connect-from-a-container-to-a-service-on-the-host
:::

コンテナ内部から`nslookup`を実行することで、解決先のIPアドレスを確認することができます（このIPアドレスはローカル環境やDockerエンジンなどによって異なるようです）。

```sh
$ docker container exec -it container-name sh
/myContainer # nslookup host.docker.internal
Server:         127.0.0.xx
Address:        127.0.0.xx:yy

Non-authoritative answer:

Non-authoritative answer:
Name:   host.docker.internal
Address: 192.168.xxx.yyy
```

`/etc/hosts`を編集し、各種マイクロサービスに向けて利用したいhostをこのIPに解決させてやることで、ホストヘッダーを保ったままローカルへのフォワードを行うことができます。

```sh                                                                                                                                     
/myContainer # cat /etc/hosts
127.0.0.1       localhost
(略)
192.168.xxx.yyy    hoge.private.net
```

docker-composeを利用して`/etc/hosts`を編集する方法は以下です。

```yaml:docker-compose.yml
services:
  service:
    build:
        ...
    extra_hosts:
      - "hoge.private.net:192.168.xxx.yyy"
```

ポートフォワードを利用する都合上、エンドポイントにはポート番号を付与する必要はあります。

```diff:.envの変更例
- SERVICE_A_ENDPOINT=http://hoge.private.net
+ SERVICE_A_ENDPOINT=http://hoge.private.net:{ポートフォワードで使用するローカルのポート番号}
```


ここまでの手順を踏むことで、**ローカルのDockerコンテナ→EC2インスタンス→Private Hosted Zone→ALB→マイクロサービス**という接続を確立することができます！


# まとめ

マイクロサービス間の通信にはPrivate Hosted Zoneを利用して安全な接続を確立させることができますが、ローカルから単純にアクセスすることができない問題がありました。
そしてこれを解決する手段として、AWS Systems Manager Session Managerを利用したポートフォワードとホスト名解決、Dockerを用いる場合の注意点に触れ、ローカルの開発環境からの接続方法について説明してきました。

これでローカルでの開発サイクルを爆速で回すことができますね！

今後皆さんに、快適なAWSライフが訪れることを祈って。


# 参考文献

* [マイクロサービスの概要 | AWS](https://aws.amazon.com/jp/microservices/)
* [AWS Systems Manager Session Managerでポートフォワーディングを使用してリモートホストに接続する | Amazon Web Services ブログ](https://aws.amazon.com/jp/blogs/news/use-port-forwarding-in-aws-systems-manager-session-manager-to-connect-to-remote-hosts-jp/)
* [DescribeTasks - Amazon Elastic Container Service](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/APIReference/API_DescribeTasks.html)
* [AWS Systems Manager Session Manager - AWS Systems Manager](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager.html)
* [start-session — AWS CLI 2.22.16 Command Reference](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ssm/start-session.html)
* [Compose ファイル・リファレンス — Docker-docs-ja 1.10.0 ドキュメント](https://docs.docker.jp/v1.10/compose/compose-file.html#extra-hosts)