포스트

[도커-2] 다중 컨테이너: 실전 서비스 환경 구성

[도커-2] 다중 컨테이너: 실전 서비스 환경 구성
도커 아이콘

사실 고래의 등엔 이미 짊어진 컨테이너가 여러개 있었다. 등에 컨테이너를 더 올려보자.

요즘 같은 시대에 물리적으로 하나의 호스트(서버)에 App, DB, Logging 등 여러 시스템을 다 넣는 경우는 없을 것이다.
그렇게 한다면 각 프로세스가 서로에게 미치는 영향이 너무 크고, VM을 사용하여 격리한다고 하더라도
OS단위로 격리하는 것은 자원 효율성면에서 크게 떨어지기 때문이다.
도커도 해당 이슈를 완전히 피해갈 수는 없지만, 효율성과 관리성에서 큰 이점을 얻을 수 있다.

MSA 방식이 주류가 되면서, 도커의 활용성은 더욱 높아졌다.
하나의 큰 애플리케이션을 도메인별로 독립적이고 배포 가능한 여러 개의 작은 서비스로 나누기에 최적화되어있기 때문이다.
해당 개념을 파악하기 위해 목적에 맞는 서비스 환경을 구성해 볼 것이다.


도커 컴포즈


도커 컴포즈란 단일 호스트 환경에서 여러 개의 도커 컨테이너를 YAML 파일 하나로 정의하고 관리하는 도구이다.
즉, 위에서 얘기했던 하나의 독립적인 서비스를 만들게 해주는 도구라는 것이다.

그럼 다중 호스트 환경은 어떻게 구성해야 함?

간단하게 도커로 구성한다면 도커 컴포즈를 활용하는 도커 스웜을,
엔터프라이즈급 대규모 환경이라면 그 유명한 쿠버네티스를 활용해야 한다.
범위가 너무 커지므로 그건 추후에 다뤄보고, 우선 도커 컴포즈부터 시작하겠다.

컴포즈 사용 방법

사용 방법은 간단하다.

  • 도커 컴포즈 도구를 설치한다.
    • 도커 엔진 v20.10.0 이상 버전부터는 컴포즈가 자동으로 포함되어있다.
  • docker-compose.yml이라는 설정 파일을 통해 서비스를 정의하고 관리한다.
도커 컴포즈 버전 확인

도커 엔진 외에 별도로 설치를 안 했는데도 이미 컴포즈 도구가 설치되어있다.


docker-compose.yml 파일 구조


파일 구조는 크게 보면 최상위 키와 하위 키로 나눌 수 있다.

최상위 키

최상위 키필수 여부역할 및 설명핵심 하위 키 예시
version필수이 설정 파일이 따르는 Docker Compose 파일 규격(문법) 버전을 지정한다.(없음, 값 자체가 버전 정보)
services필수실제 구동할 개별 컨테이너(서비스)를 정의하는 핵심 섹션이다.build, image, ports, environment, volumes
volumes선택컨테이너가 삭제되어도 데이터가 유지되도록 영속적인 저장소를 정의하는 섹션이다.driver, external
networks선택기본 네트워크 대신 사용자 지정 네트워크를 정의할 때 사용한다.driver, external
  • 가장 중요한 섹션: 실제로 대부분의 설정 작업은 services 키 아래에서 이루어지며,
    여기서 build, ports, environment와 같은 다양한 하위 키를 사용하여 컨테이너의 동작을 세밀하게 제어한다.
  • 네트워크 생략: networks 섹션을 정의하지 않으면, 컴포즈는 자동으로 기본 네트워크를 생성하고
    services에 정의된 모든 컨테이너를 여기에 연결한다.
  • 볼륨 사용: volumes 키는 볼륨 자체를 정의하는 곳이며, 실제로 컨테이너에 볼륨을 연결하는 것은
    services 섹션의 하위 키인 volumes에서 이루어진다.

하위 키

여기서는 자주 사용하는 하위 키들만 정리하도록 하겠다.
정확한 내용을 알고 싶으면 공식 문서를 참조하면 된다.

services 섹션의 핵심 하위 키

하위 키필수 여부역할 및 설명예시
build조건부 필수로컬 Dockerfile을 써서 이미지를 빌드하도록 지정한다.build: .
image조건부 필수도커 레지스트리에서 다운로드할 기존 이미지를 지정한다. (build 대신 사용).image: postgres:14-alpine
ports선택호스트컨테이너 포트를 연결(포트 포워딩)한다.ports: - “8080:5000”
volumes선택컨테이너 내부 경로와 볼륨 또는 호스트 경로를 연결하여 데이터 영속성을 확보한다.volumes: - db_data:/var/lib/data
environment선택컨테이너 내부에 주입할 환경 변수를 정의한다. (DB 비밀번호, API 키 등).environment: - DB_HOST=db
depends_on선택이 서비스가 시작되기 전에 완료되어야 할 다른 서비스를 지정한다. (순서 보장).depends_on: - db
networks선택서비스가 연결될 네트워크를 명시적으로 지정한다.networks: - backend_net


