-
노노그램 서버 제작기2: 도커와 서버 배포 준비Programming/Projects 2024. 9. 1. 08:43
Docker와 Docker Compose를 사용해 이전에 만들었던 서버를 배포하는 과정을 다룹니다.
멀티 스테이지 빌드를 이용한 도커 이미지 최적화, 도커 네트워크를 통한 컨테이너 간 통신, 도커 컴포즈를 사용한 서비스 관리 등의 내용이 포함되어 있습니다.Intro
이번엔 전편에서 만든 노노그램 퍼즐 서버를 실제로 서비스해 보려 합니다.
집에 있는 고장 난 노트북을 서버 PC로, 서버를 실제 웹에 올리는 게 목표입니다.
그래서 오늘은 도커를 사용해 퍼즐 서버를 쉽게 배포하고 실행가능하게 만들어 볼 예정입니다.
왜 벌써 배포를 고민할까?
서버는 이제 개발 초기 단계입니다. API 요청 받으면 JSON 하나 달랑 보내는 게 전부입니다.
이용자도 없습니다. 완성한다 해도 사실 저를 제외하고 누가 쓸지도 모르겠습니다.
그런데도 왜 벌써 배포를 고민하느냐면요...
- 코드가 몇 줄 없으니까 금방 바꿀 수 있다.
- 배포 과정이나 실제 서버 환경에서 문제가 발생해도 금방 문제를 찾고 수정이 가능합니다. 코드가 얼마 안 되니까요.
- 그 과정에서 배우거나 알게 된 내용을 앞으로 개발에 적용할 수 있다.
- 배포 과정을 차근차근 배워나갈 수 있다.
- 간단한 코드를 통해 기본을 먼저 배울 수 있고, 이후 요구 사항이 추가될 때마다 지식을 추가해 나가면서 학습을 이어 나갈 수 있다.
- 미리, 자주 해보면 좋을 것 같다.
- 많은 삽질과 반복을 통해 배포 과정에 능숙해질 수 있겠습니다.
- 반복되는 작업을 발견하고, 자동화하는 경험도 해볼 수 있지 않을까 하는 망상 혹은 기대도 하고 있습니다.
- 뭔가 뿌듯할 것 같다.
- 뭐라도 인터넷에 올려서 동작하는 걸 보면 뿌듯하겠죠. 테스트해 달라고 친구들에게 스팸처럼 보내기도 좋고요...
그래서 도커를 열심히 배워보기로 했습니다.
도커 설치 및 실행
도커는 이미 설치가 되어있었습니다. 언제 받았는지는 모르겠네요.
그래서 간단하게 확인만 해보았습니다.
docker version
이런 오류가 떴습니다.
The command 'docker' could not be found in this WSL 2 distro. We recommend to activate the WSL integration in Docker Desktop settings.
저처럼 윈도우즈에서 Docker Desktop을 사용 중이라면, 일단 어플리케이션을 실행해 주어야 합니다.
그리고 만약
Settings
->Resources
->WSL Integration
에서Enable integration with my default WSL distro
옵션이 꺼져 있다면, 켜 주시면 되겠습니다.도커 이미지 빌드하기
서버의 도커 이미지를 만들어 보겠습니다.
Dockerfile을 통해 도커 이미지를 만들 수 있다고 합니다.
도커공식 문서: Dockerfile Syntax
위 가이드를 따라가 보기로 했습니다.첫줄
# syntax=docker/dockerfile:1 FROM ubuntu:22.04
FROM <image>
를 통해 이미 세상에 존재하는 도커 이미지를 기본 base로 사용 가능하다고 합니다.그런데, 서버 배포 base 이미지로 우분투같이 기능이 많은 OS를 사용 할 필요가 있을까요?
궁금해서, 1편에서도 레퍼런스로 많이 참고한 포케로그 서버의 도커파일을 찾아봤습니다.
ARG GO_VERSION=1.22 FROM golang:${GO_VERSION} AS builder WORKDIR /src COPY ./go.mod /src/ COPY ./go.sum /src/ RUN go mod download && go mod verify COPY . /src/ RUN CGO_ENABLED=0 \ go build -o rogueserver RUN chmod +x /src/rogueserver # --------------------------------------------- FROM scratch WORKDIR /app COPY --from=builder /src/rogueserver . COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ EXPOSE 8001 ENTRYPOINT ["./rogueserver"]
FROM <image>
가 두 번 나옵니다. 신기하게도 base image를 두 개를 두고 있습니다.찾아보니 Multi-Stage-Build 라는 방식이었습니다.
먼저 빌드 과정에 필요한 부분만 모아 놓은
golang
이미지에서 코드를 빌드합니다.
이후 실제 최종 이미지는 더 가벼운 이미지인scartch
를사용하는 방식입니다.여기에는 빌드 과정에서 필요한 의존성 등이 포함되지 않아 컨테이너가 더 가벼워집니다.
참고해서 도커파일을 아래와 같이 작성 해 보았습니다.
# syntax=docker/dockerfile:1 ARG GO_VERSION=1.22.5 FROM golang:${GO_VERSION} AS builder WORKDIR /src COPY ./go.mod /src/ COPY ./go.sum /src/ RUN go mod download COPY . /src/ RUN go build -o main # ------------------------------- FROM scratch WORKDIR /app COPY --from=builder /src/main . EXPOSE 8080 ENTRYPOINT ["./main"]
이제 이미지를 빌드해보겠습니다.
docker build --tag=puzzle_server_img .
만든 이미지는 다음과 같이 확인이 가능합니다.
docker image ls
생성이 잘 되었습니다.
REPOSITORY TAG IMAGE ID CREATED SIZE puzzle_server_img latest 2c4a5fa6c883 47 seconds ago 8.36MB
이제 실행을 해 보겠습니다.
docker run -p 8080:8080 --name=puzzle_server_container puzzle_server_img
-p <port_left>:<port_right>
옵션은 우측 컨테이너 포트를 좌측 호스트 포트와 연결해 줍니다.실행했더니 다음과 같은 오류가 떴습니다.
exec ./main: no such file or directory
포케로그 도커파일에서 빼먹은 다음 라인을 추가해서 다시 실행해 봅니다.
RUN CGO_ENABLED=0 \ go build -o main
찾아보니
CGO_ENABLED
는 코드에서 필요한 C 라이브러리를 동적 링크할지 묻는 플래그 인 듯한데요.scratch
는 그런 것 조차 없는 이미지라서CGO_ENABLED
를 꺼서 완전히 정적으로 바이너리를 컴파일 해야 하는 모양입니다.docker build --tag=puzzle_server_img . docker run -p 8080:8080 --name=puzzle_server_container puzzle_server_img
오류가 떴습니다.
동명의 컨테이너가 이미 존재한다고 합니다.docker: Error response from daemon: Conflict. The container name "/puzzle_server_container" is already in use by container "681d73f1e51c04512fccad727568f2cb056cce7377ad8c724aa931199509fa0b". You have to remove (or rename) that container to be able to reuse that name. See 'docker run --help'.
기존 컨테이너를 지워 줍시다.
docker container prune
을 하면 모든 실행 중이 아닌 컨테이너가 지워집니다.docker container rm <container_name>
보다 간단해 보이니까 써보겠습니다.docker container help
에서 찾아낸 명령어입니다.재실행 결과
Starting server on :8080 2024/08/31 14:48:51 dial tcp [::1]:5432: connect: connection refused
서버 파일이 정상 실행 되었습니다.
아직 데이터베이스 연결을 하지 않아서 바로 꺼졌지만요.
그럼, 이제 데이터베이스를 달아 봅시다.
DB
데이터베이스 역시 도커로 이미지를 빌드하고 컨테이너에 올려줄 수 있습니다.
도커 공식 문서:Databases 문서를 참조해 보았습니다.
# syntax=docker/dockerfile:1 # Use the base image mysql:latest FROM mysql:latest # Set environment variables ENV MYSQL_DATABASE mydb # Copy custom scripts or configuration files from your host to the container COPY ./scripts/ /docker-entrypoint-initdb.d/
주요 DBMS마다 base 이미지가 있어서 이를 활용하면 되겠습니다.
단, 한 가지 문제가 있습니다.
제가 쓰고자 하는 데이터베이스가 이미 제 로컬 환경에 존재하고, 데이터도 채워져 있다는 점입니다.이럴 땐
pg_dump
를 사용해 현재 데이터베이스 상태를 sql 파일로 본뜰 수 있다고 합니다.이를 위 도커파일에 명시된
docker-entrypoint-initdb.d
디렉터리에 넣어주면, 컨테이너 시작 시 자동으로 이를 실행해 준다고 합니다.dump 파일을 아래와 같이 만들어주고
pg_dump -U test_user -W -h 127.0.0.1 puzzle_db > puzzle_db_dump.sql
이렇게 도커 파일을 하나 만들어 주었습니다.
# syntax=docker/dockerfile:1 FROM postgres:14 ENV POSTGRES_USER=test_user ENV POSTGRES_PASSWORD=test_password17 ENV POSTGRES_DB=puzzle_db ENV POSTGRES_PORT=5432 COPY ./puzzle_db_dump.sql /docker-entrypoint-initdb.d/puzzle_db_dump.sql EXPOSE 5432
이제 빌드하고 실행해 보겠습니다.
docker build --tag=puzzle_db_img . docker run -p 5432:5432 --name=puzzle_db_container -v my-db-volume:/var/lib/postgresql/data -d puzzle_db_img
-v
는 컨테이너가 사용할 볼륨을 명시하는 플래그입니다.도커 컨테이너의 모든 데이터는 컨테이너 내부에 존재합니다. 따라서 컨테이너가 삭제되면 모든 데이터가 삭제됩니다. 마치 컴퓨터 램처럼요.
도커 볼륨은 하드디스크처럼 컨테이너와 별도로 존재하는 저장소와 같은 개념입니다. 컨테이너가 지워져도 데이터는 영구 보존됩니다.
my-db-volume
볼륨을 PostgreSQL이 데이터를 저장하는/var/liv/postgresql/data
경로에 올려주면, 컨테이너가 실행될 때 컨테이너가 아닌 볼륨에 있는 데이터를 읽고 쓰게 됩니다.볼륨을 미리 만들지는 않았지만, 도커가 알아서 해당하는 볼륨이 없으면 만들어 줍니다.
-d
는 컨테이너를 백그라운드에서 실행하도록 하는 플래그입니다.이제 컨테이너에서
psql
을 실행해보겠습니다. DB가 잘 들어갔는지 확인해 보고 싶으니까요.docker exec
를 사용하면 컨테이너 안에서 명령어 실행이 가능합니다.-it
플래그를 사용하면 인터렉티브 모드로 실행이 가능합니다. psql같은 대화형 프로그램을 실행할 때 유용합니다.docker exec -it puzzle_db_container psql -U test_user puzzle_db
\d
를 통해 테이블이 잘 들어 있는지 확인해 보았습니다. 잘 들어 있습니다.DB에 아무 값이나 넣어보고, 새로운 컨테이너를 만들고,
my_db_volume
을 연결해 주어도 데이터가 잘 들어 있었습니다.두 컨테이너 연결하기
이제 컨테이너가 두 개가 되었습니다.
하나는 퍼즐 서버, 다른 하나는 데이터베이스 서버입니다.
두 컨테이너를 같은 도커 네트워크 안에 두면 통신이 가능하다고 합니다.
네트워크를 만들고, 현재 실행 중인 db를 네트워크에 연결해 줍시다.
docker network create test_net docker network connect test_net puzzle_db_container
그리고 다시 퍼즐 서버를 실행해 주면...
docker run --network=test_net -p 8080:8080 --name=puzzle_server_container puzzle_server_img
실패했습니다.
Starting server on :8080 2024/08/31 17:05:36 dial tcp [::1]:5432: connect: connection refused
구글링 끝에 문제를 찾았습니다.
도커 포럼: How to reach localhost on host from docker container
도커 컨테이너 안에서 로컬 호스트는 도커가 실행되고 있는 환경 내부 로컬 호스트를 의미한다고 합니다.
잠깐 퍼즐 서버의 Go 코드를 보겠습니다. 데이터베이스 연결을 위한 호스트 기본값이
localhost
로 되어 있습니다.dbhost := flag.String("dbhost", "localhost", "database host")
이전에는 데이터베이스와 퍼즐 서버가 같은 환경에서 실행되었습니다. 제 로컬 환경이요.
하지만 이제 데이터베이스 서버는 퍼즐 서버와 다른 환경, 다른 컨테이너에서 실행되고 있습니다.
그런데도 퍼즐 api 서버는 본인 컨테이너 내에서 자꾸 데이터베이스 서버를 찾고 연결하려고 했던 것이죠.따라서 연결해 주려면,
--dbhost
인자로 로컬호스트가 아닌 데이터베이스 컨테이너 주소를 넘겨줘야 합니다.다행히 같은 도커 네트워크상에 있는 컨테이너끼리는 컨테이너 이름을 주소로 사용할 수 있습니다
실행할 때 flag로
--dbhost=puzzle_db_container
를 넘겨주겠습니다.docker run --network=test_net -p 8080:8080 --name=puzzle_server_container puzzle_server_img --dbhost=puzzle_db_container
이제 서버가 정상 작동하네요.
curl 127.0.0.1:8080/puzzles -> 정상응답
도커 컴포즈 사용하기
지금처럼 컨테이너 여러 개가 뭉쳐 앱 하나를 구성할 경우, 도커 컴포즈를 사용해 더 편한 설정이 가능하다고 합니다.
DB 컨테이너가 현악기, 서버 컨테이너가 관악기 파트라고 하면 도커 컴포즈는 지휘자인 셈이죠.
도커 컴포즈는
.yml
파일을 통해 설정을 관리합니다.레퍼런스 를 참고해
docker-compose.yml
을 먼저 이렇게 간단하게 작성해 보았습니다.services: app: build: . ports: - 8080:8080 depends_on: - db environment: - DB_HOST=db - DB_USER=test_user - DB_PASSWORD=test_password17 - DB_NAME=puzzle_db - DB_PORT=5432 db: image: postgres:14 volumes: - ./puzzle_db_dump.sql:/docker-entrypoint-initdb.d/puzzle_db_dump.sql - pgdata:/var/lib/postgresql/data environment: - POSTGRES_USER=test_user - POSTGRES_PASSWORD=test_password17 - POSTGRES_DB=puzzle_db - POSTGRES_PORT=5432 volumes: pgdata:
지금까지 번거롭게 했던 작업을 하나의
yml
파일로 처리할 수 있습니다.app
은 우리의 Go 서버입니다.build: .
은 현재 폴더에 있는 도커파일을 가지고 도커 이미지를 만들어달라는 의미입니다.dpends_on
은 컨테이너를 시작할 순서를 나타내 줍니다.db
는 우리의 데이터베이스 컨테이너입니다.
데이터베이스를 위한 별도 도커파일 없이도, 컴포즈 파일 내에서 기본 이미지와 설정을 추가해서 컨테이너 생성이 가능합니다.
서버 코드 역시 CLI flag가 아닌, 환경 변수를 활용하도록 아래와 같이 변경하였습니다.
dbuser := os.Getenv("DB_USER") dbpass := os.Getenv("DB_PASSWORD") dbhost := os.Getenv("DB_HOST") dbport := os.Getenv("DB_PORT") dbname := os.Getenv("DB_NAME")
이제
docker compose up
을 해주면 모든 작업이 자동으로 되면서 서버가 만들어집니다.만들어져야 하는데 바로 서버가 꺼졌습니다. 로그를 확인해 봅니다.
db-1 | The files belonging to this database system will be owned by user "postgres". db-1 | This user must also own the server process. db-1 | db-1 | The database cluster will be initialized with locale "en_US.utf8". db-1 | The default database encoding has accordingly been set to "UTF8". db-1 | The default text search configuration will be set to "english". db-1 | db-1 | Data page checksums are disabled. db-1 | db-1 | fixing permissions on existing directory /var/lib/postgresql/data ... ok db-1 | creating subdirectories ... ok db-1 | selecting dynamic shared memory implementation ... posix db-1 | selecting default max_connections ... 100 db-1 | selecting default shared_buffers ... 128MB db-1 | selecting default time zone ... Etc/UTC db-1 | creating configuration files ... ok db-1 | running bootstrap script ... ok app-1 | Starting server on :8080 app-1 | 2024/08/31 18:17:30 dial tcp 172.18.0.2:5432: connect: connection refused db-1 | performing post-bootstrap initialization ... ok
데이터베이스가 제대로 시작하기도 전에 서버가 연결을 시도해 놓고 안되니까 혼자 꺼진 모양입니다.
확인해 보니
depends_on
파라미터는 컨테이너의 시작과 종료 순서만 관리 할 뿐 연결 상태를 체크 해 주거나 하지는 않는다고 합니다.다시
docker compose up
을 해주면, 이번엔 데이터베이스가 빠르게 시작되어 서버가 정상 실행되었습니다.db-1 | db-1 | PostgreSQL Database directory appears to contain a database; Skipping initialization db-1 | db-1 | 2024-08-31 18:24:30.920 UTC [1] LOG: starting PostgreSQL 14.13 (Debian 14.13-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit db-1 | 2024-08-31 18:24:30.920 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432 db-1 | 2024-08-31 18:24:30.921 UTC [1] LOG: listening on IPv6 address "::", port 5432 db-1 | 2024-08-31 18:24:30.931 UTC [1] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432" db-1 | 2024-08-31 18:24:30.943 UTC [27] LOG: database system was shut down at 2024-08-31 18:24:25 UTC db-1 | 2024-08-31 18:24:30.956 UTC [1] LOG: database system is ready to accept connections app-1 | Starting server on :8080
볼륨이 생성되고
initdb
스크립트가 생성되는 시간 때문에 오래 걸렸던 지라, 볼륨이 생성된 상태로 컨테이너를 시작하니 이번엔 서버가 정상적으로 실행됩니다.잘 실행되긴 하지만, 볼륨 상태 및 딜레이와 관계없이 데이터베이스 연결이 완벽히 준비된 이후 서버가 실행되도록 설정하고 싶은데요.
awsome-compose 에서 괜찮은 레퍼런스를 발견할 수 있었습니다.services: app: depends_on: db: condition: service_healthy db: restart: always healthcheck: test: ["CMD", "pg_isready"] interval: 10s timeout: 5s retries: 5
이렇게 하면 health체크 완료 이후에야 서버가 실행되면서, 데이터베이스가 준비될 때까지 기다릴 수 있습니다.
약간의 수정을 통해 다음과 같은docker-compose.yml
파일을 완성했습니다.services: app: build: . ports: - 8080:8080 depends_on: db: condition: service_healthy environment: - DB_HOST=db - DB_USER=pz_admin - DB_PASSWORD=${DB_PASSWORD} - DB_NAME=puzzle_db - DB_PORT=5432 db: image: postgres:14 restart: always healthcheck: test: ["CMD", "pg_isready"] interval: 10s timeout: 5s retries: 5 volumes: - ./puzzle_db_dump.sql:/docker-entrypoint-initdb.d/puzzle_db_dump.sql - pgdata:/var/lib/postgresql/data environment: - POSTGRES_USER=pz_admin - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_DB=puzzle_db - POSTGRES_PORT=5432 volumes: pgdata:
비밀번호 관리를 Docker Secrets 를 통해 해보고 싶었는데, 이것 저것 따라해 보다가 이 기능이 도커 스웜에서만 지원하는 기능이라는 걸 깨달았습니다... 그래서 일단은 넘어가는 걸루...
Outro
오늘은 여기까지 하겠습니다.
이것 저것 찾아보느라 시간이 좀 걸렸는데 글 내용이 별 게 없네요.다음 편에는 ssh를 통해 원격으로 집에 있는 노트북에 접속해서 도커로 빌드한 컨테이너들을 실행하고, 포트 포워딩까지 해서 인터넷에 서버를 올려보는 내용을 다뤄보겠습니다.
사실 이미 해 놨는데 글 쓸 생각에 또 막막하고 그러네요.
그럼 다음에 뵙겠습니다. 읽어주셔서 감사합니다. - 코드가 몇 줄 없으니까 금방 바꿀 수 있다.