간단한 스프링부트, 리액트로 구성된 애플리케이션을 k8s에 배포해본다. 이 과정을 거치며 k8s에 애플리케이션을 배포하기 위해 어떤 과정이 필요한지, 무엇이 필요한지 습득한다.
백엔드, 프론트엔드 코드 개발하기
우선 배포해야 할 샘플 애플리케이션을 작성한다. 백엔드는 spring boot, jpa, mysql 기반, 프론트엔드는 react, tailwind로 간단하게 구성하였다. form을 작성해서 버튼을 누르면 하단에 제출된 텍스트들이 적히는 간단한 애플리케이션이다.
백엔드
// controller
@RestController
@RequestMapping("/api/messages")
public class MessageController {
private final MessageService messageService;
public MessageController(MessageService messageService) {
this.messageService = messageService;
}
@PostMapping
public Message createMessage(@RequestBody String content) {
return messageService.saveMessage(content);
}
@GetMapping
public List<Message> getMessages() {
return messageService.getAllMessages();
}
@GetMapping("/health")
public String healthCheck(){
return "healthy";
}
}
// service
@Service
public class MessageService {
private final MessageRepository messageRepository;
public MessageService(MessageRepository messageRepository) {
this.messageRepository = messageRepository;
}
public Message saveMessage(String content) {
Message message = new Message();
message.setContent(content);
return messageRepository.save(message);
}
public List<Message> getAllMessages() {
return messageRepository.findAll();
}
}
// repository
@Repository
public interface MessageRepository extends JpaRepository<Message, Long> {
}
// config
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("사용하고자 하는 도메인")
.allowedMethods(
HttpMethod.GET.name(),
HttpMethod.POST.name()
)
.allowCredentials(true)
.exposedHeaders("*");
}
}
// application.properties
spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:3306/sampleapp
spring.datasource.username=username
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
프론트
import { useState, useEffect } from 'react';
import axios from 'axios';
function App() {
const [messages, setMessages] = useState([]);
const [content, setContent] = useState('');
useEffect(() => {
fetchMessages();
}, []);
const fetchMessages = () => {
axios.get('<http://domain/api/messages>')
.then(response => {
setMessages(response.data);
})
.catch(error => {
console.error("There was an error fetching the messages!", error);
});
};
const handleSubmit = (event) => {
event.preventDefault();
axios.post('<http://domain/api/messages>', content, {
headers: {
'Content-Type': 'text/plain'
}
})
.then(response => {
setMessages([...messages, response.data]);
setContent('');
})
.catch(error => {
console.error("There was an error saving the message!", error);
});
};
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="bg-white p-8 rounded shadow-md w-full max-w-md">
<h1 className="text-2xl font-bold mb-4">Message Board</h1>
<form onSubmit={handleSubmit} className="mb-4">
<input
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full px-3 py-2 border rounded"
placeholder="Write a message"
/>
<button type="submit" className="w-full bg-blue-500 text-white px-3 py-2 rounded mt-2">Submit</button>
</form>
<div>
{messages.map(message => (
<div key={message.id} className="border-b py-2">{message.content}</div>
))}
</div>
</div>
</div>
);
}
export default App;
도커 이미지 말기
k8s의 배포는 기본적으로 컨테이너 배포이다. 따라서 백엔드, 프론트엔드 모두 이미지로 말아서 docker hub에 배포한다.
이번에는 멀티스테이지 빌드를 사용하여 gradle, npm 빌드와 빌드된 아티팩트를 실행하는 것 모두 docker image 빌드 과정 중 되도록 구성하였다. 멀티스테이지 빌드를 하면 최종 결과물에 필요한 의존성들 외의 의존성들을 뺄 수 있기 때문에 이미지 사이즈가 줄어든다. (샘플 애플리케이션의 경우 크기가 그렇게 크지 않아 그런지 크기 차이는 딱히 없었다)
FROM gradle:7.6.1-jdk17 AS build
WORKDIR /app
COPY . /app
RUN gradle clean build -x test
FROM openjdk:17-jdk-slim
WORKDIR /app
COPY --from=build /app/build/libs/sampleApp-0.0.1-SNAPSHOT.jar .
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "sampleApp-0.0.1-SNAPSHOT.jar"]
FROM node:lts-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . /app
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
주의할 점은 apple silicon 등 arm 아키텍쳐 기반 컴퓨터를 사용하여 빌드할 경우 k8s에서 pod 생성 중 끊임없는 pull back, error를 마주하게 될 수도 있다. 분명 k describe pod로 봤을 때 이미지를 잘 pull 받는데 계속 에러가 반복된다면 아래 명령어를 통해 ‘exec /docker-entrypoint.sh: exec format error’ 에러가 발생하는지 확인하자
k logs -n 네임스페이스 인스턴스이름
만약 ‘exec /docker-entrypoint.sh: exec format error’에러라면, 그리고 배포 서버와 이미지 빌드 환경의 CPU 아키텍쳐가 차이가 있다면 image build 때 이를 고려하지 않아 발생했을 가능성이 있다. 이럴 경우 buildx를 통해 멀티플랫폼 빌드를 해주어야 한다.
프론트엔드, 백엔드 각각 이미지를 말고, 이를 도커 허브에 push 해야 하는데 이것이 귀찮다면 쉘 스크립트를 하나 만들어 한 방에 해결하는 방법도 있다.
#! /bin/sh
docker buildx build \\
--platform linux/amd64,linux/arm64 \\
-t 이름/backend:latest \\
--no-cache \\
--push ./backend/ # --push를 사용하여 빌드 후에 바로 hub로 push할 수 있다
docker buildx build \\
--platform linux/amd64,linux/arm64 \\
-t 이름/frontend:latest \\
--no-cache \\
--push ./frontend/
k8s 리소스 준비하기
이제 본격적인 메인요리이다.
namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: sample-app
backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend-deployment
namespace: sample-app
spec:
replicas: 1
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: 이름/backend:latest
ports:
- containerPort: 8080
env:
- name: MYSQL_HOST # properties에서 ${MYSQL_HOST}로 되어있는 부분
value: "mysql-service" # 같은 ns 내 서비스의 이름
---
apiVersion: v1
kind: Service
metadata:
name: backend-service
namespace: sample-app
spec:
selector:
app: backend
ports:
- protocol: TCP
port: 8080
targetPort: 8080
frontend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend-deployment
namespace: sample-app
spec:
replicas: 1
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: 이름/frontend:latest
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: frontend-service
namespace: sample-app
spec:
selector:
app: frontend
ports:
- protocol: TCP
port: 80
targetPort: 80
ingress.yaml (traefik의 IngressRoute를 사용한다)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: frontend-ingress
namespace: sample-app
spec:
entryPoints:
- web
routes:
- match: Host(`도메인이름`)
kind: Rule
services:
- name: frontend-service
port: 80
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: backend-ingress
namespace: sample-app
spec:
entryPoints:
- web
routes:
- match: Host(`도메인이름`) && PathPrefix(`/api`)
kind: Rule
services:
- name: backend-service
port: 8080
mysql의 경우에도 emptyDir을 활용한 deployment로 우선 배포한다. 파드가 새로 배포되면 이전 데이터가 다 날라가기 때문에 실제 프로덕션에서는 emptyDir나 hostPath가 아닌 statefulset, pv, pvc를 사용해야 한다. 하지만 빠르게 배포를 해보며 무엇이 필요한지, 어떤 프로세스가 있는지 우선 익히는 것이 목적이므로 우선 emtyDir를 사용한다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
namespace: my-app
spec:
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
containers:
- name: mysql
image: mysql:latest
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
value: password
- name: MYSQL_DATABASE
value: sampleapp
- name: MYSQL_USER
value: user
- name: MYSQL_PASSWORD
value: password
volumeMounts:
- name: mysql-storage
mountPath: /var/lib/mysql
volumes:
- name: mysql-storage
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: mysql-service
namespace: my-app
spec:
selector:
app: mysql
ports:
- protocol: TCP
port: 3306
targetPort: 3306
웹과 모바일에서 모두 잘 접속되는 것을 확인. 새로고침하거나 나갔다 들어와도 데이터도 잘 들어와있는 것을 확인 가능하다.
헬스체크용 api 호출에도 잘 응답하고 있음
'프로젝트 일지 > BoWS' 카테고리의 다른 글
Springboot 애플리케이션을 통해 helm 배포해보기 (feat. ProcessBuilder) (0) | 2024.08.01 |
---|---|
Helm으로 애플리케이션 배포 한 방에 해보기 (2) | 2024.07.22 |
Traefik과 Cert Manager를 사용하여 ArgoCD UI Https로 접속하기 (0) | 2024.07.17 |
홈서버 구축하기 - Proxmox 설치 및 쿠버네티스 클러스터 구성하기 (1) | 2024.07.08 |
홈서버 구축하기 - 미니 데스크탑 조립 및 우분투 설치 (0) | 2024.07.08 |