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:
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:
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:
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é 😎😎:
Ta đăng nhập và thêm sửa xoá user tí nha 😘😘:
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
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 /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 build
command, 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:
Ý 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 /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 /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 /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êmreturn 0
, bởi vì command build này sẽ install + build dependencies + build luôn cả project (với taskbootJar
), 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 .
Ở 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 .
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 image và Tă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