Skip to content

Commit aab1de3

Browse files
committed
feat: ci, deploy 파이프라인 추가 및 관련 내용 README 수정
1 parent 0f46d0c commit aab1de3

5 files changed

Lines changed: 410 additions & 2 deletions

File tree

.dockerignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ htmlcov/
1919
.env.*.local
2020
.DS_Store
2121
tests/
22+
experiments/
2223
*.md
24+
Dockerfile
25+
.dockerignore
2326
Makefile
2427
docker-compose.yml

.github/workflows/ci.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
push:
7+
branches: [main]
8+
9+
concurrency:
10+
group: ci-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
permissions:
14+
contents: read
15+
16+
jobs:
17+
lint-type-test:
18+
name: Lint / Type / Test
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- name: Install uv
24+
uses: astral-sh/setup-uv@v4
25+
with:
26+
version: "0.5.11"
27+
enable-cache: true
28+
cache-dependency-glob: |
29+
pyproject.toml
30+
uv.lock
31+
32+
- name: Set up Python 3.12
33+
run: uv python install 3.12
34+
35+
- name: Install dependencies (dev)
36+
run: uv sync --dev
37+
38+
- name: Ruff — lint
39+
run: uv run ruff check .
40+
41+
- name: Ruff — format check
42+
run: uv run ruff format --check .
43+
44+
- name: Mypy
45+
run: uv run mypy app
46+
47+
- name: Pytest (skip if no tests)
48+
run: |
49+
if [ -d tests ] && ls tests/test_*.py tests/**/test_*.py 2>/dev/null | grep -q .; then
50+
uv run pytest
51+
else
52+
echo "tests/ 비어있음 → pytest skip"
53+
fi
54+
55+
docker-build:
56+
name: Docker build (smoke)
57+
runs-on: ubuntu-latest
58+
needs: lint-type-test
59+
steps:
60+
- uses: actions/checkout@v4
61+
62+
- uses: docker/setup-buildx-action@v3
63+
64+
- name: Build image (no push)
65+
uses: docker/build-push-action@v6
66+
with:
67+
context: .
68+
push: false
69+
load: true
70+
tags: glit-ai:ci-${{ github.sha }}
71+
cache-from: type=gha
72+
cache-to: type=gha,mode=max
73+
74+
- name: Smoke — import app inside image
75+
run: |
76+
docker run --rm --entrypoint python glit-ai:ci-${{ github.sha }} \
77+
-c "import app.main; print('ok', app.main.app.title)"

.github/workflows/deploy.yml

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
name: Deploy (prod)
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: deploy-prod
10+
cancel-in-progress: false
11+
12+
permissions:
13+
id-token: write # OIDC AssumeRole용
14+
contents: read
15+
16+
env:
17+
AWS_REGION: ap-northeast-2
18+
ECR_REGISTRY: 092504162781.dkr.ecr.ap-northeast-2.amazonaws.com
19+
ECR_REPOSITORY: groute-ai-ecr
20+
DEPLOY_ROLE_ARN: arn:aws:iam::092504162781:role/gh-actions-deploy-ai
21+
EC2_TAG_KEY: Role
22+
EC2_TAG_VALUE: ai
23+
CONTAINER_NAME: groute-ai
24+
HOST_PORT: "8000"
25+
CONTAINER_PORT: "8000"
26+
SSM_PREFIX: /groute/ai
27+
28+
jobs:
29+
build-push:
30+
name: Build & Push → ECR
31+
runs-on: ubuntu-latest
32+
outputs:
33+
image_uri: ${{ steps.meta.outputs.image_uri }}
34+
steps:
35+
- uses: actions/checkout@v4
36+
37+
- name: Resolve image tag
38+
id: meta
39+
run: |
40+
echo "image_uri=${ECR_REGISTRY}/${ECR_REPOSITORY}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
41+
42+
- name: Configure AWS credentials (OIDC)
43+
uses: aws-actions/configure-aws-credentials@v4
44+
with:
45+
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
46+
aws-region: ${{ env.AWS_REGION }}
47+
48+
- name: Login to Amazon ECR
49+
uses: aws-actions/amazon-ecr-login@v2
50+
51+
- uses: docker/setup-buildx-action@v3
52+
53+
- name: Build & push image
54+
uses: docker/build-push-action@v6
55+
with:
56+
context: .
57+
push: true
58+
tags: ${{ steps.meta.outputs.image_uri }}
59+
cache-from: type=gha
60+
cache-to: type=gha,mode=max
61+
provenance: false
62+
63+
deploy:
64+
name: Deploy to EC2 via SSM
65+
needs: build-push
66+
runs-on: ubuntu-latest
67+
env:
68+
IMAGE_URI: ${{ needs.build-push.outputs.image_uri }}
69+
steps:
70+
- name: Configure AWS credentials (OIDC)
71+
uses: aws-actions/configure-aws-credentials@v4
72+
with:
73+
role-to-assume: ${{ env.DEPLOY_ROLE_ARN }}
74+
aws-region: ${{ env.AWS_REGION }}
75+
76+
- name: Resolve target instance IDs (tag Role=ai)
77+
id: targets
78+
run: |
79+
set -euo pipefail
80+
IDS=$(aws ec2 describe-instances \
81+
--filters "Name=tag:${EC2_TAG_KEY},Values=${EC2_TAG_VALUE}" \
82+
"Name=instance-state-name,Values=running" \
83+
--query 'Reservations[].Instances[].InstanceId' --output text)
84+
if [ -z "${IDS}" ]; then
85+
echo "::error::no running instances matched tag ${EC2_TAG_KEY}=${EC2_TAG_VALUE}"
86+
exit 1
87+
fi
88+
echo "ids=${IDS}" >> "$GITHUB_OUTPUT"
89+
echo "targets: ${IDS}"
90+
91+
- name: Render remote deploy script
92+
run: |
93+
set -euo pipefail
94+
# EC2 위에서 실행될 스크립트. GitHub Actions 시점에 env 값이 전개됨.
95+
cat > /tmp/remote-deploy.sh <<REMOTE
96+
#!/usr/bin/env bash
97+
set -euo pipefail
98+
99+
export AWS_DEFAULT_REGION="${AWS_REGION}"
100+
IMAGE_URI="${IMAGE_URI}"
101+
CONTAINER_NAME="${CONTAINER_NAME}"
102+
SSM_PREFIX="${SSM_PREFIX}"
103+
ECR_REGISTRY="${ECR_REGISTRY}"
104+
HOST_PORT="${HOST_PORT}"
105+
CONTAINER_PORT="${CONTAINER_PORT}"
106+
107+
echo "[1/5] ECR login"
108+
aws ecr get-login-password --region "\$AWS_DEFAULT_REGION" \\
109+
| docker login --username AWS --password-stdin "\$ECR_REGISTRY"
110+
111+
echo "[2/5] Pull image \$IMAGE_URI"
112+
docker pull "\$IMAGE_URI"
113+
114+
echo "[3/5] Load env from SSM (\$SSM_PREFIX)"
115+
ENV_FILE=\$(mktemp)
116+
chmod 600 "\$ENV_FILE"
117+
# SSM Parameter Store: /groute/ai/* → KEY=VALUE 형태로 env-file 생성
118+
aws ssm get-parameters-by-path \\
119+
--path "\$SSM_PREFIX/" --recursive --with-decryption \\
120+
--query 'Parameters[].[Name,Value]' --output text \\
121+
| while IFS=\$'\t' read -r name value; do
122+
printf '%s=%s\n' "\${name##*/}" "\$value"
123+
done > "\$ENV_FILE"
124+
125+
echo "[4/5] Replace container (stop & run)"
126+
docker rm -f "\$CONTAINER_NAME" 2>/dev/null || true
127+
docker run -d \\
128+
--name "\$CONTAINER_NAME" \\
129+
--restart unless-stopped \\
130+
--log-driver json-file --log-opt max-size=10m --log-opt max-file=3 \\
131+
-p "\${HOST_PORT}:\${CONTAINER_PORT}" \\
132+
--env-file "\$ENV_FILE" \\
133+
"\$IMAGE_URI"
134+
rm -f "\$ENV_FILE"
135+
136+
echo "[5/5] Health check (/readyz)"
137+
for i in \$(seq 1 30); do
138+
if curl -fsS "http://127.0.0.1:\${HOST_PORT}/readyz" >/dev/null; then
139+
echo "ready after \${i} tries"
140+
break
141+
fi
142+
if [ "\$i" -eq 30 ]; then
143+
echo "::error::health check failed"
144+
docker logs --tail 200 "\$CONTAINER_NAME" || true
145+
exit 1
146+
fi
147+
sleep 2
148+
done
149+
150+
docker image prune -f || true
151+
echo "deploy done: \$IMAGE_URI"
152+
REMOTE
153+
154+
echo "--- rendered script ---"
155+
cat /tmp/remote-deploy.sh
156+
157+
- name: Send SSM command
158+
id: send
159+
run: |
160+
set -euo pipefail
161+
# jq로 멀티라인 스크립트를 JSON 파라미터로 안전하게 인코딩
162+
PARAMS=$(jq -Rs '{commands: [.]}' < /tmp/remote-deploy.sh)
163+
164+
CMD_ID=$(aws ssm send-command \
165+
--document-name AWS-RunShellScript \
166+
--targets "Key=tag:${EC2_TAG_KEY},Values=${EC2_TAG_VALUE}" \
167+
--comment "deploy ${IMAGE_URI}" \
168+
--timeout-seconds 600 \
169+
--cloud-watch-output-config CloudWatchOutputEnabled=true \
170+
--parameters "$PARAMS" \
171+
--query 'Command.CommandId' --output text)
172+
173+
echo "command_id=$CMD_ID" >> "$GITHUB_OUTPUT"
174+
echo "CommandId: $CMD_ID"
175+
176+
- name: Wait & collect logs
177+
env:
178+
CMD_ID: ${{ steps.send.outputs.command_id }}
179+
IDS: ${{ steps.targets.outputs.ids }}
180+
run: |
181+
set -euo pipefail
182+
FAIL=0
183+
for IID in $IDS; do
184+
echo "::group::instance ${IID}"
185+
# wait는 Success가 아닌 경우 non-zero로 끝나므로 || true
186+
aws ssm wait command-executed --command-id "$CMD_ID" --instance-id "$IID" || true
187+
188+
STATUS=$(aws ssm get-command-invocation \
189+
--command-id "$CMD_ID" --instance-id "$IID" \
190+
--query 'Status' --output text)
191+
192+
echo "--- stdout ---"
193+
aws ssm get-command-invocation \
194+
--command-id "$CMD_ID" --instance-id "$IID" \
195+
--query 'StandardOutputContent' --output text || true
196+
197+
echo "--- stderr ---"
198+
aws ssm get-command-invocation \
199+
--command-id "$CMD_ID" --instance-id "$IID" \
200+
--query 'StandardErrorContent' --output text || true
201+
202+
echo "status=${STATUS}"
203+
[ "$STATUS" = "Success" ] || FAIL=1
204+
echo "::endgroup::"
205+
done
206+
exit $FAIL

