EC2からECSへ！AWS LambdaでパブリックIPアドレスを固定せよ

この記事は [クラウドワークスグループ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/crowdworks) シリーズ1 の 8日目の記事です。

[crowdworks.jp](https://crowdworks.jp/)でエンジニアをしている @kotarou1192 です！
昨年は

https://qiita.com/kotarou1192/items/8705e1e3ca0f119ff719

という記事を書かせていただきましたが、早いもので、あれからもう1年も経つのですね。
昨年書いたアドベントカレンダーの記事を眺めながら、初々しさ（？）が溢れる記事に悶絶しつつ、時の流れは早いなぁなどと雑な感想を抱いておりました。

今回の記事では、日々の改善業務の中で携わった、とある社内ツールのインフラにまつわる話をしたいと思います。

## はじめに

AWS LambdaでIPアドレスを固定したい場面に直面したことはありませんか？例えば以下のようなケースです。

- セキュリティ上の理由で、接続元のIPアドレスをホワイトリストに登録する必要がある
- 社内APIや外部システムが固定IP以外からのアクセスを許可していない
- 非常に小規模なシステムであり、お金は掛けたくない / 掛けられない

今回の記事では、こうした要件に対応するため実施した、AWS Lambdaで安価にIPアドレスを固定する方法を紹介します。
また、他の方法（NAT GatewayやECS）との比較も行いました。

なお、この改善に至るまでの背景として、社内ツールの運用効率化や技術スタックのモダン化に取り組んできました。
この記事では、その一環で生じた課題と解決策についても触れていきますが、主なテーマは AWS LambdaでパブリックIPアドレスを固定する方法 にあります。

## Slack関連社内ツールのインフラ基盤

私がメンテすることになった社内ツール、それらは以下のような構成で存在していました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/2f83d00d-b39d-50e3-9468-fea21d162b7c.png)


Amazon EC2上に複数のSlackを利用する社内ツール（`Slack App 1`, `Slack App 2`）が乗っかっており、それらがdocker composeで動くというものです。
また、EC2インスタンスには、後述する理由で、固定の Public なIPアドレスであるElastic IP （EIP）が付与されています。
`docker compose`を使っていることが気になる方もいるかも知れませんが、社内ツールであり、負荷も少ないため、この場合は非常に合理的です。

社内ツールのリリースまでの流れは以下のとおりです。

1. GitHub上で管理されているリポジトリにPull requestを作成
2. Pull Requestをマージすると、GitHub ActionsによりDocker Imageのビルドが行われ、コンテナレジストリにpushされる
3. EC2にセッションマネージャやsshなどで接続し、手順に沿って手でリリース

この手順は一見シンプルに見えますが、3の手順がかなり煩雑です。慣れてしまえばどうということはないものの、それでもリリースが手動なのは手間でした。
そういった事情も相まってメンテがあまり活発ではないのではないか、という仮説も立てられそうでした。

というか、そもそもなぜこのような状態になっているのか。
この疑問が今回のタイトルである **「EC2からECSへ！AWS LambdaでパブリックIPアドレスを固定せよ」** につながってきます。

## 制約、そして制約

社内の別の部署が管理しているツールが提供するWeb API（以下、社内Web API）は、接続を許可するIPアドレスをホワイトリストで制限しています。
そしてそれはCIDR形式ではなく、特定のIPアドレスのみを一つずつ許可するような形式です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/299ded84-6549-81ce-8240-c17408d9da69.png)

上記画像のように、EC2上の社内ツールである`Slack App 1`が社内Web APIを利用しているため、EC2にEIPを割り当て、Public IPアドレスを固定しているのです。

逆に、`Slack App 1`のためにIPアドレスを固定しなければならないという制約がある限り、このEC2は存在し続けるわけです。
そうなると、例えば`Slack App 1`をEC2に残し、`Slack App 2`だけをこのEC2から引き剥がしても、結局EC2と`Slack App 1`は残り続けてしまいます。
そうするくらいならば、同じSlack関連の社内ツールとして、`Slack App 1`と`Slack App 2`を一緒にdocker composeの仲間としてEC2に置いておいたほうが、管理しやすくて良いとも言えそうです。

そういう諸々の理由があり、かつ、潤沢にお金をかけるほどのものでもないとなると、確かにこの構成は妥当だと思われます。

