PromleeBlog
sitemap
aboutMe

posting thumbnail
실무 시나리오 종합 실습 Docker에서 Kubernetes까지 - DevOps 컨테이너에서 클러스터까지 8편
Real-World Scenario Full Practice From Docker to Kubernetes - DevOps from Containers to Clusters Part 8

📅

🚀

들어가기 전에 🔗

드디어 '컨테이너에서 클러스터까지' 시리즈의 마지막 편입니다.

1편에서 "내 컴퓨터에선 되는데..."라는 문제를 해결하기 위해 Docker 컨테이너를 처음 만났던 순간부터, 7편에서 GitHub Actions로 배포를 자동화하기까지 정말 긴 길을 함께 걸어왔습니다.

이번 시간에는 지금까지 우리가 배운 모든 지식과 기술을 하나로 엮어, 실제 서비스와 유사한
블로그 서비스
를 직접 구축해보는 종합 실습을 진행합니다.
프론트엔드(Nginx), 백엔드(API 서버), 데이터베이스(DB)로 구성된 애플리케이션을 Docker Compose로 로컬에서 개발하고, Kubernetes를 이용해 운영 환경에 배포하며, 트래픽 증가에 대응하는 과정까지 모두 경험하게 될 것입니다.

🚀

프로젝트 구조 생성하기 🔗

가장 먼저, 이번 실습을 위한 전체 프로젝트 폴더 구조를 만들겠습니다.
터미널을 열고 아래 명령어들을 순서대로 입력하여 폴더와 파일들을 생성해주세요.
 # 최상위 프로젝트 폴더 생성
mkdir my-blog-service && cd my-blog-service
 
 # 1. API 서버 관련 폴더 및 파일 생성
mkdir api
touch api/server.js api/package.json api/Dockerfile
 
 # 2. Nginx 프론트엔드 관련 폴더 및 파일 생성
mkdir nginx
touch nginx/index.html nginx/nginx.conf nginx/Dockerfile
 
 # 3. 쿠버네티스 배포용 폴더 생성
mkdir k8s
 
 # 4. Docker Compose 파일 생성
touch docker-compose.yml
이제 이 구조에 맞춰 각 파일의 내용을 채워나가겠습니다.
📦my-blog-service
 ┣ 📂api
 ┃ ┣ 📜Dockerfile
 ┃ ┣ 📜package.json
 ┃ ┗ 📜server.js
 ┣ 📂k8s
 ┣ 📂nginx
 ┃ ┣ 📜Dockerfile
 ┃ ┣ 📜index.html
 ┃ ┗ 📜nginx.conf
 ┗ 📜docker-compose.yml

🚀

1단계: Docker Compose로 로컬 개발 환경 꾸리기 🔗

내 컴퓨터에서 빠르고 편리하게 개발을 진행할 수 있도록 Docker Compose 환경을 구성하겠습니다.

1. PostgreSQL 데이터베이스 준비 🔗

데이터베이스는 공식 이미지를 그대로 사용할 것이므로 별도의 코드는 필요하지 않습니다.
DB의 데이터가 보존되도록 volumes 설정을 해주는 것이 핵심입니다.

2. Node.js 백엔드 API 서버 작성 🔗

api/package.json
{
  "name": "api",
  "version": "1.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "express": "^4.18.2",
    "pg": "^8.11.3"
  }
}
api/server.js
const express = require('express');
const { Pool } = require('pg');
const app = express();
const port = 8000;
 
app.use(express.json()); // JSON 요청 본문을 파싱하기 위해 추가
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
 
