+27

Dockerize project Java Spring Boot, MySQL, Redis

Hello các bạn lại là mình đây 👋👋

Một ngày cuối năm miền bắc lạnh quá trời lạnh 🥶🥶

Tiếp nối series học Docker và CICD, ở bài hôm nay chúng ta sẽ cùng nhau Dockerize project Java Spring Boot dùng MySQL và Redis nhé.

Từ lâu rồi mình đã nghĩ là viết thêm về nhiều ngôn ngữ để chúng ta đa dạng hoá bài toán trong thực tế, mà cứ nhớ rồi quên suốt 😂😂

Triển thôi nào 🚀🚀

Clone source

Như thường lệ các bạn clone source code của mình ở đây nhé. Nhánh master

Ở bài này ta sẽ chỉ quan tâm folder docker-java-spring-boot-mysql-redis nhé

Chạy thử ở local

Khi Dev

Tổng quan project thì ở đây ta có project Spring Boot, Java 17:

  • trong bài ta sẽ có một app CRUD users, data sẽ được lưu vào MySQL
  • có login/logout, session của chúng ta sẽ được lưu vào Redis

Phần này nếu máy local của các bạn không có Java 17/MySQL/Redis thì ngồi xem thôi cũng được nhé 😄, phần sau ta vô Docker thì các bạn có thể bắt đầu làm, nhưng các bạn nhớ phải xem phần Local này nha để tí nữa sẽ có một vài điểm ta thảo luận trong bài có liên quan đấy

Trước khi chạy ta test xem đã cài và chạy đủ các thứ cần thiết chưa nha, bao gồm: Java 17, MySQL, Redis:

java --version

curl localhost:3306

curl localhost:6379

Nếu port của MySQL hoặc Redis của các bạn khác thì các bạn thay đổi đi nha

Tiếp theo ở file src/main/java/resources/application.properties các bạn có thể thấy là:

  • app sẽ chạy ở cổng 8081
  • với MySQL mình đang để mặc định tên db là java_test, database user/password là root/rootpass
  • user mặc định để tí login vào app là user/Password1

Các bạn sửa lại những thông tin trên sao cho đúng với máy local của các bạn nhé

File application.properties là file cấu hình cho Spring project, nom na ná như .env ở các ngôn ngữ khác mà ta hay dùng thôi 😉

Ở root folder project mình cũng có 1 file db.sql để tạo sẵn 1 bảng trong database, các bạn tự chạy script đó để tạo bảng nhé.

Cuối cùng là ta chạy project lên thôi nào:

./gradlew bootRun

Ta mở trình duyệt ở địa chỉ http://localhost:8081 rồi login với user user/Password1 và ta sẽ thấy giao diện chính:

Screenshot 2024-01-28 at 9.44.37 AM.png

Các bạn thử thêm sửa xoá 1 vài users xem mọi thứ có oke không nha

Build

Ở trên ta chạy ./gradlew bootRun là dùng khi ta dev ở local, còn khi ta chạy production thì ta sẽ cần phải build ra file JAR để app được tối ưu và sẵn sàng cho deploy nhé, giống kiểu development và production mode khi làm với Frontend Javascript ấy 😉

Để build project thì ta chạy command sau:

./gradlew build

Sau khi build xong ta sẽ thấy ở folder build/libs có 2 file JAR:

Screenshot 2024-01-28 at 9.50.48 AM.png

Ta chạy thử lên xem nhé:

java -jar demo-0.0.1-SNAPSHOT.jar

Sau đó các bạn lại mở browser ở địa chỉ http://localhost:8081 rồi tự test nha

Vậy là các bạn đã thấy khi dev và deploy project java thì ta cần làm gì rồi, giờ ta tới phần tiếp theo là đưa tất cả vào Docker nha 🚀🚀🚀

Dockerize

Phần này ta sẽ tập trung nhiều vào việc build cho lúc Deploy (production) nhé, cuối bài ta sẽ nói tới đoạn build cho development.

Để Dockerize project Java thì mình thấy có 2 cách phổ biến:

  • Build môi trường ngoài -> copy JAR vào Docker image và chạy
  • Build + run toàn bộ trong Docker

Ta bắt đầu nha

Build ngoài chạy trong

Vì bước này build ở môi trường ngoài nên yêu cầu máy các bạn có Java, nếu các bạn không có thì ta cũng ngồi xem chờ phần sau sẽ có việc để làm nha 😄

Nếu ta search trên Google "Dockerize Java Spring" thì hầu hết các tutorial đều hướng dẫn ta làm theo cách Build ngoài chạy trong này cả, từ trang chủ của Spring đến như trang blog đại ca như BaelDung:

Screenshot 2024-01-28 at 10.00.33 AM.png

Screenshot 2024-01-28 at 10.00.01 AM.png

Thực tế là ở trang chủ Spring mãi cuối họ có nói tới những cách để viết Dockerfile tốt hơn, nhưng mình thấy người đọc có thể dễ dàng bỏ qua nó 😃

Cụ thể là ta sẽ build JAR ở môi trường ngoài trước, tức là yêu cầu môi trường ngoài phải có Java, sau đó copy file JAR vào trong image và chạy file JAR ở trong image đó

"Môi trường ngoài" ở đây có thể là:

  • máy local của chúng ta (hoặc của đồng đội chúng ta 😃)
  • môi trường CICD (Gitlab Runner, Github Actions...)

Ta triển thôi.

Đầu tiên ta tạo Dockerfile như thường lệ nhé:

FROM openjdk:17-jdk-alpine3.14

# Dành cho Apple Sillicon
# FROM --platform=linux/x86_64 openjdk:17-jdk-alpine3.14

WORKDIR /app
COPY ./build/libs/demo-0.0.1-SNAPSHOT.jar app.jar
CMD ["java","-jar","app.jar"]

Ở trên code ngắn gọn rõ ràng thì không cần phải giải thích gì thêm nhỉ 😉 Đơn giản là ta COPY file JAR mà tí nữa ta sẽ build từ môi trường ngoài vào trong image

Tiếp theo ta build project nhé (yêu cầu máy gốc phải có Java):

./gradlew build

Nếu bạn nào bị lỗi test fail ở bước này thì thêm dùng ./gradlew build -x test để bỏ test lúc build nhé

Xong rồi thì ta build image thôi

docker build -t java-spring:v1 .

Sau đó ở root folder project các bạn tạo cho mình file docker_application.properties, file này là cấu hình cho app của chúng ta chạy trong container với nội dung như sau nhé:


server.port=8081

spring.datasource.url=jdbc:mysql://db:3306/demo_app
spring.datasource.username=root
spring.datasource.password=rootpass

spring.security.user.name=user
spring.security.user.password=Password1
spring.security.user.roles=USER

spring.session.store-type=redis
spring.data.redis.host=redis
spring.data.redis.password=
spring.data.redis.port=6379

Tiếp theo ta tạo file docker-compose.yml để chuẩn bị chạy project nè 😉:

version: '3.7'

services: 
  app:
    image: java-spring:v1
    restart: always
    ports:
      - "8081:8081"
    volumes:
      - ./docker_application.properties:/app/application.properties:ro
    depends_on:
      - db
      - redis

  db:
    image: mysql:8
    restart: always
    volumes:
      - ./.docker/data/db:/var/lib/mysql
      - ./db.sql:/docker-entrypoint-initdb.d/db.sql:ro
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: demo_app

  redis:
    image: redis:6-alpine
    restart: always
    volumes:
      - ./.docker/data/redis:/data

Ở trên có 1 số file ta mount volume và set :ro ý bảo là đừng có ai tính thay đổi file này nha (cho chắc chắn thôi 😉)

Cuối cùng là ta chạy project lên thôi nào:

docker compose up -d

Sau đó truy cập trình duyệt ở địa chỉ http://localhost:8081, thấy. như sau là xinh tươi rồi nhé 😎😎:

Screenshot 2024-01-28 at 10.19.34 AM.png

Ta đăng nhập và thêm sửa xoá user tí nha 😘😘:

Screenshot 2024-01-28 at 10.21.16 AM.png

Thế là xong rồi, học từ đầu series đến bài này thì lại thấy đơn giản quá rồi phải không các bạn 🤣🤣

Ta tiếp tục với các làm thứ 2 nha

Build + Run trong Docker

Qua phần trên thì có vài câu hỏi đặt ra:

  • Thế máy local của tôi không có Java thì sao? Mà có thì nó không phải Java 17 mà là bản cũ Java 8/11 thì sao?
  • Máy mình có đủ vậy có chắc là tất cả các team member đều có Java?
  • Có chắc là ở trong CICD cũng có đủ như vậy? nhỡ môi trường CICD không có?

Thì phải bảo team cài cho có, đòi CICD nơi ta đang mua dịch vụ cài vào cho là xong, đơn giản mà 😂😂😂

Ta thấy vấn đề rồi chứ, đây chính là những lúc mà Docker lên tiếng với đúng mục đích của nó, giúp ta đồng bộ/tối giản quá trình build/deploy trên mọi môi trường, local/CICD/dev/production, Win/Mac/Linux... mọi nơi

Giờ việc của ta là đưa quá trình build vào Docker luôn là oke rồi 😉

Đầu tiên là ta down project đi đã nhé:

docker compose down

Ta sửa lại Dockerfile như sau nhé:

FROM  openjdk:17-jdk-alpine3.14 as build

# Apple Sillicon
# FROM --platform=linux/x86_64 openjdk:17-jdk-alpine3.14 as build

WORKDIR /app
COPY . .

# Nếu bạn đang chạy Docker trên Windows thì thêm dòng này
# RUN dos2unix gradlew

RUN ./gradlew build -x test
CMD ["java","-jar","build/libs/demo-0.0.1-SNAPSHOT.jar"]

Ở trên ta thêm -x test lúc build để skip đoạn test nhé, vì lúc test nó cũng tạo 1 instance của app chúng ta và nó cố gắng kết nối tới DB để test và sẽ failed đó

Tiếp theo ta tạo file .dockerignore để loại bỏ các files/folders không cần thiết lúc COPY vào image nhé:

HELP.md
.gradle
build
### VS Code ###
.vscode/
.docker
.idea

.git*

.dockerignore
Dockerfile
docker-compose.yml
db.sql

Cuối cùng là ta build image nha:

docker build -t java-spring:v2 .

Sau đó ta sửa lại tag của app ở docker-compose.yml thành v2 và chạy project lên nha:

docker compose up -d

Chờ chút cho app Java khởi động nha (chừng 10-15s) sau đó ta lại mở trình duyệt ở địa chỉ http://localhost:8081 và lại test như thường nha

Tối ưu image size

Giờ ta thử check các images mà ta đã build xem nhé:

docker images

--->>

REPOSITORY        TAG     IMAGE ID       CREATED          SIZE
java-spring       v2      f59c55951845   2 minutes ago    679MB
java-spring       v1      fcebd8f22de2   25 minutes ago   396MB

Sao image v2 build + run trong container lại nặng thế nhỉ?? Ta có .dockerignore rồi mà 🤔🤔🤔

Thực tế thì đúng là ta đã cho các files/folders không cần thiết vào .dockerignore để không COPY chúng lúc build image.

Nhưng trong quá trình build thì cũng có nhiều thứ được sinh ra thêm, ví dụ folder .gradle, folder build. Ta có thể kiểm tra việc này bằng cách chạy command sau để list file trong container:

docker compose exec app ls -la

Screenshot 2024-01-28 at 5.46.45 PM.png

Cùng với đó là chúng ta để ý rằng, với app Java ta chỉ cần file JAR để chạy production, vậy thì ta cần gì những thứ khác nữa sau khi build. Giống như phần trên Build ngoài chạy trong vậy.

Vậy giờ có kiểu gì mà vẫn Build + Run trong container, nhưng ta chỉ giữ lại mỗi file JAR và bỏ hết những cái khác đi không nhỉ?

May quá ở bài Dockerize ReactJS/VueJS ta đã có multi-stage build. Ý tưởng y hệt: chia quá trình build thành 2 giai đoạn, 1 để build, build xong thì lấy mỗi bundle file cần thiết để chạy production.

Đầu tiên là ta down app đi đã nhé:

docker compose down

Ta sửa lại Dockerfile như sau nha:

FROM  openjdk:17-jdk-alpine3.14 as build
WORKDIR /app
COPY . .

# Nếu bạn đang chạy Docker trên Windows thì thêm dòng này
# RUN dos2unix gradlew

RUN ./gradlew build -x test

FROM openjdk:17-jdk-alpine3.14 as production
WORKDIR /app
COPY --from=build /app/build/libs/demo-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java","-jar","app.jar"]

Ở trên ta có 2 stage (2 lần FROM), stage build sẽ chạy buildcommand, sau đó ở stage production ta COPY lấy mỗi file JAR ở stage build là xong. Cũng đơn giản nhỉ 😃

Tiếp theo ta build image nha:

docker build -t java-spring:v3 .

Sau đó ta sửa lại docker-compose.yml update tag image thành v3, chạy lên test thử nha

Bây giờ ta thử check image xem size như thế nào nha:

docker images

-->>
REPOSITORY      TAG    IMAGE ID       CREATED          SIZE
java-spring     v3     87b851cd4d65   33 seconds ago   396MB
java-spring     v2     f59c55951845   7 hours ago      679MB
java-spring     v1     fcebd8f22de2   8 hours ago      396MB

Giờ đây ta đã thấy là image v3 size nhỏ đi rất nhiều và y hệt bằng v1 rồi 🥳🥳🥳🥳🥳

Chọn image

Hiện tại ta đang FROM từ image openjdk, vậy nhưng trên trang chủ của image đó đã báo DEPRECATION NOTICE:

Screenshot 2024-01-28 at 10.50.49 PM.png

Ý họ bảo là image này đã lỗi thời và ta nên tìm các image thay thế.

Ta dùng theo trang chủ Spring Boot dùng image eclipse-temurin nhé. Các bạn sửa lại Dockerfile như sau:

FROM  eclipse-temurin:17-jdk-alpine as build
WORKDIR /app
COPY . .

# Nếu bạn đang chạy Docker trên Windows thì thêm dòng này
# RUN dos2unix gradlew

RUN ./gradlew build -x test

FROM eclipse-temurin:17-jdk-alpine as production
WORKDIR /app
COPY --from=build /app/build/libs/demo-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java","-jar","app.jar"]

Sau đó ta build image:

docker build -t java-spring:v4 .

Build xong các bạn sửa lại docker-compose.yml thành v4 rồi chạy test lại xem mọi thứ oke không nhé. Phần này các bạn tự làm nha 😄

Tiếp theo ta thử check image size xem có gì thay đổi không:

docker images

---->>
REPOSITORY        TAG   IMAGE ID       CREATED          SIZE
java-spring       v4    c2ac16deb3c4   26 minutes ago   386MB
java-spring       v3    87b851cd4d65   5 hours ago      396MB
java-spring       v2    f59c55951845   12 hours ago     679MB
java-spring       v1    fcebd8f22de2   13 hours ago     396MB

Giảm được 10MB so với khi FROM từ image của openjdk 😄.

Tối ưu image size hơn nữa

Ta để ý rằng sau khi ta đã build ra file JAR, thì trong phần lớn các app ta hầu như không cần cả bản đầy đủ JDK khi chạy nữa (runtime), mà ta có thể chuyển qua dùng JRE.

Ta sửa lại Dockerfile chút như sau nhé:

FROM  eclipse-temurin:17-jdk-alpine as build
WORKDIR /app
COPY . .

# Nếu bạn đang chạy Docker trên Windows thì thêm dòng này
# RUN dos2unix gradlew

RUN ./gradlew build -x test

FROM eclipse-temurin:17-jre-alpine as production
WORKDIR /app
COPY --from=build /app/build/libs/demo-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java","-jar","app.jar"]

Các bạn để ý rằng ở stage production ta đã chuyển qua dùng image jre rồi

Ta build lại image:

docker build -t java-spring:v5 .

Build xong các bạn nhớ test lại image v5 xem chạy oke không nhé 😉

Cuối cùng là ta check image:

docker images

---->>>
REPOSITORY      TAG  IMAGE ID       CREATED          SIZE
java-spring     v5   cf660bffa5d9   16 seconds ago   248MB
java-spring     v4   c2ac16deb3c4   26 minutes ago   386MB
java-spring     v3   87b851cd4d65   5 hours ago      396MB
java-spring     v2   f59c55951845   12 hours ago     679MB
java-spring     v1   fcebd8f22de2   13 hours ago     396MB

Giảm được gần 150MB (gần 40% so với lúc đầu - 396MB)😮😮

Ngon phết hê 😘😘

Tối ưu thời gian build

Hiện tại nếu các bạn để ý là nếu ta sửa một chút code, rồi sau đó chạy Docker build lại thì tốc độ build khá chậm, bởi vì lúc nào nó cũng cần download và build lại dependencies project của chúng ta:

 => [build 5/8] RUN ./gradlew build || return 0                                                              6.0s
 => => # Downloading https://services.gradle.org/distributions/gradle-8.5-bin.zip                                
 => => # ............10%.............20%....

Vấn đề là một project thông thường thì cái thay đổi nhiều nhất thường là code, còn dependencies ít thay đổi hơn, cũng giống như các project bên Javascript hay Python vậy.

Thế thì để giải quyết trường hợp này ta chỉ cần tách bước download + build dependencies ra, sau đó build phần code, như vậy thì chỉ bước nào thật sự thay đổi thì Docker mới chạy lại còn không thì nó sẽ tận dụng Docker cache. Ta sửa lại Dockerfile như sau nhé:

FROM eclipse-temurin:17-jdk-alpine as build
WORKDIR /app

COPY build.gradle settings.gradle gradlew ./
COPY gradle /app/gradle

RUN ./gradlew build || return 0
COPY . .
RUN ./gradlew build -x test

# Nếu bạn đang chạy Docker trên Windows thì thêm dòng này
# RUN dos2unix gradlew

RUN ./gradlew build -x test

