Docker 와 서버 관리

도커는 컨테이너 기반 오픈소스 가상화 플랫폼이다.

컨테이너(OS 내 격리된 환경에서 동작하는 프로세스) 를 제공하지만 VM 처럼 물리적 하드웨어를 Hypervisor 로 가상화하지 않고 OS 를 Docker 로 가상화한다.

https://docker-docs.uclv.cu/get-started/#containers-and-virtual-machines

도커는 별도 Guest OS 를 설치할 필요 없이 Host OS 의 커널을 공유한다.

Guest OS 에서 필요한 커널 및 시스템 파일에 대한 메모리가 절약되고 공유된 커널로 즉시 실행이 가능해지는 장점이 있다.

도커 컨테이너는 서버를 배포하는 방식의 표준을 이미지로 제공한다.

이미지는 컨테이너 실행에 필요한 설정을 담은 패키지로 도커가 설치되어있는 환경이라면 어디서든 (이식성) 컨테이너로 생성되고 실행될 수 있다. (확장성)

실습 1: PostgreSQL 컨테이너 생성과 볼륨

실습 예제로 DB 컨테이너를 생성하고 데이터를 관리하는 예제를 다뤄보려고 한다.

먼저 Docker for Mac 을 설치해주고 docker version 명령어를 실행해보면 다음과 같은 결과를 볼 수 있다.

Client:
 Cloud integration: v1.0.22
 Version:           20.10.x
 API version:       1.41
 Go version:        go1.16.12
 Git commit:        e91ed57
 Built:             Mon Dec 13 11:46:56 2021
 OS/Arch:           arm64 (Mac)
 Context:           default
 Experimental:      true

Server: Docker Desktop 4.5.0 (74594)
 Engine:
  Version:          20.10.x
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.12
  Git commit:       459d0df
  Built:            Mon Dec 13 11:43:07 2021
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.4.12
  GitCommit:        7b11cfaabd73bb80907dd23182b9347b4245eb5d
 runc:
  Version:          1.0.2
  GitCommit:        v1.0.2-0-g52b36a2
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

Docker Client 와 Docker Server 가 설치된 것을 확인할 수 있다.

Docker Client 는 실제 입력한 명령을 의미한다. 입력한 명령은 Docker Server(Host) 에 명령을 전달하고 결과를 다시 Client 에 출력하는 방식으로 상호작용한다.

Docker Desktop(=Docker Server) 이 실행되어야 Docker Client 는 명령어를 수행할 수 있다.

$ docker version

Client:
 Cloud integration: v1.0.22
 Version:           20.10.12
 API version:       1.41
 Go version:        go1.16.12
 Git commit:        e91ed57
 Built:             Mon Dec 13 11:46:56 2021
 OS/Arch:           darwin/arm64
 Context:           default
 Experimental:      true

$ docker ps

Error response from daemon: Bad response from Docker engine

https://docs.docker.com/get-started/docker-overview/#docker-architecture

먼저 PostgreSQL 컨테이너를 생성하기 위해서 이미지를 설치해야한다.

$ docker pull postgres

Using default tag: latest
latest: Pulling from library/postgres
7ce705000c39: Pull complete 
a17abc86e878: Pull complete 
533f47cc37b3: Pull complete 
2c171c713eb0: Pull complete 
ed0c27b12f94: Pull complete 
6a797b38b71e: Pull complete 
04627f37bf24: Pull complete 
f51bf6ebfbc0: Pull complete 
28f9816e24d6: Pull complete 
8639f469c02d: Pull complete 
a887fdfa9c03: Pull complete 
aea2cdf8abfe: Pull complete 
077f421f0043: Pull complete 
5cad0c187961: Pull complete 
Digest: sha256:87ec5e0a167dc7d4831729f9e1d2ee7b8597dcc49ccd9e43cc5f89e808d2adae
Status: Downloaded newer image for postgres:latest
docker.io/library/postgres:latest

버전을 따로 postgres:15 처럼 명시하지 않으면 latest 한 이미지로 설치된다.

잘 설치 되었는지 확인까지 해본다.

$ docker images

