본문 바로가기
프로젝트 일지/BoWS

Springboot - React - MySQL 애플리케이션 k8s에 배포하기

by 데브겸 2024. 7. 18.

간단한 스프링부트, 리액트로 구성된 애플리케이션을 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 호출에도 잘 응답하고 있음