FROM eclipse-temurin:17-jre-alpine as production
WORKDIR /app
COPY --from=build /app/build/libs/demo-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java","-jar","app.jar"]

Ở trên các bạn thấy rằng có một số thay đổi:

  • COPY các file gradle và cả folder gradle vào image trước (nó giống như kiểu package.json/package-lock bên Javascript hay requirements.txt bên Python vậy)
  • Tiếp đó ta chạy build, mình có để thêm return 0, bởi vì command build này sẽ install + build dependencies + build luôn cả project (với task bootJar), vấn đề là ở bước này ta chưa COPY code vào, nên nếu build project thì sẽ failed, do vậy ta cần có return 0 để bảo Docker rằng "nếu build project failed thì chú cứ cho chạy tiếp" 😃
  • Tiếp đó là ta copy toàn bộ code ở môi trường ngoài vào image
  • cuối cùng là chạy lại build 1 lần nữa, ở bước này thì vì dependencies đã được install + build rồi, nên gradle nó chỉ thực hiện lại bước build source code thôi

Giờ ta test xem nhé 😉

Đầu tiên là build image với option no-cache để chắc chắn là lần đầu tiên ta không dùng cache:

docker build --no-cache -t java-spring:6 .

Screenshot 2024-02-27 at 2.48.15 PM.png

Ở trên các bạn thấy rằng mình build hết 97 giây

Sau đó ta thêm vào code 1 chút nhé, ở file src/main/java/com/example/demo/DemoApplication.java:

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

	public static void main(String[] args) {
		System.out.println("Hello world");
		SpringApplication.run(DemoApplication.class, args);
	}
}

Ở trên ta có System.out.println để in ra message ở console

Tiếp theo ta build lại image nhé:

docker build -t java-spring:7 .

Screenshot 2024-02-27 at 2.49.05 PM.png

Lần này thì Docker đã CACHED hầu hết các bước rồi, chỉ build lại mỗi phần code thay đổi thôi, nên thời gian build giảm xuống chỉ còn 1 nửa - 43 giây 💪💪💪💪💪🥳🥳🥳🥳🥳

Phần này mình sẽ nói kĩ hơn ở bài Tối ưu Docker imageTăng tốc độ build Image nhé

Nên chọn cách nào?

Như các bạn thấy thì cách số 2 sẽ build + run project của chúng ta trên chính xác cùng 1 môi trường (2 FROM giống nhau) nhưng cách 2 thì phần test hiện tại đang failed mà ta sẽ cần phải setup thêm để nó chạy được.

Ưu điểm của cách 2 là ta có thể vác cái Dockerfile đấy đi mọi môi trường sẽ đều build và cho ra kết quả mong đợi, trong khi cách 1 yêu cầu máy gốc chúng ta phải có Java.

Mình thì hơi nghiêng về cách số 2 đấy, còn các bạn thì sao? :😃

Chạy khi dev ở local

Mặc dù project này của mình đã cài spring-devtools, nhưng để trigger restart thì ta cần phải build project thì mới có sự thay đổi về files trong classpath, cái này thường hữu ích khi ta code dùng IDE kiểu Intellij hay Android Studio, ở đó ta có thể trigger Build/Make Project trực tiếp từ IDEA và app sẽ tự restart

Do vậy mình thấy trong trường hợp dùng Docker thì có vẻ nó không thích hợp lắm cho lúc dev ở local. Tức là ở local ta cứ code trực tiếp trên môi trường gốc

Suy ngẫm một chút

Nếu các bạn để ý, xuyên suốt series, mình hay nói tới sự tiện lợi khi vừa là Software Engineer vừa làm đc DevOps.

Là người trực tiếp tạo product, code software, ta biết chính xác app của chúng ta cần những gì có thể chạy được, ví dụ:

  • Sau khi build JAR thì ta chỉ cần lấy file JAR đó để deploy thôi, những cái khác không cần nữa, vậy nên ta dùng multi-stage build
  • Khi chạy production thì hầu như ta không cần bản full JDK nữa mà dùng JRE cũng được rồi
  • .....

Nếu ta chỉ đưa project cho DevOps Engineer và hỏi họ cách Dockerize, thì có thể họ sẽ không có đủ context để đưa ra cho chúng ta giải pháp tốt nhất

Vậy nên nếu được, hãy làm chủ công việc mà ta đang làm và bớt phụ thuộc vào người khác, nếu có thể, bạn nhé 😊😊

Kết bài

Như mọi khi, hi vọng các bạn đã hiểu đc cách để dockerize project Java và áp dụng vào công việc thực tế.

Thân ái, chào tạm biệt, hẹn gặp lại các bạn ở các bài sau 👋


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí