Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 55 additions & 42 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
54 changes: 38 additions & 16 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,57 @@ 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

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"]
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading