[기초편] Docker
- [기초편] Docker
- [기본편] Docker
- [심화편] Docker
Docker가 왜 필요할까?
“내 컴퓨터에서는 되는데요” 문제
개발을 하다 보면 한 번쯤 이런 상황을 겪게 된다.
로컬에서는 잘 됐는데, 서버에 올리니까 왜 안되지?
이런 문제는 진짜 흔한데, 막상 원인을 찾으려면 시간을 꽤 잡아먹는다. 나도 실제로 운영 환경과 내 로컬 환경의 MySQL 버전이 달라서 하루를 날린적이 있다. 이 문제의 대부분은 결국 환경 차이 때문이다.
환경이 다르면 결과도 달라진다
예를 들어 팀 상황이 이렇다고 해보자.
| 항목 | 개발자 A 로컬 | 개발자 B 로컬 | 운영 서버 |
|---|---|---|---|
| OS | macOS 14 | Windows 11 | Ubuntu 22.04 |
| Java | 21 | 17 | 11 |
| MySQL | 8.0 | 8.0 | 5.7 |
코드는 똑같은데, 실행되는 환경이 다르면 결과가 달라지는건 당연하다.
- 어떤 사람은 잘되고
- 어떤 사람은 안되고
- 서버에서는 또 다르게 터진다
그래서 나온 접근 방식
여기서 나온 생각이 이거다.
“그럼 실행 환경 자체를 통째로 묶어서 가져다 쓰면 되지 않을까?”
이걸 가능하게 해주는게 Docker 같은 컨테이너 기술이다.
- 코드만 공유하는게 아니라
- 실행 환경까지 같이 공유하는 방식
그래서 누가 어디서 실행하든 결과가 동일하게 나온다.
가상머신(VM) vs 컨테이너
“환경을 통째로 묶는다는 건 알겠는데, 기존에도 이런 방식이 없었나?”
사실 이미 비슷한 접근 방식이 있었다. 바로 가상머신(VM)이다.
VM도 Docker처럼 실행 환경을 통째로 만들어서 사용하는 기술이다.
VM 구조
1
2
3
4
5
6
7
8
9
┌──────────────────────────────────┐
│ Host OS │
├──────────────────────────────────┤
│ Hypervisor │
├──────────┬──────────┬────────────┤
│ Guest │ Guest │ Guest │
│ OS │ OS │ OS │
│ (App A) │ (App B) │ (App C) │
└──────────┴──────────┴────────────┘
VM은 하이퍼바이저 위에 OS 전체를 올려서 격리된 환경을 만든다.
VM은 격리는 완벽하지만 문제가 있다.
- Guest OS를 통째로 올리기 때문에 수 GB의 용량이 필요하다.
- 부팅하는 데 수 분이 걸린다.
- 하나의 서버에 올릴 수 있는 VM 수가 제한적이다.
반면 Docker가 사용하는 컨테이너는 다른 방식으로 접근한다.
컨테이너는 쉽게 말하면 “OS를 새로 띄우지 않고, 프로세스만 격리해서 실행하는 방식”이다.
Docker 구조
1
2
3
4
5
6
7
8
┌──────────────────────────────────┐
│ Host OS │
├──────────────────────────────────┤
│ Docker Engine │
├──────────┬──────────┬────────────┤
│Container │Container │Container │
│ (App A) │ (App B) │ (App C) │
└──────────┴──────────┴────────────┘
컨테이너는 Guest OS를 따로 두지 않는다. 대신 Host OS의 커널을 공유하면서 프로세스 수준의 격리를 제공한다.
| 비교 항목 | 가상머신(VM) | 컨테이너 |
|---|---|---|
| 격리 수준 | OS 수준 (강함) | 프로세스 수준 |
| 시작 시간 | 수 분 | 수 초 이내 |
| 이미지 크기 | 수 GB | 수 MB ~ 수백 MB |
| 리소스 사용 | 높음 | 낮음 |
| 이식성 | 낮음 | 높음 |
VM이 더 안전하긴 한데, 대신 많이 무겁다.
컨테이너는 격리를 조금 덜어낸 대신 훨씬 가볍고 빠르다.
그리고 실제로 써보면, 이정도 격리로도 대부분 문제 없다.
그래서 요즘은 컨테이너를 더 많이 쓴다.
Docker의 특징
1. 이식성
Docker 이미지는 애플리케이션 실행에 필요한 모든 것(코드, 런타임, 라이브러리, 환경 변수)을 담고있다.
이 이미지만 있으면 어떤 환경에서든 동일하게 실행된다.
1
2
개발자 노트북 → Docker 이미지 → 테스트 서버 → 운영 서버
(동일한 이미지로 어디서든 동일하게 동작)
2. 격리
각 컨테이너는 독립된 환경에서 실행된다.
하나의 서버에서 Java 11을 쓰는 서비스와 Java 21을 쓰는 서비스를 충돌 없이 동시에 실행할 수 있다.
1
2
3
4
5
[컨테이너 A] [컨테이너 B]
Java 11 Java 21
MySQL 5.7 MySQL 8.0
Port 8080 Port 8081
↕ 격리된 환경, 서로 영향 없음 ↕
3. 재현성
Dockerfile이라는 설정 파일 하나로 환경을 코드로 관리한다.
이 파일만 있으면 언제든, 누구든 동일한 환경을 재현할 수 있다.
1
2
3
4
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
이 세 가지 특성 덕분에 Docker는 “내 컴퓨터에서는 되는데요” 문제를 근본적으로 해결한다.
Docker를 제대로 쓰려면 알아야 하는 핵심 개념 3가지
Docker가 좋은건 알겠는데, 막상 써보려고 하면 이런 생각이 든다.
“그래서 Image? Container? Registry? 이건 또 뭐야..”
처음 보면 용어가 좀 헷갈리는데, 사실 구조는 생각보다 단순하다.
Image: 컨테이너 설계도
Image는 한마디로 이거다.
“실행에 필요한걸 미리 다 만들어놓은 완성된 패키지”
보통 애플리케이션을 실행하려면
- Java 설치
- 라이브러리 맞추고
- 환경 변수 설정하고
이걸 다 해줘야 한다.
Image는 이 과정을 한 번에 끝내놓은 결과물이다.
- 어떤 OS를 쓸지
- 어떤 라이브러리를 설치할지
- 어떤 애플리케이션을 실행할지 이런 정보들이 전부 들어 있다.
Image는 위에서 봤던 Dockerfile로 만든다.
1
Dockerfile -> Image -> COntainer
참고로 Image는 한 번 만들면 바뀌지 않는다.(Read-Only)
Container: 실제로 실행된 상태
Container는 Image를 실행한 결과다.
“설계도를 실제로 돌린 상태”
Image가 준비된 상태라면, Container는 그걸 실제로 실행 중인 프로세스다. 그리고 하나의 Image로 여러 개를 띄울 수 있다.
1
Image 1개 -> Container 여러 개
Registry: Image 저장소
Registry는 Image를 저장하고 공유하는 곳이다.
“빌드한 Image를 올려두고, 어디서든 가져다 쓸 수 있는 저장소”
Maven Repository에서 의존성을 가져오는 것처럼, Registry에서 Image를 가져다 쓴다.
가장 많이 쓰는 건 Docker Hub로, 공식 Image들이 다 여기 있다.
1
2
docker pull mysql:8.0 # Docker Hub에서 mysql 이미지 가져오기
docker pull openjdk:21 # Docker Hub에서 openjdk 이미지 가져오기
정리하면 이렇다.
1
2
3
4
5
Dockerfile → docker build → Image → docker push → Registry
↓
docker run
↓
Container
자주 쓰는 명령어 실습
가장 쉽게 시작할 수 있는 MySQL 컨테이너를 띄워보면서 명령어를 익혀보자.
MySQL 컨테이너 띄우기
1
2
3
4
5
6
docker run -d \
--name my-mysql \
-e MYSQL_ROOT_PASSWORD=1234 \
-e MYSQL_DATABASE=testdb \
-p 3306:3306 \
mysql:8.0
옵션이 좀 있는데, 하나씩 보면 어렵지 않다.
| 옵션 | 설명 |
|---|---|
-d | 백그라운드로 실행 (detach) |
--name my-mysql | 컨테이너 이름 지정 |
-e | 환경 변수 설정 |
-p 3306:3306 | 호스트 포트:컨테이너 포트 연결 |
mysql:8.0 | 사용할 이미지 이름과 태그 |
-p 3306:3306 이 부분이 헷갈릴 수 있는데, 컨테이너는 기본적으로 외부와 격리되어 있다.
포트를 열어줘야 내 로컬에서 localhost:3306 으로 접근할 수 있다.
포트 매핑이 내부적으로 어떻게 동작하는지 궁금하다면 -> 포트 매핑 동작 원리
컨테이너 상태 확인
1
2
3
4
5
# 실행 중인 컨테이너 목록
docker ps
# 전체 컨테이너 목록 (종료된 것 포함)
docker ps -a
출력 결과가 이렇게 나오면 정상이다.
1
2
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a1b2c3d4e5f6 mysql:8.0 ... 5 seconds ago Up 4 seconds 0.0.0.0:3306->3306/tcp my-mysql
컨테이너 안으로 들어가기
1
docker exec -it my-mysql bash
-it 는 인터랙티브 모드로, 컨테이너 안의 터미널을 직접 사용할 수 있게 해준다. 들어가서 mysql 접속도 해볼 수 있다.
1
mysql -u root -p # 비밀번호: 1234
로그 확인
1
2
3
4
docker logs my-mysql
# 실시간으로 로그 보기
docker logs -f my-mysql
컨테이너 정지 / 삭제
1
2
3
4
5
6
7
8
# 컨테이너 정지
docker stop my-mysql
# 컨테이너 삭제
docker rm my-mysql
# 한 번에 정지 + 삭제
docker rm -f my-mysql
Image 관련 명령어
1
2
3
4
5
# 로컬에 있는 이미지 목록
docker images
# 이미지 삭제
docker rmi mysql:8.0
Dockerfile로 Spring Boot 앱 이미지 만들기
MySQL을 그냥 가져다 쓴 것처럼, 내가 만든 Spring Boot 앱도 Image로 만들 수 있다.
이 때 사용하는 게 Dockerfile이다.
Spring Boot 프로젝트 준비
먼저 jar 파일을 빌드한다.
1
./gradlew bootJar
build/libs/ 아래에 .jar 파일이 생긴다.
Dockerfile 작성
프로젝트 루트에 Dockerfile을 만든다.
1
2
3
4
5
6
7
8
9
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
각 명령어가 하는 일을 보면 이렇다.
| 명령어 | 설명 |
|---|---|
FROM | 베이스 이미지 지정. openjdk:21이 깔린 환경에서 시작 |
WORKDIR | 컨테이너 안의 작업 디렉토리 설정 |
COPY | 로컬 파일을 컨테이너 안으로 복사 |
EXPOSE | 컨테이너가 사용할 포트 명시 (문서용) |
ENTRYPOINT | 컨테이너 시작 시 실행할 명령어 |
Image 빌드
1
docker build -t my-spring-app:1.0 .
-t my-spring-app:1.0: 이미지 이름과 태그 지정.: 현재 디렉토리의 Dockerfile 사용
빌드가 완료되면 이미지 목록에서 확인할 수 있다.
1
2
3
docker images
# REPOSITORY TAG IMAGE ID CREATED SIZE
# my-spring-app 1.0 abc123def456 10 seconds ago 450MB
Spring Boot 컨테이너 실행
1
2
3
4
docker run -d \
--name my-app \
-p 8080:8080 \
my-spring-app:1.0
localhost:8080 으로 접근하면 앱이 실행되고 있는 것을 확인할 수 있다.
1
2
# 로그로 정상 구동 확인
docker logs -f my-app
레이어 캐시: Dockerfile 작성 시 알아두면 좋은 것
Dockerfile을 작성할 때 딱 한 가지만 더 알아두면 좋다.
Docker는 Image를 빌드할 때 각 명령어 단위로 레이어를 만들고 캐시해둔다.
그래서 다음 빌드 때 변경이 없는 레이어는 캐시를 그대로 재사용한다.
문제는 중간에 있는 레이어가 바뀌면, 그 이후 레이어는 전부 캐시가 무효화된다는 점이다.
비효율적인 Dockerfile
1
2
3
4
5
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY . . # 소스 전체 복사 (코드 한 줄만 바뀌어도 이 이하 전부 재실행)
RUN ./gradlew bootJar
ENTRYPOINT ["java", "-jar", "build/libs/app.jar"]
소스 코드 한 줄만 고쳐도 COPY . . 부터 전부 다시 실행된다.
개선된 Dockerfile
현실적으로 Spring Boot 앱은 Dockerfile 안에서 빌드하지 않고, 미리 빌드한 jar를 COPY하는 방식을 더 많이 쓴다.
1
2
3
4
5
FROM openjdk:21-jdk-slim
WORKDIR /app
COPY build/libs/*.jar app.jar # 완성된 jar만 복사
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
이렇게 하면 jar 파일이 바뀐 경우에만 레이어가 무효화되고, 그 전 단계는 캐시를 재사용한다.
정리
Docker 기초에서 핵심만 뽑으면 이렇다.
| 개념 | 한 줄 요약 |
|---|---|
| Image | 실행 환경을 담은 패키지. 한 번 만들면 불변(Read-Only) |
| Container | Image를 실행한 프로세스 |
| Registry | Image를 저장/공유하는 저장소 (Docker Hub) |
| Dockerfile | Image를 만드는 설정 파일 |
다음 편에서는 여러 컨테이너를 한 번에 관리하는 Docker Compose와, 컨테이너끼리 통신하는 네트워크 개념을 다룰 예정이다.