From 10273811ad3b2701794d9158a3471aa8d5d44696 Mon Sep 17 00:00:00 2001 From: daeyeon Date: Tue, 4 Aug 2026 15:06:31 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20CI=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?Docker=20=EB=B9=8C=EB=93=9C=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=B5=9C=EC=A0=81=ED=99=94=20[#SEOUL-151]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 저장소에는 워크플로가 하나도 없었다. PR과 main push에서 테스트 32개를 자동 실행하고, main에서는 이미지를 GHCR에 올린다. EC2 배포에서 이 저장소의 이미지가 반드시 레지스트리를 거쳐야 한다. compose의 context가 ../seoulection-admin(형제 저장소)이라, EC2에 seoulection-server만 클론하면 그 경로가 없어 빌드가 불가능하기 때문이다. Dockerfile은 api-server와 같은 문제를 갖고 있었다. COPY src가 의존성 다운로드보다 위에 있어 소스를 한 줄만 고쳐도 Gradle 배포판부터 전부 다시 받았다. 실측(소스 한 줄만 수정 후 재빌드): 48s -> 7s. PR에서는 이미지 빌드까지만 하고 push는 main에서만 한다. 머지 후에야 처음 도는 job이면 깨졌을 때 main이 빨간불이 된다. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 112 +++++++++++++++++++++++++++++++++++++++ Dockerfile | 14 ++++- build.gradle.kts | 12 +++++ 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f284467 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +name: CI + +# main이 항상 초록불이라는 보장 + 배포용 이미지 생성. 배포 실행은 여기서 하지 않는다. +# +# api-server(seoulection-server)의 같은 이름 워크플로와 짝이다. 다른 점: +# - 이 저장소는 단일 Gradle 프로젝트라 경로 필터가 없다(모노레포가 아니다). +# 그래서 "필수 체크로 지정했는데 job이 스킵돼 PR이 영원히 대기"하는 함정도 없다. +# - gradlew가 루트에 있어 working-directory가 필요 없다. +on: + pull_request: + push: + branches: [main] + +# 같은 브랜치에 연달아 push하면 앞선 실행을 취소한다. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: 테스트 + runs-on: ubuntu-latest + # Testcontainers가 컨테이너를 못 받고 매달리면 러너가 6시간까지 돈다. 상한을 둔다. + timeout-minutes: 20 + + steps: + - name: 체크아웃 + uses: actions/checkout@v7 + + - name: JDK 17 설치 + uses: actions/setup-java@v5 + with: + java-version: '17' + distribution: temurin + + # ~/.gradle 캐시. 첫 실행은 의존성을 전부 받고, 이후 실행은 복원해서 쓴다. + # ⚠️ Actions 캐시는 브랜치 스코프다 — main에서 한 번 돌기 전까지 PR은 캐시를 못 받는다. + - name: Gradle 설정 및 캐시 + uses: gradle/actions/setup-gradle@v6 + + # Testcontainers(mongo·postgres)는 ubuntu-latest에 내장된 Docker를 쓴다. + - name: 테스트 + run: ./gradlew test --no-daemon + + - name: 실패 시 테스트 리포트 업로드 + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-report + path: build/reports/tests/test + retention-days: 7 + + # 이미지를 만들어 GHCR(ghcr.io)에 올린다. EC2는 빌드하지 않고 여기서 pull만 한다. + # 이 저장소는 api-server 저장소 밖에 있어서, EC2에서는 애초에 빌드가 불가능하다 + # (compose의 context: ../seoulection-admin 은 EC2에 존재하지 않는 경로다). + # + # PR에서는 빌드까지만 하고 push는 하지 않는다. + # main 머지 후에야 처음 도는 job이면, 깨졌을 때 main이 빨간불이 된다. + image: + name: 이미지 빌드 (main이면 GHCR push) + needs: test # 테스트가 깨졌으면 이미지를 만들 이유가 없다 + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: write # GHCR에 쓰려면 필요. test job에는 주지 않는다(최소 권한). + + steps: + - name: 체크아웃 + uses: actions/checkout@v7 + + - name: Buildx 설정 + uses: docker/setup-buildx-action@v4 + + # push하지 않는 PR에서는 로그인도 불필요하다. + - name: GHCR 로그인 + if: github.event_name == 'push' + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + # 저장소가 자동으로 발급하는 토큰. 별도 시크릿 등록이 필요 없다. + password: ${{ secrets.GITHUB_TOKEN }} + + # 🔴 GHCR은 이미지 이름에 대문자를 허용하지 않는데 소유자명이 Infinite-Track이다. + # ghcr.io/${{ github.repository_owner }}/... 를 그대로 쓰면 push가 실패한다. + # 이 액션이 소문자 변환과 태그 생성을 대신 해준다. + - name: 이미지 이름·태그 계산 + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository_owner }}/seoulection-admin + tags: | + type=sha,prefix=sha-,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: 빌드 (main이면 push) + uses: docker/build-push-action@v7 + with: + context: . + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # ⚠️ EC2가 x86_64다. 맥(arm64)에서 만든 이미지는 거기서 exec format error로 안 뜬다. + platforms: linux/amd64 + # 레이어 캐시를 Actions 캐시에 export/import한다. 러너는 매번 새로 뜨므로 + # 이게 없으면 Dockerfile의 의존성 레이어가 매 실행 무효가 된다. + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index fab7302..e7b91a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,22 @@ FROM eclipse-temurin:17-jdk AS builder WORKDIR /workspace +# 1) 빌드 스크립트와 래퍼만 먼저 복사한다. +# Docker는 어떤 레이어가 바뀌면 그 아래를 전부 다시 실행하므로, 소스보다 위에 둬야 +# 아래 의존성 레이어가 살아남는다. COPY gradlew . COPY gradle ./gradle COPY settings.gradle.kts build.gradle.kts ./ -COPY src ./src - RUN chmod +x ./gradlew + +# 2) 의존성을 이 레이어에 구워둔다. build.gradle.kts가 안 바뀌면 통째로 CACHED가 된다. +# ⚠️ 캐시 마운트(--mount=type=cache)로 바꾸지 말 것 — 마운트 내용물은 레이어에 남지 않고 +# BuildKit의 캐시 exporter도 그것만은 내보내지 않아, GitHub Actions처럼 매번 새 러너인 +# 환경에서는 효과가 0이 된다. 레이어에 굽는 방식만 로컬·CI 양쪽에서 통한다. +RUN ./gradlew resolveDependencies --no-daemon + +# 3) 소스는 마지막. 여기가 바뀌어도 2)는 재사용되므로 컴파일만 다시 한다. +COPY src ./src RUN ./gradlew bootJar --no-daemon FROM eclipse-temurin:17-jre diff --git a/build.gradle.kts b/build.gradle.kts index 8172bb8..65478b0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,18 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +// Docker 빌드에서 "의존성 내려받기"를 소스 복사보다 먼저 끝내기 위한 태스크(Dockerfile 2단계). +// 이것만으로 build.gradle.kts가 안 바뀌는 한 의존성 레이어가 통째로 캐시된다. +// ⚠️ `gradlew dependencies`를 쓰면 안 된다 — 그건 의존성 "그래프"만 렌더링해서 POM 메타데이터만 받고, +// 정작 덩치 큰 jar는 안 받는다. 파일을 실제로 받으려면 configuration을 resolve해야 한다. +// bootJar에 필요한 것만 담는다(test 계열 제외) — Testcontainers·JUnit은 런타임 이미지에 불필요하다. +tasks.register("resolveDependencies") { + doLast { + listOf("compileClasspath", "runtimeClasspath", "annotationProcessor") + .forEach { configurations.getByName(it).resolve() } + } +} + tasks.withType { useJUnitPlatform() }