학습일지/K-Digital Traing

[KDT] AIaaS 마스터클래스 2주차 - 3 Tier Application 배포 실습

tierr 2025. 4. 4. 16:56

간이 3 Tier Application 배포 실습 (2+1) - Client+Server / Database

1. 로컬 개발 환경 구성

1-1. 프로젝트 구조 생성

2tier-app/
├── app/
│   ├── Dockerfile
│   ├── package.json
│   ├── app.js
│   └── config.js
├── db/
│   ├── Dockerfile
│   ├── init.sql
│   └── my.cnf
└── docker-compose.yml

1-2.  App 서비스 코드 작성

- app/package.json

더보기
{
  "name": "2tier-app",
  "version": "1.0.0",
  "description": "2-Tier Architecture Demo",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^4.17.1",
    "mysql2": "^2.3.3",
    "dotenv": "^10.0.0"
  }
}

- app/config.js

더보기
// 환경에 따른 설정 관리
const env = process.env.NODE_ENV || 'development';

const config = {
  development: {
    db: {
      host: process.env.DB_HOST || 'db', // docker-compose 서비스 이름
      user: process.env.DB_USER || 'nodeuser',
      password: process.env.DB_PASSWORD || 'password123',
      database: process.env.DB_NAME || 'nodeapp'
    },
    port: process.env.PORT || 3000
  },
  production: {
    db: {
      host: process.env.DB_HOST, // VM의 IP 주소
      user: process.env.DB_USER || 'nodeuser',
      password: process.env.DB_PASSWORD || 'password123',
      database: process.env.DB_NAME || 'nodeapp'
    },
    port: process.env.PORT || 3000
  }
};

module.exports = config[env];

- app/app.js

더보기
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
const config = require('./config');

console.log(`환경: ${process.env.NODE_ENV || 'development'}`);
console.log(`DB 연결 설정: ${JSON.stringify(config.db)}`);

// JSON 파싱 미들웨어
app.use(express.json());