volumes 섹션의 핵심 하위 키

하위 키필수 여부역할 및 설명예시
(볼륨 이름)필수볼륨 정의의 시작점. 사용자가 지정하는 이름이다.db_data:
driver선택볼륨을 만들 때 쓸 드라이버를 지정한다. (기본값: local).driver: local
external선택이 볼륨이 Compose 외부에 이미 존재함을 선언하여 새로 만들지 않도록 한다.external: true


networks 섹션의 핵심 하위 키

하위 키필수 여부역할 및 설명예시
(네트워크 이름)필수네트워크 정의의 시작점. 사용자가 지정하는 이름이다.backend_net:
driver선택사용할 네트워크 드라이버를 지정한다. (기본값: bridge).driver: bridge
external선택이 네트워크가 Compose 외부에 이미 존재함을 선언한다.external: true
ipam선택IP 주소 관리 설정을 통해 서브넷 등을 명시적으로 지정한다.ipam: {config: [{subnet: ‘172.20.0.0/16’}]}


DB가 연동된 웹 서비스를 컨테이너에 올리기


Python(Flask) app + PostgreSQL db 구조로 간단하게 구성해보았다.

디렉토리 구조

nayatrei-docker-compose/
├── docker-compose.yml # 컴포즈 설정 파일
├── app/
│   ├── Dockerfile # Flask 앱 도커 파일
│   ├── requirements.txt # Python 패키지 목록
│   └── app.py # 앱 소스 코드
└── postgres/
    └── init.sql # postgres 처음 실행시 자동으로 실행되는 SQL 스크립트

nayatrei-docker-compose/

  • docker-compose.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
version: '3.8'

services:
  # PostgreSQL 데이터베이스
  postgres:
    image: postgres:16-alpine  # PostgreSQL 공식 이미지
    container_name: myapp_postgres
    
    # 데이터베이스 설정
    environment:
      POSTGRES_USER: user          # 데이터베이스 사용자
      POSTGRES_PASSWORD: password  # 비밀번호
      POSTGRES_DB: myapp          # 데이터베이스 이름
    
    # 데이터 저장 및 초기화 스크립트
    volumes:
      - postgres_data:/var/lib/postgresql/data  # 데이터 영속성
      - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql  # 초기화
    
    # 포트 노출 (선택사항 - 외부에서 직접 접속 가능)
    ports:
      - "5432:5432"
    
    # 데이터베이스가 준비될 때까지 대기
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 5s
      timeout: 3s
      retries: 5

  # Flask 애플리케이션
  app:
    build: ./app  # app 디렉토리의 Dockerfile 사용
    container_name: myapp_backend
    
    # 데이터베이스 연결 정보
    environment:
      DB_HOST: postgres       # 서비스 이름으로 연결
      DB_NAME: myapp
      DB_USER: user
      DB_PASSWORD: password
    
    # 웹 서버 포트
    ports:
      - "5000:5000"
    
    # postgres가 정상 작동한 후 시작
    depends_on:
      postgres:
        condition: service_healthy
    
    # 자동 재시작
    restart: unless-stopped

# 데이터 저장용 볼륨
volumes:
  postgres_data:

컨테이너 실행 및 연결을 정의한 도커 컴포즈 설정 파일

app/

  • Dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Python 3.11 이미지 사용
FROM python:3.11-slim

# 작업 디렉토리 설정
WORKDIR /app

