- 1.
[Cloud Infra] 서버와 SSH - 2.[Cloud Infra] 도커의 시작현재
지난 글에 이어서, 이번 글에서는 본격적으로 도커와 도커 이미지에 대해서 알아보겠습니다.
시리즈의 1편에서는 Rocky Linux 서버에 SSH로 들어가고, Swap과 패키지, SSH 보안까지 맞춰 두었습니다. 이제 서버에 접속하고 명령어를 실행시킬 수 있는 상태입니다. 다만 그 위에 프론트엔드나 백엔드를 올리려면, 로컬에서 쓰던 런타입과 패키지 버전을 서버에 다시 맞추는 일이 남습니다. 로컬에서 잘 되던 서비스가 서버만 가면 죽는 현상은, 많은 경우 이 환경 차이에서 시작합니다.
도커는 애플리케이션과 그 실행 환경을 이미지로 묶어두고, 같은 설계도로 프로세스를 띄우는 쪽에 가깝습니다. 이번 글의 목표는 이 서버에 도커를 설치한 뒤 컨테이너를 한 번 돌려보고, 이미지가 어떤 구조로 쌓이는지와 Dockerfile로 그 이미지를 만드는 흐름까지 따라가는 것입니다.
설치는 다른 OS 환경이나 데스크톱 환경에서 테스트하는 것을 고려해 Docker CE를 기준으로 하고, OS가 Rocky Linux인 만큼 RHEL 계열에서 자주 거론되는 Podman은 차이만 짧게 짚겠습니다. 이어서 'hello-world' 컨테이터를 올려 도커 엔진이 살아 있는지, 웹 서버 이미지를 띄워 포트가 열리는지를 알아봅니다.
1. 왜 컨테이너인가?
서버에 Node나 JDK, Nginx를 직접 설치해도 서비스는 돌아갑니다. 다만 그 순간부터 서버는 "이 버전 조합에서만 검증된 머신"이 됩니다. 로컬은 Node 20인데 서버는 18이고, 로컬에만 있는 시스템 라이브러리가 서버에는 없는 식의 어긋남이 쌓이면, 코드가 아니라 환경 때문에 배포가 멈춥니다.
컨테이너는 그 환경을 애플리케이션과 한 덩어리로 포장하는 쪽에 가깝습니다. 이미지를 한 번 만들어 두면, 같은 설계도로 개발 머신과 클라우드 서버에서 같은 프로세스를 띄울 수 있습니다. 이 시리즈에서 도커를 먼저 깔는 이유도 여기에 있습니다. 이후 Compose, Traefik, GHCR은 모두 이 이미지와 컨테이너 위에 올라갑니다.
1.1. 이미지와 컨테이너
이미지는 애플리케이션을 실행하는 데 필요한 파일, 라이브러리, 기본 설정이 담긴 읽기 전용 설계도입니다. 디스크에 저장되어 있고, 그 자체로는 아직 프로세스가 아닙니다.
컨테이너는 그 이미지를 실행한 결과입니다. 호스트 커널을 공유하면서도, 프로세스·파일 시스템·네트워크가 격리된 공간에서 돌아가는 인스턴스에 가깝습니다. 같은 이미지로 컨테이너를 여러 개 띄울 수 있고, 컨테이너를 지워도 이미지는 남습니다.
비유를 짧게 두면, 이미지는 클래스이고 컨테이너는 인스턴스입니다. 설계도를 고치지 않는 한, 인스턴스를 몇 번 만들고 지워도 원본은 그대로입니다.
1.2. 가상 머신과 무엇이 다른가
가상 머신은 하이퍼바이저 위에 게스트 OS를 올립니다. 커널까지 포함하므로 격리는 뚜렷하지만, 부팅과 자원 사용량이 큽니다. 컨테이너는 호스트 커널을 그대로 쓰고, 그 위에 격리된 프로세스만 올립니다. 그래서 이미지를 받아 실행하는 단위가 가상 머신보다 가볍고, "이 앱을 이 의존성과 함께 재현한다"는 목적에 잘 맞습니다.
반대로, 커널을 공유한다는 말은 호스트와 완전히 다른 OS를 게스트로 넣는 용도는 아니라는 뜻이기도 합니다. 리눅스 서버에서 리눅스 컨테이너를 돌리는 전제입니다. Windows 커널 위에서 Linux 이미지를 돌리려면 WSL2 같은 별도 계층이 필요합니다. 이 글의 실습은 1편의 Rocky Linux 서버를 기준으로 합니다.
1.3. Podman은 어디까지 볼 것인가
Rocky Linux를 포함한 RHEL 계열에서는 Podman을 기본 컨테이너 도구로 쓰는 경우가 많습니다. Docker와 같이 OCI 이미지를 다루고, 명령어 모양도 docker와 비슷한 편입니다. 차이는 구조에 있습니다. Docker는 dockerd 데몬이 중앙에서 컨테이너를 관리하고, Podman은 데몬 없이 컨테이너를 띄우는 쪽에 가깝습니다. 또한 일반 계정으로 실행하는 rootless가 기본 설계에 가깝습니다.
명령어 모양도 docker와 비슷한 편입니다. podman pull, podman run, podman compose up 처럼 동사가 거의 같고, 이후 곧 등장할 docker-compose.yaml도 필수는 아니지만 podman-compose.yaml로 사용할 수도 있습니다. 물론 같은 명세를 그대로 넘기는 쪽이라 그냥 docker-compose.yaml을 많이 사용합니다.
podman-docker 패키지로 docker 명령을 Podman에 연결할 수도 있습니다. 다만 이 시리즈는 이후 Docker Compose, GHCR, Traefik의 도커 소켓 연동까지 같은 도구로 이어갈 예정입니다. 데스크톱이나 다른 배포판에서 테스트하기 쉬운 Docker CE를 기준으로 설치하고, Podman은 "같은 이미지를 돌리는 다른 엔진이 있다" 정도로만 두고 넘어가겠습니다.
2. Docker 설치
2.1. Docker CE 설치
배포판 저장소의 docker 패키지와 Docker 공식 CE는 버전이 다른 경우가 있습니다. 엔진, CLI, containerd, Buildx, Compose 플러그인을 한 세트로 맞추기 위해 공식 저장소를 등록합니다. RHEL의 호환 클론을 정체성으로 하는 Rocky Linux는 RHEL과 같은 저장소를 등록합니다.
Docker 공식에서는 혹시라도 충돌이 발생할 수 있는 가능성을 없애기 위해, 이전에 설치된 docker나 설치되어 있던 podman 등을 삭제하는 것을 권장하고 있습니다. 이 과정에서 dnf는 패키지가 설치되어 있지 않다고 경고할 수도 있습니다.
도커의 설치는 저장소 추가, RPM 패키지 수동 설치, 스크립트의 3가지 방법으로 설치할 수 있습니다. 이 글에서는 편리하게 rpm 저장소를 사용해 설치하는 방법을 알아봅니다.
GPG 키를 수락하라는 문구가 나오면, 지문이 Docker 문서에 적힌 값과 같은지 보고 수락합니다. 이 GPG키는 변경될 수도 있으니, 상단에 있는 참고 링크를 참조하세요.
enable --now는 지금 데몬을 켜고, 재부팅 후에도 dockerd가 올라오게 하는 명령어 입니다.Client와 Server가 모두 보이면 엔진은 살아 있는 상태입니다. 아직 일반 계정으로는 permission denied가 나는 것이 정상입니다. Compose 플러그인도 함께 설치되지만, docker compose는 다음 글에서 설명하겠습니다.
usermod와 docker run 흐름은 같습니다.2.2. 일반 계정으로 docker 쓰기
도커 데몬은 root로 동작하고, 클라이언트는 Unix 소켓 /var/run/docker.sock으로 대화합니다. 이 소켓은 docker 그룹이 쓸 수 있습니다. 현재 사용자를 그룹에 넣으면 sudo docker를 매번 치지 않아도 됩니다.
그룹이 변경되면 새로 로그인해야 반영됩니다. 지금 세션에서만 바로 시험하려면 newgrp docker를 사용하면 됩니다. 그다음 sudo 없이 명령어를 실행해봅니다.
docker 그룹에 속한 계정은 소켓을 통해 사실상 root와 같은 일을 할 수 있습니다. 호스트 디스크를 컨테이너에 마운트하는 식으로 권한을 탈 수 있기 때문입니다. 공유 계정이나 CI 러너에 그룹을 넣을 때는 이 전제를 잊지 않는 것이 좋습니다.2.3. 비특권 포트
리눅스는 기본적으로 1024 미만 포트(80, 443 등)를 root만 바인딩할 수 있게 막아 둡니다. Docker CE를 root 데몬으로 쓰는 일반적인 설치에서는 docker run -p 80:80이 데몬 권한으로 처리되므로, 지금 당장 막히는 경우는 적습니다.
다만 이후 일반 권한 프로세스가 80/443을 직접 열거나, rootless 엔진을 쓸 때에는 이 제한이 다시 나타날 수 있습니다. 시리즈 후반의 HTTP/HTTPS를 염두에 두고, 커널 설정만 미리 낮춰 두겠습니다. 만약 원치 않으면, 굳이 지금 세팅하지 않고 문제가 생겼을 때 적용해도 됩니다.
1편에서 SSH 포트를 1024 이상으로 옮긴 것과 결이 같습니다. 잘 알려진 포트를 아무 프로세스나 열 수 있게 하는 설정이므로, 방화벽과 보안 그룹이 바깥에서 그 포트를 받는지와는 별개입니다.
3. 첫 컨테이너
3.1. hello-world
설치가 끝났는지 확인하는 가장 짧은 방법은 공식 hello-world 이미지를 한 번 실행하는 것입니다. 로컬에 이미지가 없으면 Docker Hub에서 받은 뒤, 컨테이너를 만들고, 메시지를 출력하고 종료합니다.
터미널에 Docker 데몬이 요청을 처리했다는 안내가 보이면, 클라이언트 → 소켓 → dockerd → 이미지 pull → 컨테이너 실행까지 한 바퀴를 돈 것입니다. 이 컨테이너는 할 일을 마치고 바로 끝나므로, docker ps에는 안 보이고 docker ps -a에는 남아 있습니다.
3.2. nginx로 포트 열어보기
hello-world는 엔진 확인용입니다. 실제로 포트를 열어 보려면 웹 서버 이미지를 사용해 볼 수도 있습니다.
-d: 포그라운드에 붙지 않고 백그라운드로 실행합니다.-p 8080:80: 호스트의 8080을 컨테이너 안의 80으로 연결합니다. 왼쪽이 서버(호스트), 오른쪽이 컨테이너입니다.--name my-web: 나중에stop/rm할 때 해시 대신 쓸 이름입니다.
서버 안에서 응답만 보면 됩니다.
nginx와 200이 보이면 컨테이너 프로세스가 떠 있고, 포트 매핑도 된 상태입니다. 집 브라우저에서 공인 IP로 열려면 1편의 firewalld와 IaaS 보안 그룹에 8080/tcp를 추가해야 합니다. 지금은 도커가 포트를 붙이는지만 보면 되므로, 서버 내부 curl이면 충분합니다.
3.3. 라이프사이클
컨테이너는 이미지와 달리 시작했다가 멈출 수 있고, 지우지 않으면 디스크에 메타데이터가 남습니다.
stop은 실행 중인 프로세스에 종료 신호를 보냅니다. rm은 정지된 컨테이너의 쓰기 레이어와 이름을 회수합니다. 이미지를 지우는 명령은 docker rmi이고, 컨테이너를 지웠다고 이미지가 함께 사라지지는 않습니다.
| 명령어 | 역할 |
|---|---|
docker run | 이미지가 없으면 받고, 컨테이너를 만들어 실행 |
docker ps | 실행 중인 컨테이너. -a는 정지된 것 포함 |
docker stop / start | 정지 / 다시 시작 |
docker rm | 컨테이너 삭제. 이미지는 남음 |
hello-world로 생긴 종료 컨테이너는 docker ps -a에서 이름을 확인한 뒤 docker rm하면 지워집니다.
4. 이미지 구조
4.1. 레이어와 불변성
이미지는 파일 하나가 아니라, 읽기 전용 레이어를 겹쳐 놓은 유니온 파일 시스템(Union File System)입니다. Dockerfile의 FROM, RUN, COPY 같은 지시어가 대체로 레이어를 하나씩 만듭니다. 한 번 빌드된 레이어는 내용이 바뀌지 않습니다.
같은 베이스 레이어를 쓰는 이미지가 여럿이어도, 호스트는 그 레이어를 중복으로 저장하지 않고 재사용합니다. nginx를 두 번 run하더라도, 이미지 레이어는 하나 뿐입니다.
4.2. Copy-on-Write
컨테이너가 뜨면 이미지 레이어 위에 쓰기 레이어(writable layer) 가 하나 붙습니다. 컨테이너 안에서 파일을 만들거나 수정하면, 아래 이미지 레이어에 쓰지 않고 그 파일만 위 레이어로 복사한 뒤 거기서 고칩니다. 이 방식을 Copy-on-Write라고 합니다.
그래서 my-web 안에서 설정을 바꿔도 nginx 이미지 원본은 그대로입니다. docker rm으로 컨테이너를 지하면 쓰기 레이어도 함께 사라집니다. DB 데이터처럼 남겨야 하는 파일은 컨테이너 안에 두면 안 되고, 볼륨으로 호스트에 빼 두어야 합니다. 그 이야기는 다음 글의 몫입니다.
5. Dockerfile과 빌드
레지스트리에서 받은 이미지만으로도 서버는 띄울 수 있습니다. 다만 내가 짠 프론트엔드와 백엔드를 올리려면, 그 코드와 의존성을 이미지로 만드는 명세서가 필요합니다. 그 파일이 Dockerfile입니다.
5.1. 지시어
프로젝트 루트에 Dockerfile을 두고, 빌드 컨텍스트(보통 .)와 함께 docker build에 넘겨줍니다. 이때 Dockerfile에서 자주 쓰는 지시어만 정리해보겠습니다.
| 지시어 | 하는 일 |
|---|---|
FROM | 베이스 이미지. 이후 레이어의 출발점 |
WORKDIR | 컨테이너 안 작업 디렉터리. 없으면 만듦 |
COPY | 빌드 컨텍스트의 파일을 이미지로 복사 |
RUN | 빌드 시점에 실행할 명령. 패키지 설치, 컴파일 |
ARG | 빌드 시 --build-arg로 넣는 값. 빌드가 끝나면 기본적으로 사라짐 |
ENV | 컨테이너 실행 중에도 남는 환경 변수 |
EXPOSE | 컨테이너가 듣는 포트를 문서화. 실제 공개는 docker run -p |
CMD | 컨테이너가 뜰 때 기본으로 실행할 프로세스. 마지막 하나만 유효 |
EXPOSE 80만 적었다고 호스트 80이 열리는 것은 아닙니다. 이 지시어는 메타데이터이고, 바깥으로 붙이는 작업은 실제로 컨테이너를 올릴 때 옵션-p의 역할입니다.
5.2. 빌드 캐시와 캐시 최적화
도커는 이미지를 만들기 위해 Dockerfile을 위에서 아래로 순차 실행합니다. 그리고 각 단계에서의 데이터를 캐시해두고, 이전 빌드와 입력이 같은 단계는 캐시를 재사용합니다.
이를 이용하면 캐시를 최적화할 수 있습니다. 보통 Node.js 기반의 프로젝트는 package.json과 package-lock.json을 사용합니다. 의존성을 업데이트하지 않는 이상 이 파일들은 잘 변경되지 않습니다. 그런데 만약 소스코드를 한 줄 수정했고, 소스코드 복사보다 package.json을 더 나중에 하면 어떻게 될까요?
소스코드가 변경되었기 때문에, docker는 소스코드 복사 이후의 단계에 대해 캐시를 쓰지 않고 다시 수행합니다. 이렇게되면 소스코드 한 줄 변경으로 npm ci가 다시 실행되고, 이미지를 만드는 시간이 늘어납니다. 자주 바뀌는 소스보다, 덜 바뀌는 의존성 파일을 먼저 복사하는 이유가 여기 있습니다.
package.json이 그대로면 npm ci 레이어는 CACHED로 건너뜁니다. 공백 한 칸만 바뀌어도 캐시는 깨집니다.
COPY . . 앞에 .dockerignore로 node_modules, .git, 빌드 산출물을 빼 두면 컨텍스트가 작아지고, 호스트의 node_modules가 이미지 안으로 섞이는 일도 줄어듭니다.5.3. 멀티스테이지 빌드
프론트엔드를 컨테이너로 넣을 때, Node 툴체인이 최종 이미지에 남을 필요는 없습니다. Vite라면 빌드 결과인 정적 파일만 Nginx가 서빙하면 됩니다. 멀티스테이지는 빌드용 이미지에서 산출물만 골라, 런타임용 이미지로 복사하는 패턴입니다.
ARG는 빌드 시점에만 있고, Vite 같은 프론트엔드는 그 값을 빌드 산출물에 넣고 이미지를 만듭니다. 컨테이너를 나중에 띄우며 ENV만 바꿔서는 이미 구워진 번들이 바뀌지 않습니다.
백엔드는 정적 파일이 아니라 Node 프로세스가 남습니다. 빌드 스테이지에서 dist를 만들고, 실행 스테이지에서는 production 의존성과 산출물만 가져오면 됩니다.
태그 node:20-alpine은 예시입니다. 프로젝트의 실제 Node 버전에 맞추면 됩니다. 지금 단계에서는 이미지가 만들어지고, -p로 프로세스가 붙는지만 보면 됩니다. 프론트와 백, DB를 한 파일로 묶는 일은 Compose에서 이어서 실습합니다.
6. 정리
1편의 서버는 접속과 기본 운영이 되는 상태였고, 이번 글에서는 그 위에 도커 엔진을 올렸습니다. hello-world로 데몬을 확인하고, nginx로 포트 매핑을 봤고, 이미지가 레이어로 쌓이며 컨테이너의 변경은 쓰기 레이어에만 남는다는 점까지가 이번 범위입니다. Dockerfile은 그 레이어를 재현 가능한 문서로 적는 방법이고, 멀티스테이지는 빌드 도구를 최종 실행 환경에서 빼는 방법입니다.
컨테이너를 지우면 쓰기 레이어의 데이터도 함께 사라집니다. 다음 글에서는 Compose로 여러 컨테이너를 한 명세로 올리고, 볼륨을 통해 영구적으로 남길 데이터와 컨테이너 사이 네트워크에 대해 알아보겠습니다.