// MySQL 연결 설정
const pool = mysql.createPool({
  host: config.db.host,
  user: config.db.user,
  password: config.db.password,
  database: config.db.database,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

// 홈페이지
app.get('/', (req, res) => {
  res.send(`
    <html>
      <head>
        <title>2-Tier Architecture Demo</title>
        <style>
          body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
          }
          h1 {
            color: #2c3e50;
          }
          .container {
            background-color: #f9f9f9;
            border-radius: 5px;
            padding: 20px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
          }
          table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 20px;
          }
          th, td {
            padding: 10px;
            border: 1px solid #ddd;
            text-align: left;
          }
          th {
            background-color: #f2f2f2;
          }
          .server-info {
            margin-top: 20px;
            padding: 10px;
            background-color: #e8f4f8;
            border-radius: 5px;
          }
          form {
            margin-top: 20px;
            padding: 15px;
            background-color: #f0f0f0;
            border-radius: 5px;
          }
          input[type="text"], input[type="email"] {
            width: 100%;
            padding: 8px;
            margin: 5px 0 15px;
            display: inline-block;
            border: 1px solid #ccc;
            border-radius: 4px;
            box-sizing: border-box;
          }
          button {
            background-color: #4CAF50;
            color: white;
            padding: 10px 15px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
          }
          button:hover {
            background-color: #45a049;
          }
          .tab {
            overflow: hidden;
            border: 1px solid #ccc;
            background-color: #f1f1f1;
            margin-top: 20px;
          }
          .tab button {
            background-color: inherit;
            float: left;
            border: none;
            outline: none;
            cursor: pointer;
            padding: 14px 16px;
            transition: 0.3s;
            color: black;
          }
          .tab button:hover {
            background-color: #ddd;
          }
          .tab button.active {
            background-color: #ccc;
          }
          .tabcontent {
            display: none;
            padding: 6px 12px;
            border: 1px solid #ccc;
            border-top: none;
          }
          #userList {
            display: block;
          }
        </style>
      </head>
      <body>
        <div class="container">
          <h1>2-Tier Architecture Demo</h1>
          <p>이 페이지는 Docker로 구성된 2-Tier 아키텍처 애플리케이션입니다.</p>
          
          <div class="server-info">
            <h3>서버 정보:</h3>
            <ul>
              <li>환경: ${process.env.NODE_ENV || 'development'}</li>
              <li>App Server: ${require('os').hostname()} (컨테이너)</li>
              <li>DB Server: ${config.db.host}</li>
              <li>Node.js 버전: ${process.version}</li>
            </ul>
          </div>
          
          <div class="tab">
            <button class="tablinks active" onclick="openTab(event, 'userList')">사용자 목록</button>
            <button class="tablinks" onclick="openTab(event, 'addUser')">사용자 추가</button>
            <button class="tablinks" onclick="openTab(event, 'deleteUser')">사용자 삭제</button>
          </div>

          <div id="userList" class="tabcontent">
            <h2>사용자 목록</h2>
            <div id="users-table">데이터베이스에서 사용자 정보를 불러오는 중...</div>
          </div>

          <div id="addUser" class="tabcontent">
            <h2>새 사용자 추가</h2>
            <form id="addUserForm">
              <label for="username">사용자명:</label>
              <input type="text" id="username" name="username" required>
              
              <label for="email">이메일:</label>
              <input type="email" id="email" name="email" required>
              
              <button type="submit">사용자 추가</button>
            </form>
            <div id="addUserResult"></div>
          </div>

          <div id="deleteUser" class="tabcontent">
            <h2>사용자 삭제</h2>
            <p>삭제할 사용자의 ID를 입력하세요:</p>
            <form id="deleteUserForm">
              <label for="userId">사용자 ID:</label>
              <input type="text" id="userId" name="userId" required>
              
              <button type="submit">사용자 삭제</button>
            </form>
            <div id="deleteUserResult"></div>
          </div>
          
          <script>
            // 탭 기능
            function openTab(evt, tabName) {
              var i, tabcontent, tablinks;
              tabcontent = document.getElementsByClassName("tabcontent");
              for (i = 0; i < tabcontent.length; i++) {
                tabcontent[i].style.display = "none";
              }
              tablinks = document.getElementsByClassName("tablinks");
              for (i = 0; i < tablinks.length; i++) {
                tablinks[i].className = tablinks[i].className.replace(" active", "");
              }
              document.getElementById(tabName).style.display = "block";
              evt.currentTarget.className += " active";
            }

            // 사용자 목록 로딩
            function loadUsers() {
              fetch('/api/users')
                .then(response => response.json())
                .then(data => {
                  const tableEl = document.getElementById('users-table');
                  if (data.error) {
                    tableEl.innerHTML = '<p style="color: red;">에러: ' + data.error + '</p>';
                  } else if (data.length === 0) {
                    tableEl.innerHTML = '<p>사용자 데이터가 없습니다.</p>';
                  } else {
                    let tableHtml = '<table><tr><th>ID</th><th>사용자명</th><th>이메일</th><th>생성일</th></tr>';
                    data.forEach(user => {
                      tableHtml += '<tr><td>' + user.id + '</td><td>' + user.username + '</td><td>' + user.email + '</td><td>' + new Date(user.created_at).toLocaleString() + '</td></tr>';
                    });
                    tableHtml += '</table>';
                    tableEl.innerHTML = tableHtml;
                  }
                })
                .catch(err => {
                  document.getElementById('users-table').innerHTML = '<p style="color: red;">데이터 로딩 실패: ' + err.message + '</p>';
                });
            }

            // 초기 사용자 목록 로딩
            loadUsers();

            // 사용자 추가 폼 제출 처리
            document.getElementById('addUserForm').addEventListener('submit', function(e) {
              e.preventDefault();
              
              const username = document.getElementById('username').value;
              const email = document.getElementById('email').value;
              const resultEl = document.getElementById('addUserResult');
              
              resultEl.innerHTML = '처리 중...';
              
              fetch('/api/users', {
                method: 'POST',
                headers: {
                  'Content-Type': 'application/json',
                },
                body: JSON.stringify({ username, email }),
              })
              .then(response => response.json())
              .then(data => {
                if (data.error) {
                  resultEl.innerHTML = '<p style="color: red;">에러: ' + data.error + '</p>';
                } else {
                  resultEl.innerHTML = '<p style="color: green;">사용자가 성공적으로 추가되었습니다. ID: ' + data.id + '</p>';
                  document.getElementById('username').value = '';
                  document.getElementById('email').value = '';
                  loadUsers(); // 사용자 목록 새로고침
                }
              })
              .catch(err => {
                resultEl.innerHTML = '<p style="color: red;">요청 실패: ' + err.message + '</p>';
              });
            });

            // 사용자 삭제 폼 제출 처리
            document.getElementById('deleteUserForm').addEventListener('submit', function(e) {
              e.preventDefault();
              
              const userId = document.getElementById('userId').value;
              const resultEl = document.getElementById('deleteUserResult');
              
              resultEl.innerHTML = '처리 중...';
              
              fetch('/api/users/' + userId, {
                method: 'DELETE'
              })
              .then(response => {
                if (!response.ok) {
                  return response.json().then(err => { throw new Error(err.error || '삭제 실패'); });
                }
                return response.json();
              })
              .then(data => {
                resultEl.innerHTML = '<p style="color: green;">사용자가 성공적으로 삭제되었습니다.</p>';
                document.getElementById('userId').value = '';
                loadUsers(); // 사용자 목록 새로고침
              })
              .catch(err => {
                resultEl.innerHTML = '<p style="color: red;">에러: ' + err.message + '</p>';
              });
            });
          </script>
        </div>
      </body>
    </html>
  `);
});

// API 엔드포인트 - 사용자 목록 조회
app.get('/api/users', async (req, res) => {
  try {
    const [rows] = await pool.query('SELECT * FROM users');
    res.json(rows);
  } catch (error) {
    console.error('Database error:', error);
    res.status(500).json({ error: '데이터베이스 조회 중 오류가 발생했습니다.' });
  }
});

// API 엔드포인트 - 사용자 추가
app.post('/api/users', async (req, res) => {
  try {
    const { username, email } = req.body;
    
    if (!username || !email) {
      return res.status(400).json({ error: '사용자명과 이메일은 필수 입력 항목입니다.' });
    }
    
    const [result] = await pool.query(
      'INSERT INTO users (username, email) VALUES (?, ?)',
      [username, email]
    );
    
    res.status(201).json({ 
      id: result.insertId,
      username,
      email,
      message: '사용자가 성공적으로 추가되었습니다.' 
    });
  } catch (error) {
    console.error('Database error:', error);
    res.status(500).json({ error: '사용자 추가 중 오류가 발생했습니다.' });
  }
});

// TODO: API 엔드포인트 - 사용자 삭제 (구현 필요)
// app.delete('/api/users/:id', async (req, res) => {
//   // 이 부분을 구현해 주세요!
// });

// 서버 상태 확인 엔드포인트
app.get('/health', (req, res) => {
  res.status(200).json({ status: 'ok', time: new Date().toISOString() });
});

// 서버 시작
app.listen(config.port, () => {
  console.log(`App server is running on port ${config.port}`);
  console.log(`Database connection configured to: ${config.db.host}`);
});

- app/Dockerfile

더보기
FROM node:16-alpine

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install

COPY . .

EXPOSE 3000

CMD ["node", "app.js"]

1-3. DB 서비스 코드 작성
- db/init.sql

더보기
CREATE DATABASE IF NOT EXISTS nodeapp;
USE nodeapp;

CREATE TABLE IF NOT EXISTS users (
  id INT AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(50) NOT NULL,
  email VARCHAR(100) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 기본 사용자 데이터 추가
INSERT INTO users (username, email) VALUES
('user1', 'user1@example.com'),
('user2', 'user2@example.com'),
('user3', 'user3@example.com');

-- Grafana 모니터링을 위한 권한 설정
CREATE USER IF NOT EXISTS 'grafana'@'%' IDENTIFIED BY 'grafana';
GRANT SELECT ON nodeapp.* TO 'grafana'@'%';
FLUSH PRIVILEGES;

- db/my.cnf

더보기
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
default-authentication-plugin = mysql_native_password

[client]
default-character-set = utf8mb4

- db/Dockerfile

더보기
FROM mysql:8.0

COPY my.cnf /etc/mysql/conf.d/
COPY init.sql /docker-entrypoint-initdb.d/

EXPOSE 3306

1-4. Docker Compose 설정 (로컬 개발용)

- docker-compose.yml

더보기
version: '3'

services:
  app:
    build: ./app
    container_name: 2tier-app
    ports:
      - "80:3000"
    depends_on:
      - db
    environment:
      - NODE_ENV=development
    restart: always

  db:
    build: ./db
    container_name: 2tier-db
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=rootpassword
      - MYSQL_DATABASE=nodeapp
      - MYSQL_USER=nodeuser
      - MYSQL_PASSWORD=password123
    volumes:
      - mysql-data:/var/lib/mysql
    restart: always

  grafana:
    image: grafana/grafana:latest
    container_name: 2tier-grafana
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - grafana-data:/var/lib/grafana
    restart: always

volumes:
  mysql-data:
  grafana-data:

2. 로컬 환경에서 애플리케이션 실행 및 테스트

docker-compose 실행을 한 뒤에 http://localhost로 접속해서 정상 작동하는지 확인

# 프로젝트 루트 디렉토리에서
docker-compose up -d

# 서비스 상태 확인
docker-compose ps

docker ps - 실행중인 컨테이너 확인
http://localhost 에 접속하여 정상적으로 동작하는지 확인


3. Docker Hub에 이미지 업로드

# Docker Hub에 로그인
docker login

# 이미지에 태그 지정 (본인의 Docker Hub 계정명으로 변경)
docker tag 2tier-app_app:latest {계정명}/2tier-app:latest
docker tag 2tier-app_db:latest {계정명}/2tier-db:latest

# 이미지 푸시
docker push {계정명}/2tier-app:latest
docker push {계정명}/2tier-db:latest

docker images - 이미지 목록 확인
docker image를 tag하고 tierr/2tier-app, tier/2tier-db 2개의 이미지를 저장소에 push
저장소에 push한 이미지들이 제대로 올라갔는지 확인


4. 클라우드 환경 VM 설정

VM 인스턴스 생성 (DB서버, App서버)  후 퍼블릭 IP 할당

카카오클라우드에 App서버, DB서버의 인스턴스 생성 완료


5. 각 VM에 Docker 및 Docker Compose 설치

더보기
# 패키지 업데이트
sudo apt update
sudo apt upgrade -y

# Docker 설치
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io

# 사용자를 docker 그룹에 추가
sudo usermod -aG docker ubuntu

# Docker Compose 설치
sudo apt install -y docker-compose

# 변경사항 적용을 위해 로그아웃 후 다시 로그인
exit

6. DB 서버 설정

  • SSH로 DB서버에 접속
더보기
# Mac 접속 방법
ssh -i {키페어파일} ubuntu@{DB_SERVER_PUBLIC_IP}

# Window는 PuTTY or WSL
  • ~/db-server/docker-compose.yml 파일 생성하기
더보기
mkdir -p ~/db-server
cd ~/db-server

cat > docker-compose.yml << EOF
version: '3'

services:
  db:
    image: ${docker-username}/2tier-db:latest
    container_name: 2tier-db
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=rootpassword
      - MYSQL_DATABASE=nodeapp
      - MYSQL_USER=nodeuser
      - MYSQL_PASSWORD=password123
    volumes:
      - mysql-data:/var/lib/mysql
    restart: always

  grafana:
    image: grafana/grafana:latest
    container_name: 2tier-grafana
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - grafana-data:/var/lib/grafana
    restart: always

volumes:
  mysql-data:
  grafana-data:
EOF

# Docker Compose 실행
docker-compose up -d

 

+) 참고 : ${docker-username}에 내 username을 입력한 docker-compose.yml 코드

version: '3'

services:
  db:
    image: tierr/2tier-db:latest
    container_name: 2tier-db
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=rootpassword
      - MYSQL_DATABASE=nodeapp
      - MYSQL_USER=nodeuser
      - MYSQL_PASSWORD=password123
    volumes:
      - mysql-data:/var/lib/mysql
    restart: always

  grafana:
    image: grafana/grafana:latest
    container_name: 2tier-grafana
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
    volumes:
      - grafana-data:/var/lib/grafana
    restart: always

volumes:
  mysql-data:
  grafana-data:

docker ps - DB 서버 컨테이너 실행 확인


7. App 서버 설정

  • SSH로 App 서버에 접속
더보기
# Mac 접속 방법
ssh -i {키페어파일} ubuntu@{APP_SERVER_PUBLIC_IP}

# Window는 PuTTY or WSL
  • ~/app-server/docker-compose.yml 파일 생성하기
더보기
mkdir -p ~/app-server
cd ~/app-server

cat > docker-compose.yml << EOF
version: '3'

services:
  app:
    image: ${docker-username}/2tier-app:latest
    container_name: 2tier-app
    ports:
      - "80:3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=${DB_SERVER_PUBLIC_IP}
      - DB_USER=nodeuser
      - DB_PASSWORD=password123
      - DB_NAME=nodeapp
    restart: always

volumes: {}
EOF

# Docker Compose 실행
docker-compose up -d

 

+) 참고 : ${docker-username},  ${DB_SERVER_PUBLIC_IP}  입력한 docker-compose.yml 코드

mkdir -p ~/app-server
cd ~/app-server

cat > docker-compose.yml << EOF
version: '3'

services:
  app:
    image: tierr/2tier-app:latest
    container_name: 2tier-app
    ports:
      - "80:3000"
    environment:
      - NODE_ENV=production
      - DB_HOST=210.109.82.32
      - DB_USER=nodeuser
      - DB_PASSWORD=password123
      - DB_NAME=nodeapp
    restart: always

volumes: {}
EOF

# Docker Compose 실행
docker-compose up -d

docker ps - APP 서버 컨테이너 실행 확인


8. 애플리케이션 테스트

웹 브라우저에서 http://{APP_SERVER_PUBLIC_IP}로 접속하여 애플리케이션이 정상 작동하는지 확인


9. Grafana 대시보드 설정

웹 브라우저에서 `http://{DB_SERVER_PUBLIC_IP}:3000`로 Grafana에 접속후 MySQL 을 Data source로 추가

  • Host: ${public-ip-address}:3306
  • Database: nodeapp

Save & Test 버튼을 눌러서 연결이 잘 되었는지도 확인

 

대시보드를 생성하고 아래 2개 패널을 구성

  • 패널 1 : 전체 유저수와 최근 가입일
-- users 테이블에서 전체 유저 수와 최근 가입일을 조회
SELECT
    COUNT(*) AS 'Total Users',              -- 총 유저 수를 계산 (모든 행 수)
    MAX(created_at) AS 'Last Registration'  -- 가장 최근에 가입한 일시 (created_at 컬럼 중 최대값)
FROM users;                                 -- 데이터 소스 테이블: users
  • 패널 2 : 유저 목록
-- users 테이블에서 id, 사용자명, 이메일, 가입일을 조회
SELECT 
  id,                      
  username,                
  email,                   
  created_at               
FROM users                 -- 조회 대상 테이블: users
ORDER BY created_at DESC;  -- 가입일 기준으로 내림차순 정렬 (최신 가입자가 가장 위에 옴)

Grafana 대시보드 구성


심화 실습: 사용자 삭제 API 구현하기

현재 웹 애플리케이션에는 사용자 삭제 기능의 프론트엔드는 구현되어 있지만, 백엔드 API는 미구현 상태입니다. 이 심화 실습에서는 사용자 삭제 API를 구현하여 애플리케이션의 기능을 완성합니다.

 

우선 로컬 환경에서 삭제 API를 구현 및 테스트해보자. 

  • app.js 에 삭제 로직을 추가
// API 엔드포인트 - 사용자 삭제
app.delete('/api/users/:id', async (req, res) => {
  try {
    const id = Number(req.params.id);
    const [result] = await pool.query('DELETE FROM users WHERE id = ?', [id]);

    if (result.affectedRows === 0) {
      return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' });
    }

    res.status(200).json({ message: '사용자가 성공적으로 삭제되었습니다.' });
  } catch (error) {
    console.error('Database error:', error);
    res.status(500).json({ error: '사용자 삭제 중 오류가 발생했습니다.' });
  }
});

 

  • 테스트를 위해 ID 가 12인 user_delete 유저를 삭제

user_delete 를 삭제해보자
user_delete 유저의 ID (12)를 입력하고 사용자 삭제
결과


Repository Image 최신화

코드의 변경점이 생겼으니 2tier-app을 저장소에 다시 push 해줘야 한다.

# 1. 이미지 다시 빌드 (최신 코드 반영)
docker build -t tierr/2tier-app:latest .

# 2. Docker Hub에 다시 push
docker push tierr/2tier-app:latest

tierr/2tier-app 이미지 push

 

Docker Hub 저장소에 tierr/2tier-app:latest 이미지가 최신화되었을 것이다. 

카카오클라우드 VM환경에서 최신 이미지를 다운로드받아 컨테이너를 재실행해야 수정내역이 적용된다.

  • VM 에서 실행중인 서버를 종료하고, pull로 최신 이미지를 받은 후, 컨테이너 재실행
docker-compose down     # 컨테이너 down
docker-compose pull     # 최신 이미지 강제 다운로드
docker-compose up -d    # 최신 이미지로 실행

VM 환경에서 최신화가 잘 적용되었는지 테스트


트러블 슈팅

상황)

App 서버의 docker-compose.yml에 DB_HOST를 잘못 적어서 파일을 수정했다.

docker-compose up -d --build 로 다시 빌드하려고 했는데 다음과 같은 에러가 발생했다.

  • 에러 내용
더보기
ERROR: for c347c300bd9f_2tier-app  'ContainerConfig'

ERROR: for app  'ContainerConfig'
Traceback (most recent call last):
  File "/usr/bin/docker-compose", line 33, in <module>
    sys.exit(load_entry_point('docker-compose==1.29.2', 'console_scripts', 'docker-compose')())
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/cli/main.py", line 81, in main
    command_func()
  File "/usr/lib/python3/dist-packages/compose/cli/main.py", line 203, in perform_command
    handler(command, command_options)
  File "/usr/lib/python3/dist-packages/compose/metrics/decorator.py", line 18, in wrapper
    result = fn(*args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/cli/main.py", line 1186, in up
    to_attach = up(False)
                ^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/cli/main.py", line 1166, in up
    return self.project.up(
           ^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/project.py", line 697, in up
    results, errors = parallel.parallel_execute(
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/parallel.py", line 108, in parallel_execute
    raise error_to_reraise
  File "/usr/lib/python3/dist-packages/compose/parallel.py", line 206, in producer
    result = func(obj)
             ^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/project.py", line 679, in do
    return service.execute_convergence_plan(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 579, in execute_convergence_plan
    return self._execute_convergence_recreate(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 499, in _execute_convergence_recreate
    containers, errors = parallel_execute(
                         ^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/parallel.py", line 108, in parallel_execute
    raise error_to_reraise
  File "/usr/lib/python3/dist-packages/compose/parallel.py", line 206, in producer
    result = func(obj)
             ^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 494, in recreate
    return self.recreate_container(
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 612, in recreate_container
    new_container = self.create_container(
                    ^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 330, in create_container
    container_options = self._get_container_create_options(
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 921, in _get_container_create_options
    container_options, override_options = self._build_container_volume_options(
                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 960, in _build_container_volume_options
    binds, affinity = merge_volume_bindings(
                      ^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 1548, in merge_volume_bindings
    old_volumes, old_mounts = get_container_data_volumes(
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/compose/service.py", line 1579, in get_container_data_volumes
    container.image_config['ContainerConfig'].get('Volumes') or {}
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
KeyError: 'ContainerConfig'

원인)

이런 에러는 Docker Compose가 컨테이너를 재생성하려고 할 때, 기존에 존재하던 컨테이너 또는 이미지가 손상되었거나 비정상 상태일 때 발생한다고 한다. 특히 image:로 지정한 이미지가 정상적인 구조를 가지고 있지 않거나, 중간에 수동 삭제/중단된 상태에서 Compose가 기존 정보를 재활용하려고 할 때 이런 오류가 터진다고 한다.

 

해결)

실행중인 컨테이너 목록을 확인해보니, 기존 컨테이너의 이름이 이상하게 변형되어있었다.

  • 2tier-app -> c347c300bd9f_2tier-app

단순히 기존 컨테이너를 삭제하고 새로 만들어서 실행했더니 해결되었다.

# ----------------------------Code History----------------------------
# 빌드 -> 에러
docker-compose up -d --build
# compose 파일 유효성 검사 및 컨테이너 목록 확인
docker-compose config
docker ps -a

# 기존 컨테이너 삭제
docker rm c347c300bd9f_2tier-app

# 컨테이너 재구동
docker-compose up -d
docker ps

 

이 문제를 해결하는 중에 좋은 명령어를 하나 알게되었으니 기록

docker-compose config

 docker-compose.yml 파일이 잘 작성됐는지 확인하고,
실제로 도커가 이해할 수 있는 형식으로 변환된 결과를 보여주는 유용한 명령어

# docker-compose.yml 파일 유효성 검사
docker-compose config

본 후기는 [카카오엔터프라이즈x스나이퍼팩토리] 카카오클라우드로 배우는 AIaaS 마스터 클래스 (B-log) 리뷰로 작성 되었습니다.