Google Cloud Next '19 San Francisco に行ってきました!④ ~ 参考になったセッションの紹介 : GKE上でのJava/Kotlinアプリケーション開発 編 ~

2019年4月9日~11日に開催された「Google Cloud Next '19 San Francisco」の現地レポートを全5回の連載でお届けします!今回はその第4回です。

マーケティングプラットフォーム本部 開発部の高橋です。
前回から2回に分けて、「Google Cloud Next `19 San Francisco(以下、Cloud Next)」で私が参加したブレイクアウトセッションの内容を紹介しています。

今回はGKE上での Java/Kotlin アプリケーションの効率的な開発についてのセッションを紹介します。

Effective Cloud-Native Java on GCP

youtu.be

Effective C++やMore Effective C++、Effective Java、Effective Pythonなどで育ってきた身としては、「見に行かないわけにはいかない!」ということで、こちらのセッションに参加しました。

このセッションは約50分間ほぼすべて、ライブコーディングでGKE上にKotlinでWebサービスを構築していく、というかなり硬派なもので、内容も以下の通りかなり充実したものでした!

・Spring Bootのプロジェクト作成
・Datastoreとの連携
・APIの作成
・分散トレーシングの設定
・jibを利用したアプリケーションのコンテナ化
・Kubernetes用のマニュフェストの作成
・Liveness Check, Readiness Checkの設定
・Skaffoldを利用した開発環境への自動デプロイ
・サービス公開
・ログ出力の設定
・Prometheusを利用したモニタリング
・Stackdriver Debuggerとの連携

セッションを聞いただけでもかなり参考になる点が多かったのですが、「これは是非自分でも手を動かして試してみたい!」と思ったので実際に試してみました。

本ブログでは実際のコードを交えて、このセッションを紹介したいと思います。

下準備

セッション内では省略されていましたが、下記を予め実行しておく必要があります。

GKEクラスタの作成

デモを試すだけであれば、マシンタイプやノード数は自由に設定しておいていいと思います。
Cloud DatastoreとStackdriver DebuggerをEnabledにしておく必要があります。

プロジェクトの設定と認証
gcloud config set project プロジェクト名
gcloud auth application-default login
gcloud container clusters get-credentials クラスタ名 -z ゾーン名またはリージョン名

Spring Bootのプロジェクト作成

まずは、Spring、Spring Bootの雛形を生成してくれるSpring Initializr(https://start.spring.io/)で、下記の設定でプロジェクトを生成します。

項目 設定値
Project Maven Projec
Language Kotlin
Spring Boot 2.1.4
Group ~ Package Name any
Packaging Jar
Java Version 8
Dependencies ・Web
・Rest Repositories
・Rest Repositories

"Generate Project" で生成されたプロジェクトがダウンロードできます。

Datastoreとの連携

pom.xml の dependencies に以下を追加します。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-gcp-starter-data-datastore</artifactId>
</dependency>

さらに、 DemoApplication.kt に以下を追加します。

@RepositoryRestResource
interface PersonRepository : DatastoreRepository<Person, Long>
  
@Entity
data class Person(
        @Id
        var id: Long? = null,
        var name: String? = null
)

DatastoreRepositoryは、SpringのPagingAndSortingRepositoryを継承しているので、Datastoreを利用する場合でも、普通のSpringBootアプリケーション開発でのリポジトリと同様に扱うことができます。

この状態でアプリケーションを起動し、http://localhost:8080/personsにアクセスすると、下準備で設定したプロジェクトのDatastoreにアクセスし、そのperson一覧が表示されます。

{
  "_embedded" : {
    "persons" : [ ]
  },
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/persons{?page,size,sort}",
      "templated" : true
    },
    "profile" : {
      "href" : "http://localhost:8080/profile/persons"
    }
  },
  "page" : {
    "size" : 20,
    "totalElements" : 0,
    "totalPages" : 0,
    "number" : 0
  }
}

このエンドポイントはPOSTリクエスト等も受け付けることが可能です。

curl -X POST -H "Content-Type: application/json" -d '{"name":"Shuhei"}' http://localhost:8080/persons
  
  
{
  "name" : "Shuhei",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/persons/5639445604728832"
    },
    "person" : {
      "href" : "http://localhost:8080/persons/5639445604728832"
    }
  }
}

再度GETしたり、GCPのコンソールでpersonを表示すると新規データが追加されていることが確認できます。

APIの追加

リクエストで名前を受け取りhello xxxというレスポンスを返しつつ、その名前のPersonを新規保存する /hello という API と、内部で /hello を3回呼び出す /all というAPIを追加します。

package com.example.demo
  
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.cloud.gcp.data.datastore.core.mapping.Entity
import org.springframework.cloud.gcp.data.datastore.repository.DatastoreRepository
import org.springframework.context.annotation.Bean
import org.springframework.data.annotation.Id
import org.springframework.data.rest.core.annotation.RepositoryRestResource
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.client.getForObject
  
@SpringBootApplication
class DemoApplication {
    @Bean
    fun restTemplate() = RestTemplate()
}
  
fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}
  
@RepositoryRestResource
interface PersonRepository : DatastoreRepository<Person, Long>
  
@Entity
data class Person(
        @Id
        var id: Long? = null,
        var name: String? = null
)
  
@RestController
class HelloController(
        private val personRepository: PersonRepository,
        private val restTemplate: RestTemplate
) {
    private val logger = LoggerFactory.getLogger(javaClass)
  
    @GetMapping("/hello")
    fun hello(name: String): String {
        logger.info("Greeting to ${name}")
  
        personRepository.save(Person(name=name))
  
        return "hello ${name}"
    }
  
    @GetMapping("/all")
    fun all() {
        listOf("ray", "mandy", "jisha").forEach {
            restTemplate.getForObject("http://localhost:8080/hello?name=${it}", String::class.java)
        }
    }
}

この状態でhttp://localhost:8080/hello?name=NEXTにアクセスすると "hello NEXT" というレスポンスが返り、DatastoreにNEXTというデータが追加されているのが確認できると思います。

また、http://localhost:8080/allにアクセスすると、下記のようなログが出力され、Datastoreに新たに3件のデータが追加されていることが、確認できると思います。

2019-05-01 13:33:15.697  INFO 23809 --- [nio-8080-exec-2] com.example.demo.HelloController         : Greeting to ray
2019-05-01 13:33:16.604  INFO 23809 --- [nio-8080-exec-3] com.example.demo.HelloController         : Greeting to mandy
2019-05-01 13:33:16.870  INFO 23809 --- [nio-8080-exec-4] com.example.demo.HelloController         : Greeting to jisha

分散トレーシング

Stackdriver Traceなどでトレースが表示できるように、リクエストにTraceIDを付与し伝播してくれるSpring Cloud Sleuth(https://spring.io/projects/spring-cloud-sleuth)を利用します。

下記の依存関係を追加します。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-gcp-starter-trace</artifactId>
</dependency>
 
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-gcp-starter-logging</artifactId>
</dependency>

デフォルトでは、全リクエストのうちの10%にしかTraceIDを付与しないようになっているのですが、デモでは全リクエストに付与したいため、application.propertiesに下記を追加していました。

spring.sleuth.sampler.probability=1.0


また、ログからトレースIDが確認できるように src/main/resources/logback-spring.xml を作成し下記を追加します。

<configuration>
    <include resource="org/springframework/boot/logging/logback/base.xml" />
    <include resource="org/springframework/boot/logging/logback/console-appender.xml" />
    <include resource="org/springframework/cloud/gcp/autoconfigure/logging/logback-appender.xml" />
  
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="STACKDRIVER"/>
    </root>
</configuration>

これによって、ローカルの標準出力とStackdriverにTraceIDを含めたログが表示されるようになりました。

これによりStackdriver Traceの画面では /all を呼び出すと内部で /hello を3回呼び出している様子が確認できるようになりました。

jibを利用したコンテナ化

普通にDockerfileを書いてコンテナ化することももちろん可能ですが、例えばOpen JDKだとコンテナにどれだけメモリが割り当てられているか知ることができずOut Of Memory Killed が発生する問題等があるため注意が必要です(参考 : https://dzone.com/articles/why-my-java-application-is-oomkilled)。jibというMavenのプラグインを利用すると、このあたりの問題をよしなに解決してくれるそうです。

また、自分でDockerfileを書かなくてもよかったり、イメージ作成時にDockerのデーモンが不要だったりするようです。

このセッションではjibを利用して作成したアプリケーションをコンテナ化しています。

下記の設定をpom.xmlに追加します。

<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>1.0.2</version>
    <configuration>
        <from>
            <image>adoptopenjdk/openjdk8:slim</image>
        </from>
        <to>
            <image>asia.gcr.io/プロジェクト名/jib-demo</image>
        </to>
    </configuration>
</plugin>

下記のコマンドを実行するとコンテナ化し、Google Container Registry(GCR)への登録までを実行してくれます。

~/dev/effective-java/demo$ gcloud components install docker-credential-gcr
~/dev/effective-java/demo$ docker-credential-gcr configure-docker
~/dev/effective-java/demo$ gcloud auth configure-docker
  
~/dev/effective-java/demo$ ./mvnw compile jib:build

Kubernetes用のマニュフェストの作成

下記コマンドを実行すると、Kubernetes用のデプロイメントの設定ファイルを生成できます。

~/dev/effective-java/demo$ mkdir k8s
~/dev/effective-java/demo$ kubectl run demoservice --image=asia.gcr.io/プロジェクト名/jib-demo --dry-run -oyaml > k8s/deployment.yaml

これによって、下記のようなファイルが生成されます。

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    run: demoservice
  name: demoservice
spec:
  replicas: 1
  selector:
    matchLabels:
      run: demoservice
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        run: demoservice
    spec:
      containers:
      - image: asia.gcr.io/adpencil-dev/jib-demo
        name: demoservice
        resources: {}
status: {}

一部記述が古かったり不要なコードが存在するので、下記のように修正します。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    run: demoservice
  name: demoservice
spec:
  replicas: 1
  selector:
    matchLabels:
      run: demoservice
  strategy: {}
  template:
    metadata:
      labels:
        run: demoservice
    spec:
      containers:
      - image: asia.gcr.io/adpencil-dev/jib-demo
        name: demoservice

Liveness Check, Readiness Check用のエンドポイント追加とGracefulシャットダウン

Kubernetesには、以下2種類のチェック機能が存在します。

・Liveness Check ... Podが正しく動作しているかのチェック
 → チェックに失敗したらPodは再起動される
・Readiness Check ... Podがリクエストを受け取れる状態かのチェック
 → チェックに成功するまでPodにリクエストは割り当てられない


また、終了処理中に新たなリクエストが割り振られないように、SIG_TERMが送られたらReadiness Checkが失敗するようにしておくということも重要です。

まずはLiveness Check用のエンドポイントをHelloController内に追加します。

@GetMapping("/alive")
fun alive() = "ok"

Readiness Check は spring-boot-sterter-actuator を利用するのが良いそうです。

spring-boot-starter-actuator を追加すると様々なエンドポイントが自動で追加されます(https://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-endpoints.html)。

この中の /health がReadiness Check に利用できます。

SIG_TERMのハンドル用に springboot-graceful-shutdown も利用します。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
 
<dependency>
    <groupId>ch.sbb</groupId>
    <artifactId>springboot-graceful-shutdown</artifactId>
    <version>2.0.1</version>
</dependency>

mainを下記のように修正します。

fun main(args: Array<String>) {
    GracefulshutdownSpringApplication.run(DemoApplication::class.java, *args)
}

Skaffoldを利用した開発環境への自動デプロイ

kubernetesでの開発は、コードを修正するたびに下記の操作が必要になり若干面倒です。

・コードを修正
・Docker イメージをビルド
・イメージをpush
・kubernetes環境へデプロイ

Skaffold(https://skaffold.dev/)というツールを利用すると、コードの変更を検知して ビルド・push・デプロイを自動で行ってくれます。

https://skaffold.dev/docs/how-tos/builders/ の手順に従って、Skaffold をインストールした上で、下記の内容で skaffold.yaml を作成します。

apiVersion: skaffold/v1beta7
kind: Config
build:
    artifacts:
      - image: asia.gcr.io/プロジェクト名/jib-demo
        jibMaven:
          args:
            - "-DskipTests"

IntelliJ + Cloud Code を利用している場合、"Develop on Kubernetes"を実行するとSkaffoldが起動され、以降コードを修正するたびにビルドからデプロイまでが自動で実行されます。

サービス公開

前述のSkaffoldによってGKE上にアプリケーションがデプロイされましたが、この時点ではGKEのクラスタ外からはアクセスすることができません。

下記のコマンドでKubernetesのサービス用のマニュフェストを生成します。

kubectl expose deployment demoservice --target-port=8080 --port=8080 --dry-run -oyaml > k8s/service.yaml

これによって、下記のようなファイルが生成されます。

apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    cleanup: "true"
    docker-api-version: "1.39"
    ide: Idea
    ideVersion: 2019.1.1.0.0
    ijPluginVersion: unknown
    run: demoservice
    skaffold-builder: local
    skaffold-deployer: kubectl
    skaffold-tag-policy: git-commit
    tail: "true"
  name: demoservice
spec:
  type: LoadBalancer # <- クラスタの外部からアクセスする場合はこの一行を追加する
  ports:
  - port: 8080
    protocol: TCP
    targetPort: 8080
  selector:
    run: demoservice
status:
  loadBalancer: {}

このファイルを保存すると、Skaffoldにより自動でサービスが作成されます。

しばらくすると、外部IPアドレスが割り当てられます。

~/dev/effective-java/demo$ kubectl get services
NAME          TYPE           CLUSTER-IP     EXTERNAL-IP     PORT(S)          AGE
demoservice   LoadBalancer   10.55.247.62   34.85.127.134   8080:30631/TCP   52s
kubernetes    ClusterIP      10.55.240.1    <none>          443/TCP          4m

この場合、http://34.85.127.134/personsなどで、GKE上のアプリケーションへアクセスすることができるようになりました。

Liveness Check, Readiness Checkの設定

Liveness Check、Readiness Check用のエンドポイントはすでに準備しましたが、実際のチェックのための設定がまだなのでここで設定します。

deployment.yamlを、下記のように修正します。

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    run: demoservice
  name: demoservice
spec:
  replicas: 1
  selector:
    matchLabels:
      run: demoservice
  strategy: {}
  template:
    metadata:
      labels:
        run: demoservice
    spec:
      containers:
      - image: asia.gcr.io/adpencil-dev/jib-demo
        name: demoservice
        livenessProbe:
          initialDelaySeconds: 5
          httpGet:
            port: 8080
            path: /alive
            httpHeaders:
              - name: x-b3-sampled
                value: "0"
        readinessProbe:
          httpGet:
            port: 8080
            path: /actuator/health

ログ出力の設定

logback-spring.xml を下記のように修正します。

<configuration>
    <include resource="org/springframework/boot/logging/logback/base.xml" />
    <include resource="org/springframework/cloud/gcp/autoconfigure/logging/logback-json-appender.xml" />
  
    <root level="INFO">
        <appender-ref ref="CONSOLE_JSON"/>
    </root>
</configuration>

Stackdriver Debuggerとの連携

GAEのスタンダード環境で動作するアプリケーションの場合などは、特に設定せずにStackdriver Debuggerを利用することができますが、GKE上のアプリケーションの場合少々作業が必要になります。

src/main/jib/agents というディレクトリを作成し、下記からダウンロードしたファイルの中身を配置しておきます。

https://storage.googleapis.com/cloud-profiler/java/latest/profiler_java_agent.tar.gz
https://storage.googleapis.com/cloud-debugger/compute-java/debian-wheezy/cdbg_java_agent_gce.tar.gz

ディレクトリの中身は下記のようになります。

~/dev/effective-java/demo$ ls src/main/jib/agents/
NOTICES  cdbg_java_agent.so  cdbg_java_agent_internals.jar  profiler_java_agent.so

jibの設定を下記のように修正します。

<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>1.0.2</version>
    <configuration>
        <from>
            <image>adoptopenjdk/openjdk8:slim</image>
        </from>
        <to>
            <image>asia.gcr.io/adpencil-dev/jib-demo</image>
        </to>
        <container>
            <jvmFlags>
                <jvmFlag>-agentpath:/agents/profiler_java_agent.so=-cprof_service=${project.artifactId}</jvmFlag>
                <jvmFlag>-agentpath:/agents/cdbg_java_agent.so=--logtostderr=1</jvmFlag>
                <jvmFlag>-Dcom.google.cdbg.module=${project.artifactId}</jvmFlag>
                <jvmFlag>-Dcom.google.cdbg.version=${project.version}</jvmFlag>
            </jvmFlags>
        </container>
    </configuration>
</plugin>

こちらも修正すると、Skaffoldが自動で反映してくれます。

Stackdriver Debuggerのコンソールでソースコードをアップロードすると、下記画面のとおりGKE上で動作しているアプリケーションにコードを変更することなくログを追加したり、実行中のアプリケーションの変数やコールスタックのキャプチャを取得することが可能となります。

まとめ

弊社では、いくつかのJavaやKotlinのアプリケーションをGKE上で動かしていますが、jibやSkaffoldなどのツールは利用していなかったので、これらを利用することで普段の開発を効率化できそう!だと感じました。

また、ログの設定等の設定ファイルを書いて自分たちで対応したものの中にも、Spring Cloud GCPによって提供されている機能を利用していれば開発工数を削減できたように思う内容もかなりあり、今後の製品開発に取り入れられそうな内容が豊富で、非常に勉強になるセッションでした。

最後に

Google Cloud Next 2019で私が参加したセッションのうち、特に参考になった3つのセッションについて詳細を紹介させていただきました。

弊社では、GAEやGKEを利用して自社サービスを一緒に開発してくれるエンジニアを募集しています!
www.brainpad.co.jp