# 필요한 시스템 패키지 설치
# PostgreSQL 연결에 필요
RUN apt-get update && \
    apt-get install -y --no-install-recommends gcc postgresql-client && \
    rm -rf /var/lib/apt/lists/*

# Python 패키지 먼저 설치 (캐싱 최적화)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 애플리케이션 코드 복사
COPY . .

# 5000번 포트 사용
EXPOSE 5000

# Flask 앱 실행
CMD ["python", "app.py"]

Python 환경을 만들고, Flask 앱과 필요한 패키지를 설치해서, python app.py를 실행하는 도커 파일

  • requirements.txt
1
2
Flask==3.0.0 # 웹 프레임워크
psycopg2-binary==2.9.9 # PostgreSQL DB 연결 드라이버

설치할 Python 라이브러리 목록을 적어놓은 파일

  • app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# 간단한 Flask 웹 애플리케이션
from flask import Flask, jsonify
import psycopg2
import os

app = Flask(__name__)

# 데이터베이스 연결 정보
DB_HOST = os.getenv('DB_HOST', 'postgres')
DB_NAME = os.getenv('DB_NAME', 'myapp')
DB_USER = os.getenv('DB_USER', 'user')
DB_PASSWORD = os.getenv('DB_PASSWORD', 'password')

def get_db_connection():
    """PostgreSQL 연결"""
    conn = psycopg2.connect(
        host=DB_HOST,
        database=DB_NAME,
        user=DB_USER,
        password=DB_PASSWORD
    )
    return conn

@app.route('/')
def home():
    """메인 페이지"""
    return jsonify({
        'message': 'Hello Docker Compose! I am nayatrei.',
        'status': 'running',
        'user': 'nayatrei'
    })

@app.route('/users')
def get_users():
    """데이터베이스에서 사용자 목록 조회"""
    try:
        conn = get_db_connection()
        cursor = conn.cursor()
        cursor.execute('SELECT id, name, email FROM users;')
        users = cursor.fetchall()
        cursor.close()
        conn.close()
        
        # 결과를 JSON으로 변환
        users_list = []
        for user in users:
            users_list.append({
                'id': user[0],
                'name': user[1],
                'email': user[2]
            })
        
        return jsonify({'users': users_list})
    except Exception as e:
        return jsonify({'error': str(e)}), 500

if __name__ == '__main__':
    # 모든 인터페이스에서 접근 가능하도록 설정
    app.run(host='0.0.0.0', port=5000)

app.py는 소스 코드이므로 이걸 이해할 필요는 없다.

postgres/

  • init.sql
1
2
3
4
5
6
7
8
9
10
11
12
-- 사용자 테이블 생성
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL
);

-- 샘플 데이터 추가
INSERT INTO users (name, email) VALUES
    ('nayatrei', 'nayatrei3@naver.com'),
    ('kaero313', 'kaero313@naver.com'),
    ('yj', 'https://kaero313.github.io/posts/docker_2/');

초기 테이블 및 데이터 세팅

예상 실행흐름

1. docker compose up -d --build 실행(빌드하고 백그라운드에서 실행)
   ↓
2. docker-compose.yml 읽기
   ↓
3. app/Dockerfile로 Flask 이미지 빌드
   → requirements.txt로 패키지 설치
   → app.py 복사
   ↓
4. postgres 이미지 다운로드
   → init.sql 실행 (테이블 및 데이터 생성)
   ↓
5. 두 컨테이너 실행 및 연결
   ↓
6. http://localhost:5000(/users) 접속 가능


이제 실제로 수행해 볼 차례다.
폴더 및 파일은 직접 생성하고 내용은 본문의 내용 그대로 복사하여 사용하였다.

이미지가 등록되었고, DB에 저장했던 데이터가 정상적으로 출력된다.

각 구문별 해석

  • docker compose up -d --build
    • 컴포즈의 모든 서비스를 빌드 및 시작한다.
    • -d: 컨테이너를 백그라운드에서 실행한다.
  • docker compose ps
    • 실행 중인 컨테이너를 확인한다.

이전 장에서도 사용했었던 명령어는 생략했다.

볼륨의 데이터 영속성

위에서 볼륨은 컨테이너가 삭제되어도 데이터가 유지되도록 영속적인 저장소를 정의하는 섹션이라고 했었다.
눈으로 직접 확인해보자.

down시 삭제되는 것

파란색: 프로세스 / 노란색: 네트워크 / 빨간색: 볼륨

docker compose down 명령어를 실행하여
실행 중인 컨테이너를 정지시킨 후 관련 리소스들을 제거하여 환경을 정리하였다.
프로세스와 네트워크는 초기화되었지만 볼륨은 그대로 남아있는걸 확인할 수 있다.
이 속성 때문에 도커에서는 컨테이너가 제거되더라도 데이터는 보존되어 있어,
데이터베이스, 로그, 사용자 업로드 파일 등 다양하게 활용을 할 수 있다.

이미지 재사용(캐싱)

1장에서 이미지는 레이어 단위의 모음으로 만들어지고,
도커 파일에 수정이 발생하면 변경이 되지 않은 레이어는 캐시에서 가져와 그대로 재사용한다고 기술했었다.
마찬가지로 그것도 실제로 적용되는건지 눈으로 확인해보자.

이미지 캐싱

첫 빌드와 두 번째 빌드시 시간 차이: 1분8초 > 10초

도커 파일은 변경하지 않고 다시 빌드 및 실행 했을때의 결과이다.
출력 로그를 보면 베이스 레이어 FROM 말고 2~5 레이어는 CACHED 했다는 것을 확인할 수 있다.
로컬 저장소의 이미지를 따로 삭제하지 않는 이상 캐싱되어 활용되기 때문에,
빌드 시간, CI/CD, 네트워크 트래픽, 오버헤드 감소 등 관리 효율성을 크게 올릴 수 있다.


다음 목표

도커 컴포즈를 통해 다중 서비스 환경을 성공적으로 구조화했다면,
이제 다음 과제는 이 환경을 견고하고 효율적으로 만드는 일이다.
다음 장에서는 이미지 저장소(Registry) 관리를 통해 배포 파이프라인의 핵심을 이해하고,
도커 파일 최적화 기법을 적용하여 이미지 용량을 줄이는 실질적인 성과를 기록할 예정이다.
단순 실행을 넘어 운영, 관리로 나아가는 중요한 전환점이 될 것이다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.