REPOSITORY   TAG       IMAGE ID       CREATED        SIZE
postgres     latest    e9e3b6fe0ce3   2 months ago   457MB

이미지를 실제 작동하는 프로세스로 컨테이너화 해보자.

docker run -d \
  --name postgres-container \
  --restart always \
  -p 5432:5432 \
  -e POSTGRES_USER=user \
  -e POSTGRES_PASSWORD=0000 \
  -e POSTGRES_DB=postgres_db \
  -v $(pwd)/postgres-data:/var/lib/postgresql/data \
  postgres

-d : Detached Mode, 컨테이너를 백그라운드 형태로 실행한다.

—name : 컨테이너 이름을 postgres-container 로 설정하고

-p : Publishing Ports, [host_port]:[container_port] 형태로 작성하며 컨테이너 5432 포트를 호스트 5432 포트에 노출할 수 있다.

-e : 환경 변수를 설정한다.

-v : 도커 컨테이너는 삭제되면 내부 데이터도 함께 사라진다. Volumn Options 을 사용하면 컨테이너가 종료되어도 데이터를 볼륨에 유지시킬 수 있다. 현재 경로를 기준으로 postgres-data 라는 볼륨(디렉토리)을 생성하고 PostgreSQL 데이터베이스가 데이터를 저장하는 기본 경로인 /var/lib/postgresql/data 에 마운트한다.

생성된 컨테이너를 확인해보자.

$ docker ps

CONTAINER ID   IMAGE      COMMAND                  CREATED         STATUS         PORTS                    NAMES
60ef397d69ee   postgres   "docker-entrypoint.s…"   2 minutes ago   Up 2 minutes   0.0.0.0:5432->5432/tcp   postgres-container

postgres-container(60ef397d69ee) 컨테이너가 생성된 것을 확인할 수 있다.

생성한 도커 볼륨도 확인해보자.

$ docker volume ls

DRIVER    VOLUME NAME
local     postgres-data

$ docker volume inspect postgres-data

[
    {
        "CreatedAt": "2025-02-02T06:44:38Z",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/postgres-data/_data",
        "Name": "postgres-data",
        "Options": null,
        "Scope": "local"
    }
]

/var/lib/docker/volumes/postgres-data/_data 경로에 마운트한 볼륨이 생성된 것을 확인할 수 있다.

이제 컨테이너에 직접 접근해보자.

$ docker exec -it postgres-container sh

docker exec 로 실행중인 컨테이너에 접근할 수 있고 -it 옵션으로 컨테이너와 sh 셀로 통신할 수 있다.

(-i Interactive, 컨테이너와 상호작용 + -t : tty, 터미널을 할당하여 셸을 실행)

이제 설정한 사용자로 접근해서 DB 를 확인해보자.

$ psql -U user -d postgres_db

psql (17.2 (Debian 17.2-1.pgdg120+1))
Type "help" for help.

$ \l

List of databases
    Name     | Owner | Encoding | Locale Provider |  Collate   |   Ctype    | Locale | ICU Rules | Access privileges 
-------------+-------+----------+-----------------+------------+------------+--------+-----------+-------------------
 postgres    | user  | UTF8     | libc            | en_US.utf8 | en_US.utf8 |        |           | 
 postgres_db | user  | UTF8     | libc            | en_US.utf8 | en_US.utf8 |        |           | 
 template0   | user  | UTF8     | libc            | en_US.utf8 | en_US.utf8 |        |           | =c/user          +
             |       |          |                 |            |            |        |           | user=CTc/user
 template1   | user  | UTF8     | libc            | en_US.utf8 | en_US.utf8 |        |           | =c/user          +
             |       |          |                 |            |            |        |           | user=CTc/user

DB 를 생성해보고 데이터를 실제 생성해보자.

$ CREATE DATABASE "pgdb";

CREATE DATABASE

$ \l

List of databases
    Name     | Owner | Encoding | Locale Provider |  Collate   |   Ctype    | Locale | ICU Rules | Access privileges 
-------------+-------+----------+-----------------+------------+------------+--------+-----------+-------------------
 users       | user  | UTF8     | libc            | en_US.utf8 | en_US.utf8 |        |           | 
# use pgdb 
\c my_database;

$ CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL
);

$ \dt

List of relations
 Schema | Name  | Type  | Owner 
--------+-------+-------+-------
 public | users | table | user
$ INSERT INTO users (name, email) VALUES ('Jack', 'jack@example.com');

INSERT 0 1

$ INSERT INTO users (name, email) VALUES ('James', 'james@example.com');

INSERT 0 1
$ SELECT * FROM users;

 id | name  |       email       
----+-------+-------------------
  1 | Jack  | jack@example.com
  2 | James | james@example.com

이제 psql 에서 나와서 컨테이너를 종료해보고 실제 생성한 데이터를 볼륨에서 확인해보자.

볼륨에서 확인할 DB 는 OID(Object Identifier) 형태로 저장되므로 생성한 DB 의 OID 를 시스템 DB 에서 먼저 확인해야한다.

$ SELECT oid, datname FROM pg_database WHERE datname='users';

  oid  | datname 
-------+---------
 16388 | users

# psql 종료
$ \q

# container exit
$ exit
$ docker inspect postgres-container | grep Mounts -A 9

"Mounts": [
	{
	  "Type": "bind",
	  "Source": "~/postgres-data",
	  "Destination": "/var/lib/postgresql/data",
	  "Mode": "",
	  "RW": true,
	  "Propagation": "rprivate"
  }
],

# Destination
$ ls -l ~/postgres-data/base

docker inspect 명령어는 docker volume inspect 와 다르다.

docker volume inspect 명령어는 볼륨 자체에 대한 설명만 포함할 뿐 어떤 컨테이너가 볼륨을 마운트했는지 확인 할 수 없다.

Destination 경로에 저장된 users(16388) DB 를 확인해볼 수 있다.

실습 2: Next.js 이미지 생성과 배포

앞서 실습한 예제에서는 Docker Hub 에 배포되어있는 이미지를 사용했었다.

이제 컨테이너를 실제 배포할 수 있도록 이미지를 생성하는 실습을 진행해보려고 한다.

Next.js 프로젝트를 생성한 뒤 프로젝트 루트에 Dockerfile 을 만들어준다.

# dockerfile from https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile

FROM node:20-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* .npmrc* ./
RUN npm ci

FROM base AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

FROM base AS runner
WORKDIR /app

ENV NODE_ENV=production

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

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

Docker Multi-stage builds 를 활용하여 이미지를 경량화할 수 있다.

각 단계(AS deps, builder, runner) 를 나누고 단계마다 필요한 파일을 이전 단계에서 --from 으로 가져온다. 최종 이미지 산출물에 불필요한 파일을 포함하지 않도록 하는 방법이다.

Docker 는 RUN COPY WORKDIR 마다 레이어를 생성하며 변경되지 않은 레이어는 캐시된다.

COPY package.json package-lock.json* .npmrc* ./
RUN npm ci

package.json package-lock.json* .npmrc* 가 변경되지 않았다면 RUN npm ci 로 생성한 node_modules 까지 캐싱될 것이다. 자주 변경될 여지가 없는 부분을 앞 단에 위치하여 빌드 속도를 개선할 수 있다.

추가로 Next.js Standalone 빌드 설정이 필요하다.

const nextConfig: NextConfig = {
  output: "standalone",
}

이제 도커 명령어를 통해 Dockerfile 기반으로 이미지를 생성할 수 있다.

$ docker build -t next-app .