しかし、デプロイが手動なのはやはり色々つらい気持ちになります。
dependabotが作成したPull Requestの取り込みや、TypeScriptにしたことによって発生し始めた各種修正やメンテナンスを行うたびに、都度手でデプロイを行わねばなりません。

であれば、何らかのツールや仕組みを利用し、EC2上へのデプロイを自動化してしまえばよいのでは？

たしかにそれは一つの解決策でもあります。
しかし、そもそもEC2上で`docker compose up -d`を行うこと自体なんかイケてない感じがしており、そのうえで更にその仕組みを作り込むというのは、なんとなく筋が悪いようにも思えます。

そういった諸事情があり、今までこの構成で運用されてきたのだと思われます。
しかし、やはりデプロイが辛いのはそうで、これをなんとかしたいという気持ちもそうです。できればAmazon ECSで運用したい。

その夢を叶えるためにも、まずはIPの制約をなんとかしようと決意しました。

## IPアドレスの固定方法を考える

### IPアドレス固定方法: 検討

この時点での構成の再掲です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/299ded84-6549-81ce-8240-c17408d9da69.png)

これを何とかするプランは、いくつか考えられました。

#### 方法1: NAT GWを使う

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/676e3e90-0cec-8d70-3d6e-40fc348a0e60.png)

王道だと思います。しかし、お金がかかってしまいます。
現状問題なく動いているのに、あえてお金を出してまでNAT GWを利用する必要性を説明できる理由を見つけてくるのは、至難の業のように思えます。
EC2上でdocker composeするのが嫌だという理由だけでは少し弱いかもしれません。

#### 方法2: VPCピアリングを社内Web APIのVPCに対して行い、Private IPからの接続は許可してもらう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/4dfbcbb6-10a6-136e-149b-8ee0132adf84.png)

社内Web APIの細かな仕様は不明です。そもそもPrivate IPからの接続だけは許可なんてことはできないかもしれません。
また、VPCピアリングを行った場合、相手とこちら側のVPC間で意図しない通信が行えないように、細やかな設定が必要になります。
詳しく調査してみないことにはわかりませんが、非常に手間なのは想像に難くありません。

#### 方法3: 踏み台専用EC2を立てる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/f5f02500-583d-feb7-d2b4-47e168e2d8e2.png)

この方法では、EC2の料金が踏み台の数だけ増えてしまうだけでなく、踏み台専用EC2の管理という新たな仕事まで増えてしまいます。
しかし、踏み台という方法自体は悪くなさそうにも思えます。
とはいえ、24時間年中無休で踏み台が起動しているのは、なんだかもったいない気もしますね。

#### 方法4: ECS on EC2のEC2にEIPを付与し、社内Web APIにつなげるようにする

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/addd2d94-b87a-1963-7a71-01efbd976102.png)

この構成は、社内のとあるシステムで似たようなことをやっているので、実績があります。
ECSのノード（EC2）をAMI更新などで洗い替える際でも、EIPの付け替えを適切に行なってやれば、何ら問題はないでしょう。また、その方法にも実績があります。
しかし、この構成だと、同じECS on EC2クラスタ上のすべてのサービスが、社内Web APIにつなげるようになってしまいます。
とはいえ、現状も同じEC2上にいるdocker composeの仲間たちがそうなので、許容範囲でしょう。
仮に（コストを抑えるために）`Slack App 1`と`Slack App 2`を既存のECS on EC2クラスタに相乗りさせる形になったとしても、セキュリティ的な懸念が許容範囲内であれば、問題なさそうだと言えそうです。

#### 方法5: VPCにLambdaを配置し、EIPを紐づける

参考にさせていただいたのは、以下の記事です。

https://zenn.dev/nix/articles/7dd29a1e9edc55

今回のユースケースに当てはめると、以下のようなアーキテクチャになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/c27f985f-5bd1-4c4f-4f69-25ab19f168b6.png)

IPアドレスを固定したAWS Lambda上にプロキシ的なアプリケーション（以下Lambdaプロキシ）を構築し、それが社内Web APIの呼び出しを肩代わりする仕組みです。
そうすると、社内Web APIを利用したいアプリケーションのIPアドレスが固定されている必要はなくなり、ただ単にこのLambdaプロキシの実行権限をもてば良いことになります。

ただ、裏技というだけあって、この方法が使えなくなる可能性もありそうです。
信頼性が求められる場合、この点が非常にネックになってきます。

### IPアドレス固定方法: 結論

方法4にするか方法5にするか、非常に悩みました。

方法4は最も無難であり、実績があり信頼性も高いです。
一方、方法5は実績もなく、AWSの仕様の裏をついた、まさに「裏技」と呼ぶにふさわしいものです。
AWSの仕様変更等により利用できなくなる可能性もあります。

しかし、IAMポリシーなどでLambdaプロキシの呼び出しを細かく制限することで、社内Web APIへのアクセス制御をセキュアに実装することができます。
また、方法5はECS on EC2でなくとも動作することもメリットです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/1bc757c9-48a5-3343-ecc1-bb617566aa66.png)

例えば、上記画像のようにAWS Fargateから社内Web APIを利用することはこれまで難しかったのですが、Lambdaプロキシの実行権限さえあれば、実現可能になります。

[crowdworks.jp](https://crowdworks.jp/)ではAWS Fargateを利用したバッチジョブで社内Web APIを呼び出したいという要件がありました。
そのため、それが可能である方法5を選択することとしました。

#### 費用について

AWS Lambdaを利用する分の費用増加については、EC2上のdocker composeで動いているものをすべてECSサービスにし、既存のECSクラスタに適切に振り分けることによって、docker composeを行っているEC2が不要になるため、その浮いた分のお金で充分に賄えそうです。[^1]

また、AWS Lambdaの呼び出し回数も、日に数十回程度で収まることが事前にわかっていました。[^2]

つまり、コストを抑えるという点を達成できるのです。
むしろコスト削減にも繋がります。

#### 信頼性について

あくまで非公式の方法である「裏技」が使えなくなる可能性については、一旦受け入れることとしました。
仮に使えなくなった場合は、前述の方法4をベースとした方法に変更すれば良さそうだと言う判断です。

また、今回の対象は小さな社内ツール（Slack App）の一部の機能です。
確かに重要な社内ツールではあるのですが、無いと業務が停止するといった類のものでもないため、試すにはちょうどよいものとも言えます。

色々相談してみた結果、やってよしという判断になりました。

## Lambdaプロキシの実装

### Lambdaで動かすコードの実装

AWS Lambda上で動かすソースコードの実装自体はシンプルです。
社内Web APIの認証情報をもたせ、BFFの要領でHTTPリクエストを肩代わりして、呼び出し元に返却してあげればよいのです。

実装時に気をつけたポイントとしては、例えば以下のようなリクエストが必要なとき

```
GET /items/1
GET /items/2
GET /items/3
```

`items/:id`の数だけAWS Lambdaを呼び出してしまわないように、Lambdaプロキシに`:id`に当たる部分をまとめて渡し、バルクで処理を行うようにした点です。
そうすれば、社内Web APIの呼び出しは複数回行われますが、Lambdaの呼び出しは1回だけですみます。
コストを抑える工夫は惜しみません。

### Terraformによる「裏技」の実装

[crowdworks.jp](https://crowdworks.jp/)では、AWS上のインフラは基本的にTerraformで管理するようにしています。
そのため、今回の「裏技」もTerraformで実装するのが好ましいです。

参考にしたZennの記事にもありますが、VPC内に配置したLambdaのENIは、セキュリティグループの組み合わせとVPCのAZの組み合わせ毎に生成されます。
また、ENIの「ネットワークインターフェースのタイプ」は`lambda`という特殊なものです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/772574/b376c80c-58ae-fc54-5640-896641afde57.png)

さらに厄介なことに、ENIはAWS LambdaをVPC内へ配置した際に動的に生成されるものであり、Terraform上でLambdaを定義しても、そのLambdaのENIを作成したリソースからたどる形で直接参照することはできません。

そのため、`terraform apply`によってAWS Lambdaの作成が完了したあと、ENIが生成されたところを見計らって、いい感じにdataとして引っ張ってこねばなりません。

似たようなことを考える人は意外と多いようで、検索すると様々な試みが見られます。
例えば以下のように`local-exec`でawsコマンドを実行し、ENIを引いてくる方法などです。

https://zenn.dev/nian/articles/02adce7dc24274

私は`local-exec`を使いたくなかったので、以下のように実装しました。

```hcl
# Lambda に割り当てるための EIP
resource "aws_eip" "lambda" {
  domain = "vpc"
}

# Lambda の ENI を filter して引っ張ってくる
# VPCに配置したLambdaのENIは
data "aws_network_interface" "lambda" {
  # 「ネットワークインターフェースのタイプ」が`lambda`であり
  filter {
    name   = "interface-type"
    values = ["lambda"]
  }
  # Lambdaを配置したVPCのサブネットと
  filter {
    name   = "subnet-id"
    values = [local.subnet_1_id]
  }
  # Lambdaに割り当てたセキュリティグループによって一意に定まる
  filter {
    name   = "group-id"
    values = [aws_security_group.lambda.id]
  }
  # lambda が作成されるまで待つ
  depends_on = [module.vpc_lambda]
}

resource "aws_eip_association" "lambda" {
  network_interface_id = data.aws_network_interface.lambda.id
  allocation_id        = aws_eip.lambda.id
  # lambda が作成されるまで待つ
  depends_on = [module.vpc_lambda]
}
```

LambdaのENIは動的に生成されるため、`depends_on`で待っても、タイミングの問題で`terraform apply`に失敗するかもしれません。この場合、しばらく待って何度か`terraform apply`を叩けば、そのうち上手くいくはずです。

ENIを引いてくる方法は色々あると思うので、参考までにお願いします。

## 今後の展望

ここまでで、Lambdaプロキシは無事実装が完了しました。
あとは以下のような手順を進めることで、EC2上のdocker composeを捨て去り、ECSにすることができます。

1. 社内Web APIの呼び出し箇所をLambdaの呼び出しに置き換える
2. 該当の社内ツール（Slack App）を徐々にECSタスクに置き換えていく
3. すべて置き換えが完了したらEC2を削除する

さらに、社内ツールそれぞれでGitHub Actionsを用いたECSのデプロイの仕組みも用意してあげれば、Pull Requestを作成してマージするだけでデプロイが完了します。
手動でデプロイする作業はもう行わなくても良くなるのです。

現在「該当の社内ツール（Slack App）を徐々にECSタスクに置き換えていく」の部分を進めており、半分程度置き換えが完了しています。

引き続きECS化を進めつつ、長らく塩漬けとなっていた社内ツール（Slack App）への機能追加も実施していき、継続的な改善を実施していきます。

## まとめ

当初は社内ツールのソースコードのメンテナンスを行っていたところ、気づけばVPCにLambdaを配置してIPを固定する事になったのは、まさにyak shaving[^3]な道のりでした。

しかし、偉大なるGoogle先生の検索結果も、人生はyak shavingだと言っています。
（「yak shaving 人生」で検索するとたくさん出てきますよ、ホントです！）

なので、Google先生も仰るように、人生はyak shavingなのかもしれません。

今回の社内ツールのインフラ改善では、以下のポイントを学びました。

- 古くなった技術や非効率な構成は、開発速度や保守性を低下させるので、積極的に技術的負債を解消・管理していくことが重要です
- 制約条件にとらわれず、柔軟な発想と新しい技術を組み合わせることで、課題を解決できるかもしれません
- 日々の業務の中で見つけた小さな課題を解決していくことが、開発環境全体の改善に繋がります

小さな一歩かもしれませんが、これからもyak shaving精神で、改善を積み重ねていきたいと思います！


[^1]: 現在のEC2（t3.micro想定）の月額コストは、東京リージョンにおいてざっくり約1,500円（為替レート1USD = 150JPY）です。これが3環境分存在しており、合計で4,500円/月程度の費用が発生します。
[^2]: AWS Lambdaの呼び出し回数を1日50回、実行時間が500ms、メモリサイズが128MBとすると、月額コストはほぼ無料のようなものです。仮にEC2をなくすことができれば、Lambdaの運用費用を十分にカバーできるだけでなく、大幅なコスト削減が可能です。
[^3]: 「yak shaving」とは、目標を達成するために副次的なタスクが次々と発生することを指す言葉です。このプロセス自体が目的から逸れることを揶揄する意味も含まれています。