--- title: Spring WebFlux.fnハンズオン - 4. R2DBCによるデータベースアクセス tags: ["Spring WebFlux.fn Handson", "Reactor", "Reactor Netty", "Netty", "Spring 5", "Spring WebFlux", "Java", "Cloud Foundry", "Pivotal Web Services", "Pivotal Cloud Foundry", "R2DBC"] categories: ["Programming", "Java", "org", "springframework", "web", "reactive"] date: 2019-06-26T18:16:19Z updated: 1970-01-01T00:00:00Z --- 本ハンズオンで、次の図のような簡易家計簿のAPIサーバーをSpring WebFlux.fnを使って実装します。 あえてSpring BootもDependency Injectionも使わないシンプルなWebアプリとして実装します。 **ハンズオンコンテンツ** 1. [はじめに](/entries/500) 1. [簡易家計簿Moneygerプロジェクトの作成](/entries/501) 1. [YAVIによるValidationの実装](/entries/502) 1. [R2DBCによるデータベースアクセス](/entries/503) 👈 1. [Web UIの追加](/entries/504) 1. [例外ハンドリングの改善](/entries/505) 1. [収入APIの実装](/entries/506) 1. [Spring Bootアプリに変換](/entries/507) 1. [GraalVMのSubstrateVMでNative Imageにコンパイル](/entries/510) **目次** ### R2DBCによるデータベースアクセス 次は[R2DBC](https://r2dbc.io)を用いて`ExpenditureBuilderRepository`のデータベース実装を作成します。 `pom.xml`に次の`dependency`を追加してください。 ```xml org.springframework.data spring-data-r2dbc 1.0.0.BUILD-SNAPSHOT io.r2dbc r2dbc-h2 io.r2dbc r2dbc-postgresql ``` `R2dbcExpenditureRepository`を作成して次の内容を記述してください。 **TODO部分を実装してください**。動作を確認するためのテストコードは以下に続きます。TODOを実装する前にテストを実行してくだい。 * [参考資料](https://docs.spring.io/spring-data/r2dbc/docs/1.0.0.BUILD-SNAPSHOT/reference/html/#r2dbc.datbaseclient.queries) ```java package com.example.expenditure; import org.springframework.data.r2dbc.core.DatabaseClient; import org.springframework.transaction.reactive.TransactionalOperator; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import static org.springframework.data.r2dbc.query.Criteria.where; public class R2dbcExpenditureRepository implements ExpenditureRepository { private final DatabaseClient databaseClient; private final TransactionalOperator tx; public R2dbcExpenditureRepository(DatabaseClient databaseClient, TransactionalOperator tx) { this.databaseClient = databaseClient; this.tx = tx; } @Override public Flux findAll() { return this.databaseClient.select().from(Expenditure.class) .as(Expenditure.class) .all(); } @Override public Mono findById(Integer expenditureId) { // TODO // "expenditure_id"が引数のexpenditureIdに一致する1件のExpenditureを返す return Mono.empty(); } @Override public Mono save(Expenditure expenditure) { return this.databaseClient.insert().into(Expenditure.class) .using(expenditure) .fetch() .one() .map(map -> new ExpenditureBuilder(expenditure) .withExpenditureId((Integer) map.get("expenditure_id")) .build()) .as(this.tx::transactional); } @Override public Mono deleteById(Integer expenditureId) { return this.databaseClient.delete().from(Expenditure.class) .matching(where("expenditure_id").is(expenditureId)) .then() .as(this.tx::transactional); } } ``` `App.java`に次のメソッドを追加してください。 ```java static ConnectionFactory connectionFactory() { // postgresql://username:password@hostname:5432/dbname String databaseUrl = Optional.ofNullable(System.getenv("DATABASE_URL")).orElse("h2:file:///./target/demo?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"); return ConnectionFactories.get("r2dbc:" + databaseUrl); } public static Mono initializeDatabase(String name, DatabaseClient databaseClient) { if ("H2".equals(name)) { return databaseClient.execute("CREATE TABLE IF NOT EXISTS expenditure (expenditure_id INT PRIMARY KEY AUTO_INCREMENT, expenditure_name VARCHAR(255), unit_price INT NOT NULL, quantity INT NOT NULL, expenditure_date DATE NOT NULL)") .then(); } else if ("PostgreSQL".equals(name)) { return databaseClient.execute("CREATE TABLE IF NOT EXISTS expenditure (expenditure_id SERIAL PRIMARY KEY, expenditure_name VARCHAR(255), unit_price INT NOT NULL, quantity INT NOT NULL, expenditure_date DATE NOT NULL)") .then(); } return Mono.error(new IllegalStateException(name + " is not supported.")); } ``` また、`routes`メソッドも次のように変更してください。 ```java static RouterFunction routes() { final ConnectionFactory connectionFactory = connectionFactory(); final DatabaseClient databaseClient = DatabaseClient.builder() .connectionFactory(connectionFactory) .build(); final TransactionalOperator transactionalOperator = TransactionalOperator.create(new R2dbcTransactionManager(connectionFactory)); initializeDatabase(connectionFactory.getMetadata().getName(), databaseClient).subscribe(); return new ExpenditureHandler(new R2dbcExpenditureRepository(databaseClient, transactionalOperator)).routes(); } ``` `src/test/java/com/example/expenditure`に`R2dbcExpenditureRepositoryTest`を作成して、次のテストコードを記述してください。 ```java package com.example.expenditure; import com.example.App; import io.r2dbc.spi.ConnectionFactories; import io.r2dbc.spi.ConnectionFactory; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.data.r2dbc.connectionfactory.R2dbcTransactionManager; import org.springframework.data.r2dbc.core.DatabaseClient; import org.springframework.transaction.reactive.TransactionalOperator; import reactor.core.publisher.Flux; import reactor.test.StepVerifier; import java.time.LocalDate; import java.util.Arrays; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class R2dbcExpenditureRepositoryTest { R2dbcExpenditureRepository expenditureRepository; DatabaseClient databaseClient; TransactionalOperator transactionalOperator; private List fixtures = Arrays.asList( new ExpenditureBuilder() .withExpenditureName("本") .withUnitPrice(2000) .withQuantity(1) .withExpenditureDate(LocalDate.of(2019, 4, 1)) .build(), new ExpenditureBuilder() .withExpenditureName("コーヒー") .withUnitPrice(300) .withQuantity(2) .withExpenditureDate(LocalDate.of(2019, 4, 2)) .build()); @BeforeAll void init() { final ConnectionFactory connectionFactory = ConnectionFactories.get("r2dbc:h2:mem:///test?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"); this.databaseClient = DatabaseClient.builder() .connectionFactory(connectionFactory) .build(); this.transactionalOperator = TransactionalOperator.create(new R2dbcTransactionManager(connectionFactory)); this.expenditureRepository = new R2dbcExpenditureRepository(this.databaseClient, transactionalOperator); App.initializeDatabase("H2", this.databaseClient).block(); } @BeforeEach void each() throws Exception { this.databaseClient.execute("TRUNCATE TABLE expenditure") .then() .thenMany(Flux.fromIterable(this.fixtures) .flatMap(expenditure -> this.databaseClient.insert() .into(Expenditure.class) .using(expenditure) .then()) .as(transactionalOperator::transactional)) .blockLast(); } @Test void findAll() { StepVerifier.create(this.expenditureRepository.findAll()) .consumeNextWith(expenditure -> { assertThat(expenditure.getExpenditureId()).isNotNull(); assertThat(expenditure.getExpenditureName()).isEqualTo("本"); assertThat(expenditure.getUnitPrice()).isEqualTo(2000); assertThat(expenditure.getQuantity()).isEqualTo(1); assertThat(expenditure.getExpenditureDate()).isEqualTo(LocalDate.of(2019, 4, 1)); }) .consumeNextWith(expenditure -> { assertThat(expenditure.getExpenditureId()).isNotNull(); assertThat(expenditure.getExpenditureName()).isEqualTo("コーヒー"); assertThat(expenditure.getUnitPrice()).isEqualTo(300); assertThat(expenditure.getQuantity()).isEqualTo(2); assertThat(expenditure.getExpenditureDate()).isEqualTo(LocalDate.of(2019, 4, 2)); }) .verifyComplete(); } @Test void findById() { Integer expenditureId = this.databaseClient.execute("SELECT expenditure_id FROM expenditure WHERE expenditure_name = :expenditure_name") .bind("expenditure_name", "本") .map((row, rowMetadata) -> row.get("expenditure_id", Integer.class)) .one() .block(); StepVerifier.create(this.expenditureRepository.findById(expenditureId)) .consumeNextWith(expenditure -> { assertThat(expenditure.getExpenditureId()).isNotNull(); assertThat(expenditure.getExpenditureName()).isEqualTo("本"); assertThat(expenditure.getUnitPrice()).isEqualTo(2000); assertThat(expenditure.getQuantity()).isEqualTo(1); assertThat(expenditure.getExpenditureDate()).isEqualTo(LocalDate.of(2019, 4, 1)); }) .verifyComplete(); } @Test void findById_Empty() { Integer latestId = this.databaseClient.execute("SELECT MAX(expenditure_id) AS max FROM expenditure") .map((row, rowMetadata) -> row.get("max", Integer.class)) .one() .block(); StepVerifier.create(this.expenditureRepository.findById(latestId + 1)) .verifyComplete(); } @Test void save() { Integer latestId = this.databaseClient.execute("SELECT MAX(expenditure_id) AS max FROM expenditure") .map((row, rowMetadata) -> row.get("max", Integer.class)) .one() .block(); Expenditure create = new ExpenditureBuilder() .withExpenditureName("ビール") .withUnitPrice(250) .withQuantity(1) .withExpenditureDate(LocalDate.of(2019, 4, 3)) .build(); StepVerifier.create(this.expenditureRepository.save(create)) .consumeNextWith(expenditure -> { assertThat(expenditure.getExpenditureId()).isGreaterThan(latestId); assertThat(expenditure.getExpenditureName()).isEqualTo("ビール"); assertThat(expenditure.getUnitPrice()).isEqualTo(250); assertThat(expenditure.getQuantity()).isEqualTo(1); assertThat(expenditure.getExpenditureDate()).isEqualTo(LocalDate.of(2019, 4, 3)); }) .verifyComplete(); } @Test void deleteById() { Integer expenditureId = this.databaseClient.execute("SELECT expenditure_id FROM expenditure WHERE expenditure_name = :expenditure_name") .bind("expenditure_name", "本") .map((row, rowMetadata) -> row.get("expenditure_id", Integer.class)) .one() .block(); StepVerifier.create(this.expenditureRepository.deleteById(expenditureId)) .verifyComplete(); StepVerifier.create(this.expenditureRepository.findById(expenditureId)) .verifyComplete(); } } ``` TODOを実装しないでテストを実行すると次のように`findById`テストが失敗します。 ![image](https://user-images.githubusercontent.com/106908/58765856-374d5a80-85b2-11e9-86e0-b3ec15d40b0a.png) TODOを実装して、全てのテストが成功したら、`App`クラスの`main`メソッドを実行して、次のリクエストを送り、正しくレスポンスが返ることを確認してください。 ``` $ curl localhost:8080/expenditures -d "{\"expenditureName\":\"コーヒー\",\"unitPrice\":300,\"quantity\":1,\"expenditureDate\":\"2019-06-03\"}" -H "Content-Type: application/json" {"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"} ``` ``` $ curl localhost:8080/expenditures [{"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"}] ``` ``` $ curl localhost:8080/expenditures/1 {"expenditureId":1,"expenditureName":"コーヒー","unitPrice":300,"quantity":1,"expenditureDate":"2019-06-03"} ``` ``` $ curl -XDELETE localhost:8080/expenditures/1 ``` ``` $ curl localhost:8080/expenditures [] ``` > SQLログを確認したい場合は`logback.xml`に次の設定を追加してください。 > > ```xml > > ```
R2dbcExpenditureRepositoryの正解例 ```java package com.example.expenditure; import org.springframework.data.r2dbc.core.DatabaseClient; import org.springframework.transaction.reactive.TransactionalOperator; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import static org.springframework.data.r2dbc.query.Criteria.where; public class R2dbcExpenditureRepository implements ExpenditureRepository { private final DatabaseClient databaseClient; private final TransactionalOperator tx; public R2dbcExpenditureRepository(DatabaseClient databaseClient, TransactionalOperator tx) { this.databaseClient = databaseClient; this.tx = tx; } @Override public Flux findAll() { return this.databaseClient.select().from(Expenditure.class) .as(Expenditure.class) .all(); } @Override public Mono findById(Integer expenditureId) { return this.databaseClient.select().from(Expenditure.class) .matching(where("expenditure_id").is(expenditureId)) .as(Expenditure.class) .one(); } @Override public Mono save(Expenditure expenditure) { return this.databaseClient.insert().into(Expenditure.class) .using(expenditure) .fetch() .one() .map(map -> new ExpenditureBuilder(expenditure) .withExpenditureId((Integer) map.get("expenditure_id")) .build()) .as(this.tx::transactional); } @Override public Mono deleteById(Integer expenditureId) { return this.databaseClient.delete().from(Expenditure.class) .matching(where("expenditure_id").is(expenditureId)) .then() .as(this.tx::transactional); } } ```
#### Cloud Foundryへのデプロイ Pivotal Web ServicesでPostgreSQLサービスをプロビジョニングします。`cf create-service`コマンドで`moneyger-db`インスタンスを作成してください。 ``` cf create-service elephantsql turtle moneyger-db ``` `manifest.yml`も更新してください。 ```yaml applications: - name: moneyger path: target/moneyger-1.0.0-SNAPSHOT.jar memory: 128m env: JAVA_OPTS: '-XX:ReservedCodeCacheSize=22M -XX:MaxDirectMemorySize=22M -XX:MaxMetaspaceSize=54M -Xss512K' JBP_CONFIG_OPEN_JDK_JRE: '[memory_calculator: {stack_threads: 30}]' services: - moneyger-db ``` ビルドして`cf push`してください。 ``` ./mvnw clean package -DskipTests=true cf push ``` 次のリクエストを送り、正しくレスポンスが返ることを確認してください。 ``` curl https://moneyger-.cfapps.io/expenditures -d "{\"expenditureName\":\"コーヒー\",\"unitPrice\":300,\"quantity\":1,\"expenditureDate\":\"2019-06-03\"}" -H "Content-Type: application/json" cf restart moneyger curl https://moneyger-.cfapps.io/expenditures/1 ``` "Kubernetesへのデプロイ"をスキップする場合は、[こちら](#Connection%20Pool%E3%81%AE%E8%A8%AD%E5%AE%9A)へ進んでください。 #### Kubernetesへのデプロイ Cloud Foundryを使う人はこのセクションはスキップしてください。 ##### Dockerイメージの作成 まずはDockerイメージをビルド&プッシュします。 ``` $ pack build --builder cloudfoundry/cnb:bionic --publish # 例: pack build making/moneyger --builder cloudfoundry/cnb:bionic --publish ``` または ``` $ ./mvnw clean package -DskipTests=true $ pack build -p target/moneyger-1.0.0-SNAPSHOT.jar --builder cloudfoundry/cnb:bionic --publish # 例: pack build making/moneyger -p target/moneyger-1.0.0-SNAPSHOT.jar --builder cloudfoundry/cnb:bionic --publish ``` を実行してください。 ##### PostgreSQLのデプロイ PostgreSQLをKubernetesにデプロイするためのマニフェストファイルを作成します。 `moneyger-db.yml`を作成して、次の内容を記述してください。 ```yaml apiVersion: v1 kind: PersistentVolumeClaim metadata: name: moneyger-db namespace: moneyger spec: accessModes: - ReadWriteOnce resources: requests: storage: 1G --- apiVersion: apps/v1 kind: Deployment metadata: name: moneyger-db namespace: moneyger spec: selector: matchLabels: app: moneyger-db strategy: type: Recreate template: metadata: labels: app: moneyger-db spec: initContainers: - name: remove-lost-found image: busybox command: - sh - -c - | rm -fr /var/lib/postgresql/data/lost+found volumeMounts: - name: moneyger-db mountPath: /var/lib/postgresql/data containers: - image: postgres:11.5 name: postgres env: - name: POSTGRES_INITDB_ARGS value: "--encoding=UTF-8 --locale=C" - name: POSTGRES_DB valueFrom: secretKeyRef: name: moneyger-db key: postgres-db - name: POSTGRES_USER valueFrom: secretKeyRef: name: moneyger-db key: postgres-user - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: moneyger-db key: postgres-password ports: - containerPort: 5432 name: moneyger-db volumeMounts: - name: moneyger-db mountPath: /var/lib/postgresql/data volumes: - name: moneyger-db persistentVolumeClaim: claimName: moneyger-db --- apiVersion: v1 kind: Service metadata: name: moneyger-db namespace: moneyger spec: ports: - port: 5432 selector: app: moneyger-db clusterIP: None ``` PostgreSQLのユーザー情報を`Secret`に作成するためのマニフェストファイルを作成します。次のコマンドを実行してください。 ``` kubectl -n moneyger create secret generic moneyger-db \ --dry-run -o yaml \ --from-literal postgres-user=moneyger \ --from-literal postgres-password=moneyger \ --from-literal postgres-db=moneyger \ > moneyger-db-secret.yml ``` 次のコマンドでPostgreSQLをデプロイします。 ``` kubectl apply -f moneyger-db.yml -f moneyger-db-secret.yml ``` 次のコマンドを実行し、`moneyger-db-*****`という名前のPodの`STATUS`が`Runing` になっていることを確認してください。 ``` $ kubectl get -n moneyger all NAME READY STATUS RESTARTS AGE pod/moneyger-59c9b56d7-ddkdn 1/1 Running 0 5h3m pod/moneyger-db-77f767f6d5-nfwc8 1/1 Running 0 34m NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/moneyger LoadBalancer 10.100.200.144 a812e3e18c7e511e99cb7066f53a5a1a-1913282802.ap-northeast-1.elb.amazonaws.com 8080:31684/TCP 5h3m service/moneyger-db ClusterIP None 5432/TCP 34m NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/moneyger 1/1 1 1 5h3m deployment.apps/moneyger-db 1/1 1 1 34m NAME DESIRED CURRENT READY AGE replicaset.apps/moneyger-5779d966cb 1 1 1 5h3m replicaset.apps/moneyger-db-77f767f6d5 1 1 1 34m ``` 次のコマンドを実行してPostgreSQLの動作確認をしてください。 ``` $ kubectl run -it --rm --image=postgres:11.5 --generator=run-pod/v1 --restart=Never --env="PGPASSWORD=moneyger" psql -- psql -h moneyger-db.moneyger.svc.cluster.local -U moneyger -d moneyger -c '\l' List of databases Name | Owner | Encoding | Collate | Ctype | Access privileges -----------+----------+----------+---------+-------+----------------------- moneyger | moneyger | UTF8 | C | C | postgres | moneyger | UTF8 | C | C | template0 | moneyger | UTF8 | C | C | =c/moneyger + | | | | | moneyger=CTc/moneyger template1 | moneyger | UTF8 | C | C | =c/moneyger + | | | | | moneyger=CTc/moneyger ``` ##### Moneygerのアップデート `moneyger.yml`を次のように更新します。PostgreSQLの接続情報を環境変数に設定します。 ```yaml apiVersion: v1 kind: Namespace metadata: name: moneyger --- apiVersion: apps/v1 kind: Deployment metadata: name: moneyger namespace: moneyger spec: replicas: 1 selector: matchLabels: app: moneyger template: metadata: labels: app: moneyger spec: containers: - image: :latest # 例: # image: making/moneyger:latest name: moneyger ports: - containerPort: 8080 env: - name: _JAVA_OPTIONS value: "-Xmx15m -XX:ReservedCodeCacheSize=22M -XX:MaxDirectMemorySize=22M -XX:MaxMetaspaceSize=54M -Xss512K" ## ここから追加 - name: POSTGRES_DB valueFrom: secretKeyRef: name: moneyger-db key: postgres-db - name: POSTGRES_USER valueFrom: secretKeyRef: name: moneyger-db key: postgres-user - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: moneyger-db key: postgres-password - name: DATABASE_URL value: "postgresql://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@moneyger-db.moneyger.svc.cluster.local:5432/$(POSTGRES_DB)" ## ここまで追加 resources: limits: memory: "128Mi" requests: memory: "128Mi" readinessProbe: httpGet: path: /expenditures port: 8080 scheme: HTTP initialDelaySeconds: 5 timeoutSeconds: 3 failureThreshold: 3 periodSeconds: 5 --- kind: Service apiVersion: v1 metadata: name: moneyger namespace: moneyger spec: type: LoadBalancer selector: app: moneyger ports: - protocol: TCP port: 8080 ``` 次のコマンドで変更を反映してください。 ``` kubectl apply -f moneyger.yml ``` #### Connection Poolの設定 今回Pivotal Web Servicesで使用したPostgreSQLサービスは無料プランを使用しており、同時接続数が4という制限があります。 現在のコードでは5コネクション以上同時に接続するとエラーが発生します。 Connection Poolを設定し、最大Pool数を4にすることでエラーを防ぎます。 `pom.xml`に次の`dependency`を追加してください。 ```xml io.r2dbc r2dbc-pool ``` `App.java`に次のメソッドを追加してください。 ```java static ConnectionPool connectionPool(ConnectionFactory connectionFactory) { return new ConnectionPool(ConnectionPoolConfiguration.builder(connectionFactory) .initialSize(4) .maxSize(4) .maxIdleTime(Duration.ofSeconds(3)) .validationQuery("SELECT 1") .build()); } ``` `routes`メソッドの次の箇所を、 ```java static RouterFunction routes() { final ConnectionFactory connectionFactory = connectionFactory(); // ... } ``` 次のように変更してください。 ```java static RouterFunction routes() { final ConnectionFactory connectionFactory = connectionPool(connectionFactory()); // ... } ``` Cloud Foundryの場合は、ビルドして`cf push`してください。 ``` ./mvnw clean package -DskipTests=true cf push ``` [`wrk`](https://github.com/wg/wrk)コマンドで負荷をかけてもエラーが発生しないことが確認できます。 ``` wrk -t32 -c100 -d60s --latency --timeout 30s https://moneyger-.cfapps.io/expenditures ``` ``` Running 1m test @ https://moneyger-chatty-zebra.cfapps.io/expenditures 32 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 266.43ms 121.88ms 1.41s 88.77% Req/Sec 12.60 5.94 30.00 57.44% Latency Distribution 50% 223.84ms 75% 292.53ms 90% 404.77ms 99% 745.04ms 21965 requests in 1.00m, 6.37MB read Requests/sec: 365.52 Transfer/sec: 108.51KB ``` Kubernetesの場合は、 ``` $ pack build --builder cloudfoundry/cnb:bionic --publish # 例: pack build making/moneyger --builder cloudfoundry/cnb:bionic --publish ``` または ``` $ ./mvnw clean package -DskipTests=true $ pack build -p target/moneyger-1.0.0-SNAPSHOT.jar --builder cloudfoundry/cnb:bionic --publish # 例: pack build making/moneyger -p target/moneyger-1.0.0-SNAPSHOT.jar --builder cloudfoundry/cnb:bionic --publish ``` を実行してDockerイメージのビルド&プッシュを実行してください。 `moneyger.yml`で使用するDockerイメージを確実に更新するために、 ```yaml image: :latest ``` の部分を ```yaml image: @sha256: ``` 形式にしてください。 ``の値は`pack`コマンドの出力から確認できます。 例えば、次の出力の場合、`image`に設定する値は`making/moneyger@sha256:c0bac5367237f5c1a7989ca7bc21c574d890829f162caace81fc72897221e2f7`です。 ``` ===> EXPORTING [exporter] Reusing layers from image 'index.docker.io/making/moneyger@sha256:b1c8bd34de9eb1993e836191908e1cb6468d08fe9ac1546bec4ce5712b932243' [exporter] Exporting layer 'app' with SHA sha256:bad05a602ca8572ebf90aff7405c7b8ca12ca47df74ccbca381034f2da1026ef [exporter] Exporting layer 'config' with SHA sha256:332c6f162dbfa1df40280262e08279deb0ec17876cdfd86bdd84d188110e3c77 [exporter] Reusing layer 'launcher' with SHA sha256:2187c4179a3ddaae0e4ad2612c576b3b594927ba15dd610bbf720197209ceaa6 [exporter] Reusing layer 'org.cloudfoundry.openjdk:openjdk-jre' with SHA sha256:9c84525dcbc758ce1754cce9b8f4d59f5ea6cf103a6c47043d900cad838052da [exporter] Reusing layer 'org.cloudfoundry.jvmapplication:executable-jar' with SHA sha256:3d9310c8403c8710b6adcd40999547d6dc790513c64bba6abc7a338b429c35d2 [exporter] Exporting layer 'org.cloudfoundry.springboot:spring-boot' with SHA sha256:f8df93a1fec49ede0909fda3631f3a08d04a6ae947494deae4c73b98266ec2f3 [exporter] Reusing layer 'org.cloudfoundry.springautoreconfiguration:auto-reconfiguration' with SHA sha256:f61d2b65c75f9f5f2f2185fccb0be37ec39535bf89975c1632291f5116720479 [exporter] *** Images: [exporter] index.docker.io/making/moneyger:latest - succeeded [exporter] [exporter] *** Digest: sha256:c0bac5367237f5c1a7989ca7bc21c574d890829f162caace81fc72897221e2f7 ``` `moneyger.yml`を更新したら次のコマンドで更新を反映してください。 ``` kubectl apply -f moneyger.yml ``` [`kbld`](https://get-kbld.io)を使うと、``を更新する作業を次のコマンドで自動化できます。 ``` kbld -f moneyger.yml | kubectl apply -f - ``` 次の章でも利用するので[こちら](https://github.com/k14s/kbld/releases)からバイナリをダウンロードして`kbld`をインストールしてください。