[+] Building 67.2s (17/17) FINISHED                                                                                                 
 => [internal] load build definition from Dockerfile                                                                           0.0s
 => => transferring dockerfile: 687B                                                                                           0.0s
 => [internal] load .dockerignore                                                                                              0.0s
 => => transferring context: 2B                                                                                                0.0s
 => [internal] load metadata for docker.io/library/node:20-alpine                                                              2.6s
 => [base 1/1] FROM docker.io/library/node:20-alpine@sha256:2cd2a6f4cb37cf8a007d5f1e9aef090ade6b62974c7a274098c390599e8c72b4   5.2s
 => => resolve docker.io/library/node:20-alpine@sha256:2cd2a6f4cb37cf8a007d5f1e9aef090ade6b62974c7a274098c390599e8c72b4        0.0s
 => => sha256:52f827f723504aa3325bb5a54247f0dc4b92bb72569525bc951532c4ef679bd4 3.99MB / 3.99MB                                 0.3s
 => => sha256:2b1b01593fee8621396cac78e2488a7abf9f3d11742b595d740ba9f1e576332c 42.26MB / 42.26MB                               1.7s
 => => sha256:feec47f82b2f7279e331a9b4c104eda6ef672f421e3bb3dcdcc73b513b9892dd 1.26MB / 1.26MB                                 0.5s
 => => sha256:2cd2a6f4cb37cf8a007d5f1e9aef090ade6b62974c7a274098c390599e8c72b4 7.67kB / 7.67kB                                 0.0s
 => => sha256:57070292ac3174ad96c45ea4e3265b1f9fd984608f927e5bbdf88ad73cae32ac 1.72kB / 1.72kB                                 0.0s
 => => sha256:4accf4954c0342fc0e93a7345998068bf33fe082c40942c4edcc9f08abcf6cad 6.20kB / 6.20kB                                 0.0s
 => => extracting sha256:52f827f723504aa3325bb5a54247f0dc4b92bb72569525bc951532c4ef679bd4                                      0.3s
 => => sha256:1ffe482b6ccf2b801e13d405db007a5370915c1f8ecdb37b77853eb791b9c1b0 443B / 443B                                     0.8s
 => => extracting sha256:2b1b01593fee8621396cac78e2488a7abf9f3d11742b595d740ba9f1e576332c                                      2.7s
 => => extracting sha256:feec47f82b2f7279e331a9b4c104eda6ef672f421e3bb3dcdcc73b513b9892dd                                      0.2s
 => => extracting sha256:1ffe482b6ccf2b801e13d405db007a5370915c1f8ecdb37b77853eb791b9c1b0                                      0.0s
 => [internal] load build context                                                                                             14.0s
 => => transferring context: 337.01MB                                                                                         14.0s
 => [builder 1/4] WORKDIR /app                                                                                                 0.1s
 => [deps 1/4] RUN apk add --no-cache libc6-compat                                                                             4.4s
 => [deps 2/4] WORKDIR /app                                                                                                    0.1s
 => [deps 3/4] COPY package.json package-lock.json* .npmrc* ./                                                                 0.2s
 => [deps 4/4] RUN npm ci                                                                                                     11.8s
 => [builder 2/4] COPY --from=deps /app/node_modules ./node_modules                                                            2.3s
 => [builder 3/4] COPY . .                                                                                                     3.8s
 => [builder 4/4] RUN npm run build                                                                                           29.4s
 => [runner 2/4] COPY --from=builder /app/public ./public                                                                      0.0s
 => [runner 3/4] COPY --from=builder /app/.next/standalone ./                                                                  0.2s
 => [runner 4/4] COPY --from=builder /app/.next/static ./.next/static                                                          0.0s
 => exporting to image                                                                                                         0.3s
 => => exporting layers                                                                                                        0.3s
 => => writing image sha256:1c5a178a66ddabbbcb1bb2fc76507c31a65175818c7465d038117ec0c6705783                                   0.0s
 => => naming to docker.io/library/next-app                                                                                    0.0s

도커 이미지가 정상적으로 생성되었는지 확인해보고 컨테이너까지 생성해보자.

$ docker images

REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
next-app     latest    1c5a178a66dd   43 seconds ago   211MB
$ docker run -d -p 3000:3000 next-app

4acaa965cd84007a287f1451ef90640d637bdacbe14f6ab1b578a8d78be4f47c
$ docker ps

CONTAINER ID   IMAGE      COMMAND                  CREATED          STATUS          PORTS                    NAMES
4acaa965cd84   next-app   "docker-entrypoint.s…"   36 seconds ago   Up 36 seconds   0.0.0.0:3000->3000/tcp   angry_wescoff

백그라운드(-d)로 컨테이너 3000 포트를 Host 3000 포트로 개방(-p)해주었다.

이제 http://localhost:3000/ 로 접속해보면 Next.js 프로젝트가 구동되는 것을 확인할 수 있다.