// 서버 시작 시 테이블 생성 및 초기 데이터 삽입
const initDb = async () => {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS posts (
      id SERIAL PRIMARY KEY,
      title VARCHAR(255) NOT NULL,
      content TEXT,
      created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
    );
  `);
  const res = await pool.query('SELECT COUNT(*) FROM posts');
  if (res.rows.count === '0') {
    await pool.query(`
      INSERT INTO posts (title, content) VALUES
      ('Docker로 개발하기', 'Docker Compose를 사용하면 정말 편리해요.'),
      ('Kubernetes란?', '컨테이너 오케스트레이션의 사실상 표준입니다.');
    `);
  }
};
initDb();
 
// 모든 글 조회 (Read)
app.get('/api/posts', async (req, res) => {
  try {
    const result = await pool.query('SELECT * FROM posts ORDER BY id DESC');
    res.json(result.rows);
  } catch (err) {
    console.error(err);
    res.status(500).send('Server Error');
  }
});
 
// 새 글 작성 (Create)
app.post('/api/posts', async (req, res) => {
  try {
    const { title, content } = req.body;
    if (!title) {
      return res.status(400).json({ error: 'Title is required' });
    }
    const newPost = await pool.query(
      'INSERT INTO posts (title, content) VALUES ($1, $2) RETURNING *',
      [title, content]
    );
    res.status(201).json(newPost.rows);
  } catch (err) {
    console.error(err);
    res.status(500).send('Server Error');
  }
});
 
// 글 삭제 (Delete)
app.delete('/api/posts/:id', async (req, res) => {
  try {
    const { id } = req.params;
    await pool.query('DELETE FROM posts WHERE id = $1', [id]);
    res.status(204).send(); // No Content
  } catch (err) {
    console.error(err);
    res.status(500).send('Server Error');
  }
});
 
 
app.listen(port, () => {
  console.log(`API server listening on port ${port}`);
});
api/Dockerfile
FROM node:18-alpine
 
WORKDIR /usr/src/app
 
COPY package*.json ./
RUN npm install
 
COPY . .
 
EXPOSE 8000
CMD [ "npm", "start" ]

3. Nginx 프론트엔드 작성 🔗

nginx/index.html
<!DOCTYPE html>
<html>
<head>
  <title>My Docker & K8s Blog</title>
  <style>
    body { font-family: sans-serif; max-width: 800px; margin: auto; padding: 20px; }
    .post { border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; border-radius: 5px; position: relative; }
    .post h2 { margin-top: 0; }
    form { margin-bottom: 20px; }
    input, textarea { width: 100%; margin-bottom: 10px; padding: 8px; }
    button { cursor: pointer; }
    .delete-btn { position: absolute; top: 10px; right: 10px; color: red; cursor: pointer; border: 1px solid red; background: none; border-radius: 5px; }
  </style>
</head>
<body>
  <h1>Docker & Kubernetes 블로그</h1>
 
  <form id="new-post-form">
    <h3>새 글 작성</h3>
    <input type="text" id="title" placeholder="제목" required>
    <textarea id="content" placeholder="내용"></textarea>
    <button type="submit">작성</button>
  </form>
 
  <hr>
  <h2>글 목록</h2>
  <div id="posts"></div>
 
  <script>
    const postsDiv = document.getElementById('posts');
    const form = document.getElementById('new-post-form');
 
    // 글 목록 가져오기 함수
    const fetchPosts = async () => {
      try {
        const response = await fetch('/api/posts');
        const posts = await response.json();
        postsDiv.innerHTML = ''; // 목록 초기화
        posts.forEach(post => {
          const postDiv = document.createElement('div');
          postDiv.className = 'post';
          postDiv.innerHTML = `
            <h2>${post.title}</h2>
            <p>${post.content || ''}</p>
            <button class="delete-btn" data-id="${post.id}">삭제</button>
          `;
          postsDiv.appendChild(postDiv);
        });
      } catch (error) {
        console.error('Error fetching posts:', error);
      }
    };
 
    // 새 글 작성 이벤트 리스너
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      const title = document.getElementById('title').value;
      const content = document.getElementById('content').value;
      try {
        await fetch('/api/posts', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ title, content }),
        });
        form.reset();
        fetchPosts();
      } catch (error) {
        console.error('Error creating post:', error);
      }
    });
 
    // 글 삭제 이벤트 리스너 (이벤트 위임)
    postsDiv.addEventListener('click', async (e) => {
      if (e.target.classList.contains('delete-btn')) {
        const id = e.target.dataset.id;
        if (confirm('정말로 이 글을 삭제하시겠습니까?')) {
          try {
            await fetch(`/api/posts/${id}`, { method: 'DELETE' });
            fetchPosts();
          } catch (error) {
            console.error('Error deleting post:', error);
          }
        }
      }
    });
 
    // 페이지 로드 시 글 목록 가져오기
    fetchPosts();
  </script>
</body>
</html>
nginx/nginx.conf
server {
    listen 80;
    
    location / {
        root   /usr/share/nginx/html;
        index  index.html;
    }
 
    location /api/ {
        # 'api'는 docker-compose.yml에 정의된 서비스 이름입니다.
        proxy_pass http://api:8000;
    }
}
nginx/Dockerfile
FROM nginx:alpine
 
COPY ./index.html /usr/share/nginx/html/index.html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf

4. Docker Compose 파일로 모두 연결하기 🔗

docker-compose.yml
version: '3.8'
services:
  nginx:
    build: ./nginx
    ports:
      - "8080:80"
    depends_on:
      - api
 
  api:
    build: ./api
    environment:
      # 'db'는 이 파일에 정의된 PostgreSQL 서비스 이름입니다.
      - DATABASE_URL=postgres://user:password@db:5432/mydatabase
    depends_on:
      - db
 
  db:
    image: postgres:13
    restart: always
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=mydatabase
    volumes:
      - postgres-data:/var/lib/postgresql/data
 
volumes:
  postgres-data:
이제 터미널에서 docker-compose up --build 명령어를 실행하고, 웹 브라우저에서 http://localhost:8080에 접속해보세요.
메인 페이지가 보인다면 로컬 개발 환경 구성에 성공한 것입니다.
docker build 성공
docker build 성공

🚀

2단계: Kubernetes로 스테이징/운영 배포하기 🔗

이제 로컬에서 완성된 서비스를 Kubernetes 클러스터에 배포하겠습니다.
앞서 생성한 k8s 폴더 안에 아래의 YAML 파일들을 모두 작성해주세요.
k8s/01-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: blog-service
k8s/02-db-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-secret
  namespace: blog-service
stringData:
  POSTGRES_USER: "user"
  POSTGRES_PASSWORD: "password"
  POSTGRES_DB: "mydatabase"
k8s/03-db-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: db-pvc
  namespace: blog-service
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
k8s/04-db-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: db-deployment
  namespace: blog-service
spec:
  replicas: 1
  selector:
    matchLabels: { app: db }
  template:
    metadata: { labels: { app: db } }
    spec:
      containers:
      - name: postgres
        image: postgres:13
        envFrom:
        - secretRef: { name: db-secret }
        ports:
        - containerPort: 5432
        volumeMounts:
        - name: db-storage
          mountPath: /var/lib/postgresql/data
      volumes:
      - name: db-storage
        persistentVolumeClaim: { claimName: db-pvc }
k8s/05-db-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: db-service
  namespace: blog-service
spec:
  selector: { app: db }
  ports:
  - port: 5432
k8s/06-api-deployment.yaml
apiVersion: apps/v1 
kind: Deployment
metadata:
  name: api-deployment
  namespace: blog-service
spec:
  replicas: 1
  selector: { matchLabels: { app: api } }
  template:
    metadata: { labels: { app: api } }
    spec:
      containers:
      - name: api
        # CI/CD를 통해 빌드된 이미지를 사용합니다.
        image: [YOUR_DOCKERHUB_ID]/my-blog-api:latest
        ports:
        - containerPort: 8000
        env:
        - name: DATABASE_URL
          value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@db-service:5432/$(POSTGRES_DB)"
        envFrom:
        - secretRef: { name: db-secret }
k8s/07-api-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-service
  namespace: blog-service
spec:
  selector: { app: api }
  ports:
  - port: 8000
k8s/08-nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  namespace: blog-service
spec:
  replicas: 1
  selector: { matchLabels: { app: nginx } }
  template:
    metadata: { labels: { app: nginx } }
    spec:
      containers:
      - name: nginx
        # CI/CD를 통해 빌드된 이미지를 사용합니다.
        image: [YOUR_DOCKERHUB_ID]/my-blog-nginx:latest
        ports:
        - containerPort: 80
k8s/09-nginx-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
  namespace: blog-service
spec:
  selector: { app: nginx }
  ports:
  - port: 80
k8s/10-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: blog-ingress
  namespace: blog-service
spec:
  rules:
  - host: "blog.example.com" # 실제 도메인으로 변경하거나 hosts 파일에 등록
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: nginx-service
            port: { number: 80 }
이제 터미널에서 kubectl apply -f k8s/ 명령어 하나로 모든 리소스를 배포합니다.

🚀

3단계: 트래픽 증가에 대응하기 🔗

마지막으로, 6편에서 배운 HPA를 API 서버에 적용하여 트래픽에 따라 자동으로 확장되도록 설정합니다.
k8s/11-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-hpa
  namespace: blog-service
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-deployment
  minReplicas: 1
  maxReplicas: 5
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50
kubectl apply -f k8s/11-hpa.yaml 명령어로 HPA를 적용하면 모든 배포 과정이 마무리됩니다.

🚀

결론: Docker에서 Kubernetes까지 - 마무리 🔗

우리는 1편에서 '컨테이너'로 여정을 시작했습니다.
그리고 오늘, 8편에서 이 모든 조각을 맞춰 Nginx, API, DB로 구성된 하나의 완전한 서비스를 직접 구축해냈습니다.

Docker Compose로 편리한 로컬 개발 환경을 만들고, Kubernetes의 수많은 리소스(Deployment, Service, Secret, PVC, Ingress, HPA)를 YAML로 정의하여 견고한 운영 환경에 배포하는 전체 사이클을 경험했습니다.

여기까지 함께해주셔서 정말 감사합니다.

참고 🔗