Dockerfile

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# syntax=docker/dockerfile:1.7
2+
3+
# =========================
4+
# Stage 1 — builder (uv sync)
5+
# =========================
6+
FROM python:3.12-slim-bookworm AS builder
7+
8+
# uv는 별도 이미지에서 바이너리만 복사 (버전 고정)
9+
COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /uvx /usr/local/bin/
10+
11+
ENV UV_COMPILE_BYTECODE=1 \
12+
UV_LINK_MODE=copy \
13+
UV_PYTHON_DOWNLOADS=never \
14+
PYTHONDONTWRITEBYTECODE=1 \
15+
PYTHONUNBUFFERED=1
16+
17+
WORKDIR /app
18+
19+
# 의존성 먼저 설치 — 코드 변경 시 캐시 재사용
20+
COPY pyproject.toml ./
21+
# uv.lock은 있으면 복사 (없어도 빌드 실패하지 않도록 glob)
22+
COPY uv.loc[k] ./
23+
24+
RUN --mount=type=cache,target=/root/.cache/uv \
25+
uv sync --no-dev --no-install-project
26+
27+
COPY app ./app
28+
29+
30+
# =========================
31+
# Stage 2 — runtime (slim)
32+
# =========================
33+
FROM python:3.12-slim-bookworm AS runtime
34+
35+
ENV PYTHONDONTWRITEBYTECODE=1 \
36+
PYTHONUNBUFFERED=1 \
37+
PATH="/app/.venv/bin:$PATH"
38+
39+
# curl은 컨테이너 HEALTHCHECK / 디버깅용 — 불필요하면 제거 가능
40+
RUN apt-get update \
41+
&& apt-get install -y --no-install-recommends curl \
42+
&& rm -rf /var/lib/apt/lists/* \
43+
&& groupadd --system --gid 1001 app \
44+
&& useradd --system --uid 1001 --gid app --home-dir /app --shell /usr/sbin/nologin app
45+
46+
WORKDIR /app
47+
48+
COPY --from=builder --chown=app:app /app/.venv /app/.venv
49+
COPY --from=builder --chown=app:app /app/app /app/app
50+
51+
USER app
52+
53+
EXPOSE 8000
54+
55+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
56+
CMD curl -fsS http://127.0.0.1:8000/healthz || exit 1
57+
58+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0 commit comments

Comments
 (0)