터미널(CLI), Docker(컨테이너), Git/GitHub(버전 관리)를 활용하여 재현 가능한 개발 환경을 구축하는 미션입니다.
- 터미널로 작업 디렉토리와 권한을 정리
- Docker로 컨테이너를 빌드/실행/관리
- Dockerfile 기반 커스텀 웹 서버 이미지 제작
- 포트 매핑, 바인드 마운트, 볼륨 영속성 검증
- Git 설정 및 GitHub 연동
| 항목 | 버전/정보 |
|---|---|
| OS | macOS 26.3.1 (Darwin) |
| Shell | zsh |
| Terminal | iTerm / macOS Terminal |
| Docker | 29.3.0 (OrbStack context) |
| Docker Compose | v2.40.3 |
| Git | 2.53.0 |
OrbStack을 통해 Docker 엔진을 구동하며, sudo 권한 없이 컨테이너를 실행할 수 있습니다.
- 터미널 기본 조작 및 폴더 구성
- 권한 변경 실습 (파일 + 디렉토리)
- Docker 설치/점검 (
docker --version,docker info) -
hello-world컨테이너 실행 -
ubuntu컨테이너 내부 진입 및 명령 실행 - Dockerfile 기반 커스텀 이미지 빌드/실행
- 포트 매핑 접속 (8080, 8081)
- 바인드 마운트 반영 확인
- Docker 볼륨 영속성 검증
- Git 설정 + GitHub 저장소 연동
- (보너스) Docker Compose 멀티 컨테이너
- (보너스) 환경 변수 활용 (
APP_ENV)
$ pwd
/Users/thesis/Dev/codyssey/dev-workstation
$ ls -la
total 0
drwxr-xr-x@ 4 thesis staff 128 Apr 2 10:53 .
drwxr-xr-x 3 thesis staff 96 Apr 2 10:53 ..
drwxr-xr-x@ 2 thesis staff 64 Apr 2 10:53 app
drwxr-xr-x@ 3 thesis staff 96 Apr 2 10:53 docs
$ mkdir -p practice/subdir
$ ls -la practice/
drwxr-xr-x@ 3 thesis staff 96 Apr 2 10:54 .
drwxr-xr-x@ 2 thesis staff 64 Apr 2 10:54 subdir
$ touch practice/hello.txt
$ echo "Hello, Dev Workstation!" > practice/hello.txt
$ cat practice/hello.txt
Hello, Dev Workstation!
$ cp practice/hello.txt practice/hello_backup.txt
$ ls -la practice/hello*.txt
-rw-r--r--@ 1 thesis staff 24 Apr 2 10:54 practice/hello.txt
-rw-r--r--@ 1 thesis staff 24 Apr 2 10:54 practice/hello_backup.txt
$ mv practice/hello_backup.txt practice/renamed.txt
$ rm practice/renamed.txt
$ rm -r practice/subdir| 구분 | 예시 | 설명 |
|---|---|---|
| 절대 경로 | /Users/thesis/Dev/codyssey/dev-workstation/practice/hello.txt |
루트(/)부터 시작하는 전체 경로. 어디서든 같은 파일을 가리킴 |
| 상대 경로 | practice/hello.txt |
현재 디렉토리(pwd) 기준. 위치가 바뀌면 가리키는 파일도 달라짐 |
# 변경 전 (644 = rw-r--r--)
$ ls -la practice/hello.txt
-rw-r--r--@ 1 thesis staff 24 Apr 2 10:54 practice/hello.txt
# 755로 변경 (rwxr-xr-x) - 소유자 실행 가능, 그룹/기타 읽기+실행
$ chmod 755 practice/hello.txt
$ ls -la practice/hello.txt
-rwxr-xr-x@ 1 thesis staff 24 Apr 2 10:54 practice/hello.txt
# 644로 원복
$ chmod 644 practice/hello.txt
-rw-r--r--@ 1 thesis staff 24 Apr 2 10:54 practice/hello.txt# 변경 전 (755 = rwxr-xr-x)
$ ls -ld practice/test_dir
drwxr-xr-x@ 2 thesis staff 64 Apr 2 10:54 practice/test_dir
# 700으로 변경 (rwx------) - 소유자만 접근 가능
$ chmod 700 practice/test_dir
$ ls -ld practice/test_dir
drwx------@ 2 thesis staff 64 Apr 2 10:54 practice/test_dir
# 755로 원복
$ chmod 755 practice/test_dir
drwxr-xr-x@ 2 thesis staff 64 Apr 2 10:54 practice/test_dir| 숫자 | 의미 | 풀어쓰기 |
|---|---|---|
7 |
r+w+x (4+2+1) | 읽기+쓰기+실행 |
5 |
r+x (4+1) | 읽기+실행 |
4 |
r (4) | 읽기만 |
0 |
없음 | 접근 불가 |
755= 소유자(rwx) / 그룹(r-x) / 기타(r-x)644= 소유자(rw-) / 그룹(r--) / 기타(r--)700= 소유자(rwx) / 그룹(---) / 기타(---)
$ docker --version
Docker version 29.3.0, build 5927d80c76$ docker info
Client: Docker Engine - Community
Version: 29.3.0
Context: orbstack
Debug Mode: false
Plugins:
compose: Docker Compose (Docker Inc.) v2.40.3
buildx: Docker Buildx (Docker Inc.) v0.29.1
...OrbStack context에서 Docker 데몬이 정상 구동 중입니다.
# 이미지 목록
$ docker images
IMAGE ID DISK USAGE CONTENT SIZE
hello-world:latest eb84fdc6f2a3 5.2kB 0B
ubuntu:22.04 f1daabb6b5b4 69.6MB 0B
# 컨테이너 목록 (실행 중 + 종료된 전체)
$ docker ps -a
# 컨테이너 로그
$ docker logs my-web-8080
192.168.215.1 - - [02/Apr/2026:01:56:21 +0000] "GET / HTTP/1.1" 200 789 ...$ docker run --rm hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
...$ docker run --rm ubuntu:22.04 bash -c "ls / && echo 'Hello from Ubuntu container!' && uname -a"
bin boot dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
Hello from Ubuntu container!
Linux 0bf0b249999c 6.17.8-orbstack-00308-g8f9c941121b1 ... aarch64 GNU/Linux| 방식 | 설명 | 종료 시 컨테이너 |
|---|---|---|
docker run -it ... bash |
컨테이너 메인 프로세스로 쉘 실행 | exit 시 컨테이너도 종료 |
docker exec -it ... bash |
실행 중인 컨테이너에 추가 프로세스 연결 | exit 시 컨테이너 유지 |
docker attach |
메인 프로세스(PID 1)에 재연결 | Ctrl+C 시 컨테이너 종료 가능 |
| 항목 | 내용 |
|---|---|
| 베이스 이미지 | nginx:alpine (경량 NGINX) |
| 커스텀 #1 | LABEL - 이미지 메타데이터(관리자, 설명) |
| 커스텀 #2 | ENV APP_ENV=development - 환경 변수 주입 |
| 커스텀 #3 | COPY nginx.conf - 커스텀 서버 설정 (헬스체크 엔드포인트 추가) |
| 커스텀 #4 | COPY index.html - 정적 콘텐츠 교체 |
| 커스텀 #5 | HEALTHCHECK - 컨테이너 상태 자동 점검 |
FROM nginx:alpine
LABEL maintainer="thesis"
LABEL description="Custom NGINX web server for dev-workstation mission"
ENV APP_ENV=development
COPY app/nginx.conf /etc/nginx/conf.d/default.conf
COPY app/index.html /usr/share/nginx/html/index.html
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://localhost/health || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]$ docker build -t my-web:1.0 .
...
#9 naming to docker.io/library/my-web:1.0 done
$ docker images my-web
IMAGE ID DISK USAGE CONTENT SIZE
my-web:1.0 7a8950a63769 61.6MB 0B이미지와 컨테이너는 빌드(Build) / 실행(Run) / 변경(Change) 세 관점에서 명확히 구분됩니다.
| 관점 | 이미지 (Image) | 컨테이너 (Container) |
|---|---|---|
| 빌드 | Dockerfile의 각 명령어가 읽기 전용 레이어(layer) 로 쌓여 만들어짐. docker build로 생성 |
이미지 위에 쓰기 가능한 레이어 하나를 덧붙여 docker run으로 생성 |
| 실행 | 실행 불가. 설계도(템플릿) 역할만 함 | 실제 프로세스가 동작하는 런타임 인스턴스. 같은 이미지에서 여러 컨테이너를 동시에 실행 가능 |
| 변경 | 불변(immutable). 한번 빌드된 이미지는 수정 불가. 변경이 필요하면 Dockerfile을 수정한 뒤 다시 빌드해야 함 | 실행 중 파일 생성/수정/삭제가 가능하지만, 컨테이너를 삭제하면 변경 내용도 함께 사라짐. 영속적으로 보존하려면 볼륨을 사용해야 함 |
이를 비유하면, 이미지는 붕어빵 틀이고 컨테이너는 그 틀로 만든 붕어빵입니다. 틀(이미지) 하나로 여러 붕어빵(컨테이너)을 만들 수 있고, 만들어진 붕어빵에 크림을 추가(파일 수정)할 수 있지만, 붕어빵을 버리면(컨테이너 삭제) 추가한 크림도 사라집니다. 틀 자체는 변하지 않으며, 다음에 만들 때도 동일한 모양이 보장됩니다.
이번 실습에서의 예:
docker build -t my-web:1.0 .→ Dockerfile로 이미지(my-web:1.0) 생성docker run -d -p 8080:80 my-web:1.0→ 이미지에서 컨테이너(my-web-8080) 생성/실행- 컨테이너 내부에서 파일을 수정해도,
docker rm으로 삭제하면 변경 사항은 소멸 - 다시
docker run하면 이미지 원본 상태의 새 컨테이너가 생성
컨테이너는 격리된 네트워크 네임스페이스에서 동작합니다. 컨테이너 내부의 80번 포트는 호스트에서 직접 접근할 수 없으므로, -p <호스트포트>:<컨테이너포트> 옵션으로 포트를 연결(매핑)해야 합니다.
포트 매핑(-p 8080:80)을 통해 호스트의 8080 포트로 접속하면, 컨테이너 내부의 NGINX가 서빙하는 커스텀 웹 페이지가 정상 표시됩니다.
# 포트 매핑 #1: 호스트 8080 → 컨테이너 80
$ docker run -d -p 8080:80 --name my-web-8080 my-web:1.0
$ curl http://localhost:8080
<h1>Hello, Dev Workstation!</h1>
# ✅ 정상 응답
# 포트 매핑 #2: 호스트 8081 → 컨테이너 80
$ docker run -d -p 8081:80 --name my-web-8081 my-web:1.0
$ curl http://localhost:8081
<h1>Hello, Dev Workstation!</h1>
# ✅ 정상 응답
# 헬스체크 엔드포인트
$ curl http://localhost:8080/health
OK
# 실행 중인 컨테이너 확인
$ docker ps
CONTAINER ID IMAGE PORTS NAMES
4c86056636f9 my-web:1.0 0.0.0.0:8081->80/tcp my-web-8081
f79bcb8edd59 my-web:1.0 0.0.0.0:8080->80/tcp my-web-8080같은 이미지로 서로 다른 호스트 포트(8080, 8081)에 매핑하여 두 개의 독립 컨테이너를 동시에 실행할 수 있습니다.
# 바인드 마운트로 실행
$ docker run -d -p 8080:80 --name bind-test \
-v $(pwd)/app:/usr/share/nginx/html my-web:1.0
# 변경 전
$ curl -s http://localhost:8080 | grep "<h1>"
<h1>Hello, Dev Workstation!</h1>
# 호스트에서 파일 수정
$ sed -i.bak 's/Hello, Dev Workstation!/Bind Mount Updated!/' app/index.html
# 변경 후 (즉시 반영됨!)
$ curl -s http://localhost:8080 | grep "<h1>"
<h1>Bind Mount Updated!</h1>바인드 마운트는 호스트 파일시스템을 컨테이너에 직접 연결합니다. 호스트에서 파일을 수정하면 컨테이너 재시작 없이 즉시 반영됩니다.
# 볼륨 생성
$ docker volume create mydata
mydata
# 컨테이너에서 데이터 작성
$ docker run -d --name vol-test -v mydata:/data ubuntu:22.04 sleep infinity
$ docker exec vol-test bash -c "echo 'persistent data!' > /data/hello.txt && cat /data/hello.txt"
persistent data!
# 컨테이너 삭제
$ docker rm -f vol-test
# 새 컨테이너에서 데이터 확인 → 유지됨!
$ docker run --rm -v mydata:/data ubuntu:22.04 cat /data/hello.txt
persistent data!Docker 볼륨은 컨테이너 생명주기와 독립적으로 데이터를 보존합니다. 컨테이너를 삭제해도 볼륨의 데이터는 유지됩니다. 데이터베이스, 로그 등 영속 데이터에 적합합니다.
$ git config --list --global | grep -E "user.|init."
user.email=1sup1@kakao.com
user.name=1sup1
init.defaultbranch=main| 항목 | Git | GitHub |
|---|---|---|
| 역할 | 로컬 버전관리 도구 | 원격 협업 플랫폼 |
| 위치 | 내 컴퓨터 | 클라우드 (github.com) |
| 핵심 기능 | 커밋, 브랜치, 머지 | PR, Issue, Actions, 코드 리뷰 |
| 네트워크 | 불필요 | 필요 |
services:
web:
build: .
ports:
- "8080:80"
environment:
- APP_ENV=production
depends_on:
- redis
networks:
- app-net
redis:
image: redis:alpine
ports:
- "6379:6379"
networks:
- app-net
networks:
app-net:
driver: bridgedocker-compose.yml은 docker run 명령의 모든 옵션(포트, 볼륨, 네트워크, 환경 변수 등)을 선언적 YAML 파일 한 곳에 문서화하여, 누구든 docker compose up -d 한 줄로 동일한 환경을 재현할 수 있도록 합니다.
| 설정 항목 | Compose 설정 | 동등한 docker run 옵션 | 재현성 포인트 |
|---|---|---|---|
| 포트 매핑 | ports: ["8080:80"] |
-p 8080:80 |
호스트-컨테이너 포트 쌍이 파일에 고정되어, 매번 옵션을 기억할 필요 없음 |
| 포트 매핑 (Redis) | ports: ["6379:6379"] |
-p 6379:6379 |
각 서비스별 포트가 명시적으로 선언되어, 충돌 방지와 문서화를 겸함 |
| 네트워크 | networks: app-net (bridge) |
--network |
서비스 간 격리된 네트워크를 자동 생성하고, 서비스 이름으로 DNS를 등록 |
| 환경 변수 | environment: APP_ENV=production |
-e APP_ENV=production |
환경별 설정을 파일에 명시하여 개발/운영 환경 전환이 용이 |
| 서비스 의존성 | depends_on: redis |
(수동으로 순서 관리) | redis가 먼저 시작된 후 web이 시작되도록 순서를 보장 |
만약 볼륨을 추가한다면 아래처럼 선언합니다:
volumes:
redis-data: # 이름 있는 볼륨 선언
services:
redis:
volumes:
- redis-data:/data # 컨테이너 /data에 볼륨 마운트이렇게 하면 docker compose down 후 다시 up해도 Redis 데이터가 유지되며, 볼륨 이름이 YAML에 기록되어 있으므로 팀원 누구나 동일한 영속성 설정을 재현할 수 있습니다.
$ docker compose up -d
✔ Container dev-workstation-redis-1 Started
✔ Container dev-workstation-web-1 Started
$ docker compose ps
NAME IMAGE SERVICE PORTS
dev-workstation-redis-1 redis:alpine redis 0.0.0.0:6379->6379/tcp
dev-workstation-web-1 dev-workstation-web web 0.0.0.0:8080->80/tcp
# 컨테이너 간 네트워크 통신 확인
$ docker compose exec web sh -c "ping -c 2 redis"
64 bytes from 192.168.97.2: seq=0 ttl=64 time=2.050 ms
64 bytes from 192.168.97.2: seq=1 ttl=64 time=0.197 ms
# ✅ web → redis 네트워크 통신 성공 (서비스 이름으로 DNS 해석)
$ docker compose down
✔ Container dev-workstation-web-1 Removed
✔ Container dev-workstation-redis-1 Removed
✔ Network dev-workstation_app-net RemovedCompose는
docker run명령을 **선언적 설정(YAML)**으로 문서화합니다. 서비스 이름(redis)으로 DNS가 자동 등록되어 IP 대신 이름으로 통신할 수 있습니다.
문제
level=warning msg="the attribute `version` is obsolete, it will be ignored"
원인 가설: Docker Compose V2에서 version 필드가 더 이상 필요하지 않음
확인: Docker Compose 공식 문서에서 V2 이후 version 필드는 무시된다고 명시
해결: docker-compose.yml에서 version: "3.8" 행을 제거해도 동작에 차이 없음. 호환성을 위해 남겨두되, 경고는 무시 가능
문제: -v $(pwd)/app:/usr/share/nginx/html로 바인드 마운트하면 nginx.conf가 HTML 디렉토리에도 노출됨
원인 가설: app/ 디렉토리에 index.html과 nginx.conf가 함께 있어서, 마운트 시 NGINX가 conf 파일을 정적 파일로 서빙할 수 있음
확인: curl http://localhost:8080/nginx.conf로 접속하면 설정 파일 내용이 그대로 노출됨
해결/대안:
- 정적 파일과 설정 파일을 분리된 디렉토리에 배치 (
app/html/,app/conf/) - 또는 NGINX location 블록에서
.conf확장자 접근을 차단
location ~* \.conf$ {
deny all;
return 404;
}문제
docker run -p 8080:80을 실행했을 때 아래와 같은 오류가 발생할 수 있습니다:
Error response from daemon: driver failed programming external connectivity:
Bind for 0.0.0.0:8080 failed: port is already allocated
진단 순서
# 1단계: 해당 포트를 점유 중인 프로세스 확인
$ lsof -i :8080
COMMAND PID USER FD TYPE DEVICE NAME
docker-p 1234 thesis 4u IPv4 ... TCP *:http-alt (LISTEN)
# 2단계: 해당 PID의 정체 확인 (Docker 컨테이너인지, 다른 서비스인지)
$ docker ps --filter "publish=8080"
CONTAINER ID IMAGE PORTS NAMES
f79bcb8edd59 my-web:1.0 0.0.0.0:8080->80/tcp my-web-8080해결 방법
| 상황 | 해결 |
|---|---|
| 이전에 띄운 컨테이너가 남아있는 경우 | docker rm -f my-web-8080으로 기존 컨테이너 제거 후 재실행 |
| 다른 애플리케이션이 포트를 사용 중인 경우 | 해당 프로세스를 종료하거나, -p 8081:80처럼 다른 호스트 포트로 변경 |
| Compose 환경에서 발생한 경우 | docker compose down으로 이전 서비스를 정리한 후 다시 up |
이번 실습에서는 8080, 8081 두 개의 호스트 포트를 사용했는데, 두 번째 컨테이너 실행 시 실수로 같은 포트를 지정하면 이 오류가 발생합니다. lsof -i :<포트번호>로 점유 프로세스를 먼저 확인하는 습관이 중요합니다.
실습 초반에 볼륨 없이 컨테이너를 실행한 뒤, 컨테이너 내부에서 작업한 데이터가 docker rm 이후 완전히 사라지는 경험을 했습니다. 구체적으로, ubuntu 컨테이너에 진입하여 apt update && apt install -y curl로 curl을 설치한 뒤 작업했는데, 컨테이너를 삭제하고 다시 docker run하니 curl이 없는 원래 상태로 돌아갔습니다.
이는 컨테이너의 쓰기 가능 레이어가 컨테이너 생명주기에 종속되기 때문입니다. 컨테이너가 삭제되면 쓰기 레이어도 함께 사라집니다.
데이터 소실을 방지하는 3가지 대안:
| 방법 | 명령 예시 | 적합한 상황 |
|---|---|---|
| Docker 볼륨 | -v mydata:/data |
DB 파일, 애플리케이션 상태 등 영속 데이터. Docker가 관리하므로 백업/이관이 용이 |
| 바인드 마운트 | -v $(pwd)/app:/usr/share/nginx/html |
개발 중 소스코드를 실시간 반영할 때. 호스트 파일시스템에 직접 연결 |
| 이미지에 포함 | Dockerfile의 COPY/ADD |
설정 파일, 정적 에셋 등 변경이 드문 파일. 이미지 자체에 내장 |
이 경험을 통해, 컨테이너는 일회용(ephemeral) 으로 설계해야 하며, 보존이 필요한 데이터는 반드시 볼륨이나 외부 저장소로 분리해야 한다는 원칙을 체감했습니다.
이번 미션에서 가장 어려웠던 부분은 바인드 마운트와 Dockerfile COPY의 경로 관계를 이해하는 것이었습니다.
Dockerfile에서 COPY app/nginx.conf /etc/nginx/conf.d/default.conf로 파일을 이미지에 내장한 뒤, 실행 시 -v $(pwd)/app:/usr/share/nginx/html로 바인드 마운트를 걸었더니, NGINX 설정과 HTML 파일이 뒤섞여 nginx.conf가 웹 브라우저에서 그대로 노출되는 문제가 발생했습니다.
처음에는 "이미지에 COPY한 파일이 바인드 마운트로 덮어씌워지는 것인가?" 하고 혼란스러웠는데, 정리해보니 핵심은 이렇습니다:
COPY는 빌드 시점에 이미지 레이어에 파일을 넣는 것- 바인드 마운트는 실행 시점에 호스트 디렉토리를 컨테이너 경로에 덮어쓰는 것
- 같은 경로에 두 가지가 겹치면 바인드 마운트가 이미지 내용을 가림
결국 app/ 디렉토리에 설정 파일과 HTML을 함께 넣은 것이 문제의 원인이었고, 역할별로 디렉토리를 분리하거나 NGINX location 블록에서 .conf 접근을 차단하는 방식으로 해결했습니다. 이 과정에서 Docker의 레이어 구조와 마운트 우선순위를 제대로 이해하게 되었습니다.
dev-workstation/
├── README.md # 기술 문서 (현재 파일)
├── Dockerfile # 커스텀 NGINX 이미지
├── docker-compose.yml # 멀티 컨테이너 구성
├── .gitignore
├── app/
│ ├── index.html # 정적 웹 페이지
│ └── nginx.conf # NGINX 커스텀 설정
└── docs/
└── screenshots/ # 스크린샷 저장 디렉토리
이 프로젝트의 디렉토리 구조는 "빌드 컨텍스트 최소화" 와 "역할별 분리" 를 기준으로 구성했습니다.
-
루트에 Dockerfile과 docker-compose.yml 배치:
docker build .명령은 현재 디렉토리를 빌드 컨텍스트로 사용합니다. Dockerfile이 루트에 있어야COPY app/... ...같은 상대 경로가 직관적으로 동작하고,docker compose up도 별도 경로 지정 없이 바로 실행할 수 있습니다. -
app/디렉토리로 소스 파일 분리: NGINX에 COPY할 파일들(index.html,nginx.conf)을 하나의 디렉토리에 모았습니다. 이렇게 하면 Dockerfile에서 복사 대상을 명확히 할 수 있고, 바인드 마운트 시에도app/디렉토리 하나만 지정하면 되어 관리가 단순해집니다. -
docs/디렉토리로 문서 자료 분리: 스크린샷 등 실행에 필요 없는 참고 자료는docs/에 분리하여, 빌드 시 불필요한 파일이 Docker 이미지에 포함되지 않도록 했습니다..dockerignore를 사용하면 더 확실히 제외할 수 있습니다. -
.gitignore로 불필요 파일 제외:.DS_Store,*.bak등 OS/편집기 부산물을 Git 추적에서 제외하여 저장소를 깔끔하게 유지합니다.
| 검증 항목 | 명령어 | 확인 내용 |
|---|---|---|
| Docker 설치 | docker --version |
버전 출력 |
| 데몬 동작 | docker info |
Server 정보 출력 |
| 이미지 빌드 | docker build -t my-web:1.0 . |
빌드 성공 |
| 포트 매핑 | curl http://localhost:8080 |
HTML 응답 |
| 바인드 마운트 | 호스트 파일 수정 → curl 재확인 | 변경 반영 |
| 볼륨 영속성 | 컨테이너 삭제 → 새 컨테이너에서 cat | 데이터 유지 |
| Compose | docker compose up -d && docker compose ps |
서비스 실행 |
| 네트워크 | docker compose exec web ping redis |
응답 수신 |
