나의 사이트 수정
This commit is contained in:
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# 빌드 컨텍스트에서 제외할 것들.
|
||||
# 특히 wwwroot/uploads 는 올려 둔 사진이 수십 MB 라 이미지에 들어가면 안 된다.
|
||||
|
||||
**/bin
|
||||
**/obj
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/node_modules
|
||||
**/*.user
|
||||
**/*.suo
|
||||
|
||||
# 업로드된 사진은 볼륨으로 관리한다. 이미지에 굽지 않는다.
|
||||
GameServer/wwwroot/uploads/
|
||||
|
||||
# 비밀값
|
||||
.env
|
||||
**/appsettings.Development.json
|
||||
|
||||
# 문서·산출물
|
||||
README.md
|
||||
*.md
|
||||
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# 이 파일을 .env 로 복사한 뒤 값을 채운다. (.env 는 git 에 올라가지 않는다)
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# 여기 넣은 값이 appsettings.json 보다 우선한다.
|
||||
|
||||
# MariaDB 연결 문자열.
|
||||
# 컨테이너 안에서는 localhost 가 컨테이너 자신을 가리킨다.
|
||||
# DB 가 같은 호스트에서 돌더라도 localhost 가 아니라 실제 주소를 적어야 한다.
|
||||
DB_CONNECTION=Server=152.69.235.249;Port=3306;Database=mygame;Uid=root;Pwd=여기에-DB-비밀번호;
|
||||
|
||||
# 포트폴리오 관리자 비밀번호 (글쓰기 로그인)
|
||||
ADMIN_PASSWORD=여기에-관리자-비밀번호
|
||||
|
||||
# 업로드 한 장의 최대 크기 (바이트). 기본 100MB
|
||||
MAX_UPLOAD_BYTES=104857600
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -276,3 +276,10 @@ __pycache__/
|
||||
# Portfolio uploaded media
|
||||
GameServer/wwwroot/uploads/*
|
||||
!GameServer/wwwroot/uploads/.gitkeep
|
||||
|
||||
# 배포 산출물
|
||||
/artifacts/
|
||||
|
||||
# 컨테이너 배포용 비밀값 · 호스트 업로드 폴더
|
||||
.env
|
||||
/uploads/
|
||||
|
||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# Genesis 게임 서버 + 포트폴리오 사이트 — 리눅스 컨테이너 이미지
|
||||
#
|
||||
# 빌드 컨텍스트는 저장소 루트다. (Dockerfile 이 루트에 있으므로 `docker build .`)
|
||||
#
|
||||
# docker build -t genesis-gameserver .
|
||||
# docker run -d --name genesis -p 5281:8080 genesis-gameserver
|
||||
#
|
||||
# 주의: 업로드된 이미지는 /app/wwwroot/uploads 에 쌓인다.
|
||||
# 볼륨으로 빼 두지 않으면 컨테이너를 새로 만들 때 전부 사라진다.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 1단계 — 빌드
|
||||
# 프로젝트가 net9.0 을 대상으로 하므로 SDK 9 이미지를 쓴다.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# csproj 만 먼저 복사해 restore 한다.
|
||||
# 소스만 바뀐 경우 이 레이어가 캐시되어 재빌드가 빨라진다.
|
||||
COPY GameServer/GameServer.csproj GameServer/
|
||||
RUN dotnet restore GameServer/GameServer.csproj
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish GameServer/GameServer.csproj \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
-p:UseAppHost=false
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 2단계 — 실행
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# 업로드 폴더를 만들고 비루트 사용자(app, UID 1654)에게 쓰기 권한을 준다.
|
||||
# 호스트 폴더를 마운트할 때도 같은 UID 로 맞춰야 한다. (README 참고)
|
||||
RUN mkdir -p /app/wwwroot/uploads && chown -R app:app /app/wwwroot/uploads
|
||||
|
||||
# 마운트를 잊어도 최소한 컨테이너 수명 동안은 동작하도록 볼륨을 선언해 둔다.
|
||||
VOLUME ["/app/wwwroot/uploads"]
|
||||
|
||||
# .NET 8 부터 컨테이너 기본 포트는 8080 이다. (루트 권한이 필요 없는 포트)
|
||||
ENV ASPNETCORE_HTTP_PORTS=8080 \
|
||||
ASPNETCORE_ENVIRONMENT=Production \
|
||||
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
EXPOSE 8080
|
||||
|
||||
USER app
|
||||
ENTRYPOINT ["dotnet", "GameServer.dll"]
|
||||
@@ -5,6 +5,13 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- 업로드된 사진은 배포 산출물에 넣지 않는다.
|
||||
서버에서는 볼륨(컨테이너) 또는 서버의 uploads 폴더로 따로 관리한다.
|
||||
빼지 않으면 dotnet publish 가 사진 수십 MB 를 함께 배포한다. -->
|
||||
<Content Remove="wwwroot/uploads/**" />
|
||||
<None Remove="wwwroot/uploads/**" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.13">
|
||||
|
||||
@@ -448,209 +448,37 @@ em {
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
6. 인라인 카테고리 필터 — 칩이 아니라 글자
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.filter {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) 0;
|
||||
color: var(--text-mute);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: color var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.filter:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter.is-active {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 활성 항목은 카테고리 색 밑줄 */
|
||||
.filter.is-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--card-accent);
|
||||
}
|
||||
|
||||
.filter__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-mute);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filter.is-active .filter__count {
|
||||
color: var(--card-accent);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
7. 인덱스 목록 + 커서 썸네일
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
/* 대표 작업 — 목록 맨 앞 글을 크게 보여 준다.
|
||||
글이 하나뿐이어도 화면이 비어 보이지 않게 하는 역할. */
|
||||
.lead {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-6);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-7);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lead__media {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.lead__media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 620ms var(--ease);
|
||||
}
|
||||
|
||||
.lead:hover .lead__media img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 첫 글자 */
|
||||
.lead__media--none,
|
||||
/* 이미지가 없는 글의 자리 — 카테고리 색으로 옅게 물든 판 + 제목 첫 글자 */
|
||||
.idx__thumb--none {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: var(--surface-2);
|
||||
background-image:
|
||||
linear-gradient(140deg,
|
||||
color-mix(in srgb, var(--card-accent) 16%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card-accent) 18%, transparent) 0%,
|
||||
transparent 62%);
|
||||
color: color-mix(in srgb, var(--card-accent) 45%, var(--text-mute));
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.lead__media--none {
|
||||
font-size: clamp(56px, 8vw, 104px);
|
||||
}
|
||||
|
||||
.idx__thumb--none {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* color-mix 미지원 브라우저 대비 */
|
||||
@supports not (background: color-mix(in srgb, red 10%, transparent)) {
|
||||
.lead__media--none,
|
||||
.idx__thumb--none {
|
||||
background-image: none;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
}
|
||||
|
||||
.lead__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 한글은 자간을 크게 벌리면 글자가 흩어져 보인다. 살짝만 준다. */
|
||||
.lead__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.lead__label::before {
|
||||
content: "";
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--card-accent);
|
||||
}
|
||||
|
||||
.lead__cat {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
.lead__title {
|
||||
font-size: var(--step-3);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.2;
|
||||
word-break: keep-all;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lead__sum {
|
||||
font-size: var(--step-0);
|
||||
color: var(--text-dim);
|
||||
line-height: 1.75;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.lead__meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.lead__more {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 700;
|
||||
color: var(--card-accent);
|
||||
box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--card-accent) 32%, transparent);
|
||||
transition: box-shadow var(--dur) var(--ease);
|
||||
}
|
||||
|
||||
.lead__more::after {
|
||||
content: " →";
|
||||
}
|
||||
|
||||
.lead:hover .lead__more {
|
||||
box-shadow: inset 0 -2px 0 var(--card-accent);
|
||||
}
|
||||
|
||||
/* 인덱스 행의 작은 썸네일 */
|
||||
/* 인덱스 행의 썸네일 */
|
||||
.idx__thumb {
|
||||
margin: 0;
|
||||
width: 96px;
|
||||
width: 132px;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -671,6 +499,41 @@ em {
|
||||
border-color: color-mix(in srgb, var(--card-accent) 45%, var(--rule));
|
||||
}
|
||||
|
||||
/* 카테고리 묶음 — 분야 제목 + 그 아래 목록 */
|
||||
.group {
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.group__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.group__count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--2);
|
||||
font-weight: 700;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.index {
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
@@ -683,11 +546,12 @@ em {
|
||||
/* 행 하나. 번호 / 본문 / 메타 세 칸 그리드. */
|
||||
.idx {
|
||||
display: grid;
|
||||
/* 번호 · 썸네일 · 본문 · 메타 */
|
||||
grid-template-columns: 2.6rem auto 1fr auto;
|
||||
/* 번호 · 썸네일 · 본문 · 날짜 */
|
||||
grid-template-columns: 2.4rem auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) 0;
|
||||
gap: var(--space-5);
|
||||
/* 썸네일이 잘 보이도록 위아래를 넉넉히 둔다 */
|
||||
padding: var(--space-5) 0;
|
||||
color: var(--text);
|
||||
transition: padding-left var(--dur) var(--ease), color var(--dur) var(--ease);
|
||||
}
|
||||
@@ -1794,13 +1658,17 @@ a.sr-only:focus-visible {
|
||||
|
||||
/* 좁은 화면에서는 메타를 제목 아래로 내린다 */
|
||||
.idx {
|
||||
grid-template-columns: 2rem 64px 1fr;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: 2rem 88px 1fr;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.idx__thumb {
|
||||
width: 64px;
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.idx__meta {
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// views/home.js — 홈(작업 인덱스) 화면
|
||||
// views/home.js — 홈(작업 목록) 화면
|
||||
// 라우트: #/ · #/?cat=<slug>
|
||||
// 구성: 히어로 → 인라인 카테고리 필터 → 대표 작업 한 건 → 번호 매긴 인덱스
|
||||
// 구성: 히어로 → 카테고리별 묶음, 각 묶음 안에서 번호 매긴 목록
|
||||
//
|
||||
// 카드 그리드 대신 '작품집 목차' 형태를 쓰되, 이미지가 늘 자리를 갖도록
|
||||
// 맨 앞 글은 크게(lead) 보여 주고 나머지 행에는 작은 썸네일을 붙인다.
|
||||
// 이미지가 없는 글은 제목 첫 글자를 넣은 파스텔 판으로 대신한다.
|
||||
// 날짜순으로 전부 섞어 보여 주지 않는다. 작업의 선후보다 분야가 중요해서
|
||||
// 카테고리마다 제목을 달고 그 아래에 작업을 나열한다.
|
||||
// 모든 항목은 같은 크기다. 대신 행 간격을 넉넉히 두어 썸네일이 잘 보이게 한다.
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { el, escapeHtml, formatDate, setBusy, skeletonRows, toast } from '../ui.js';
|
||||
import { el, escapeHtml, formatDate, setBusy, skeletonRows } from '../ui.js';
|
||||
|
||||
/** 한 번에 불러오는 게시물 수 */
|
||||
const PAGE_SIZE = 20;
|
||||
/** 한 번에 긁어오는 게시물 수 (여러 번 나눠 받아 전부 모은다) */
|
||||
const FETCH_SIZE = 50;
|
||||
/** 카테고리 캐시 수명 (ms) */
|
||||
const CATEGORY_TTL_MS = 60000;
|
||||
|
||||
// 카테고리 목록은 모듈 스코프에 캐시한다.
|
||||
// 필터만 바뀌는 경우(#/ ↔ #/?cat=game) 뷰가 다시 마운트돼도 다시 요청하지 않는다.
|
||||
let catCache = null;
|
||||
let catCachedAt = 0;
|
||||
let catInflight = null;
|
||||
@@ -78,7 +76,7 @@ function padNo(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 'YYYY. MM. DD.' → 'YY.MM' (인덱스에서는 짧게 쓴다) */
|
||||
/** 'YYYY. MM. DD.' → 'YY.MM' */
|
||||
function shortDate(iso) {
|
||||
const full = formatDate(iso);
|
||||
const m = /^(\d{4})\.\s*(\d{2})\./.exec(full);
|
||||
@@ -92,98 +90,44 @@ function firstChar(title) {
|
||||
return Array.from(text)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판.
|
||||
* @param {string} cls 바깥 figure 의 클래스
|
||||
* @param {string} url 안전하게 걸러진 이미지 주소 ('' 가능)
|
||||
* @param {string} title alt / 대체 글자용
|
||||
*/
|
||||
function mediaHtml(cls, url, title) {
|
||||
/** 이미지 자리. 그림이 있으면 img, 없으면 첫 글자를 넣은 판. */
|
||||
function thumbHtml(url, title) {
|
||||
if (url) {
|
||||
return (
|
||||
'<figure class="' + cls + '">' +
|
||||
'<figure class="idx__thumb">' +
|
||||
'<img src="' + escapeHtml(url) + '" alt="' + escapeHtml(title) + '"' +
|
||||
' loading="lazy" decoding="async">' +
|
||||
'</figure>'
|
||||
);
|
||||
}
|
||||
return (
|
||||
'<figure class="' + cls + ' ' + cls + '--none" aria-hidden="true">' +
|
||||
'<figure class="idx__thumb idx__thumb--none" aria-hidden="true">' +
|
||||
'<span>' + escapeHtml(firstChar(title)) + '</span>' +
|
||||
'</figure>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 필터 한 칸. 칩이 아니라 글자 링크다.
|
||||
*/
|
||||
function filterHtml(slug, name, count, accent, active) {
|
||||
const href = slug === 'all' ? '#/' : '#/?cat=' + encodeURIComponent(slug);
|
||||
const style = accent ? ' style="--card-accent:' + accent + '"' : '';
|
||||
return (
|
||||
'<a class="filter' + (active ? ' is-active' : '') + '" href="' + escapeHtml(href) + '"' +
|
||||
style + (active ? ' aria-current="true"' : '') + '>' +
|
||||
escapeHtml(name) +
|
||||
'<span class="filter__count">' + toInt(count) + '</span>' +
|
||||
'</a>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 대표 작업(가장 최근 글) 한 건을 크게 보여 준다.
|
||||
* 목록의 한 줄. 묶음 안에서의 순번을 쓴다.
|
||||
* @param {Object} post PostSummaryDto
|
||||
*/
|
||||
function leadHtml(post) {
|
||||
const postNo = toInt(post && post.postNo);
|
||||
const title = String((post && post.title) || '(제목 없음)');
|
||||
const catName = String((post && post.categoryName) || '미분류');
|
||||
const summary = post && post.summary ? String(post.summary) : '';
|
||||
const accent = safeAccent(post && post.categoryAccent);
|
||||
const thumb = safeUrl(post && post.thumbnailUrl);
|
||||
|
||||
return (
|
||||
'<a class="lead" href="#/post/' + postNo + '"' +
|
||||
(accent ? ' style="--card-accent:' + accent + '"' : '') + '>' +
|
||||
mediaHtml('lead__media', thumb, title) +
|
||||
'<div class="lead__body">' +
|
||||
'<span class="lead__label">최신 작업</span>' +
|
||||
'<span class="lead__cat">' + escapeHtml(catName) + '</span>' +
|
||||
'<h3 class="lead__title">' + escapeHtml(title) + '</h3>' +
|
||||
(summary ? '<p class="lead__sum">' + escapeHtml(summary) + '</p>' : '') +
|
||||
'<span class="lead__meta">' +
|
||||
escapeHtml(formatDate(post && post.createdAt)) +
|
||||
'</span>' +
|
||||
'<span class="lead__more">자세히 보기</span>' +
|
||||
'</div>' +
|
||||
'</a>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 행 하나.
|
||||
* @param {Object} post PostSummaryDto
|
||||
* @param {number} order 화면상의 순번 (1 부터)
|
||||
* @param {number} order 묶음 안 순번 (1 부터)
|
||||
*/
|
||||
function rowHtml(post, order) {
|
||||
const postNo = toInt(post && post.postNo);
|
||||
const title = String((post && post.title) || '(제목 없음)');
|
||||
const catName = String((post && post.categoryName) || '미분류');
|
||||
const summary = post && post.summary ? String(post.summary) : '';
|
||||
const accent = safeAccent(post && post.categoryAccent);
|
||||
const thumb = safeUrl(post && post.thumbnailUrl);
|
||||
|
||||
return (
|
||||
'<li class="index__item">' +
|
||||
'<a class="idx" href="#/post/' + postNo + '"' +
|
||||
(accent ? ' style="--card-accent:' + accent + '"' : '') + '>' +
|
||||
'<a class="idx" href="#/post/' + postNo + '">' +
|
||||
'<span class="idx__no">' + padNo(order) + '</span>' +
|
||||
mediaHtml('idx__thumb', thumb, title) +
|
||||
thumbHtml(thumb, title) +
|
||||
'<span class="idx__body">' +
|
||||
'<span class="idx__title">' + escapeHtml(title) + '</span>' +
|
||||
(summary ? '<span class="idx__sum">' + escapeHtml(summary) + '</span>' : '') +
|
||||
'</span>' +
|
||||
'<span class="idx__meta">' +
|
||||
'<span class="idx__cat">' + escapeHtml(catName) + '</span>' +
|
||||
'<span class="idx__date">' + escapeHtml(shortDate(post && post.createdAt)) + '</span>' +
|
||||
'</span>' +
|
||||
'</a>' +
|
||||
@@ -191,6 +135,57 @@ function rowHtml(post, order) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 카테고리 묶음 하나 */
|
||||
function groupHtml(group) {
|
||||
const rows = group.posts.map((p, i) => rowHtml(p, i + 1)).join('');
|
||||
return (
|
||||
'<section class="group"' + (group.accent ? ' style="--card-accent:' + group.accent + '"' : '') + '>' +
|
||||
'<h3 class="group__title">' +
|
||||
'<span class="group__dot" aria-hidden="true"></span>' +
|
||||
escapeHtml(group.name) +
|
||||
'<span class="group__count">' + group.posts.length + '</span>' +
|
||||
'</h3>' +
|
||||
'<ol class="index">' + rows + '</ol>' +
|
||||
'</section>'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물을 카테고리별로 묶는다. 순서는 카테고리 목록의 순서(sortOrder)를 따른다.
|
||||
* @param {Array} cats CategoryDto[]
|
||||
* @param {Array} posts PostSummaryDto[]
|
||||
*/
|
||||
function groupByCategory(cats, posts) {
|
||||
const rank = new Map();
|
||||
(Array.isArray(cats) ? cats : []).forEach((c, i) => rank.set(toInt(c.categoryNo), i));
|
||||
|
||||
const buckets = new Map();
|
||||
for (const p of posts) {
|
||||
const key = toInt(p && p.categoryNo);
|
||||
if (!buckets.has(key)) buckets.set(key, []);
|
||||
buckets.get(key).push(p);
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const [categoryNo, list] of buckets) {
|
||||
const head = list[0] || {};
|
||||
out.push({
|
||||
categoryNo,
|
||||
name: String(head.categoryName || '미분류'),
|
||||
accent: safeAccent(head.categoryAccent),
|
||||
posts: list
|
||||
});
|
||||
}
|
||||
|
||||
// 카테고리 목록에 없는 분류(삭제된 것 등)는 뒤로 보낸다.
|
||||
out.sort((a, b) => {
|
||||
const ra = rank.has(a.categoryNo) ? rank.get(a.categoryNo) : 9999;
|
||||
const rb = rank.has(b.categoryNo) ? rank.get(b.categoryNo) : 9999;
|
||||
return ra - rb;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 화면 뼈대 (API 데이터가 섞이지 않는 정적 마크업) */
|
||||
const SHELL_HTML =
|
||||
'<section class="hero">' +
|
||||
@@ -200,12 +195,10 @@ const SHELL_HTML =
|
||||
'<section class="section">' +
|
||||
'<div class="section__head">' +
|
||||
'<h2 class="section__title">작업</h2>' +
|
||||
'<nav class="filters" id="home-cats" aria-label="카테고리 필터" aria-busy="true"></nav>' +
|
||||
'<div id="home-scope"></div>' +
|
||||
'</div>' +
|
||||
'<div id="home-lead"></div>' +
|
||||
'<ol class="index" id="home-grid"></ol>' +
|
||||
'<div id="home-groups"></div>' +
|
||||
'<div id="home-state"></div>' +
|
||||
'<div id="home-more"></div>' +
|
||||
'</section>';
|
||||
|
||||
/**
|
||||
@@ -219,86 +212,44 @@ export async function view(params, outlet) {
|
||||
const activeSlug = normalizeSlug(query.cat);
|
||||
|
||||
let disposed = false;
|
||||
let page = 1;
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
|
||||
outlet.insertAdjacentHTML('beforeend', SHELL_HTML);
|
||||
|
||||
const statsEl = outlet.querySelector('#home-stats');
|
||||
const catsEl = outlet.querySelector('#home-cats');
|
||||
const leadEl = outlet.querySelector('#home-lead');
|
||||
const listEl = outlet.querySelector('#home-grid');
|
||||
const scopeEl = outlet.querySelector('#home-scope');
|
||||
const groupsEl = outlet.querySelector('#home-groups');
|
||||
const stateEl = outlet.querySelector('#home-state');
|
||||
const moreEl = outlet.querySelector('#home-more');
|
||||
|
||||
// ── 카테고리 필터 ─────────────────────────────────────────────
|
||||
function renderFilters(cats) {
|
||||
const list = Array.isArray(cats) ? cats : [];
|
||||
const totalPosts = list.reduce((sum, c) => sum + toInt(c && c.postCount), 0);
|
||||
|
||||
let html = filterHtml('all', '전체', totalPosts, '', activeSlug === 'all');
|
||||
for (const cat of list) {
|
||||
const slug = String((cat && cat.slug) || '');
|
||||
if (!slug) continue;
|
||||
html += filterHtml(
|
||||
slug,
|
||||
String((cat && cat.name) || slug),
|
||||
toInt(cat && cat.postCount),
|
||||
safeAccent(cat && cat.accent),
|
||||
slug.toLowerCase() === activeSlug
|
||||
);
|
||||
}
|
||||
catsEl.innerHTML = html;
|
||||
catsEl.setAttribute('aria-busy', 'false');
|
||||
}
|
||||
|
||||
function renderStats(cats) {
|
||||
const list = Array.isArray(cats) ? cats : [];
|
||||
const totalPosts = list.reduce((sum, c) => sum + toInt(c && c.postCount), 0);
|
||||
statsEl.innerHTML =
|
||||
'<span class="hero__stat">작업 <b>' + totalPosts + '</b></span>' +
|
||||
'<span class="hero__stat">카테고리 <b>' + list.length + '</b></span>' +
|
||||
'<span class="hero__stat">분야 <b>' + list.length + '</b></span>' +
|
||||
'<span class="hero__stat">Unity · .NET · MariaDB</span>';
|
||||
}
|
||||
|
||||
// 필터를 누르면 해시에 기록한다.
|
||||
// 보조 버튼·수정키 클릭은 브라우저 기본 동작(새 탭 등)에 맡긴다.
|
||||
function onCatsClick(ev) {
|
||||
if (ev.defaultPrevented) return;
|
||||
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
||||
const target = ev.target instanceof Element ? ev.target.closest('a.filter') : null;
|
||||
if (!target || !catsEl.contains(target)) return;
|
||||
ev.preventDefault();
|
||||
const next = target.getAttribute('href');
|
||||
if (next && next !== location.hash) location.hash = next;
|
||||
}
|
||||
|
||||
// ── 상태 표시 ─────────────────────────────────────────────────
|
||||
function clearState() {
|
||||
stateEl.innerHTML = '';
|
||||
/** 한 분야만 보고 있을 때 전체로 돌아가는 링크 */
|
||||
function renderScope(show) {
|
||||
scopeEl.innerHTML = show
|
||||
? '<a class="btn btn--ghost btn--sm" href="#/">← 전체 작업</a>'
|
||||
: '';
|
||||
}
|
||||
|
||||
function showEmpty() {
|
||||
leadEl.innerHTML = '';
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.add('is-hidden');
|
||||
moreEl.innerHTML = '';
|
||||
groupsEl.innerHTML = '';
|
||||
const filtered = activeSlug !== 'all';
|
||||
stateEl.innerHTML =
|
||||
'<div class="empty">' +
|
||||
'<p>' + (filtered ? '이 카테고리에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '</p>' +
|
||||
'<p class="tagline">' + (filtered ? '다른 카테고리도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '</p>' +
|
||||
'<p>' + (filtered ? '이 분야에는 아직 작업이 없습니다.' : '아직 등록된 작업이 없습니다.') + '</p>' +
|
||||
'<p class="tagline">' + (filtered ? '다른 분야도 살펴보세요.' : '관리자로 로그인해 첫 글을 써 보세요.') + '</p>' +
|
||||
(filtered ? '<a class="btn btn--ghost" href="#/">전체 보기</a>'
|
||||
: '<a class="btn btn--ghost" href="#/admin">관리자</a>') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function showError(err) {
|
||||
leadEl.innerHTML = '';
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.add('is-hidden');
|
||||
moreEl.innerHTML = '';
|
||||
groupsEl.innerHTML = '';
|
||||
stateEl.innerHTML =
|
||||
'<div class="empty">' +
|
||||
'<p>작업 목록을 불러오지 못했습니다.</p>' +
|
||||
@@ -311,61 +262,47 @@ export async function view(params, outlet) {
|
||||
class: 'btn btn--ghost',
|
||||
type: 'button',
|
||||
text: '다시 시도',
|
||||
onclick: () => { loadFirstPage(); }
|
||||
onclick: () => { load(); }
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 목록 ──────────────────────────────────────────────────────
|
||||
/** 첫 페이지: 맨 앞 글은 lead 로, 나머지는 인덱스 행으로. */
|
||||
function drawFirst(items) {
|
||||
leadEl.innerHTML = leadHtml(items[0]);
|
||||
const rest = items.slice(1);
|
||||
listEl.innerHTML = rest.map((p, i) => rowHtml(p, i + 2)).join('');
|
||||
listEl.classList.toggle('is-hidden', rest.length === 0);
|
||||
/** 나눠 받은 페이지를 전부 모은다. 포트폴리오라 개수가 많지 않다. */
|
||||
async function fetchAllPosts() {
|
||||
const all = [];
|
||||
let page = 1;
|
||||
for (;;) {
|
||||
const res = await api.posts.list({ category: activeSlug, page, size: FETCH_SIZE });
|
||||
const items = res && Array.isArray(res.items) ? res.items : [];
|
||||
all.push(...items);
|
||||
const total = toInt(res && res.total);
|
||||
if (!items.length || all.length >= total || page > 40) break;
|
||||
page += 1;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function drawMore(items) {
|
||||
const startAt = loaded - items.length;
|
||||
listEl.classList.remove('is-hidden');
|
||||
listEl.insertAdjacentHTML('beforeend', items.map((p, i) => rowHtml(p, startAt + i + 1)).join(''));
|
||||
}
|
||||
|
||||
function renderMoreButton() {
|
||||
moreEl.innerHTML = '';
|
||||
if (loaded >= total) return;
|
||||
const btn = el('button', {
|
||||
class: 'btn btn--ghost',
|
||||
type: 'button',
|
||||
text: '더 보기',
|
||||
onclick: () => { loadNextPage(btn); }
|
||||
});
|
||||
moreEl.appendChild(btn);
|
||||
}
|
||||
|
||||
async function loadFirstPage() {
|
||||
page = 1;
|
||||
loaded = 0;
|
||||
total = 0;
|
||||
clearState();
|
||||
moreEl.innerHTML = '';
|
||||
leadEl.innerHTML = '';
|
||||
listEl.classList.remove('is-hidden');
|
||||
listEl.innerHTML = skeletonRows(4);
|
||||
async function load() {
|
||||
stateEl.innerHTML = '';
|
||||
groupsEl.innerHTML = '<ol class="index">' + skeletonRows(4) + '</ol>';
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.posts.list({ category: activeSlug, page: 1, size: PAGE_SIZE });
|
||||
const [cats, posts] = await Promise.all([
|
||||
getCategories().catch(() => []),
|
||||
fetchAllPosts()
|
||||
]);
|
||||
if (disposed) return;
|
||||
const items = res && Array.isArray(res.items) ? res.items : [];
|
||||
loaded = items.length;
|
||||
total = Math.max(toInt(res && res.total), loaded);
|
||||
if (!items.length) {
|
||||
|
||||
renderStats(cats);
|
||||
renderScope(activeSlug !== 'all');
|
||||
|
||||
if (!posts.length) {
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
drawFirst(items);
|
||||
renderMoreButton();
|
||||
|
||||
groupsEl.innerHTML = groupByCategory(cats, posts).map(groupHtml).join('');
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
showError(err);
|
||||
@@ -374,56 +311,10 @@ export async function view(params, outlet) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNextPage(btn) {
|
||||
if (btn.disabled) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '불러오는 중…';
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.posts.list({ category: activeSlug, page: page + 1, size: PAGE_SIZE });
|
||||
if (disposed) return;
|
||||
const items = res && Array.isArray(res.items) ? res.items : [];
|
||||
page += 1;
|
||||
loaded += items.length;
|
||||
total = Math.max(toInt(res && res.total), loaded);
|
||||
// 빈 페이지가 오면 더 이상 없는 것으로 본다(무한 클릭 방지).
|
||||
if (!items.length) total = loaded;
|
||||
else drawMore(items);
|
||||
renderMoreButton();
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
// 이미 그려진 행은 그대로 두고 버튼만 되돌린다.
|
||||
btn.disabled = false;
|
||||
btn.textContent = '더 보기';
|
||||
toast((err && err.message) || '더 불러오지 못했습니다.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
catsEl.addEventListener('click', onCatsClick);
|
||||
|
||||
// 카테고리와 첫 페이지는 서로를 기다리지 않는다.
|
||||
getCategories().then(
|
||||
(cats) => {
|
||||
if (disposed) return;
|
||||
renderFilters(cats);
|
||||
renderStats(cats);
|
||||
},
|
||||
() => {
|
||||
if (disposed) return;
|
||||
// 카테고리를 못 받아도 목록 자체는 보여준다.
|
||||
renderFilters([]);
|
||||
statsEl.innerHTML = '<span class="hero__stat">Unity · .NET · MariaDB</span>';
|
||||
}
|
||||
);
|
||||
|
||||
// await 하지 않는다: 라우터가 곧바로 cleanup 을 받아 갈 수 있게 한다.
|
||||
loadFirstPage();
|
||||
load();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
catsEl.removeEventListener('click', onCatsClick);
|
||||
setBusy(false);
|
||||
};
|
||||
}
|
||||
|
||||
291
README.md
291
README.md
@@ -164,7 +164,296 @@ GameServer/
|
||||
|
||||
---
|
||||
|
||||
## 5. 보안 메모
|
||||
## 5. 리눅스에 배포하기 — 도커 없이
|
||||
|
||||
런타임까지 통째로 묶은 **자체 포함(self-contained) 실행 파일**을 만들어 올리는 방식입니다.
|
||||
**서버에 .NET 을 설치할 필요가 없습니다.** Visual Studio 게시 대화상자의
|
||||
"배포 모드: 자체 포함 / 대상 런타임: linux-x64" 와 같은 결과물입니다.
|
||||
|
||||
### 만들기
|
||||
|
||||
윈도우에서 만들어도 됩니다. 리눅스용 실행 파일이 나옵니다.
|
||||
|
||||
**PowerShell** (VS Code 기본 터미널)
|
||||
```powershell
|
||||
.\deploy\publish-linux.cmd
|
||||
```
|
||||
|
||||
**Git Bash · WSL · 맥 · 리눅스**
|
||||
```bash
|
||||
./deploy/publish-linux.sh
|
||||
```
|
||||
|
||||
셋 다 같은 일을 합니다. 상황에 맞는 것을 쓰세요.
|
||||
|
||||
| 파일 | 쓰는 곳 |
|
||||
|---|---|
|
||||
| `publish-linux.cmd` | PowerShell·cmd. 실행 정책을 건드리지 않아도 됩니다 |
|
||||
| `publish-linux.ps1` | 실제 내용. 아래 정책 문제를 해결했다면 직접 실행해도 됩니다 |
|
||||
| `publish-linux.sh` | Git Bash · WSL · 맥 · 리눅스 |
|
||||
|
||||
#### `.ps1` 이 "이 시스템에서 스크립트를 실행할 수 없으므로" 오류가 날 때
|
||||
|
||||
윈도우 기본 실행 정책이 `Restricted` 라 `.ps1` 이 막힙니다. 세 가지 방법이 있습니다.
|
||||
|
||||
1. **`.cmd` 를 쓴다** — 가장 간단합니다. 정책을 바꾸지 않습니다.
|
||||
```powershell
|
||||
.\deploy\publish-linux.cmd
|
||||
```
|
||||
> `cmd` 파일은 앞에 `.\` 를 꼭 붙이세요. 없으면 현재 폴더를 찾지 못합니다.
|
||||
|
||||
2. **한 번만 우회한다** — 시스템 설정을 바꾸지 않습니다.
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\deploy\publish-linux.ps1
|
||||
```
|
||||
|
||||
3. **내 계정에만 정책을 푼다** — 앞으로 계속 `.ps1` 을 쓰고 싶다면.
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
||||
```
|
||||
`RemoteSigned` 는 직접 만든 스크립트는 실행하고, 인터넷에서 받은 것은
|
||||
서명을 요구합니다. 관리자 권한이 필요 없고 이 계정에만 적용됩니다.
|
||||
|
||||
`artifacts/linux-x64/` 에 이런 결과가 나옵니다. **53MB, 파일 네 개뿐입니다.**
|
||||
|
||||
```
|
||||
GameServer 리눅스 실행 파일 (런타임 포함, 단일 파일)
|
||||
GameServer.staticwebassets.endpoints.json
|
||||
appsettings.json
|
||||
wwwroot/ 사이트 (html · css · js)
|
||||
```
|
||||
|
||||
스크립트가 쓰는 명령은 이것 하나입니다.
|
||||
|
||||
```bash
|
||||
dotnet publish GameServer/GameServer.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true -o artifacts/linux-x64
|
||||
```
|
||||
|
||||
> `PublishTrimmed` 는 켜지 마세요. 용량은 더 줄지만 EF Core 가 리플렉션으로 찾는 타입이
|
||||
> 잘려 나가 실행 중에 터집니다.
|
||||
|
||||
### 서버에서 바로 실행해 보기
|
||||
|
||||
복사한 폴더로 들어가 `run.sh` 를 실행하면 끝입니다. 서버에 .NET 을 깔 필요가 없습니다.
|
||||
|
||||
```bash
|
||||
cd /srv/genesis
|
||||
chmod +x run.sh
|
||||
./run.sh # PORT=8080 ./run.sh 처럼 포트를 바꿀 수 있습니다
|
||||
```
|
||||
|
||||
`run.sh` 가 두 가지를 대신 해 줍니다.
|
||||
|
||||
- **실행 권한** — 윈도우에서 복사하면 실행 비트가 사라져 `Permission denied` 가 납니다.
|
||||
- **바인딩 주소** — `ASPNETCORE_URLS` 를 주지 않으면 `localhost:5000` 에만 묶여
|
||||
서버 안에서만 보이고 브라우저로는 안 열립니다. `0.0.0.0:5281` 로 열어 줍니다.
|
||||
|
||||
클라우드 서버라면 방화벽도 열어야 합니다. `sudo ufw allow 5281/tcp`
|
||||
|
||||
SSH 를 끊으면 함께 종료되므로, 계속 띄워 두려면 아래 systemd 등록을 하세요.
|
||||
|
||||
### 이미 돌고 있는 서버 멈추기
|
||||
|
||||
새로 올린 것이 같은 포트를 쓰려면 기존 프로세스를 먼저 내려야 합니다.
|
||||
`Address already in use` 가 뜨면 이 경우입니다.
|
||||
|
||||
**1) 무엇이 포트를 잡고 있는지 본다**
|
||||
|
||||
```bash
|
||||
sudo ss -tlnp | grep 5281 # 없으면: sudo lsof -i :5281
|
||||
ps aux | grep -iE 'GameServer|dotnet' | grep -v grep
|
||||
systemctl list-units --type=service | grep -iE 'genesis|game|portfolio'
|
||||
docker ps # 도커로 띄웠다면
|
||||
```
|
||||
|
||||
**2) 나온 결과에 맞춰 끈다**
|
||||
|
||||
| 어떻게 떠 있나 | 끄는 법 |
|
||||
|---|---|
|
||||
| systemd 서비스 | `sudo systemctl stop 이름` |
|
||||
| 도커 컨테이너 | `docker stop 이름` |
|
||||
| 그냥 실행한 프로세스 | `kill -INT <PID>` |
|
||||
| screen · tmux 안 | `screen -r` / `tmux attach` 후 `Ctrl+C` |
|
||||
|
||||
**systemd 로 등록돼 있다면 `kill` 은 소용없습니다.** `Restart=always` 때문에
|
||||
바로 되살아납니다. 반드시 `systemctl stop` 을 쓰고, 다시 안 뜨게 하려면
|
||||
`sudo systemctl disable 이름` 까지 하세요.
|
||||
|
||||
**3) 정말 내려갔는지 확인**
|
||||
|
||||
```bash
|
||||
sudo ss -tlnp | grep 5281 # 아무것도 안 나오면 성공
|
||||
```
|
||||
|
||||
> .NET 은 `SIGINT`(`kill -INT`) 를 받아야 정상 종료합니다.
|
||||
> 그냥 `kill` (SIGTERM) 이나 `kill -9` 는 쓰던 파일을 정리하지 못하고 끊길 수 있습니다.
|
||||
|
||||
### 서버 준비 (처음 한 번)
|
||||
|
||||
```bash
|
||||
sudo useradd -r -s /usr/sbin/nologin genesis
|
||||
sudo mkdir -p /srv/genesis/wwwroot/uploads
|
||||
sudo chown -R genesis:genesis /srv/genesis
|
||||
|
||||
# 비밀값
|
||||
sudo cp deploy/genesis.env.example /srv/genesis/genesis.env
|
||||
sudo nano /srv/genesis/genesis.env
|
||||
sudo chown genesis:genesis /srv/genesis/genesis.env
|
||||
sudo chmod 600 /srv/genesis/genesis.env
|
||||
|
||||
# 서비스 등록
|
||||
sudo cp deploy/genesis.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now genesis
|
||||
```
|
||||
|
||||
### 올리기 · 갱신
|
||||
|
||||
```powershell
|
||||
.\deploy\publish-linux.ps1 -Target user@서버주소 # PowerShell
|
||||
```
|
||||
```bash
|
||||
./deploy/publish-linux.sh user@서버주소 # bash
|
||||
```
|
||||
|
||||
빌드 → 전송 → 실행 권한 부여까지 한 번에 합니다.
|
||||
(bash 판은 rsync 를, PowerShell 판은 scp 를 씁니다. 둘 다 서버의 사진을 지우지 않습니다.)
|
||||
**업로드된 사진(`wwwroot/uploads`)과 `genesis.env` 는 건드리지 않습니다.**
|
||||
|
||||
수동으로 할 때도 이 두 가지는 반드시 지켜야 합니다.
|
||||
|
||||
```bash
|
||||
rsync -avz --delete --exclude 'wwwroot/uploads/' --exclude 'genesis.env' artifacts/linux-x64/ user@서버:/srv/genesis/
|
||||
|
||||
ssh user@서버 "chmod +x /srv/genesis/GameServer && sudo systemctl restart genesis"
|
||||
```
|
||||
|
||||
> `chmod +x` 를 빼먹기 쉽습니다. 윈도우에서 복사하면 실행 권한이 사라져
|
||||
> `Permission denied` 로 서비스가 안 뜹니다.
|
||||
|
||||
### 배포 후 확인
|
||||
|
||||
산출물에 들어 있는 `healthcheck.sh` 를 서버에서 실행하면 한 번에 점검합니다.
|
||||
|
||||
```bash
|
||||
cd /home/ubuntu/gameserver # 앱이 있는 폴더
|
||||
./healthcheck.sh 5000 GameServer # 포트, 서비스 이름
|
||||
```
|
||||
|
||||
이런 것들을 확인합니다.
|
||||
|
||||
1. 서비스가 `active (running)` 인지, 반복 재시작 중은 아닌지
|
||||
2. 포트를 실제로 듣고 있는지
|
||||
3. **기존 게임 API (`/myGame/*`) 가 그대로 되는지**
|
||||
4. 포트폴리오 API 와 카테고리 개수 (0 개면 DB 스키마 생성 실패)
|
||||
5. 사이트 정적 파일 (`/`, `css`, `js`)
|
||||
6. 업로드 폴더 쓰기 권한
|
||||
7. 최근 로그의 오류
|
||||
|
||||
실패한 항목이 있으면 종료 코드 1 을 돌려주므로 자동화에도 쓸 수 있습니다.
|
||||
|
||||
직접 보고 싶다면:
|
||||
|
||||
```bash
|
||||
sudo systemctl status GameServer
|
||||
sudo journalctl -u GameServer -n 100 --no-pager
|
||||
sudo journalctl -u GameServer -f # 실시간
|
||||
sudo ss -tlnp | grep 5000
|
||||
curl -s localhost:5000/api/portfolio/categories
|
||||
```
|
||||
|
||||
정상이라면 로그에 이 세 줄이 보입니다.
|
||||
|
||||
```
|
||||
포트폴리오 스키마 준비 완료 (카테고리 N건).
|
||||
Now listening on: http://0.0.0.0:5000
|
||||
Application started. Press Ctrl+C to shut down.
|
||||
```
|
||||
|
||||
> `Failed to determine the https port for redirect.` 경고는 무시해도 됩니다.
|
||||
> HTTP 로만 띄웠을 때 늘 나오는 안내입니다.
|
||||
|
||||
### 어느 방식을 고를까
|
||||
|
||||
| | 자체 포함 배포 | 도커 |
|
||||
|---|---|---|
|
||||
| 서버에 필요한 것 | 없음 | Docker |
|
||||
| 전송량 | 53MB | 이미지 레이어 |
|
||||
| 격리 | systemd 수준 | 컨테이너 |
|
||||
| 롤백 | 이전 폴더로 교체 | 이전 이미지 태그 |
|
||||
|
||||
혼자 쓰는 서버라면 **자체 포함 배포가 더 단순합니다.** 도커 쪽은 아래 6절에 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 리눅스 컨테이너로 배포하기
|
||||
|
||||
### 준비물
|
||||
|
||||
로컬(윈도우)에서 이미지를 만들 필요는 없습니다. **서버에서 바로 빌드하는 쪽이 간단합니다.**
|
||||
|
||||
- 서버: Docker Engine + Compose 플러그인
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER # 다시 로그인해야 적용됩니다
|
||||
```
|
||||
- VS Code(선택): 확장 두 개
|
||||
- **Docker** (`ms-azuretools.vscode-docker`) — Dockerfile 문법 지원, 이미지·컨테이너 목록, 우클릭 빌드/실행
|
||||
- **Remote - SSH** (`ms-vscode-remote.remote-ssh`) — 서버에 붙어서 그대로 편집·실행
|
||||
|
||||
Visual Studio 의 "Docker 지원 추가 → 게시" 같은 원클릭 배포는 VS Code 에 없습니다.
|
||||
대신 Remote-SSH 로 서버에 붙어 아래 명령을 터미널에서 실행하는 흐름이 표준입니다.
|
||||
|
||||
### 첫 배포
|
||||
|
||||
```bash
|
||||
git clone <저장소 주소> genesis && cd genesis
|
||||
|
||||
cp .env.example .env
|
||||
nano .env # DB 연결 문자열 · 관리자 비밀번호 입력
|
||||
|
||||
mkdir -p uploads
|
||||
sudo chown -R 1654:1654 uploads # 컨테이너의 app 사용자가 쓸 수 있게
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`http://서버주소:5281` 로 접속됩니다.
|
||||
|
||||
### 다시 배포
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
업로드된 사진은 호스트의 `./uploads` 에 있으므로 재배포해도 그대로 남습니다.
|
||||
|
||||
### 확인 · 문제 해결
|
||||
|
||||
```bash
|
||||
docker compose ps # 상태 (healthy 여야 정상)
|
||||
docker compose logs -f # 로그
|
||||
docker compose down # 중지
|
||||
```
|
||||
|
||||
- **업로드가 403/500 으로 실패** → `uploads` 폴더 소유자 문제입니다.
|
||||
`sudo chown -R 1654:1654 uploads` 를 다시 확인하세요.
|
||||
- **DB 에 못 붙음** → 컨테이너 안에서 `localhost` 는 컨테이너 자신입니다.
|
||||
DB 가 같은 서버에 있어도 `.env` 에는 실제 IP 를 적어야 합니다.
|
||||
- **앞에 nginx 를 두는 경우** → `docker-compose.yml` 의 포트를
|
||||
`"127.0.0.1:5281:8080"` 으로 바꿔 외부 직접 접근을 막으세요.
|
||||
|
||||
### 컨테이너 없이 배포할 때
|
||||
|
||||
`dotnet publish` 산출물만 올려도 됩니다. 이때 **업로드 폴더는 덮어쓰지 마세요.**
|
||||
`GameServer.csproj` 에서 `wwwroot/uploads` 를 배포 산출물에서 빼 두었으므로
|
||||
publish 결과에는 사진이 들어가지 않습니다(12MB). 서버의 기존 `wwwroot/uploads` 는
|
||||
그대로 두고 나머지 파일만 교체하면 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 보안 메모
|
||||
|
||||
- 조회는 누구나, **작성 · 수정 · 삭제는 로그인한 관리자만** 가능합니다 (쿠키 세션).
|
||||
- 로그인은 IP별 **5분에 8회**로 제한됩니다.
|
||||
|
||||
15
deploy/genesis.env.example
Normal file
15
deploy/genesis.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# 서버의 /srv/genesis/genesis.env 로 복사해 값을 채운다.
|
||||
#
|
||||
# sudo cp deploy/genesis.env.example /srv/genesis/genesis.env
|
||||
# sudo nano /srv/genesis/genesis.env
|
||||
# sudo chown genesis:genesis /srv/genesis/genesis.env
|
||||
# sudo chmod 600 /srv/genesis/genesis.env
|
||||
#
|
||||
# systemd 가 읽는 형식이라 KEY=VALUE 한 줄씩, 따옴표는 붙이지 않는다.
|
||||
# 값 안에 $ 가 있으면 systemd 가 변수로 해석하므로 $$ 로 두 번 쓴다.
|
||||
#
|
||||
# 여기 넣은 값이 appsettings.json 보다 우선한다.
|
||||
|
||||
ConnectionStrings__DefaultConnection=Server=152.69.235.249;Port=3306;Database=mygame;Uid=root;Pwd=여기에-DB-비밀번호;
|
||||
Portfolio__AdminPassword=여기에-관리자-비밀번호
|
||||
Portfolio__MaxUploadBytes=104857600
|
||||
42
deploy/genesis.service
Normal file
42
deploy/genesis.service
Normal file
@@ -0,0 +1,42 @@
|
||||
# systemd 서비스 등록 파일.
|
||||
# 서버에서:
|
||||
# sudo cp deploy/genesis.service /etc/systemd/system/
|
||||
# sudo systemctl daemon-reload
|
||||
# sudo systemctl enable --now genesis
|
||||
#
|
||||
# 경로나 사용자 이름을 바꿨다면 아래 값도 함께 고치세요.
|
||||
|
||||
[Unit]
|
||||
Description=Genesis 게임 서버 + 포트폴리오 사이트
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
# 실행 파일과 wwwroot, appsettings.json 이 함께 있는 폴더
|
||||
WorkingDirectory=/srv/genesis
|
||||
ExecStart=/srv/genesis/GameServer
|
||||
|
||||
# 비밀값은 이 파일에서 읽는다. (git 에 올리지 않는다)
|
||||
EnvironmentFile=/srv/genesis/genesis.env
|
||||
|
||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||
# 0.0.0.0 이어야 외부에서 접속된다. nginx 를 앞에 둔다면 127.0.0.1 로 좁히세요.
|
||||
Environment=ASPNETCORE_URLS=http://0.0.0.0:5281
|
||||
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||
|
||||
User=genesis
|
||||
Group=genesis
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# .NET 은 SIGINT 를 받아야 정상 종료한다. (기본 SIGTERM 이면 강제 종료됨)
|
||||
KillSignal=SIGINT
|
||||
SyslogIdentifier=genesis
|
||||
|
||||
# 최소한의 격리. 업로드 폴더에는 써야 하므로 전체 읽기전용은 걸지 않는다.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
115
deploy/healthcheck.sh
Normal file
115
deploy/healthcheck.sh
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# 배포 후 서버가 제대로 떴는지 확인한다. 서버에서 실행한다.
|
||||
#
|
||||
# ./healthcheck.sh # 기본 http://localhost:5000
|
||||
# ./healthcheck.sh 5281 # 포트만 바꿔서
|
||||
# ./healthcheck.sh 5000 GameServer # 서비스 이름까지 지정
|
||||
# ./healthcheck.sh 5000 GameServer /home/ubuntu/gameserver # 앱 폴더까지
|
||||
#
|
||||
# 실패한 항목이 하나라도 있으면 종료 코드 1 을 돌려준다.
|
||||
|
||||
BASE="http://localhost:${1:-5000}"
|
||||
SERVICE="${2:-GameServer}"
|
||||
# 앱 폴더(GameServer 실행 파일과 wwwroot 가 있는 곳). 기본은 이 스크립트가 있는 폴더.
|
||||
APPDIR="${3:-$(cd "$(dirname "$0")" && pwd)}"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
ok() { PASS=$((PASS+1)); printf ' \033[32mOK \033[0m %s\n' "$1"; }
|
||||
bad() { FAIL=$((FAIL+1)); printf ' \033[31m실패\033[0m %s\n %s\n' "$1" "$2"; }
|
||||
|
||||
# code URL -> 응답 코드만
|
||||
code() { curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" 2>/dev/null; }
|
||||
|
||||
echo
|
||||
echo "=== 1. 서비스 상태 ==="
|
||||
if systemctl is-active --quiet "$SERVICE" 2>/dev/null; then
|
||||
ok "$SERVICE 실행 중"
|
||||
since=$(systemctl show -p ActiveEnterTimestamp --value "$SERVICE" 2>/dev/null)
|
||||
[ -n "$since" ] && echo " 시작: $since"
|
||||
# 계속 죽었다 살아나는 중인지
|
||||
n=$(systemctl show -p NRestarts --value "$SERVICE" 2>/dev/null || echo 0)
|
||||
if [ "${n:-0}" -gt 0 ]; then
|
||||
echo " 재시작 횟수: $n (반복되면 로그를 확인하세요)"
|
||||
fi
|
||||
else
|
||||
bad "$SERVICE 가 실행 중이 아님" "sudo systemctl status $SERVICE"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 2. 포트 열림 ==="
|
||||
PORT="${1:-5000}"
|
||||
if ss -tln 2>/dev/null | grep -q ":$PORT "; then
|
||||
ok "포트 $PORT 수신 중"
|
||||
ss -tlnp 2>/dev/null | grep ":$PORT " | sed 's/^/ /'
|
||||
else
|
||||
bad "포트 $PORT 가 열려 있지 않음" "sudo ss -tlnp | grep $PORT"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 3. 기존 게임 API (회귀 확인) ==="
|
||||
for p in /myGame/playableCharacters /myGame/userCharacters; do
|
||||
c=$(code "$BASE$p")
|
||||
if [ "$c" = "200" ]; then ok "GET $p"
|
||||
else bad "GET $p -> $c" "DB 연결이나 라우팅을 확인하세요"; fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 4. 포트폴리오 ==="
|
||||
c=$(code "$BASE/api/portfolio/categories")
|
||||
if [ "$c" = "200" ]; then
|
||||
ok "카테고리 API"
|
||||
n=$(curl -s --max-time 10 "$BASE/api/portfolio/categories" | grep -o '"categoryNo"' | wc -l)
|
||||
echo " 카테고리 $n 개"
|
||||
[ "$n" -eq 0 ] && echo " (0 개면 DB 스키마 생성이 실패했을 수 있습니다. 로그를 보세요)"
|
||||
else
|
||||
bad "카테고리 API -> $c" "DB 연결 실패일 가능성이 큽니다"
|
||||
fi
|
||||
|
||||
c=$(code "$BASE/api/portfolio/posts")
|
||||
[ "$c" = "200" ] && ok "게시물 API" || bad "게시물 API -> $c" ""
|
||||
|
||||
echo
|
||||
echo "=== 5. 사이트 (정적 파일) ==="
|
||||
for p in / /css/style.css /js/app.js; do
|
||||
c=$(code "$BASE$p")
|
||||
if [ "$c" = "200" ]; then ok "GET $p"
|
||||
else bad "GET $p -> $c" "wwwroot 가 함께 배포됐는지 확인하세요"; fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 6. 업로드 폴더 쓰기 권한 ==="
|
||||
UPDIR="$APPDIR/wwwroot/uploads"
|
||||
if [ -d "$UPDIR" ]; then
|
||||
if [ -w "$UPDIR" ]; then ok "쓰기 가능 ($UPDIR)"
|
||||
else bad "쓰기 불가 ($UPDIR)" "서비스 실행 사용자에게 권한을 주세요"; fi
|
||||
echo " 보관된 파일 $(find "$UPDIR" -type f ! -name '.gitkeep' 2>/dev/null | wc -l) 개"
|
||||
else
|
||||
bad "업로드 폴더 없음 ($UPDIR)" "mkdir -p '$UPDIR'"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 7. 최근 오류 로그 ==="
|
||||
# grep -c 는 일치가 없으면 0 을 출력하고 종료코드 1 을 낸다.
|
||||
# '|| echo 0' 을 붙이면 0 이 두 번 나와 숫자 비교가 깨지므로 쓰지 않는다.
|
||||
errs=$(journalctl -u "$SERVICE" -n 200 --no-pager 2>/dev/null | grep -icE 'fail|error|exception')
|
||||
errs=$(printf '%s' "${errs:-0}" | head -1)
|
||||
case "$errs" in ''|*[!0-9]*) errs=0 ;; esac
|
||||
|
||||
if [ "$errs" -eq 0 ]; then
|
||||
ok "최근 200줄에 오류 없음"
|
||||
else
|
||||
echo " 오류로 보이는 줄 $errs 개 — 확인:"
|
||||
journalctl -u "$SERVICE" -n 200 --no-pager 2>/dev/null \
|
||||
| grep -iE 'fail|error|exception' | tail -5 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "================================"
|
||||
printf ' 통과 %d 실패 %d\n' "$PASS" "$FAIL"
|
||||
echo "================================"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo
|
||||
echo "로그 전체 보기: sudo journalctl -u $SERVICE -n 100 --no-pager"
|
||||
exit 1
|
||||
fi
|
||||
5
deploy/publish-linux.cmd
Normal file
5
deploy/publish-linux.cmd
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
REM PowerShell 실행 정책을 바꾸지 않고 게시 스크립트를 실행한다.
|
||||
REM publish-linux.cmd
|
||||
REM publish-linux.cmd -Target user@서버주소
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0publish-linux.ps1" %*
|
||||
144
deploy/publish-linux.ps1
Normal file
144
deploy/publish-linux.ps1
Normal file
@@ -0,0 +1,144 @@
|
||||
# 리눅스용 자체 포함 실행 파일을 만든다. (윈도우 PowerShell 용)
|
||||
# bash 가 필요 없다. VS Code 기본 터미널에서 그대로 돌아간다.
|
||||
#
|
||||
# .\deploy\publish-linux.ps1
|
||||
# .\deploy\publish-linux.ps1 -Target ubuntu@서버주소 # 만들고 복사까지
|
||||
#
|
||||
# 경로·서비스 이름이 다르면:
|
||||
# .\deploy\publish-linux.ps1 -Target ubuntu@서버 `
|
||||
# -RemoteDir /home/ubuntu/gameserver -Service GameServer
|
||||
#
|
||||
# 서버에는 .NET 을 설치할 필요가 없다.
|
||||
|
||||
param(
|
||||
[string]$Target = "",
|
||||
[string]$RemoteDir = "/srv/genesis",
|
||||
# 비우면 서버에서 자동으로 찾는다.
|
||||
[string]$Service = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# 저장소 루트로 이동 (이 스크립트는 deploy/ 안에 있다)
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $root
|
||||
|
||||
$project = "GameServer/GameServer.csproj"
|
||||
$out = "artifacts/linux-x64"
|
||||
|
||||
Write-Host "== 1) 빌드 ==" -ForegroundColor Cyan
|
||||
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
|
||||
|
||||
dotnet publish $project `
|
||||
-c Release `
|
||||
-r linux-x64 `
|
||||
--self-contained true `
|
||||
-p:PublishSingleFile=true `
|
||||
-p:EnableCompressionInSingleFile=true `
|
||||
-p:DebugType=none `
|
||||
-p:DebugSymbols=false `
|
||||
-o $out `
|
||||
--nologo
|
||||
if ($LASTEXITCODE -ne 0) { throw "publish 실패" }
|
||||
|
||||
# 개발용 설정은 서버로 보내지 않는다.
|
||||
Remove-Item "$out/appsettings.Development.json" -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# 서버에서 바로 띄울 실행 스크립트.
|
||||
# - ASPNETCORE_URLS 가 없으면 localhost:5000 에만 묶여 외부에서 안 보인다.
|
||||
# - 윈도우에서 복사하면 실행 권한이 사라지므로 스스로 chmod 한다.
|
||||
# 반드시 LF 로 저장해야 한다. CRLF 면 리눅스에서 "bad interpreter" 로 죽는다.
|
||||
$runSh = @(
|
||||
'#!/usr/bin/env bash',
|
||||
'# 서버에서 이 폴더로 들어와 ./run.sh 하면 바로 뜬다.',
|
||||
'# PORT=8080 ./run.sh 처럼 포트를 바꿀 수 있다.',
|
||||
'set -euo pipefail',
|
||||
'cd "$(dirname "$0")"',
|
||||
'',
|
||||
'PORT="${PORT:-5281}"',
|
||||
'',
|
||||
'chmod +x ./GameServer 2>/dev/null || true',
|
||||
'mkdir -p wwwroot/uploads',
|
||||
'',
|
||||
'# 0.0.0.0 이어야 바깥에서 접속된다. 127.0.0.1 로 두면 서버 안에서만 보인다.',
|
||||
'export ASPNETCORE_URLS="http://0.0.0.0:${PORT}"',
|
||||
'export ASPNETCORE_ENVIRONMENT=Production',
|
||||
'',
|
||||
'# genesis.env 가 있으면 거기 값이 appsettings.json 보다 우선한다.',
|
||||
'if [ -f ./genesis.env ]; then',
|
||||
' set -a; . ./genesis.env; set +a',
|
||||
'fi',
|
||||
'',
|
||||
'echo "http://0.0.0.0:${PORT} 에서 실행합니다. (Ctrl+C 로 종료)"',
|
||||
'exec ./GameServer',
|
||||
''
|
||||
) -join "`n"
|
||||
|
||||
# UTF-8(BOM 없음) + LF 로 기록
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText((Join-Path $root "$out/run.sh"), $runSh, $utf8NoBom)
|
||||
|
||||
# 배포 후 확인 스크립트도 함께 넣는다.
|
||||
Copy-Item (Join-Path $PSScriptRoot "healthcheck.sh") "$out/" -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$size = "{0:N0} MB" -f ((Get-ChildItem $out -Recurse -File | Measure-Object Length -Sum).Sum / 1MB)
|
||||
Write-Host ""
|
||||
Write-Host " 산출물: $out ($size)" -ForegroundColor Green
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Target)) {
|
||||
Write-Host ""
|
||||
Write-Host " 서버로 올리려면:" -ForegroundColor Yellow
|
||||
Write-Host " .\deploy\publish-linux.ps1 -Target user@서버주소"
|
||||
Write-Host ""
|
||||
Write-Host " 직접 복사한다면:"
|
||||
Write-Host " scp -r $out/* user@서버주소:$RemoteDir/"
|
||||
Write-Host " ssh user@서버주소 `"cd $RemoteDir && chmod +x run.sh && ./run.sh`""
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "== 2) 업로드 ==" -ForegroundColor Cyan
|
||||
Write-Host " 대상: ${Target}:$RemoteDir"
|
||||
|
||||
# scp 는 지우지 않고 덮어쓰기만 한다.
|
||||
# 산출물에 wwwroot/uploads 가 없으므로 서버의 사진은 그대로 남는다.
|
||||
ssh $Target "mkdir -p '$RemoteDir'"
|
||||
if ($LASTEXITCODE -ne 0) { throw "서버 접속 실패" }
|
||||
|
||||
scp -r "$out/*" "${Target}:$RemoteDir/"
|
||||
if ($LASTEXITCODE -ne 0) { throw "복사 실패" }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "== 3) 마무리 ==" -ForegroundColor Cyan
|
||||
|
||||
# 서버에서 실행할 명령. 작은따옴표 here-string 이라 PowerShell 이 손대지 않는다.
|
||||
$remoteScript = @'
|
||||
set -e
|
||||
cd "__DIR__"
|
||||
chmod +x GameServer run.sh
|
||||
mkdir -p wwwroot/uploads
|
||||
echo " 실행 권한 설정 완료"
|
||||
|
||||
SVC="__SVC__"
|
||||
if [ -z "$SVC" ]; then
|
||||
for c in GameServer genesis gameserver portfolio; do
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q "^$c.service"; then SVC="$c"; break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$SVC" ]; then
|
||||
sudo systemctl restart "$SVC" && echo " $SVC 서비스 재시작"
|
||||
else
|
||||
echo " systemd 서비스를 찾지 못했습니다. ./run.sh 로 직접 실행하세요."
|
||||
fi
|
||||
'@
|
||||
|
||||
$remoteScript = $remoteScript.Replace('__DIR__', $RemoteDir).Replace('__SVC__', $Service)
|
||||
$remoteScript | ssh $Target "bash -s"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "완료. 서버에서 실행하려면:" -ForegroundColor Green
|
||||
Write-Host " ssh $Target"
|
||||
Write-Host " cd $RemoteDir && ./run.sh"
|
||||
Write-Host ""
|
||||
Write-Host " (계속 띄워 두려면 README 5절의 systemd 등록을 참고하세요)"
|
||||
124
deploy/publish-linux.sh
Normal file
124
deploy/publish-linux.sh
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env bash
|
||||
# 리눅스용 자체 포함 실행 파일을 만들고 서버로 올린다.
|
||||
# 윈도우(Git Bash)·맥·리눅스 어디서 돌려도 된다. 서버에는 .NET 을 설치할 필요가 없다.
|
||||
#
|
||||
# ./deploy/publish-linux.sh # 빌드만
|
||||
# ./deploy/publish-linux.sh ubuntu@서버 # 빌드 후 업로드까지
|
||||
#
|
||||
# 경로·서비스 이름이 다르면 환경변수로 준다:
|
||||
# REMOTE_DIR=/home/ubuntu/gameserver SERVICE=GameServer # ./deploy/publish-linux.sh ubuntu@서버
|
||||
#
|
||||
# 업로드된 사진(wwwroot/uploads)은 건드리지 않는다.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT="GameServer/GameServer.csproj"
|
||||
OUT="artifacts/linux-x64"
|
||||
TARGET="${1-}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/srv/genesis}"
|
||||
# 비우면 서버에서 자동으로 찾는다.
|
||||
SERVICE="${SERVICE:-}"
|
||||
|
||||
echo "== 1) 빌드 =="
|
||||
rm -rf "$OUT"
|
||||
dotnet publish "$PROJECT" \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:EnableCompressionInSingleFile=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
-o "$OUT" \
|
||||
--nologo
|
||||
|
||||
# 개발용 설정은 서버로 보내지 않는다.
|
||||
rm -f "$OUT/appsettings.Development.json"
|
||||
|
||||
# 서버에서 바로 띄울 수 있는 실행 스크립트를 함께 넣는다.
|
||||
# - ASPNETCORE_URLS 를 지정하지 않으면 localhost:5000 에만 묶여 외부에서 안 보인다.
|
||||
# - 윈도우에서 복사하면 실행 권한이 사라지므로 여기서 다시 붙인다.
|
||||
cat > "$OUT/run.sh" <<'RUNSH'
|
||||
#!/usr/bin/env bash
|
||||
# 서버에서 이 폴더로 들어와 ./run.sh 하면 바로 뜬다.
|
||||
# PORT=8080 ./run.sh 처럼 포트를 바꿀 수 있다.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
PORT="${PORT:-5281}"
|
||||
|
||||
chmod +x ./GameServer 2>/dev/null || true
|
||||
mkdir -p wwwroot/uploads
|
||||
|
||||
# 0.0.0.0 이어야 바깥에서 접속된다. 127.0.0.1 로 두면 서버 안에서만 보인다.
|
||||
export ASPNETCORE_URLS="http://0.0.0.0:${PORT}"
|
||||
export ASPNETCORE_ENVIRONMENT=Production
|
||||
|
||||
# genesis.env 가 있으면 거기 값이 appsettings.json 보다 우선한다.
|
||||
if [ -f ./genesis.env ]; then
|
||||
set -a; . ./genesis.env; set +a
|
||||
fi
|
||||
|
||||
echo "http://0.0.0.0:${PORT} 에서 실행합니다. (Ctrl+C 로 종료)"
|
||||
exec ./GameServer
|
||||
RUNSH
|
||||
chmod +x "$OUT/run.sh"
|
||||
|
||||
# 배포 후 확인 스크립트도 함께 넣는다.
|
||||
cp "$(dirname "$0")/healthcheck.sh" "$OUT/" 2>/dev/null || true
|
||||
chmod +x "$OUT/healthcheck.sh" 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo " 산출물: $OUT ($(du -sh "$OUT" | cut -f1))"
|
||||
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo
|
||||
echo " 서버로 올리려면: $0 user@서버주소"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 2) 업로드 =="
|
||||
echo " 대상: $TARGET:$REMOTE_DIR"
|
||||
|
||||
ssh "$TARGET" "mkdir -p '$REMOTE_DIR'"
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
# rsync 가 있으면 지운 파일까지 맞춰 준다.
|
||||
# 다만 uploads 와 비밀값 파일은 제외해 서버 것을 남긴다.
|
||||
rsync -avz --delete --exclude 'wwwroot/uploads/' --exclude 'genesis.env' --exclude 'appsettings.Production.json' "$OUT/" "$TARGET:$REMOTE_DIR/"
|
||||
else
|
||||
# 윈도우 Git Bash 에는 보통 rsync 가 없다. scp 로 보낸다.
|
||||
# scp 는 덮어쓰기만 하고 지우지 않으므로 서버의 사진은 그대로 남는다.
|
||||
echo " rsync 가 없어 scp 로 보냅니다."
|
||||
# 산출물에 appsettings.Production.json 이 없으므로 서버 것이 덮어써지지 않는다.
|
||||
scp -r "$OUT"/* "$TARGET:$REMOTE_DIR/"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 3) 마무리 =="
|
||||
# 윈도우에서 복사하면 실행 권한이 사라진다. 서버에서 다시 붙인다.
|
||||
ssh "$TARGET" "
|
||||
cd '$REMOTE_DIR' &&
|
||||
chmod +x GameServer run.sh &&
|
||||
mkdir -p wwwroot/uploads &&
|
||||
echo ' 실행 권한 설정 완료'
|
||||
|
||||
SVC='$SERVICE'
|
||||
# 이름을 안 줬으면 이 폴더를 실행하는 서비스를 찾아본다.
|
||||
if [ -z \"\$SVC\" ]; then
|
||||
for c in GameServer genesis gameserver portfolio; do
|
||||
if systemctl list-unit-files 2>/dev/null | grep -q \"^\$c.service\"; then SVC=\$c; break; fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n \"\$SVC\" ]; then
|
||||
sudo systemctl restart \"\$SVC\" && echo \" \$SVC 서비스 재시작\"
|
||||
else
|
||||
echo ' systemd 서비스를 찾지 못했습니다. ./run.sh 로 직접 실행하세요.'
|
||||
fi
|
||||
"
|
||||
|
||||
echo
|
||||
echo "완료."
|
||||
echo " 바로 띄워 보려면: ssh $TARGET \"cd $REMOTE_DIR && ./run.sh\""
|
||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
# 리눅스 서버에서 한 번에 띄우기.
|
||||
#
|
||||
# cp .env.example .env # 값 채우고
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# 다시 배포할 때:
|
||||
# git pull && docker compose up -d --build
|
||||
#
|
||||
# 업로드 폴더는 호스트의 ./uploads 로 빼 두었다.
|
||||
# 컨테이너를 지우고 다시 만들어도 올려 둔 사진은 그대로 남는다.
|
||||
|
||||
services:
|
||||
gameserver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: genesis-gameserver:latest
|
||||
container_name: genesis
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
# 호스트 5281 → 컨테이너 8080
|
||||
# 앞에 nginx 같은 리버스 프록시를 둔다면 "127.0.0.1:5281:8080" 으로 묶어
|
||||
# 외부에서 직접 접근하지 못하게 하는 편이 낫다.
|
||||
- "5281:8080"
|
||||
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
# 환경변수가 appsettings.json 보다 우선한다. 비밀값은 여기(.env)로만 넣는다.
|
||||
ConnectionStrings__DefaultConnection: ${DB_CONNECTION}
|
||||
Portfolio__AdminPassword: ${ADMIN_PASSWORD}
|
||||
Portfolio__MaxUploadBytes: ${MAX_UPLOAD_BYTES:-104857600}
|
||||
|
||||
volumes:
|
||||
# 업로드된 사진. 이게 없으면 재배포 때 전부 사라진다.
|
||||
# 호스트 폴더 소유자를 컨테이너의 app 사용자(UID 1654)로 맞춰야 쓰기가 된다:
|
||||
# mkdir -p uploads && sudo chown -R 1654:1654 uploads
|
||||
- ./uploads:/app/wwwroot/uploads
|
||||
|
||||
healthcheck:
|
||||
# 포트폴리오 공개 API 가 응답하면 정상으로 본다.
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/portfolio/categories >/dev/null 2>&1 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
Reference in New Issue
Block a user