Run Java Analytics as a Kubernetes Job
Containerize a Java pipeline and run it as a resilient Kubernetes Job.
Introduction
30 Second Summary
A project that only runs on your laptop is difficult for someone else to trust. Interviewers need evidence that your work behaves predictably beyond one machine.
In this project, you will package the Sales Intelligence Framework as a reproducible Docker image. You will run it with Docker Compose before demonstrating failure recovery through a Kubernetes Job.
What You'll Build
Picture one command producing the familiar dashboard plus CSV reports on your Mac from a containerized analytics pipeline.
By the end of this project, you'll have:
- A reproducible Java image that builds on Apple silicon or AMD64 hosts without keeping Maven inside the runtime image.
- A one-command analytics run that waits for MySQL readiness before writing the dashboard plus CSV reports to your Mac.
- A visible failure story where incorrect credentials trigger Job retries before a corrected Kubernetes Secret leads to Complete.
- Secret Mission: Harden the workload to run as a locked-down non-root user with UID 10001.
Are there any prerequisites?
You need a Mac supported by Docker Desktop, at least 4 GB of RAM, and permission to modify a runnable copy of the repository.
Your toolkit should include Java JDK 17, IntelliJ IDEA, Git, and GitHub access.
Before We Start
Before any hands-on work begins, commit to productionizing the existing Sales Intelligence Framework as a reproducible Java batch workload. Your Docker-to-Kubernetes failure and recovery evidence will give that work a clear purpose in placement interviews.
Set Up Docker Desktop
Your existing Java application already runs through Maven. The productionized version also needs a local container engine.
Docker Desktop 4.94.0 supplies that engine plus Docker Compose. Its integrated Kubernetes path prepares your Mac for the Job workflow later.
In this step, get ready to:
- Install Docker Desktop for Apple silicon.
- Verify the Docker engine and Docker Compose plugin.
- Open your existing Sales Intelligence Framework repository in Visual Studio Code.
Install Docker Desktop
Docker Desktop packages the container engine behind a Mac application. The Apple silicon build matches your Mac's processor.
The subscription agreement can feel like a billing step. Docker Desktop is free for educational use.
Start with a quick availability check. Each possible result below gives you a clear path.
- Check the installed Docker state by running this command:
docker version
What does this check prove?
- The client section proves that Terminal can find the Docker command-line tool.
- The server section proves that the Docker Desktop engine is running.
- The server details identify the Docker Desktop release behind the engine.
Choose the tab that matches your result.
✔️ I see the required Docker Desktop version
Your output includes client details plus server details for Docker Desktop 4.94.0. Your installation is ready.
- Keep Docker Desktop running while you complete the rest of this project.
ⓧ I see an older Docker Desktop version
Your Docker tools are available. Update the application so this project uses Docker Desktop 4.94.0.
- Visit the Docker Desktop release notes.
- Find version 4.94.0.
- Select Mac (Apple chip).
- Open Docker.dmg from your Downloads folder.
- Drag Docker to Applications.
The first launch can take several minutes while macOS completes security checks. A quiet window during that wait is expected.
- Press Cmd+Space to open Spotlight.
- Type Docker and press Enter to start Docker.app.
- Accept the subscription agreement.
- Wait until the Docker Desktop Dashboard opens.
ⓧ Command not found
Docker Desktop is not installed yet. Install the Apple silicon release from Docker's official Mac page.
- Visit the official Docker Desktop installation page.
- Download Docker Desktop for Mac with Apple silicon.
- Open Docker.dmg from your Downloads folder.
- Drag Docker to Applications.
The first launch can take several minutes while macOS completes security checks. A quiet window during that wait is expected.
- Press Cmd+Space to open Spotlight.
- Type Docker and press Enter to start Docker.app.
- Accept the subscription agreement.
- Wait until the Docker Desktop Dashboard opens.
Docker Desktop not starting?
Confirm that you downloaded the Apple silicon build. Check that Docker.app is inside Applications.
If the Dashboard is still loading, leave it open for several minutes before retrying the version check.
Help me troubleshoot Docker Desktop installation on my Apple silicon Mac.
- Choose About Docker Desktop from the Docker menu.
- Confirm that the installed version is 4.94.0.
Verify the Docker tools
Docker Desktop exposes separate command-line interfaces for the engine and Compose. Checking both confirms that Terminal can reach the running engine.
- Press Cmd+Space to open Spotlight.
- Type Terminal and press Enter to open it.
Before you run the checks, do you expect both tools to respond from the same Docker Desktop installation?
- Verify the engine and Compose plugin by running these commands:
docker version
docker compose version
What do these checks prove?
- The first line asks the client and server for their version information.
- The second line confirms that the Compose plugin is available through the Docker command-line tool.
You should see client information plus server information from the first check. You should also see Docker Compose version information without a connection failure.
That is the local container foundation working. Your Mac can now build images and coordinate multi-container workflows.
Commands still not working?
If Terminal cannot find the Docker command, quit Terminal after Docker Desktop finishes starting. Open Terminal again so it reloads the command path.
If client information appears without server information, return to the Docker Desktop Dashboard. Wait for the engine to finish starting before rerunning the checks.
Help me diagnose my Docker engine or Compose verification result.
Confirm the repository workspace
Your runnable repository copy already contains the Maven workflow that you will containerize. Reusing that copy preserves your existing project history.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code and press Enter to open it.
- Select File from the top menu.
- Select Open Folder....
- Select your existing Sales-Intelligence-Framework folder.
- Select Open.
- Select Yes, I trust the authors to enable all features in the workspace. if the Workspace Trust dialog appears.
Before you inspect the Explorer, which files do you expect from the existing Java and Maven workflow?
- Confirm that pom.xml appears at the top level of the repository.
- Confirm that src/main/java/com/sales/Main.java appears in the Explorer.
- Confirm that src/main/java/com/sales/DBConnection.java appears in the Explorer.
- Confirm that src/main/resources/Schema.sql appears in the Explorer.
- Confirm that src/main/resources/seed_data.sql appears in the Explorer.
You should now see the Maven configuration, Java entry point, database connection class, and SQL resources in one workspace. That proves the existing application is ready for container packaging.
Docker Desktop is running beside your existing analytics code. Next up, you will package the pipeline and expose its first hidden container-boundary failure.
Package the Pipeline
Your Docker engine is ready. The repository still depends on files from your Mac.
Packaging the pipeline exposes that hidden dependency. The first container run deliberately reaches the existing config.properties requirement while the application reports a misleading success status.
In this step, get ready to:
- Keep local credentials outside every committed or containerized artifact.
- Package the Java application as a multi-stage container image.
- Run the application with Docker Compose to expose its hidden configuration dependency.
Prepare the container inputs
A repeatable MySQL environment needs an initialization script that can safely encounter an existing database. Ignore files also keep credentials and machine-specific output outside the image.
- In src/main/resources/Schema.sql, find this database statement:
CREATE DATABASE sales_db;
Why does this line need changing?
This statement assumes sales_db does not exist. Reusing an initialized database can make the script stop at this line.
- Replace the existing statement with this idempotent version:
CREATE DATABASE IF NOT EXISTS sales_db;
What does the new statement do?
The IF NOT EXISTS clause creates sales_db only when it is absent. Repeated initialization attempts can now pass this line safely.
- Save src/main/resources/Schema.sql.
You should now see CREATE DATABASE IF NOT EXISTS sales_db; near the top of the saved SQL file.
- In .gitignore, replace the file contents with this configuration:
# IntelliJ IDEA
.idea/
*.iml
*.iws
*.ipr
out/
# Maven build output
target/
*.class
# SECURITY: credentials - never commit
src/main/resources/config.properties
.env
# SECURITY: generated output contains customer names
output/
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Logs
*.log
What does this file protect?
- The credential rules keep config.properties plus .env out of commits.
- The build rules keep generated Maven files out of version control.
- The output rule protects generated files that may contain customer names.
- Save .gitignore.
The repository now has explicit exclusions for credentials, build output, generated reports, operating-system files, and logs.
- Create .env.example in the repository folder with this template:
MYSQL_ROOT_PASSWORD=replace-with-a-local-root-password
DB_USER=sales_app
DB_PASSWORD=replace-with-a-local-app-password
Why keep an environment template?
The template records the required setting names without storing working passwords. Anyone using the repository can create a private local copy from the same three entries.
- Save .env.example.
You should now see .env.example in the repository file tree.
- Duplicate .env.example as .env inside the repository folder.
The new .env file is your private local configuration.
- Edit both password placeholders in .env to use local-only password values.
- Save .env.
Your local environment file now supplies MYSQL_ROOT_PASSWORD, DB_USER, and DB_PASSWORD without placing working credentials in the shared template.
- Create .dockerignore in the repository folder with these exclusions:
.git
.github
.idea
.vscode
target
output
*.iml
*.log
.DS_Store
.env
src/main/resources/config.properties
What stays outside the build context?
The image build receives the source files it needs. Git history, editor settings, previous output, local credentials, and existing build results stay on your Mac.
- Save .dockerignore.
Your repository file tree should now include .env.example, .env, and .dockerignore.
Build the multi-stage image
A multi-stage image build separates compilation from execution. Maven packages the application in one stage while the final image carries only Java classes, runtime dependencies, and the JRE.
- Create Dockerfile in the repository folder with this image definition:
# syntax=docker/dockerfile:1
FROM maven:3.10.0-eclipse-temurin-17-noble AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B clean package \
org.apache.maven.plugins:maven-dependency-plugin:3.11.0:copy-dependencies \
-DincludeScope=runtime \
-DoutputDirectory=target/dependency
FROM eclipse-temurin:17.0.20.1_1-jre-noble
WORKDIR /app
COPY --from=build /workspace/target/classes ./classes
COPY --from=build /workspace/target/dependency ./lib
ENTRYPOINT ["java", "-cp", "/app/classes:/app/lib/*", "com.sales.Main"]
How does the image work?
- The build stage uses maven:3.10.0-eclipse-temurin-17-noble to compile the Java 17 project.
- The dependency goal copies runtime libraries into target/dependency.
- The runtime stage uses eclipse-temurin:17.0.20.1_1-jre-noble without carrying Maven or the source tree.
- The entry point starts com.sales.Main with the compiled classes plus copied libraries on its classpath.
- Save Dockerfile.
The repository now contains a two-stage recipe for the sales-intelligence:1.0 image.
Orchestrate the first container run
Docker Compose coordinates the database with the batch application. Its health check holds the application until MySQL responds.
The services also use different storage patterns. MySQL receives a named volume while generated reports return to the repository through a bind mount.
- Create compose.yaml in the repository folder with this database service:
services:
db:
image: mysql:8.0.46
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: sales_db
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "3307:3306"
volumes:
- db-data:/var/lib/mysql
- ./src/main/resources/Schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./src/main/resources/seed_data.sql:/docker-entrypoint-initdb.d/02-seed-data.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u\"$${MYSQL_USER}\" --password=\"$${MYSQL_PASSWORD}\""]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s
What does the database service do?
- The service starts mysql:8.0.46 with values from .env.
- Port 3307 on your Mac maps to MySQL port 3306.
- The two SQL resources initialize the schema before the application connects.
- The health check allows up to twenty probes after its startup window.
You should now see a complete db service at the top of compose.yaml.
- Add this application service plus volume declaration below the database service:
app:
image: sales-intelligence:1.0
build:
context: .
depends_on:
db:
condition: service_healthy
environment:
DB_URL: jdbc:mysql://db:3306/sales_db
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
volumes:
- ./output:/app/output
volumes:
db-data:
What does the application service do?
- The service builds the local image with the tag sales-intelligence:1.0.
- The dependency condition waits for the database health check.
- The DB_URL value uses the Compose service name db as its network host.
- The output mount maps /app/output back to the repository's output folder.
- Save compose.yaml.
The saved Compose file now defines both services plus the db-data volume.
Use this checkpoint to compare every packaged artifact before starting the services.
✔️ Awesome, I've got everything!
Great. Confirm that every file is saved before you start the first container run.
ⓧ I'd like to double check the full code
Compare each saved file with the references below. Keep your real password values only in .env.
.git
.github
.idea
.vscode
target
output
*.iml
*.log
.DS_Store
.env
src/main/resources/config.properties
This is the complete .dockerignore file.
MYSQL_ROOT_PASSWORD=replace-with-a-local-root-password
DB_USER=sales_app
DB_PASSWORD=replace-with-a-local-app-password
This is the complete shareable environment template.
# IntelliJ IDEA
.idea/
*.iml
*.iws
*.ipr
out/
# Maven build output
target/
*.class
# SECURITY: credentials - never commit
src/main/resources/config.properties
.env
# SECURITY: generated output contains customer names
output/
# OS files
.DS_Store
Thumbs.db
desktop.ini
# Logs
*.log
This is the complete .gitignore file.
# syntax=docker/dockerfile:1
FROM maven:3.10.0-eclipse-temurin-17-noble AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B clean package \
org.apache.maven.plugins:maven-dependency-plugin:3.11.0:copy-dependencies \
-DincludeScope=runtime \
-DoutputDirectory=target/dependency
FROM eclipse-temurin:17.0.20.1_1-jre-noble
WORKDIR /app
COPY --from=build /workspace/target/classes ./classes
COPY --from=build /workspace/target/dependency ./lib
ENTRYPOINT ["java", "-cp", "/app/classes:/app/lib/*", "com.sales.Main"]
This is the complete two-stage Dockerfile.
services:
db:
image: mysql:8.0.46
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: sales_db
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
ports:
- "3307:3306"
volumes:
- db-data:/var/lib/mysql
- ./src/main/resources/Schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
- ./src/main/resources/seed_data.sql:/docker-entrypoint-initdb.d/02-seed-data.sql:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u\"$${MYSQL_USER}\" --password=\"$${MYSQL_PASSWORD}\""]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s
app:
image: sales-intelligence:1.0
build:
context: .
depends_on:
db:
condition: service_healthy
environment:
DB_URL: jdbc:mysql://db:3306/sales_db
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
volumes:
- ./output:/app/output
volumes:
db-data:
This is the complete two-service Compose configuration.
-- Copyright (c) 2026 Swagat Samal. All rights reserved.
-- Licensed under CC BY-NC-ND 4.0
CREATE DATABASE IF NOT EXISTS sales_db;
USE sales_db;
-- TABLE 1: customers
CREATE TABLE customers (
customer_id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
region VARCHAR(50) NOT NULL,
segment ENUM('B2B','B2C','Enterprise') NOT NULL,
created_at DATE NOT NULL,
PRIMARY KEY (customer_id)
);
-- TABLE 2: products
CREATE TABLE products (
product_id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
category VARCHAR(50) NOT NULL,
price DECIMAL(10,2) NOT NULL,
cost DECIMAL(10,2) NOT NULL,
PRIMARY KEY (product_id)
);
-- TABLE 3: transactions
CREATE TABLE transactions (
txn_id INT NOT NULL AUTO_INCREMENT,
customer_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
txn_date DATE NOT NULL,
PRIMARY KEY (txn_id),
CONSTRAINT fk_customer FOREIGN KEY (customer_id)
REFERENCES customers(customer_id) ON DELETE CASCADE,
CONSTRAINT fk_product FOREIGN KEY (product_id)
REFERENCES products(product_id) ON DELETE CASCADE
);
-- VIEW 1: rfm scores - recency, frequency, monetary per customer
CREATE VIEW rfm_scores AS
SELECT
c.customer_id,
c.name,
c.region,
c.segment,
DATEDIFF(CURDATE(), MAX(t.txn_date)) AS recency_days,
COUNT(t.txn_id) AS frequency,
SUM(t.amount) AS monetary
FROM customers c
JOIN transactions t ON c.customer_id = t.customer_id
GROUP BY c.customer_id, c.name, c.region, c.segment;
-- VIEW 2: product pairs - co-purchase matrix for recommendations
CREATE VIEW product_pairs AS
SELECT
t1.product_id AS product_a,
t2.product_id AS product_b,
COUNT(DISTINCT t1.customer_id) AS co_purchase_count,
p1.name AS product_a_name,
p2.name AS product_b_name,
p1.category AS category_a,
p2.category AS category_b
FROM transactions t1
JOIN transactions t2
ON t1.customer_id = t2.customer_id
AND t1.product_id < t2.product_id
JOIN products p1 ON t1.product_id = p1.product_id
JOIN products p2 ON t2.product_id = p2.product_id
GROUP BY t1.product_id, t2.product_id, p1.name, p2.name, p1.category, p2.category
ORDER BY co_purchase_count DESC;
-- VIEW 3: region demand - powers demand by location and trends
CREATE VIEW region_demand AS
SELECT
c.region,
p.category,
p.name AS product_name,
YEAR(t.txn_date) AS yr,
MONTH(t.txn_date) AS mo,
SUM(t.quantity) AS total_units,
SUM(t.amount) AS total_revenue,
COUNT(DISTINCT t.customer_id) AS unique_buyers
FROM transactions t
JOIN customers c ON t.customer_id = c.customer_id
JOIN products p ON t.product_id = p.product_id
GROUP BY c.region, p.category, p.name, yr, mo
ORDER BY c.region, total_revenue DESC;
-- Verify Tables
SHOW TABLES;
This is the complete schema with idempotent database creation.
The first build can take several minutes while Docker downloads the images and Maven dependencies. A quiet stretch during this first run can be normal.
Before you run this, do you think the application's process status will reveal the missing credential file?
- Build the image plus start both services by running this command:
docker compose up --build --abort-on-container-exit --exit-code-from app app
Why did the workload report success?
You should see the application banner followed by config.properties not found. Compose still reports exit code 0 for the application.
The container has exposed two application assumptions. Database settings still come from a classpath file while the fatal catch block allows the Java process to finish successfully.
Did the run stop before the application banner?
Confirm that Docker Desktop is still running. Check that src/main/resources/seed_data.sql exists in the repository.
Check that .env contains the three setting names from .env.example.
Help me diagnose why the Compose build or database startup stops before the Java application banner.
You have exposed the exact container boundary this project needs. Next, you will replace the hidden file dependency with environment-based configuration and make fatal errors return a nonzero exit code.
Make the Application Container-Aware
Your container image now reaches the Java application. The first run also exposed its hidden dependency on config.properties.
Kubernetes can only manage failures that the application exposes. A successful process exit currently hides the fatal startup error from the container runtime.
In this step, get ready to:
- Load database settings from environment variables with a local properties fallback.
- Make successful and failed runs visible to the container runtime.
- Leave MySQL running for the next Kubernetes Job.
Read database settings from the environment
Environment-based configuration lets the same image receive different database settings at runtime. The properties file remains available as a fallback for your original local Maven workflow.
- In the Project tool window in IntelliJ IDEA, select src/main/java/com/sales/DBConnection.java.
- Locate the existing getConnection() method near the bottom of the file.
- Add these helper methods directly above getConnection() by copying the following code:
private static String getSetting(String environmentName,
Properties properties, String propertyName) {
String environmentValue = System.getenv(environmentName);
return isBlank(environmentValue)
? properties.getProperty(propertyName)
: environmentValue;
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
How do these helpers work?
- getSetting() checks the requested environment variable first. It returns the matching properties value when the environment value is blank.
- isBlank() safely handles missing values. It also catches empty or whitespace-only strings.
- Save src/main/java/com/sales/DBConnection.java.
- Confirm that getSetting() appears directly above isBlank() in the file.
Seeing Java errors in the helper methods?
Check that both methods sit inside the closing brace for DBConnection. Make sure the method names match getSetting() and isBlank() exactly.
Help me fix the helper methods in DBConnection.java.
The static initializer now needs to use those helpers. It loads the optional properties file before choosing an environment value or its fallback for each database setting.
- Locate the three database field declarations near the top of DBConnection.
- Select those declarations plus the existing static initializer.
- Replace the selected code with this environment-first configuration:
private static final String URL;
private static final String USER;
private static final String PASS;
static {
Properties properties = new Properties();
try (InputStream in = DBConnection.class
.getClassLoader()
.getResourceAsStream("config.properties")) {
if (in != null) {
properties.load(in);
}
} catch (IOException e) {
throw new RuntimeException("Failed to load config: " + e.getMessage(), e);
}
URL = getSetting("DB_URL", properties, "db.url");
USER = getSetting("DB_USER", properties, "db.user");
PASS = getSetting("DB_PASSWORD", properties, "db.password");
if (isBlank(URL) || isBlank(USER) || isBlank(PASS)) {
throw new IllegalStateException(
"Database configuration missing. Set DB_URL, DB_USER, and DB_PASSWORD "
+ "or provide config.properties.");
}
}
What does this configuration do?
- Properties stores any fallback values loaded from config.properties.
- DB_URL, DB_USER, and DB_PASSWORD take priority when the container supplies them.
- db.url, db.user, and db.password preserve the original local properties workflow.
- The final check stops startup when any required value remains blank.
- Save src/main/java/com/sales/DBConnection.java.
Before you run the pipeline, predict whether the application will still depend on config.properties inside the image.
This rebuild can take a few minutes while Maven packages the application. A quiet terminal during that work is expected.
- Run the containerized pipeline by running this command:
docker compose up --build --abort-on-container-exit --exit-code-from app app
What does this run prove?
- --build rebuilds the application image with the updated Java classes.
- --abort-on-container-exit stops the Compose project when the application container exits.
- --exit-code-from app makes Compose report the application service status.
- The logs should now end with Run complete instead of reporting the missing properties file.
- Expand the output directory in the IntelliJ IDEA Project tool window.
- Confirm that dashboard.html appears in the directory.
- Confirm that results.csv appears in the directory.
- Confirm that recommendations.csv appears in the directory.
- Confirm that region_report.csv appears in the directory.
That is the container boundary crossed. Your image now receives its database configuration at runtime and writes the familiar reports back to your Mac.
Missing the successful run or report files?
Check that your local .env contains values for MYSQL_ROOT_PASSWORD, DB_USER, and DB_PASSWORD. Check that the output bind mount in compose.yaml still maps to /app/output.
Help me diagnose the failed Compose run or missing output files.
✔️ Awesome, I've got everything!
Your database configuration now prefers container environment variables. Double-check that you saved the file.
ⓧ I'd like to double check the full code
/*
* Copyright (c) 2026 Swagat Samal
* Licensed under CC BY-NC-ND 4.0
* https://creativecommons.org/licenses/by-nc-nd/4.0/
*/
package com.sales;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Properties;
public class DBConnection {
private static final String URL;
private static final String USER;
private static final String PASS;
static {
Properties properties = new Properties();
try (InputStream in = DBConnection.class
.getClassLoader()
.getResourceAsStream("config.properties")) {
if (in != null) {
properties.load(in);
}
} catch (IOException e) {
throw new RuntimeException("Failed to load config: " + e.getMessage(), e);
}
URL = getSetting("DB_URL", properties, "db.url");
USER = getSetting("DB_USER", properties, "db.user");
PASS = getSetting("DB_PASSWORD", properties, "db.password");
if (isBlank(URL) || isBlank(USER) || isBlank(PASS)) {
throw new IllegalStateException(
"Database configuration missing. Set DB_URL, DB_USER, and DB_PASSWORD "
+ "or provide config.properties.");
}
}
private static String getSetting(String environmentName,
Properties properties, String propertyName) {
String environmentValue = System.getenv(environmentName);
return isBlank(environmentValue)
? properties.getProperty(propertyName)
: environmentValue;
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASS);
}
}
Expose fatal errors through the process exit
The application now starts successfully with container configuration. Its fatal catch still needs to report failure through the process exit status so an orchestrator can respond.
- Select src/main/java/com/sales/Main.java in the IntelliJ IDEA Project tool window.
- Locate the outer catch block at the end of main().
- Find this final statement inside that catch block:
e.printStackTrace();
Why does the current status look successful?
e.printStackTrace() writes diagnostic details to the logs. The method does not change the process exit status, so the container reports status 0 after the catch block finishes.
- Add System.exit(1) directly below the stack trace so the end of the catch block looks like this:
e.printStackTrace();
System.exit(1);
What does the new exit do?
- System.exit(1) ends the Java process with a nonzero status after a fatal error.
- Docker Compose can now distinguish a failed workload from a completed workload.
- The future Kubernetes Job can use the same signal to trigger its retry behavior.
- Save src/main/java/com/sales/Main.java.
Before you rerun the pipeline, predict whether valid database settings will still reach the successful completion path.
- Rebuild the image and run the application by running this command:
docker compose up --build --abort-on-container-exit --exit-code-from app app
What should this run show?
Your valid local credentials should keep the application on its successful path. The logs should end with Run complete.
A future fatal startup error now returns status 1. That gives the container runtime a dependable failure signal.
Does the rebuilt application fail?
Check that System.exit(1) sits inside the outer fatal catch block. Check that the closing braces around main() still match the full file below.
Help me fix the fatal catch block in Main.java.
✔️ Awesome, I've got everything!
Your success path still completes. Fatal startup errors now return a nonzero process status.
ⓧ I'd like to double check the full code
/*
* Copyright (c) 2026 Swagat Samal
* Licensed under CC BY-NC-ND 4.0
* https://creativecommons.org/licenses/by-nc-nd/4.0/
*/
package com.sales;
import java.sql.Connection;
import java.util.List;
public class Main {
public static void main(String[] args) {
java.util.logging.Logger.getLogger("com.github.fommil")
.setLevel(java.util.logging.Level.OFF);
java.util.logging.Logger.getLogger("weka")
.setLevel(java.util.logging.Level.OFF);
System.out.println("===========================================");
System.out.println(" SALES INTELLIGENCE FRAMEWORK v1.0");
System.out.println("===========================================");
try {
System.out.println("\n[1/6] Connecting to MySQL...");
Connection conn = DBConnection.getConnection();
System.out.println(" Connected successfully.");
System.out.println("\n[2/6] Loading RFM data...");
RFMCalculator rfm = new RFMCalculator();
List<double[]> features = rfm.getRFMFeatures();
List<String[]> meta = rfm.getCustomerMeta();
rfm.exportARFF(features);
System.out.printf(" Loaded %d customer records.%n", features.size());
System.out.println("\n[3/6] Running K-Means (k=4)...");
MLEngine ml = new MLEngine();
int[] clusters = ml.clusterCustomers(features);
String[] labels = {"PREMIUM", "GROWTH", "AT-RISK", "LOST"};
System.out.println("\n[4/6] Detecting market gaps...");
try { new GapDetector().detectGaps(); }
catch (Exception e) { System.out.println(" GapDetector error: " + e.getMessage()); }
System.out.println("\n[5/6] Revenue forecast...");
try { new RevenueForecaster().forecast(); }
catch (Exception e) { System.out.println(" Forecaster error: " + e.getMessage()); }
System.out.println("\n[6/6] Product recommendations...");
try { new ProductRecommender().runForAllCustomers(); }
catch (Exception e) { System.out.println(" Recommender error: " + e.getMessage()); }
System.out.println("\n[BONUS] Region analysis...");
try { new RegionAnalyser().runAll(); }
catch (Exception e) { System.out.println(" RegionAnalyser error: " + e.getMessage()); }
System.out.println("\nExporting results...");
try {
exportCSV(meta, features, clusters, labels);
ChartExporter.export(clusters, labels, features, meta);
} catch (Exception e) { System.out.println(" Export error: " + e.getMessage()); }
conn.close();
System.out.println("\n========== Run complete ==========");
System.out.println("Check output/ folder for all files + dashboard.html");
} catch (Exception e) {
System.err.println("FATAL ERROR: " + e.getMessage());
e.printStackTrace();
System.exit(1);
}
}
private static void exportCSV(List<String[]> meta, List<double[]> features,
int[] clusters, String[] labels) throws Exception {
new java.io.File("output").mkdirs();
try (java.io.PrintWriter pw = new java.io.PrintWriter(
new java.io.FileWriter("output/results.csv"))) {
pw.println("customer_id,name,region,segment,recency,frequency,monetary,cluster,label");
for (int i = 0; i < meta.size(); i++) {
String[] m = meta.get(i);
double[] f = features.get(i);
pw.printf("%s,%s,%s,%s,%.0f,%.0f,%.2f,%d,%s%n",
m[0], m[1], m[2], m[3],
f[0], f[1], f[2],
clusters[i], labels[clusters[i]]);
}
}
System.out.println(" Saved → output/results.csv");
}
}
Keep MySQL running for Kubernetes
The next Job runs inside the local Kubernetes cluster. Its application container needs the existing MySQL service to remain available through the published host port.
Before you start the database by itself, predict which Compose service will remain active after the command returns.
- Start only the database service in the background by running this command:
docker compose up -d db
What does this command leave running?
The -d option leaves the db service running after the terminal returns. The Compose port mapping makes MySQL available on host port 3307.
The Kubernetes Job can reach the Mac through host.docker.internal. Keeping the database separate lets the Job focus on running the analytics workload once.
The terminal should return after reporting that the database service is running. MySQL now stays available in the background for the Kubernetes failure test.
Database service not staying active?
Confirm that Docker Desktop is still running. Check the database logs for a port conflict or a MySQL initialization problem.
Help me diagnose why the Compose db service will not stay running.
Your application now exposes both success and failure to its runtime. Next up, you will let Kubernetes detect a real credential failure and retry the Job.
Deploy a Failing Kubernetes Job
Your containerized pipeline now reaches MySQL through environment-based configuration. A fatal startup problem also produces a nonzero exit code that an orchestrator can detect.
A Kubernetes Job turns that process exit into workload status. You will use deliberately incorrect credentials so the retry controller can expose a real failure through failed Pods and logs.
In this step, get ready to:
- Activate a local kind cluster through Docker Desktop.
- Define the sales intelligence Job with Secret-backed database credentials.
- Observe the failed attempts through Job status and Pod logs.
Enable the local Kubernetes cluster
The Kubernetes integration in Docker Desktop provides the local cluster for this workload. The kind provisioner works with Docker Desktop's container image store so the cluster can use your local image.
- Switch back to Docker Desktop.
- Select the Kubernetes view.
- Select Create cluster.
- Choose kind as the provisioner.
- Select Create.
Cluster provisioning can take a few minutes. A short wait here is expected.
Before you check the cluster, make a mental prediction about how many local nodes the command will list.
- Verify that the cluster accepts requests by running this command:
kubectl get nodes
What does this check prove?
The command asks the active Kubernetes context for its nodes. A listed Docker Desktop node confirms that the cluster is running and that kubectl can reach its control plane.
Good work. Your Docker Desktop cluster is now accepting kubectl requests.
Cluster node missing?
Confirm that cluster creation has finished in Docker Desktop. Check that your terminal is using the Docker Desktop Kubernetes context.
Ask for help if the cluster is running but no node appears. Help me diagnose why kubectl cannot list my Docker Desktop Kubernetes node.
Create the failing Job configuration
The manifest describes how Kubernetes runs the batch process. A Secret supplies the database credentials to each Pod without placing their values in the manifest.
An ephemeral volume gives the process a writable output directory for the lifetime of each Pod. You will create the manifest in VS Code using two focused sections.
- Use VS Code's file sidebar to create a k8s folder at the top level of the repository.
You should now see the k8s folder beside the existing project files.
- Use the file control inside k8s to create job.yaml.
The file sidebar should now list job.yaml beneath k8s.
- Define the Job metadata and container image by pasting this first section into k8s/job.yaml:
apiVersion: batch/v1
kind: Job
metadata:
name: sales-intelligence
labels:
app.kubernetes.io/name: sales-intelligence
spec:
backoffLimit: 1
template:
metadata:
labels:
app.kubernetes.io/name: sales-intelligence
spec:
restartPolicy: Never
containers:
- name: sales-intelligence
image: sales-intelligence:1.0
imagePullPolicy: IfNotPresent
What does this section define?
- The Job name gives every command a consistent workload target.
- The backoff limit allows Kubernetes to retry after the Java process exits with an error.
- The restart policy preserves failed Pods so you can inspect their logs.
- The image pull policy lets the cluster use the local sales-intelligence:1.0 image when it is available.
- Save k8s/job.yaml.
The saved file should contain the Job metadata followed by one container named sales-intelligence.
YAML indentation looks uneven?
Keep each nested level aligned with spaces. Make sure the container entry sits beneath the containers list.
Use this prompt if the structure still looks wrong. Help me compare the indentation in the first section of my Kubernetes Job manifest.
The remaining section injects the connection settings and mounts the output volume. Its database URL points from the Kubernetes Pod to the Compose database on your Mac.
- Append this section directly below imagePullPolicy: IfNotPresent in k8s/job.yaml:
env:
- name: DB_URL
value: jdbc:mysql://host.docker.internal:3307/sales_db
- name: DB_USER
valueFrom:
secretKeyRef:
name: sales-db-credentials
key: DB_USER
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: sales-db-credentials
key: DB_PASSWORD
volumeMounts:
- name: output
mountPath: /app/output
volumes:
- name: output
emptyDir: {}
How does the Job reach its dependencies?
- The database URL sends traffic to the Compose database through host.docker.internal:3307.
- The username and password come from matching keys in sales-db-credentials.
- The emptyDir volume provides a fresh output location for each Pod.
- Save k8s/job.yaml.
Your manifest should now include three environment variables and an output mount at /app/output.
Secret references outside the container?
Check that env and volumeMounts align beneath the container entry. Check that volumes aligns beneath containers.
Use this prompt for an indentation check. Help me place the environment variables and output volume correctly in my Kubernetes Job YAML.
✔️ Awesome, I've got everything!
Great. Double-check that you saved k8s/job.yaml before creating the Secret.
ⓧ I'd like to double check the full code
apiVersion: batch/v1
kind: Job
metadata:
name: sales-intelligence
labels:
app.kubernetes.io/name: sales-intelligence
spec:
backoffLimit: 1
template:
metadata:
labels:
app.kubernetes.io/name: sales-intelligence
spec:
restartPolicy: Never
containers:
- name: sales-intelligence
image: sales-intelligence:1.0
imagePullPolicy: IfNotPresent
env:
- name: DB_URL
value: jdbc:mysql://host.docker.internal:3307/sales_db
- name: DB_USER
valueFrom:
secretKeyRef:
name: sales-db-credentials
key: DB_USER
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: sales-db-credentials
key: DB_PASSWORD
volumeMounts:
- name: output
mountPath: /app/output
volumes:
- name: output
emptyDir: {}
These credentials are deliberately disposable. Your real local password stays in .env.
- Create the test Secret with an incorrect password by running this command:
kubectl create secret generic sales-db-credentials --from-literal=DB_USER='sales_app' --from-literal=DB_PASSWORD='intentionally-wrong'
Why use a Secret?
The command stores the test username and password under the keys referenced by the Job. The manifest can request those keys without containing the credential values itself.
The cluster now holds the failure input for this demonstration. The real value in .env remains untouched.
Secret creation failed?
Confirm that the Docker Desktop cluster is still running. A previous Secret with the same name can also block creation.
Use this prompt to diagnose the command without sharing any real credentials. Help me troubleshoot creating the sales-db-credentials Secret with disposable test values.
Apply the Job and inspect the failure
The Job controller now has everything required to run the image. The deliberately incorrect password makes the Java process exit with the failure signal you added earlier.
- Submit the Job manifest to the cluster by running this command:
kubectl apply -f k8s/job.yaml
What did Kubernetes create?
Kubernetes creates the sales-intelligence Job from the manifest. The Job controller then creates a Pod that runs the local image once.
Before you watch the Job, predict whether the controller will record completion or keep retrying the workload.
- Watch the Job status change by running this command:
kubectl get jobs --watch
What does the watch show?
The watch stream updates as the Job controller receives each Pod result. The configured backoff limit controls how many retries Kubernetes allows before treating the Job as failed.
- Stop the watch with Control+C after the failed attempts accumulate.
The Job stays below Complete. Failed attempts accumulate until the configured backoff limit is reached.
This failure is the goal
The incorrect password causes MySQL authentication to fail. The Java process returns a nonzero exit code, so Kubernetes records failed work instead of successful completion.
This gives you visible proof that the Job controller can distinguish a broken pipeline run from a completed one.
- List the Pods created by the failed Job by running this command:
kubectl get pods
Why are there multiple failed Pods?
The Job creates another Pod when the first attempt fails. The restartPolicy: Never setting keeps each failed attempt available for inspection.
Before you read the logs, predict which stage of the Java pipeline ran before the process exited.
- Read the Job logs by running this command:
kubectl logs jobs/sales-intelligence
What do the logs prove?
The application banner appears before the database connection attempt reports a MySQL authentication error. The fatal catch block prints FATAL ERROR before returning the nonzero exit code.
The logs connect the wrong credential to the failed Job status. You now have an observable failure story across the application and Kubernetes layers.
Your Job now exposes a genuine workload failure through retries and logs. Next up, you will replace the disposable credential input and prove that the same manifest can reach completion.
Recover the Kubernetes Job
The failed Kubernetes Job from the previous step proved that a real process failure reaches the orchestrator. Its Pods preserved the authentication error that explained every retry.
Now you will replace only the incorrect Secret. The unchanged image and manifest make this recovery easy to explain in an interview.
In this step, get ready to:
- Remove the failed Job and its incorrect credential Secret.
- Recreate the Secret with the database credentials from .env.
- Reapply the Job and capture evidence that the pipeline completes.
Remove the failed run
The failed Job owns the Pods that recorded its attempts. Deleting the Job clears that failure history before a clean run uses the same resource name.
- Remove the failed Job and incorrect Secret by running these commands:
kubectl delete job sales-intelligence
kubectl delete secret sales-db-credentials
What do these deletions change?
- Deleting the Job also deletes the failed Pods it created.
- Deleting the Secret removes the incorrect credentials before you create the replacement.
- The local image and Job manifest remain unchanged.
The terminal confirms each deletion. Your local image remains available for the recovery run.
Could not delete a resource?
If a resource is already absent, continue with the next deletion. If the command cannot reach the cluster, confirm that Docker Desktop Kubernetes is still running.
Help me diagnose the failed Kubernetes resource deletion.
Replace the database credentials
The manifest already reads its username and password from two Secret keys. The replacement values must match the database credentials in .env exactly.
The next command uses your local database password. Keep this command out of screenshots.
- Switch back to the .env file from earlier.
- Locate the value assigned to DB_USER.
- Locate the value assigned to DB_PASSWORD.
- Replace your-db-user-here in the command below with the exact DB_USER value.
- Replace your-db-password-here with the exact DB_PASSWORD value.
- Create the replacement Secret by running your edited command:
kubectl create secret generic sales-db-credentials \
--from-literal=DB_USER='your-db-user-here' \
--from-literal=DB_PASSWORD='your-db-password-here'
How does the replacement Secret work?
- The DB_USER key supplies the application username referenced by the manifest.
- The DB_PASSWORD key supplies the matching password.
- Both values now match the running MySQL database.
The terminal confirms that the replacement Secret exists. Its credential values stay out of the manifest.
Secret creation failed?
Confirm that the earlier Secret deletion finished. Check both placeholder replacements for copied spaces or missing quote marks before rerunning the command.
Help me fix the replacement Secret command.
The recovery needs no manifest edit. The second tab provides an exact comparison copy of k8s/job.yaml.
✔️ Awesome, I've got everything!
Your manifest already references the replacement Secret. No code changes are required for this recovery.
ⓧ I'd like to double check the full code
apiVersion: batch/v1
kind: Job
metadata:
name: sales-intelligence
labels:
app.kubernetes.io/name: sales-intelligence
spec:
backoffLimit: 1
template:
metadata:
labels:
app.kubernetes.io/name: sales-intelligence
spec:
restartPolicy: Never
containers:
- name: sales-intelligence
image: sales-intelligence:1.0
imagePullPolicy: IfNotPresent
env:
- name: DB_URL
value: jdbc:mysql://host.docker.internal:3307/sales_db
- name: DB_USER
valueFrom:
secretKeyRef:
name: sales-db-credentials
key: DB_USER
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: sales-db-credentials
key: DB_PASSWORD
volumeMounts:
- name: output
mountPath: /app/output
volumes:
- name: output
emptyDir: {}
Why does the manifest stay unchanged?
The manifest already maps both application settings to Secret keys. Replacing the Secret changes the values supplied to the next Pod without changing the workload definition.
- Reapply the unchanged Job manifest by running this command:
kubectl apply -f k8s/job.yaml
What does reapplying create?
Kubernetes creates a fresh Job from k8s/job.yaml. Its Pod reads the replacement credentials when the container starts.
The terminal confirms that the Job resource was created. The recovery attempt now uses the same image with corrected runtime configuration.
Job Pod cannot start?
If the Pod reports ImagePullBackOff, confirm that the local image tag is sales-intelligence:1.0. Confirm that Docker Desktop is using its containerd image store.
Help me diagnose why the reapplied Job Pod cannot start.
Prove the recovered Job completes
A completed Job proves that the process reached a successful exit. The wait command checks that condition instead of treating a started Pod as success.
This check can take up to three minutes while the analytics pipeline connects to the database and generates its reports.
Before you run the check, do you expect the corrected credentials to change the Job's final condition?
- Wait for the Job's completion condition by running this command:
kubectl wait --for=condition=complete --timeout=180s job/sales-intelligence
What does this check prove?
The command waits until the Job controller records successful completion. A successful result proves that the application returned a zero exit code before the timeout.
The wait command confirms that the completion condition was reached. The failed configuration has now been recovered.
Did the wait time out?
Confirm that the Compose database is still running on host port 3307. Compare the two Secret values with .env without sharing them.
Help me diagnose why the recovered Job did not complete.
- Collect the Job status and final pipeline logs by running these commands:
kubectl get job sales-intelligence
kubectl get pods
kubectl logs jobs/sales-intelligence
What should the evidence show?
- The Job status shows Complete.
- The Pod list shows the workload created for the recovered run.
- The final logs end with Run complete.
That is the recovery proven: the same image now completes because the Secret matches the database.
- Take a terminal screenshot that includes the Complete Job status and the final Run complete log message.
Secret mission
Run the Workload as a Locked-Down Non-Root User
Harden the Java image with a fixed non-root identity. Apply a restricted Kubernetes security context while keeping the analytics pipeline working.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local resources running, pause them for later, or delete them entirely. This project costs $0 to keep, but active resources continue to use your Mac's CPU, memory, disk space, and battery.
Resources you used:
- A local Docker image tagged sales-intelligence:1.0.
- A MySQL 8.0.46 container managed through Docker Compose.
- A Compose project network.
- A named db-data volume containing the initialized database.
- A Docker Desktop Kubernetes cluster using the kind provisioner.
- The completed sales-intelligence Job.
- The retained Pods created by the Job.
- The sales-db-credentials Secret.
- The ephemeral emptyDir volume attached at /app/output.
- The generated dashboard and CSV reports in the repository's output/ directory.
Keep everything running
No action is needed. Choose this option if you want the database and completed Kubernetes demonstration ready for another run.
- Leave the Compose db service available through host port 3307.
- Keep the Docker Desktop Kubernetes cluster active with the completed sales-intelligence Job.
- Keep the sales-intelligence:1.0 image for future Job runs.
- Keep the generated files in the repository's output/ directory.
- Expect the active database and cluster to continue using local computing resources.
Pause - I'll come back to this later
Pause the running services to free local CPU and memory. Your database volume, image, manifests, and generated reports remain available for later.
- Stop the Compose services from the repository folder containing compose.yaml by running this command:
docker compose stop
What Does This Command Stop?
This command stops the Compose containers without deleting the named db-data volume. Your initialized database remains stored for the next start.
You'll see confirmation that the Compose containers have stopped. The database no longer listens on host port 3307.
Compose Services Still Running?
- Confirm that Docker Desktop is still running.
- Confirm that your terminal is in the repository folder containing compose.yaml.
Help me diagnose why the Compose services did not stop.
- Return to the Kubernetes view in Docker Desktop.
- Select Stop to pause the local cluster.
The cluster stops using active resources. Your local image and project files remain on your Mac.
Resume the Database First
Start the Compose database before rerunning the Job. The Job reaches that database through host port 3307.
Delete - I don't want to use this again
Deletion permanently removes the runtime resources and stored database data. Your repository source files remain untouched.
- Remove the Job, credential Secret, Compose stack, and database volume from the repository folder by running these commands:
kubectl delete -f k8s/job.yaml
kubectl delete secret sales-db-credentials
docker compose down --volumes
What Do These Commands Remove?
- The first command deletes the sales-intelligence Job and its Pods. Removing the Pods also removes their emptyDir volumes.
- The second command deletes the sales-db-credentials Secret from the cluster.
- The third command removes the Compose containers and project network. It also deletes the named db-data volume.
You'll see deletion confirmations for the Kubernetes resources and Compose stack. The database state stored in db-data is now gone.
Cleanup Command Failed?
- Keep Docker Desktop and its Kubernetes cluster running until both Kubernetes deletion commands finish.
- Confirm that your terminal is in the repository folder containing compose.yaml.
- Confirm that the same repository folder contains k8s/job.yaml.
Help me diagnose the cleanup command that failed.
- Return to Docker Desktop's Kubernetes view.
- Reset the Docker Desktop Kubernetes cluster to remove its remaining local state.
- Locate the local image tagged sales-intelligence:1.0 in Docker Desktop.
- Use the image's delete control to remove it from your Mac.
The cluster no longer retains project resources. The hardened application image no longer appears in Docker Desktop's local image list.
- Delete the generated dashboard and CSV reports from the repository folder by running this command:
rm -rf output
What Does This Command Remove?
This command removes the repository's output/ directory. That directory contains the generated dashboard and CSV reports.
You should no longer see the output/ directory beside compose.yaml in the repository.
Still See the Output Directory?
- Confirm that your terminal is in the repository folder containing compose.yaml.
- Refresh the repository files shown in VS Code.
Help me remove the generated output safely.
Your local cluster, project containers, database volume, application image, credential Secret, and generated reports are now removed.
Nice Work!
Nice Work!
You did it. You shipped the Sales Intelligence Framework as a reproducible Docker image that runs as a Kubernetes Job.
What you learned:
- Built a reproducible multi-stage container image that compiles the existing Java application in a Maven builder stage. Kept Maven plus source code out of the final runtime stage.
- Connected the pipeline to MySQL through environment-based database configuration. Made fatal startup failures visible with a nonzero process exit.
- Diagnosed an intentional Job failure through retry status plus Pod logs. Proved declarative recovery by replacing only the Kubernetes Secret. The image stayed unchanged. The k8s/job.yaml manifest stayed unchanged.
- Secret Mission: Hardened the runtime as a locked-down non-root workload. It runs as UID 10001 under GID 10001. The security context enforces runAsNonRoot. It drops ALL capabilities. It disables privilege escalation. It applies the RuntimeDefault seccomp profile.
Ready to quiz yourself?