1. Overview
This lab demonstrates features and capabilities designed to streamline the development workflow for software engineers tasked with developing Java applications in a containerized environment. Typical container development requires the user to understand details of containers and the container build process. Additionally, developers typically have to break their flow, moving out of their IDE to test and debug their applications in remote environments. With the tools and technologies mentioned in this tutorial, developers can work effectively with containerized applications without leaving their IDE.
What you will learn
In this lab you will learn methods for developing with containers in GCP including:
- Setup and Requirements
- Creating a new Java starter application
- Walking through the development process
- Developing a simple CRUD Rest Service
- Cleanup
2. Setup and Requirements
Self-paced environment setup
- Sign-in to the Google Cloud Console and create a new project or reuse an existing one. If you don't already have a Gmail or Google Workspace account, you must create one.
- The Project name is the display name for this project's participants. It is a character string not used by Google APIs, and you can update it at any time.
- The Project ID must be unique across all Google Cloud projects and is immutable (cannot be changed after it has been set). The Cloud Console auto-generates a unique string; usually you don't care what it is. In most codelabs, you'll need to reference the Project ID (and it is typically identified as
PROJECT_ID
), so if you don't like it, generate another random one, or, you can try your own and see if it's available. Then it's "frozen" after the project is created. - There is a third value, a Project Number which some APIs use. Learn more about all three of these values in the documentation.
- Next, you'll need to enable billing in the Cloud Console in order to use Cloud resources/APIs. Running through this codelab shouldn't cost much, if anything at all. To shut down resources so you don't incur billing beyond this tutorial, follow any "clean-up" instructions found at the end of the codelab. New users of Google Cloud are eligible for the $300 USD Free Trial program.
Start Cloudshell Editor
This lab was designed and tested for use with Google Cloud Shell Editor. To access the editor,
- access your google project at https://console.cloud.google.com.
- In the top right corner click on the cloud shell editor icon
- A new pane will open in the bottom of your window
- Click on the Open Editor button
- The editor will open with an explorer on the right and editor in the central area
- A terminal pane should also be available in the bottom of the screen
- If the terminal is NOT open use the key combination of `ctrl+`` to open a new terminal window
Set up gcloud
In Cloud Shell, set your project ID and the region you want to deploy your application to. Save them as PROJECT_ID
and REGION
variables.
export PROJECT_ID=$(gcloud config get-value project)
export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
Get the source code
The source code for this lab is located in the container-developer-workshop in GoogleCloudPlatform on GitHub. Clone it with the command below then change into the directory.
git clone https://github.com/GoogleCloudPlatform/container-developer-workshop.git
cd container-developer-workshop/labs/spring-boot
Provision the infrastructure used in this lab
In this lab you will deploy code to GKE and access data stored in a CloudSql database. The setup script below prepares this infrastructure for you. The provisioning process will take over 10 minutes. You can continue with the next few steps while the setup is processing.
./setup.sh
3. Creating a new Java starter application
In this section you'll create a new Java Spring Boot application from scratch utilizing a sample application provided by spring.io
Clone the Sample Application
- Create a starter application
curl https://start.spring.io/starter.zip -d dependencies=web -d type=maven-project -d javaVersion=11 -d packageName=com.example.springboot -o sample-app.zip
- Unzip the application
unzip sample-app.zip -d sample-app
- Change into the sample-app directory and open the folder in the Cloud Shell IDE workspace
cd sample-app && cloudshell workspace .
Add spring-boot-devtools & Jib
To enable the Spring Boot DevTools find and open the pom.xml from the explorer in your editor. Next paste the following code after the description line which reads <description>Demo project for Spring Boot</description>
- Add spring-boot-devtools in pom.xml
Open the pom.xml in the root of the project. Add the following configuration after the Description
entry.
pom.xml
<!-- Spring profiles-->
<profiles>
<profile>
<id>sync</id>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>
</dependencies>
</profile>
</profiles>
- Enable jib-maven-plugin in pom.xml
Jib is an open-source Java containerizing tools from Google that lets Java developers build containers using the Java tools they know. Jib is a fast and simple container image builder that handles all the steps of packaging your application into a container image. It does not require you to write a Dockerfile or have docker installed, and it is directly integrated into Maven and Gradle.
Scroll down in the pom.xml file and update the Build
section to include the Jib plugin. The build section should match the following when completed.
pom.xml
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Jib Plugin-->
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.2.0</version>
</plugin>
<!-- Maven Resources Plugin-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.1.0</version>
</plugin>
</plugins>
</build>
Choose Always
if prompted about build file change.
Generate Manifests
Skaffold provides integrated tools to simplify container development. In this step you will initialize skaffold which will automatically create base Kubernetes YAML files. The process tries to identify directories with container image definitions, like a Dockerfile, and then creates a deployment and service manifest for each.
Execute the command below to begin the process.
- Execute the following command in the terminal
skaffold init --generate-manifests
- When prompted:
- Use the arrows to move your cursor to
Jib Maven Plugin
- Press the spacebar to select the option.
- Press enter to continue
- Enter 8080 for the port
- Enter y to save the configuration
Two files are added to the workspace viz, skaffold.yaml
and deployment.yaml
Update app name
The default values included in the configuration don't currently match the name of your application. Update the files to reference your application name rather than the default values.
- Change entries in Skaffold config
- Open
skaffold.yaml
- Select the image name currently set as
pom-xml-image
- Right click and choose Change All Occurrences
- Type in the new name as
demo-app
- Change entries in Kubernetes config
- Open
deployment.yaml
file - Select the image name currently set as
pom-xml-image
- Right click and choose Change All Occurrences
- Type in the new name as
demo-app
Enable hot sync
To facilitate an optimized hot reload experience you'll utilize the Sync feature provided by Jib. In this step you will configure Skaffold to utilize that feature in the build process.
Note that the "sync" profile you are configuring in the skaffold configuration leverages the Spring "sync" Profile you have configured in the previous step, where you have enabled support for spring-dev-tools.
- Update skaffold config
In the skaffold.yaml file replace the entire build section of the file with the following specification. Do not alter other sections of the file.
skaffold.yaml
build:
artifacts:
- image: demo-app
jib:
project: com.example:demo
type: maven
args:
- --no-transfer-progress
- -Psync
fromImage: gcr.io/distroless/java:debug
sync:
auto: true
Add a default route
Create a file called HelloController.java at /src/main/java/com/example/springboot/
Paste the following contents in the file to create a default http route
HelloController.java
package com.example.springboot;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.beans.factory.annotation.Value;
@RestController
public class HelloController {
@Value("${target:local}")
String target;
@GetMapping("/")
public String hello()
{
return String.format("Hello from your %s environment!", target);
}
}
4. Walking through the development process
In this section you'll walk through a few steps using the Cloud Code plugin to learn the basic processes and to validate the configuration and setup of your starter application.
Cloud Code integrates with skaffold to streamline your development process. When you deploy to GKE in the following steps, Cloud Code and Skaffold will automatically build your container image, push it to a Container Registry, and then deploy your application to GKE. This happens behind the scenes abstracting the details away from the developer flow. Cloud Code also enhances your development process by providing traditional debug and hotsync capabilities to container based development.
Deploy to Kubernetes
- In the pane at the bottom of Cloud Shell Editor, select Cloud Code 
- In the panel that appears at the top, select Debug on Kubernetes. If prompted, select Yes to use the current Kubernetes context.
- The first time you run the command a prompt will appear at the top of the screen asking if you want the current Kubernetes context, select "Yes" to accept and use the current context.
- Next a prompt will be displayed asking which container registry to use. Press enter to accept the default value provided
- Select the Output tab in the lower pane to view progress and notifications
- Select "Kubernetes: Run/Debug - Detailed" in the channel drop down to the right to view additional details and logs streaming live from the containers
- Return to the simplified view by selecting "Kubernetes: Run/Debug" from the dropdown
- When the build and tests are done, the Output tab says:
Resource deployment/demo-app status completed successfully
, and a url is listed: "Forwarded URL from service demo-app: http://localhost:8080" - In the Cloud Code terminal, hover over the URL in the output (http://localhost:8080), and then in the tool tip that appears select Open Web Preview.
The response will be:
Hello from your local environment!
Utilize Breakpoints
- Open the HelloController.java application located at /src/main/java/com/example/springboot/HelloController.java
- Locate the return statement for the root path which reads
return String.format("Hello from your %s environment!", target);
- Add a breakpoint to that line by clicking the blank space to the left of the line number. A red indicator will show to note the breakpoint is set
- Reload your browser and note the debugger stops the process at the breakpoint and allows you to investigate the variable sand state of the application which is running remotely in GKE
- Click down into the variables section until you find the "Target" variable.
- Observe the current value as "local"
- Double click on the variable name "target" and in the popup, change the value to something different like "Cloud"
- Click the Continue button in the debug control panel
- Review the response in your browser which now shows the updated value you just entered.
Hot Reload
- Change the statement to return a different value such as "Hello from %s Code"
- The file is automatically saved and synced into the remote containers in GKE
- Refresh your browser to see the updated results.
- Stop the debugging session by clicking on the red square in the debug toolbar
5. Developing a simple CRUD Rest Service
At this point your application is fully configured for containerized development and you've walked through the basic development workflow with Cloud Code. In the following sections you practice what you've learned by adding rest service endpoints connecting to a managed database in Google Cloud.
Configure Dependencies
The application code uses a database to persist the rest service data. Ensure the dependencies are available by adding the following in the pom.xl
- Open the
pom.xml
file and add the following into the dependencies section of the config
pom.xml
<!-- Database dependencies-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
Code the rest service
Quote.java
Create a file called Quote.java in /src/main/java/com/example/springboot/ and copy in the code below. This defines the Entity model for the Quote object used in the application.
package com.example.springboot;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import java.util.Objects;
@Entity
@Table(name = "quotes")
public class Quote
{
@Id
@Column(name = "id")
private Integer id;
@Column(name="quote")
private String quote;
@Column(name="author")
private String author;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getQuote() {
return quote;
}
public void setQuote(String quote) {
this.quote = quote;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Quote quote1 = (Quote) o;
return Objects.equals(id, quote1.id) &&
Objects.equals(quote, quote1.quote) &&
Objects.equals(author, quote1.author);
}
@Override
public int hashCode() {
return Objects.hash(id, quote, author);
}
}
QuoteRepository.java
Create a file called QuoteRepository.java at src/main/java/com/example/springboot and copy in the following code
package com.example.springboot;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface QuoteRepository extends JpaRepository<Quote,Integer> {
@Query( nativeQuery = true, value =
"SELECT id,quote,author FROM quotes ORDER BY RANDOM() LIMIT 1")
Quote findRandomQuote();
}
This code uses JPA for persisting the data. The class extends the Spring JPARepository
interface and allows the creation of custom code. In the code you've added a findRandomQuote
custom method.
QuoteController.java
To expose the endpoint for the service, a QuoteController
class will provide this functionality.
Create a file called QuoteController.java at src/main/java/com/example/springboot and copy in the following contents
package com.example.springboot;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class QuoteController {
private final QuoteRepository quoteRepository;
public QuoteController(QuoteRepository quoteRepository) {
this.quoteRepository = quoteRepository;
}
@GetMapping("/random-quote")
public Quote randomQuote()
{
return quoteRepository.findRandomQuote();
}
@GetMapping("/quotes")
public ResponseEntity<List<Quote>> allQuotes()
{
try {
List<Quote> quotes = new ArrayList<Quote>();
quoteRepository.findAll().forEach(quotes::add);
if (quotes.size()==0 || quotes.isEmpty())
return new ResponseEntity<List<Quote>>(HttpStatus.NO_CONTENT);
return new ResponseEntity<List<Quote>>(quotes, HttpStatus.OK);
} catch (Exception e) {
System.out.println(e.getMessage());
return new ResponseEntity<List<Quote>>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@PostMapping("/quotes")
public ResponseEntity<Quote> createQuote(@RequestBody Quote quote) {
try {
Quote saved = quoteRepository.save(quote);
return new ResponseEntity<Quote>(saved, HttpStatus.CREATED);
} catch (Exception e) {
System.out.println(e.getMessage());
return new ResponseEntity<Quote>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@PutMapping("/quotes/{id}")
public ResponseEntity<Quote> updateQuote(@PathVariable("id") Integer id, @RequestBody Quote quote) {
try {
Optional<Quote> existingQuote = quoteRepository.findById(id);
if(existingQuote.isPresent()){
Quote updatedQuote = existingQuote.get();
updatedQuote.setAuthor(quote.getAuthor());
updatedQuote.setQuote(quote.getQuote());
return new ResponseEntity<Quote>(updatedQuote, HttpStatus.OK);
} else {
return new ResponseEntity<Quote>(HttpStatus.NOT_FOUND);
}
} catch (Exception e) {
System.out.println(e.getMessage());
return new ResponseEntity<Quote>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@DeleteMapping("/quotes/{id}")
public ResponseEntity<HttpStatus> deleteQuote(@PathVariable("id") Integer id) {
try {
quoteRepository.deleteById(id);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
} catch (RuntimeException e) {
System.out.println(e.getMessage());
return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
Add Database Configurations
application.yaml
Add configuration for the backend database accessed by the service. Edit (or create if not present) the file called application.yaml
file under src/main/resources
and add a parameterized Spring configuration for the backend.
target: local spring: config: activate: on-profile: cloud-dev datasource: url: 'jdbc:postgresql://${DB_HOST:127.0.0.1}/${DB_NAME:quote_db}' username: '${DB_USER:user}' password: '${DB_PASS:password}' jpa: properties: hibernate: jdbc: lob: non_contextual_creation: true dialect: org.hibernate.dialect.PostgreSQLDialect hibernate: ddl-auto: update
Add Database Migration
Create a folder at src/main/resources/db/migration/
Create a SQL file: V1__create_quotes_table.sql
Paste the following contents into the file
V1__create_quotes_table.sql
CREATE TABLE quotes(
id INTEGER PRIMARY KEY,
quote VARCHAR(1024),
author VARCHAR(256)
);
INSERT INTO quotes (id,quote,author) VALUES (1,'Never, never, never give up','Winston Churchill');
INSERT INTO quotes (id,quote,author) VALUES (2,'While there''s life, there''s hope','Marcus Tullius Cicero');
INSERT INTO quotes (id,quote,author) VALUES (3,'Failure is success in progress','Anonymous');
INSERT INTO quotes (id,quote,author) VALUES (4,'Success demands singleness of purpose','Vincent Lombardi');
INSERT INTO quotes (id,quote,author) VALUES (5,'The shortest answer is doing','Lord Herbert');
Kubernetes Config
The following additions to the deployment.yaml
file allow the application to connect to the CloudSQL instances.
- TARGET - configures the variable to indicate the environment where the app is executed
- SPRING_PROFILES_ACTIVE - shows the active Spring profile, which will be configured to
cloud-dev
- DB_HOST - the private IP for the database, which has been noted when the database instance has been created or by clicking
SQL
in the Navigation Menu of the Google Cloud Console - please change the value ! - DB_USER and DB_PASS - as set in the CloudSQL instance configuration, stored as a Secret in GCP
Update your deployment.yaml with the contents below.
deployment.yaml
apiVersion: v1
kind: Service
metadata:
name: demo-app
labels:
app: demo-app
spec:
ports:
- port: 8080
protocol: TCP
clusterIP: None
selector:
app: demo-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo-app
labels:
app: demo-app
spec:
replicas: 1
selector:
matchLabels:
app: demo-app
template:
metadata:
labels:
app: demo-app
spec:
containers:
- name: demo-app
image: demo-app
env:
- name: PORT
value: "8080"
- name: TARGET
value: "Local Dev - CloudSQL Database - K8s Cluster"
- name: SPRING_PROFILES_ACTIVE
value: cloud-dev
- name: DB_HOST
value: ${DB_INSTANCE_IP}
- name: DB_PORT
value: "5432"
- name: DB_USER
valueFrom:
secretKeyRef:
name: gke-cloud-sql-secrets
key: username
- name: DB_PASS
valueFrom:
secretKeyRef:
name: gke-cloud-sql-secrets
key: password
- name: DB_NAME
valueFrom:
secretKeyRef:
name: gke-cloud-sql-secrets
key: database
Replace the DB_HOST value with the address of your Database
export DB_INSTANCE_IP=$(gcloud sql instances describe quote-db-instance \
--format=json | jq \
--raw-output ".ipAddresses[].ipAddress")
envsubst < deployment.yaml > deployment.new && mv deployment.new deployment.yaml
Deploy and Validate Application
- In the pane at the bottom of Cloud Shell Editor, select Cloud Code then select Debug on Kubernetes at the top of the screen.
- When the build and tests are done, the Output tab says:
Resource deployment/demo-app status completed successfully
, and a url is listed: "Forwarded URL from service demo-app: http://localhost:8080" - View Random Quotes
From cloudshell Terminal, run the command below multiple times against the random-quote endpoint. Observe repeated call returning different quotes
curl -v 127.0.0.1:8080/random-quote
- Add a Quote
Create a new quote, with id=6 using the command listed below and observe the request being echoed back
curl -v -H 'Content-Type: application/json' -d '{"id":"6","author":"Henry David Thoreau","quote":"Go confidently in the direction of your dreams! Live the life you have imagined"}' -X POST 127.0.0.1:8080/quotes
- Delete a quote
Now delete the quote you just added with the delete method and observe an HTTP/1.1 204
response code.
curl -v -X DELETE 127.0.0.1:8080/quotes/6
- Server Error
Experience an error state by running the last request again after the entry has already been deleted
curl -v -X DELETE 127.0.0.1:8080/quotes/6
Notice the response returns an HTTP:500 Internal Server Error
.
Debug the application
In the previous section you found an error state in the application when you tried to delete an entry that was not in the database. In this section you'll set a breakpoint to locate the issue. The error occurred in the DELETE operation, so you'll work with the QuoteController class.
- Open src.main.java.com.example.springboot.QuoteController.java
- Find the
deleteQuote()
method - Find the the line where delete an item from the database:
quoteRepository.deleteById(id);
- Set a breakpoint on that line by clicking the blank space to the left of the line number.
- A red indicator will appear indicating the breakpoint is set
- Run the
delete
command again
curl -v -X DELETE 127.0.0.1:8080/quotes/6
- Switch back to the debug view by clicking the icon in the left column
- Observe the debug line stopped in the QuoteController class.
- In the debugger, click the
step over
icon and observe that an exception is thrown - Observe that a very generic
RuntimeException was caught.
This returns an Internal Server Error HTTP 500 to the client which is not ideal.
Trying 127.0.0.1:8080... * Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0) > DELETE /quotes/6 HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/7.74.0 > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/1.1 500 < Content-Length: 0 < Date: < * Connection #0 to host 127.0.0.1 left intact
Update the code
The code is incorrect and the exception block should be refactored to catch the EmptyResultDataAccessException
exception and send back an HTTP 404 not found status code.
Correct the error.
- With the Debug session still running, complete the request by pressing the "continue" button in the debug control panel.
- Next add the following block to the code:
} catch (EmptyResultDataAccessException e){
return new ResponseEntity<HttpStatus>(HttpStatus.NOT_FOUND);
}
The method should look like the following
public ResponseEntity<HttpStatus> deleteQuote(@PathVariable("id") Integer id) { try { quoteRepository.deleteById(id); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } catch(EmptyResultDataAccessException e){ return new ResponseEntity<HttpStatus>(HttpStatus.NOT_FOUND); } catch (RuntimeException e) { System.out.println(e.getMessage()); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } }
- Rerun the delete command
curl -v -X DELETE 127.0.0.1:8080/quotes/6
- Step through the debugger and observe the
EmptyResultDataAccessException
being caught and an HTTP 404 Not Found returned to the caller.
Trying 127.0.0.1:8080... * Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0) > DELETE /quotes/6 HTTP/1.1 > Host: 127.0.0.1:8080 > User-Agent: curl/7.74.0 > Accept: */* > * Mark bundle as not supporting multiuse < HTTP/1.1 404 < Content-Length: 0 < Date: < * Connection #0 to host 127.0.0.1 left intact
- Stop the debugging session by clicking on the red square in the debug toolbar
6. Cleanup
Congratulations! In this lab you've created a new Java application from scratch and configured it to work effectively with containers. You then deployed and debugged your application to a remote GKE cluster following the same developer flow found in traditional application stacks.
To clean up after completing the lab:
- Delete the files used in the lab
cd ~ && rm -rf container-developer-workshop
- Delete the project to remove all related infrastructure and resources