From f2676e4af8be5b28313d6f00982414e1aa00d919 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Sun, 27 Sep 2026 03:11:07 +0900 Subject: [PATCH] =?UTF-8?q?fix:=201GB=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4(=ED=94=84=EB=A6=AC=ED=8B=B0=EC=96=B4)=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=B0=ED=8F=AC=C2=B7=EA=B8=B0=EB=8F=99=EC=9D=B4?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측으로 확인한 문제 세 가지를 고친다. 모두 같은 이미지·같은 DB 로 측정했다. 제한 없음(현재 설정) 858MB 사용 ← 호스트 전체의 75%를 힙으로 계산한다 --memory=512m + 예전 설정 OOM 종료(137) --memory=512m + 새 설정 464MB 기동 성공 --memory=640m + 새 설정 520MB → 부하 후 575MB 1) 배포 중 JVM 두 개 예비 컨테이너로 먼저 검증한 뒤 교체하던 방식은 1GB 에서 불가능하다(JVM 2개 ≈ 1.3GB). "교체 → 실패 시 이전 이미지로 롤백" 으로 바꿨다. 되돌릴 대상은 태그가 아니라 이미지 ID 라, 같은 태그가 새 이미지로 덮여도 정확히 이전 버전으로 돌아간다. 순단 20~40초를 받아들인다. 2) JVM 기본값 MaxRAMPercentage 75 → 55, MaxMetaspaceSize 192m, G1 → SerialGC. 힙 밖(메타스페이스·스레드·코드캐시)이 150MB 가까이 되므로 70%면 한도를 넘겨 죽는다. vCPU 1~2개에서는 G1 의 백그라운드 스레드가 부담이다. 배포 시 --memory 를 함께 건다(기본 640m, 서버 .env 의 APP_MEMORY 로 조정). 3) 이미지 1.25GB → 686MB JDK → JRE, chown -R 이 156MB JAR 을 레이어에 한 번 더 복사하던 것 제거, 스프링 부트 레이어 분리(재배포 때 애플리케이션 레이어 수 MB 만 받는다). JDK 를 뒀던 이유(jcmd·jstack)는 JDK 컨테이너를 같은 PID 공간에 붙이는 방법으로 대체하고 문서에 적었다. 그 외 - 운영 DB 풀 20/10 → 8/2. 커넥션마다 DB 가 버퍼를 잡아 앱 몫을 가져간다. - compose 에도 같은 한도를 건다. 제한이 없으면 개발 PC 에서만 잘 뜨고 운영에서만 죽는다. - MariaDB 96M 버퍼풀·performance_schema off, Redis 48mb·noeviction(토큰이 들어 있어 내보내면 안 된다). - 운영 문서: 측정 표, 스왑 2GB, MariaDB·Redis 준비 명령(문서에 아예 없었다), CPU 크레딧·디스크 주의. --- .github/workflows/ci-cd.yml | 97 ++++++++++++---------- Dockerfile | 54 ++++++++---- docker-compose.yml | 14 ++++ docs/features/operations.md | 105 ++++++++++++++++++++---- src/main/resources/application-prod.yml | 8 +- 5 files changed, 202 insertions(+), 76 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index fbb18ae..510ee93 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -252,7 +252,7 @@ jobs: IMAGE: ${{ needs.build-docker.outputs.image-tag }} run: | ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \ - "docker pull $IMAGE && (docker stop carecode-staging || true) && (docker rm carecode-staging || true) && docker run -d --name carecode-staging -p 8082:8082 --env-file /opt/carecode/.env $IMAGE" + "docker pull $IMAGE && (docker stop carecode-staging || true) && (docker rm carecode-staging || true) && docker run -d --name carecode-staging --memory=640m --memory-swap=640m -p 8082:8082 --env-file /opt/carecode/.env $IMAGE" - name: Run health check env: @@ -369,66 +369,79 @@ jobs: set -euo pipefail APP=carecode - PROBE=carecode-probe PORT=8082 - # 예전 blue/green 이 8083 을 쓰므로 검증 포트는 겹치지 않는 곳으로 잡는다. - PROBE_PORT=18082 ENV_FILE=/opt/carecode/.env + # 컨테이너 메모리 한도. 없으면 JVM 이 호스트 전체를 기준으로 힙을 잡아(측정: 858MB) + # 같은 머신의 MariaDB·Redis 가 쓸 메모리를 먹는다. 1GB 인스턴스 기준 기본값이며, + # 서버 .env 에 APP_MEMORY 를 두면 그 값을 쓴다(여유 있는 인스턴스에서 올리면 된다). + APP_MEMORY="$(grep -E '^APP_MEMORY=' "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2)" + APP_MEMORY="${APP_MEMORY:-640m}" + if [ ! -f "$ENV_FILE" ]; then echo "$ENV_FILE 이 없습니다." exit 1 fi + # 되돌릴 대상. 지금 돌고 있는 컨테이너가 쓰는 이미지 ID 를 먼저 붙잡아 둔다. + # 태그가 아니라 ID 를 쓰는 이유: 같은 태그가 새 이미지로 덮여도 예전 것을 가리키게 하기 위해서다. + PREV_IMAGE="$(docker inspect -f '{{.Image}}' "$APP" 2>/dev/null || true)" + echo "===== pull $IMAGE =====" docker pull "$IMAGE" - # 이전 실행이 남긴 검증 컨테이너 정리 - docker rm -f "$PROBE" >/dev/null 2>&1 || true - - # 1) 예비 포트에서 먼저 띄워 본다. 살아 있는 컨테이너는 아직 그대로다. - echo "===== 새 이미지 검증 (:$PROBE_PORT) =====" - docker run -d --name "$PROBE" -p "127.0.0.1:$PROBE_PORT:8082" --env-file "$ENV_FILE" "$IMAGE" - - ok=0 - for _ in $(seq 1 40); do - if curl -fsS "http://127.0.0.1:$PROBE_PORT/actuator/health" 2>/dev/null | grep -q '"status":"UP"'; then - ok=1; break - fi - sleep 5 + run_app() { + docker run -d --name "$APP" --restart unless-stopped --memory="$APP_MEMORY" --memory-swap="$APP_MEMORY" -p "$PORT:8082" --env-file "$ENV_FILE" "$1" >/dev/null + } + + wait_healthy() { + for _ in $(seq 1 48); do + if curl -fsS "http://127.0.0.1:$PORT/actuator/health" 2>/dev/null | grep -q '"status":"UP"'; then + return 0 + fi + # 컨테이너가 이미 죽었으면 더 기다릴 이유가 없다 (OOM 이면 여기서 잡힌다). + if [ -z "$(docker ps -q -f name="^${APP}$")" ]; then + return 1 + fi + sleep 5 + done + return 1 + } + + # 교체. 1GB 인스턴스에서는 새 컨테이너를 미리 띄워 검증할 메모리가 없다 + # (JVM 두 개 = 측정 기준 1.3GB). 그래서 짧은 순단을 받아들이고, 실패하면 되돌린다. + echo "===== 교체 ($APP_MEMORY) =====" + for name in "$APP" carecode-probe carecode-blue carecode-green; do + docker rm -f "$name" >/dev/null 2>&1 || true done - if [ "$ok" -ne 1 ]; then - echo "새 이미지가 기동하지 못했습니다. 운영 컨테이너는 건드리지 않습니다." - echo "----- 컨테이너 로그 (마지막 100줄) -----" - docker logs --tail 100 "$PROBE" 2>&1 || true - docker rm -f "$PROBE" >/dev/null 2>&1 || true - exit 1 + run_app "$IMAGE" + + if wait_healthy; then + echo "===== 교체 완료 =====" + docker image prune -f >/dev/null 2>&1 || true + exit 0 fi - echo "검증 통과. 교체합니다." - docker rm -f "$PROBE" >/dev/null 2>&1 || true + echo "새 이미지가 기동하지 못했습니다." + echo "----- 컨테이너 로그 (마지막 100줄) -----" + docker logs --tail 100 "$APP" 2>&1 || true - # 2) 교체. 여기서부터 짧은 순단이 있다. - # 예전 워크플로가 만들던 blue/green 이름도 함께 정리한다. 남아 있으면 포트를 잡고 있다. - for name in "$APP" carecode-blue carecode-green; do - docker rm -f "$name" >/dev/null 2>&1 || true - done + if [ -z "$PREV_IMAGE" ]; then + echo "::error::되돌릴 이전 이미지가 없습니다(첫 배포로 보입니다). 서비스가 내려간 상태입니다." + exit 1 + fi - docker run -d --name "$APP" --restart unless-stopped \ - -p "$PORT:8082" --env-file "$ENV_FILE" "$IMAGE" + echo "===== 이전 이미지로 되돌립니다 ($PREV_IMAGE) =====" + docker rm -f "$APP" >/dev/null 2>&1 || true + run_app "$PREV_IMAGE" - for _ in $(seq 1 40); do - if curl -fsS "http://127.0.0.1:$PORT/actuator/health" 2>/dev/null | grep -q '"status":"UP"'; then - echo "===== 교체 완료 =====" - docker image prune -f >/dev/null 2>&1 || true - exit 0 - fi - sleep 5 - done + if wait_healthy; then + echo "::error::새 이미지 기동 실패. 이전 이미지로 되돌렸고 서비스는 살아 있습니다." + exit 1 + fi - echo "교체 후 기동에 실패했습니다." - echo "----- 컨테이너 로그 (마지막 100줄) -----" + echo "::error::되돌린 이미지도 기동하지 못했습니다. 서비스가 내려간 상태입니다 — 서버를 직접 확인하세요." docker logs --tail 100 "$APP" 2>&1 || true exit 1 REMOTE diff --git a/Dockerfile b/Dockerfile index 8c75109..95d5ed3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,30 +6,51 @@ COPY . . RUN gradle clean bootJar --no-daemon -# 2단계: JDK 17로 실행용 이미지 구성 +# 스프링 부트 레이어로 쪼갠다. 의존성(156MB 중 대부분)은 거의 바뀌지 않으므로 별도 레이어로 두면 +# 재배포 때 애플리케이션 레이어(수 MB)만 내려받는다. 1GB 인스턴스에서 배포 시간이 크게 줄어든다. +RUN java -Djarmode=tools -jar build/libs/carecode-app.jar extract --layers --launcher --destination build/extracted + +# 2단계: 실행용 이미지 # -# openjdk 공식 이미지는 폐기되어 Docker Hub 에서 태그가 내려갔다. openjdk:17-jdk-slim 은 -# 더 이상 존재하지 않아 이미지 빌드가 "not found" 로 실패한다. Docker 가 후속으로 안내하는 -# eclipse-temurin 으로 옮긴다. +# openjdk 공식 이미지는 폐기되어 Docker Hub 에서 태그가 내려갔다. Docker 가 후속으로 안내하는 +# eclipse-temurin 으로 옮겼다. # -# JRE 가 아니라 JDK 를 쓰는 건 이전과 같다. 운영 중 jcmd·jstack 으로 들여다보던 걸 -# 이 교체 때문에 잃지 않도록 한다. (이미지 크기를 줄이려면 -jre-jammy 로 바꿀 수 있는데, -# 아래 HEALTHCHECK 의 wget 과 addgroup/adduser 는 그쪽에도 모두 있다.) -FROM eclipse-temurin:17-jdk-jammy +# JDK 가 아니라 JRE 를 쓴다. 이미지가 1.26GB → 300MB 대로 줄어, 1GB 인스턴스에서 배포마다 +# 받는 양과 디스크 사용이 크게 줄어든다. 예전에 JDK 를 둔 이유는 운영 중 jcmd·jstack 이었는데, +# 그건 필요할 때 JDK 컨테이너를 같은 PID 공간에 붙여 쓰면 된다(운영 문서에 명령을 적어 두었다). +# docker run --rm --pid=container:carecode eclipse-temurin:17-jdk-jammy jcmd 1 VM.native_memory +FROM eclipse-temurin:17-jre-jammy ENV TZ=Asia/Seoul RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone -ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError" -WORKDIR /app +# 작은 인스턴스(1GB)를 기준으로 잡은 기본값. 컨테이너에 --memory 가 걸려 있어야 의미가 있다 +# (제한이 없으면 JVM 이 호스트 전체를 기준으로 계산해 858MB 까지 썼다). +# +# - MaxRAMPercentage=55: --memory=512m 에서 힙 약 280MB. 힙 밖(메타스페이스·스레드·코드캐시· +# 다이렉트 버퍼)이 150MB 가까이 되므로 70% 로 두면 컨테이너 한도를 넘겨 OOM 으로 죽는다. +# - SerialGC: vCPU 1~2개에서는 G1 의 백그라운드 스레드가 오히려 부담이다. +# - MaxMetaspaceSize: 상한이 없으면 메타스페이스가 조용히 늘어 컨테이너 한도를 밀어낸다. +# - ExitOnOutOfMemoryError: 반쯤 죽은 상태로 버티는 대신 죽는다. 그래야 --restart 가 살린다. +# +# 여유 있는 인스턴스라면 배포 시 JAVA_OPTS 로 덮어쓴다 (예: -XX:+UseG1GC -XX:MaxRAMPercentage=75). +ENV JAVA_OPTS="-XX:MaxRAMPercentage=55.0 -XX:MaxMetaspaceSize=192m -XX:+UseSerialGC -XX:+ExitOnOutOfMemoryError" -# 빌드된 JAR 복사 (하나만 있는 경우 자동 복사 가능) -COPY --from=builder /app/build/libs/carecode-app.jar app.jar +WORKDIR /app RUN addgroup --system carecode && adduser --system --ingroup carecode carecode -# 업로드 저장소. 이미지에 폴더가 있어야 볼륨을 처음 붙일 때 소유권이 이어진다. -# 없으면 볼륨이 root 소유로 생겨 carecode 사용자가 파일을 쓰지 못한다. -RUN mkdir -p /app/uploads && chown -R carecode:carecode /app + +# 업로드 저장소. 이미지에 폴더가 있어야 볼륨을 처음 붙일 때 소유권이 이어진다 +# (없으면 볼륨이 root 소유로 생겨 carecode 사용자가 파일을 쓰지 못한다). +RUN mkdir -p /app/uploads && chown carecode:carecode /app /app/uploads + +# 바뀌지 않는 것부터 복사해 레이어 캐시를 살린다. --chown 으로 복사하는 이유는 +# 나중에 chown -R 을 걸면 156MB JAR 이 레이어에 한 번 더 복사돼 이미지가 두 배가 되기 때문이다. +COPY --from=builder --chown=carecode:carecode /app/build/extracted/dependencies/ ./ +COPY --from=builder --chown=carecode:carecode /app/build/extracted/spring-boot-loader/ ./ +COPY --from=builder --chown=carecode:carecode /app/build/extracted/snapshot-dependencies/ ./ +COPY --from=builder --chown=carecode:carecode /app/build/extracted/application/ ./ + USER carecode EXPOSE 8082 @@ -37,4 +58,5 @@ EXPOSE 8082 HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \ CMD wget -qO- http://127.0.0.1:8082/actuator/health | grep -q '"status":"UP"' || exit 1 -ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] +# 레이어로 쪼갠 실행 파일은 JarLauncher 로 띄운다 (app.jar 이 그대로 있지 않다). +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"] diff --git a/docker-compose.yml b/docker-compose.yml index fb40d84..0ec383e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,10 +23,16 @@ services: - --collation-server=utf8mb4_unicode_ci # 운영(Linux MariaDB)과 같게. 테이블 이름 대소문자를 구분해야 대문자 매핑 불일치가 여기서 잡힌다. - --lower-case-table-names=0 + # 1GB 인스턴스에서 앱과 같이 사는 전제. 측정값은 유휴 113MB 였다. + - --innodb-buffer-pool-size=96M + - --performance-schema=OFF + - --max-connections=30 ports: - "${DB_PORT:-3307}:3306" volumes: - mariadb-data:/var/lib/mysql + # 작은 인스턴스에 맞춘 값. 기본 설정은 버퍼 풀만 128MB 를 잡는다. + mem_limit: ${DB_MEMORY:-320m} healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s @@ -35,6 +41,10 @@ services: redis: image: redis:7-alpine + mem_limit: ${REDIS_MEMORY:-64m} + # 리프레시 토큰·인증코드가 들어 있어 함부로 내보내면 로그아웃이 깨진다. + # 한도에 닿으면 새 쓰기를 거절하는 편이 낫다(noeviction 이 기본이지만 명시한다). + command: ["redis-server", "--maxmemory", "48mb", "--maxmemory-policy", "noeviction"] ports: - "${REDIS_PORT:-6380}:6379" healthcheck: @@ -96,6 +106,10 @@ services: CLAMD_HOST: ${CLAMD_HOST:-clamav} volumes: - uploads:/app/uploads + # 운영(1GB 인스턴스)과 같은 한도로 돌린다. 한도가 없으면 JVM 이 개발 PC 메모리를 기준으로 + # 힙을 잡아(측정: 858MB) 운영에서만 OOM 으로 죽는 차이가 생긴다. + # 512m 에서도 뜨지만(측정 464MB) 여유가 없어 640m 로 둔다. + mem_limit: ${APP_MEMORY:-640m} # 선택: 업로드 악성코드 검사기. 시그니처 DB 를 받느라 첫 기동에 몇 분 걸리고 메모리를 1GB 넘게 쓴다. clamav: diff --git a/docs/features/operations.md b/docs/features/operations.md index 49c09cf..1fedc6a 100644 --- a/docs/features/operations.md +++ b/docs/features/operations.md @@ -254,27 +254,25 @@ Blue/Green 이라는 사실이 알림 설계에 직접 영향을 줍니다. ### Blue/Green 을 뺀 이유 -전환 마지막 단계가 라우터 HTTP API 두 개(`PRODUCTION_ROUTER_STATUS_URL`, `_SWITCH_URL`)를 -호출했는데, **그 API 를 제공하는 구현이 어디에도 없습니다.** +예전 워크플로는 두 색을 번갈아 띄우고 마지막에 라우터 HTTP API 를 호출해 전환했습니다. +그 API 를 제공하는 구현이 **어디에도 없습니다.** 호출은 항상 실패했고, 배포는 초록불이었지만 +실제로는 트래픽이 새 컨테이너로 옮겨가지 않았습니다. -별도 저장소의 블루/그린 도구(`CareCode_Nohub_Deploy`)는 `paramiko` 로 서버에 붙어 -nginx conf 를 고치는 CLI 입니다. 의존성이 `requests` / `python-dotenv` / `paramiko` 뿐이고 -웹 프레임워크가 없습니다. 즉 시크릿을 다 채워도 전환 단계에서 반드시 멈췄고, -그 시점에는 이미 서버의 컨테이너가 갈아치워진 뒤라 **어중간한 상태**로 끝났습니다. - -진짜 무중단은 nginx 를 제어할 수 있어야 성립합니다. 그때까지는 이렇게 갑니다. +### 지금 방식 — 교체하고, 안 뜨면 되돌린다 ``` -새 이미지 pull - → 예비 포트(127.0.0.1:18082)에서 먼저 기동 운영 컨테이너는 그대로 - → /actuator/health 가 UP 이 될 때까지 대기 (최대 200초) - └ 실패하면 컨테이너 로그를 남기고 중단 운영은 건드리지 않음 - → 통과하면 교체 (여기서 짧은 순단) - → 다시 헬스체크 → 외부 URL 로 재확인 +docker pull + → 현재 컨테이너가 쓰는 이미지 ID 를 기억 (태그가 아니라 ID: 같은 태그가 덮여도 예전 것을 가리킨다) + → 기존 컨테이너 제거 → 새 이미지로 기동 → 헬스체크 + 성공 → 끝 (오래된 이미지 정리) + 실패 → 기억해 둔 이전 이미지로 다시 기동 → 헬스체크 → 실패로 종료(서비스는 살아 있음) ``` -**깨진 이미지가 운영에 올라가지 않는다**는 성질은 유지하면서, 순단만 감수합니다. -검증 포트를 18082 로 잡은 건 예전 blue/green 이 쓰던 8083 과 겹치지 않게 하기 위해서입니다. +교체 구간에 **20~40초 순단**이 있습니다. 무중단 검증(새 컨테이너를 먼저 띄워 확인)을 쓰지 않는 이유는 +메모리입니다 — JVM 두 개가 동시에 뜨면 1GB 인스턴스에서는 그 순간 둘 다 죽습니다(측정: 제한 없을 때 858MB). +인스턴스를 2GB 이상으로 올리면 예비 포트 검증 방식으로 되돌릴 수 있습니다. + +첫 배포에서 실패하면 되돌릴 이미지가 없으므로 서비스가 내려간 상태로 끝납니다. 로그와 함께 그 사실을 명시합니다. ### 필요한 GitHub 시크릿 @@ -310,6 +308,81 @@ ssh 인자로 넘기면 서버의 프로세스 목록에 그대로 보입니다. 기동 단계에서 실패합니다(의도된 fail-fast). 검증 단계에서 걸리므로 **운영은 무사합니다**. 이슈 #90 - 컨테이너 이름은 `carecode` 로 통일합니다. 예전 워크플로가 만들던 `carecode-blue` / `carecode-green` 은 교체 단계에서 함께 정리합니다 +- **MariaDB 와 Redis 는 서버에 미리 있어야 합니다.** 배포는 앱 컨테이너만 교체합니다. + Redis 는 운영에서 선택이 아닙니다 — 리프레시 토큰 폐기, 레이트 리밋, 이메일 인증코드가 여기에 있습니다. + 준비 명령은 [1GB 인스턴스(프리티어)에 올리기](#1gb-인스턴스프리티어에-올리기) 에 있습니다 + +## 1GB 인스턴스(프리티어)에 올리기 + +t2.micro·t3.micro 는 **메모리 1GB** 입니다. 여기서 앱·MariaDB·Redis 를 함께 돌릴 수 있는지 +실제로 재봤습니다(같은 이미지, 같은 DB). + +| 조건 | 결과 | +|------|------| +| 컨테이너 메모리 제한 없음 | **858MB 사용** — 제한이 없으면 JVM 이 호스트 전체(15.5GB)의 75%를 기준으로 잡는다 | +| `--memory=512m`, 예전 기본값(75%·G1) | **OOM 으로 죽음** (exit 137) | +| `--memory=512m`, 현재 기본값(55%·Serial) | 기동 성공, 464MB (91%) | +| `--memory=640m`, 현재 기본값 | 기동 성공, 520MB → 부하 후 575MB (90%) | + +### 메모리 배분 (측정값) + +| 구성 | 사용량 | 한도 | +|------|--------|------| +| 앱 | 520~575MB | `--memory=640m` | +| MariaDB (`innodb-buffer-pool-size=96M`) | 96~99MB | 320m | +| Redis | 9MB | 64m | +| OS + Docker | 150~200MB | — | +| **합계** | **약 800MB** | 1GB | + +여유가 200MB 뿐이라 **스왑 2GB 는 필수**입니다. 배포 중 이미지 압축 해제와 주간 동기화가 겹치면 +이 여유를 넘길 수 있습니다. + +```bash +# 스왑 2GB (t2/t3.micro 에서 관례적으로 하는 설정) +sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile +sudo mkswap /swapfile && sudo swapon /swapfile +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +``` + +### DB·Redis 준비 + +배포는 앱만 교체하므로 이 둘은 미리 띄워 둡니다. 앱은 같은 호스트의 `127.0.0.1` 로 붙습니다. + +```bash +docker run -d --name carecode-mariadb --restart unless-stopped --memory=320m -p 127.0.0.1:3306:3306 -e MARIADB_DATABASE=carecode -e MARIADB_USER=carecode -e MARIADB_PASSWORD=... -e MARIADB_ROOT_PASSWORD=... -e TZ=Asia/Seoul -v carecode-db:/var/lib/mysql mariadb:10.11 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --lower-case-table-names=0 --innodb-buffer-pool-size=96M --performance-schema=OFF --max-connections=30 + +docker run -d --name carecode-redis --restart unless-stopped --memory=64m -p 127.0.0.1:6379:6379 redis:7-alpine redis-server --maxmemory 48mb --maxmemory-policy noeviction +``` + +`--lower-case-table-names=0` 은 로컬·CI 와 같은 조건을 만들기 위한 것입니다. 이게 다르면 +대문자 테이블명 마이그레이션과 매핑이 어긋나 기동이 실패합니다. + +### 1GB 를 전제로 바꿔 둔 기본값 + +| 설정 | 값 | 이유 | +|------|-----|------| +| `JAVA_OPTS` | `-XX:MaxRAMPercentage=55 -XX:MaxMetaspaceSize=192m -XX:+UseSerialGC` | 힙 밖(메타스페이스·스레드·코드캐시)이 150MB 가까이 된다. 70%로 두면 한도를 넘겨 죽는다. vCPU 1~2개에서는 G1 의 백그라운드 스레드가 부담이다 | +| 배포 `--memory` | 640m (서버 `.env` 의 `APP_MEMORY` 로 변경) | 제한이 없으면 JVM 이 DB 몫까지 가져간다 | +| `DB_POOL_MAX_SIZE` | 8 (기존 20) | 커넥션마다 DB 가 버퍼를 잡는다. vCPU 수보다 조금 많은 정도가 처리량이 가장 좋다 | +| 이미지 | JRE + 레이어 분리 (1.25GB → 686MB) | 재배포 때 바뀐 애플리케이션 레이어(수 MB)만 받는다 | + +### 진단 도구 (JRE 로 바꾼 뒤) + +이미지에 `jcmd`·`jstack` 이 없습니다. 필요할 때 JDK 컨테이너를 같은 PID 공간에 붙여 씁니다. + +```bash +docker run --rm --pid=container:carecode eclipse-temurin:17-jdk-jammy jcmd 1 VM.native_memory +docker run --rm --pid=container:carecode eclipse-temurin:17-jdk-jammy jstack 1 +``` + +### 프리티어에서 더 볼 것 + +| 항목 | 내용 | +|------|------| +| CPU | 주 1회 전국 동기화가 202개 지역을 순회합니다(새벽 3시). t2.micro 는 CPU 크레딧이 고갈될 수 있습니다 — 서비스 지역만 남기면 크게 줄어듭니다 | +| 디스크 | 30GB EBS 로 충분합니다. 이미지가 쌓이지 않도록 배포가 `docker image prune` 을 돌립니다 | +| 업로드 파일 | 로컬 디스크입니다. 인스턴스를 늘리면 공유되지 않습니다 (이슈 #49) | +| 실시간 알림 | 연결이 인스턴스 메모리에 있습니다. 한 대 전제입니다 ([실시간 알림](realtime-notifications.md)) | ## 미해결 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index a17391e..506bd2a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -11,8 +11,12 @@ spring: datasource: hikari: - maximum-pool-size: ${DB_POOL_MAX_SIZE:20} - minimum-idle: ${DB_POOL_MIN_IDLE:10} + # 1~2 vCPU 인스턴스 기준. 커넥션은 공짜가 아니다 — 같은 머신의 MariaDB 가 + # 커넥션마다 버퍼를 잡으므로, 풀을 크게 잡으면 앱이 쓸 메모리를 DB 가 먼저 가져간다. + # vCPU 수보다 조금 많은 정도가 실제 처리량이 가장 좋다(대기는 풀에서 해도 된다). + # 인스턴스를 올리면 DB_POOL_MAX_SIZE 로 함께 올린다. + maximum-pool-size: ${DB_POOL_MAX_SIZE:8} + minimum-idle: ${DB_POOL_MIN_IDLE:2} app: rate-limit: