AWS CopilotでSpringBootアプリケーション(Kotlin)をECSにデプロイする

# 概要
AWS Copilotを使い、AWS ECS x Fargateへアプリケーションをデプロイしてみます。

# AWS Copilotとは？
- 「Copilot」を和訳すると「副操縦士」
- AWSが提供するECS CLIの後継
- 従来のECS CLIより**簡単にFargateへのコンテナデプロイを実現できる**
- 詳しくは[AWSのブログ](https://aws.amazon.com/jp/blogs/news/introducing-aws-copilot/)を参照
- AWS Copilotのソースコードは[github](https://github.com/aws/copilot-cli)に公開されている

簡単に言うと、**「ちゃんと動くDockerfile」**があれば、`$ copilot init` とか `$ copilot deploy` といった**簡単なコマンドだけでECSにアプリケーションをデプロイできるよ！**というツールです。

# モチベーション
- 従来であれば、こうしたECS環境を使った公開アプリケーションを開発しようとしたとき、ある程度のネットワーク構成なども同時に構築する必要があり、手間がかかるもの (ポート設定、ロードバランサー、サービスディスカバリ etc...)
- Copilotがイケてると思ったのはそのあたりの低レイヤの構築をいい感じにやってくれる、という点 (その恩恵としてアプリケーションエンジニアはより開発に集中できる)
- PaaSに近い感覚で、さっと小さなコンテナベースのアプリケーションを公開するのに使えそう、と思い基本的な使い方を学習しておこうと

# 環境概要
- Mac OS Catalina
- Dockerインストール済
- AWS CLI v2インストール済

なので、ここではCopilotのインストールから実行していきます。

# AWS Copilotのインストール

インストール方法はAWS Copilotのドキュメントに記載されています。
https://github.com/aws/copilot-cli/wiki

```bash
$ brew install aws/tap/copilot-cli
```

# サンプルアプリケーションの作成

Dockerfileで構築可能なアプリケーションであればなんでも良いのですが、
ここではタイトルの通り、Kotlinで単純なSpringBootアプリケーションを作成し、それをECS上に構築してみます。

利用したJavaとGradleのバージョンは以下。
(ちなみに、jenvとsdkmanで入れたもの)

```bash
$ java --version
openjdk 13.0.1 2019-10-15
OpenJDK Runtime Environment (build 13.0.1+9)
OpenJDK 64-Bit Server VM (build 13.0.1+9, mixed mode, sharing)

$ gradle -v

------------------------------------------------------------
Gradle 6.5
------------------------------------------------------------
...
```

## ディレクトリ構造

最終的にこんな感じになります。

```bash
$ tree aws-copilot-app-example/
aws-copilot-app-example/
├── Dockerfile
├── README.md
├── build.gradle.kts
├── copilot
│   └── aws-copilot-app-example-service
│       └── manifest.yml
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── example
    │   │           ├── ExampleApplication.kt
    │   │           └── ExampleController.kt
    │   └── resources
    │       └── application.yml
    └── test
        ├── kotlin
        │   └── com
        │       └── example
        │           └── ExampleControllerTest.kt
        └── resources

15 directories, 14 files
```

## SpringBootプロジェクト作成

[Spring Initializr](https://start.spring.io/)を使ってもいいですが、自分の場合`$ gradle init`で作りました。

```bash
$ gradle init

Select type of project to generate:
  1: basic
  2: application
  3: library
  4: Gradle plugin
Enter selection (default: basic) [1..4] 2

Select implementation language:
  1: C++
  2: Groovy
  3: Java
  4: Kotlin
  5: Swift
Enter selection (default: Java) [1..5] 4

Select build script DSL:
  1: Groovy
  2: Kotlin
Enter selection (default: Kotlin) [1..2] 2

Project name (default: foo): aws-copilot-app-example
Source package (default: aws.copilot.app.example): com.example

BUILD SUCCESSFUL in 33s
2 actionable tasks: 2 executed

```

その後、各種ファイルを作成・修正していきます。

## build.gradle.kts

ビルド設定です。
こんな感じ。

```build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
  id("org.springframework.boot") version "2.3.2.RELEASE"
  id("io.spring.dependency-management") version "1.0.8.RELEASE"
  kotlin("jvm") version "1.3.72"
  kotlin("plugin.spring") version "1.3.72"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"

repositories {
  mavenCentral()
}

dependencies {
  implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
  implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")

  // SpringBoot
  implementation("org.springframework.boot:spring-boot-starter-web")

  implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.11.2")

  // Testing
  testImplementation("org.springframework.boot:spring-boot-starter-test") {
    exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
  }
  testImplementation("io.mockk:mockk:1.10.0")
}

tasks.withType<Test> {
  useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
  kotlinOptions {
    freeCompilerArgs = listOf("-Xjsr305=strict")
    jvmTarget = "1.8"
  }
}
```

## ExampleApplication.kt

SpringBootアプリケーションの起動部です。

```ExampleApplication.kt
package com.example

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class ExampleApplication

fun main(args: Array<String>) {
  runApplication<ExampleApplication>(*args)
}
```

## ExampleController.kt

`Hello AWS Copilot!!!` というメッセージを返す `GET`のAPIを作成します。

```ExampleController.kt
package com.example

import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class ExampleController {
  @GetMapping("/")
  fun get(): ResponseEntity<Result> =
      ResponseEntity(Result(message = "Hello AWS Copilot!!!"), HttpStatus.OK)

  data class Result(
      val message: String
  )
}
```

## ExampleControllerTest.kt

こちらはコントローラのテストコードです。

```ExampleControllerTest.kt
package com.example

import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension

@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureMockMvc
class ExampleControllerTest {

  @Autowired
  private lateinit var mockMvc: MockMvc

  @Autowired
  private val mapper = jacksonObjectMapper()

  @Test
  fun `response data contains welcome message`() {

    val expected = ExampleController.Result(
        message = "Hello AWS Copilot!!!"
    )

    mockMvc.perform(MockMvcRequestBuilders.get("/"))
        .andExpect(status().isOk)
        .andExpect(content().json(mapper.writeValueAsString(expected)))
  }
}
```

# Dockerfileの作成

上記で作成したSpringBootアプリケーションをコンテナ化するために、以下のようなDockerfileを作成します。

```Dockerfile
FROM openjdk:jdk-alpine
VOLUME /tmp
RUN mkdir /aws-copilot-app-example
WORKDIR /aws-copilot-app-example

ENV JAVA_OPTS=""
ENV APP_VERSION=0.0.1-SNAPSHOT

COPY ./build/libs/aws-copilot-app-example-$APP_VERSION.jar /aws-copilot-app-example

EXPOSE 8080

ENTRYPOINT [ "sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar ./build/libs/aws-copilot-app-example-$APP_VERSION.jar" ]
```

試しにローカルで起動してみます。

```bash
# アプリケーションのビルド(jarを生成)
$ gradle clean build

# Dockerイメージのビルド
$ docker build -t aws-copilot-app-example:latest ./

# Dockerイメージを起動
$ docker run --rm -it -p 80:8080 --name aws-copilot-app-example aws-copilot-app-example:latest
```

動作確認

```bash
$ curl http://localhost
{"message":"Hello AWS Copilot!!!"}
```

成功すると上記のようにメッセージが返ってきます。

# AWS Copilotを使ってECSに上記のSpringBootアプリケーションをデプロイ

Dockerfileがあるディレクトリで以下のコマンドを実行します。

```bash
$ copilot init
```

すると、作成したいアプリケーションについて順に聞かれるので、対話式に入力していきます。
ここでは以下のように入力しました。

項目 | 入力内容
-----|------------
Application name | aws-copilot-app-example
Service type | Load Balanced Web Service
Service name | aws-copilot-app-example-service
Dockerfile | ./Dockerfile

動作確認を簡単化するため、公開サービスを想定した`Load Balanced Web Service`として立ち上げていますが、`Backend Service`も試してみたところ、Cloud Map上にネームスペースもきちんと作成されていました(サービスディスカバリもバッチリ。しゅごい)

その後、以下のようにtest環境にデプロイしてよいか聞かれるので「y」と入力しEnter

```bash
Would you like to deploy a test environment? [? for help] (y/N) y
```

諸々回答していくと、出力は以下のようになります。

```bash
$  copilot init
Note: It's best to run this command in the root of your Git repository.
Welcome to the Copilot CLI! We're going to walk you through some questions
to help you get set up with an application on ECS. An application is a collection of
containerized services that operate together.

Application name: aws-copilot-app-example
Service name: aws-copilot-app-example-service
Dockerfile: ./Dockerfile
Ok great, we'll set up a Load Balanced Web Service named aws-copilot-app-example-service in application aws-copilot-app-example listening on port 8080.

✔ Created the infrastructure to manage services under application aws-copilot-app-example.

✔ Manifest file for service aws-copilot-app-example-service already exists at copilot/aws-copilot-app-example-service/manifest.yml, skipping writing it.
Your manifest contains configurations like your container size and port (:8080).

✔ Created ECR repositories for service aws-copilot-app-example-service.

All right, you're all set for local development.
Deploy: Yes

⠹ Proposing infrastructure changes for the test environment.
```

さすがに環境をまるっと一式作るのでけっこう時間がかかる。しばらく待つ。
ちゃんと処理が進んでいるのか気になるようであれば、AWSコンソールよりCloudFormationのイベントなどを参照すると進捗状況を確認できる。

```bash
✔ Deployed aws-copilot-app-example-service, you can access it at http://aws-c-Publi-xxx-xxx.ap-northeast-1.elb.amazonaws.com.
```

# AWSにデプロイしたアプリケーションの動作確認

```
$ curl http://aws-c-Publi-xxx-xxx.ap-northeast-1.elb.amazonaws.com
{"message":"Hello AWS Copilot!!!"}
```

すげぇ、マジでできてしまった(当たり前ですが)
実質、`$ copilot init` しか打ってないんだがｗ (神なの)

特に面白いと思ったのは、`Dockerfile`上で`EXPOSE 8080`と指定し、コンテナの8080ポートを公開しているのですが、
これを解釈してELBの80ポートからコンテナの8080ポートまでのルートをマッピングしてくれているところです。

これは本当に気が利いているｗ

# 作成したサービスの削除

放っておくとお金もかかるので、作成したサービスを削除します。

```bash
$ copilot app ls
aws-copilot-app-example

$ copilot app delete aws-copilot-app-example
```

AWSのprofileが複数ある場合、「この環境ではどのプロファイルを使って消します？」と聞かれるので、自身の環境に合わせて削除用のprofileを選ぶ。(ここでは`default`を選択)

```bash
Which named profile should we use to delete test? default
```

# 今回作成したサンプルコード

※執筆時点から変更される可能性もあります
https://github.com/otajisan/aws-copilot-app-example