홈서버에 블로그를 올리기까지 - 스택 선택부터 로그 수집까지
시리즈 - 블로그 만들기
이 블로그는 거실에 있는 홈서버에서 돌아간다. 같은 서버에서 이미 nginx, PostgreSQL, Keycloak, 사진 서비스 같은 것들이 Docker 컨테이너로 돌고 있었고, 블로그도 그 옆에 조용히 붙는 형태로 만들고 싶었다. 이 글은 그 첫 번째 기록이다.
출발점#
서버 상태부터 확인했다.
| 항목 | 값 |
|---|---|
| OS | Ubuntu 24.04 LTS |
| CPU / 메모리 | 8코어 / 15GB (여유 약 12GB) |
| 디스크 | 233GB 중 19% 사용 |
| 웹 | nginx 컨테이너 하나가 80/443을 받고, 도메인별로 각 서비스에 넘긴다 |
| DB | PostgreSQL 16 컨테이너 |
| 인증 | Keycloak (다른 서비스들이 이미 쓰는 중) |
| DNS | Cloudflare, 프록시 없이 DNS만 |
자원은 넉넉했지만, 블로그 하나 때문에 기존 서비스가 흔들리는 일은 없어야 했다. 그래서 처음부터 "기존 설정은 고치지 않고 추가만 한다"를 원칙으로 잡았다.
요구 사항#
- 방문자는 로그인하지 않는다. 읽고, 필요하면 댓글만 남긴다.
- 글은 대부분 자동화된 작업으로 올라간다. 관리자 화면은 만들지 않는다.
- 최신글, 인기글, 조회수, 댓글이 있어야 한다. 단, 조회수 숫자는 화면에 보여주지 않는다.
- 접속 기록은 자세히 남긴다. 블로그만이 아니라 서버의 웹 로그 전체가 필요하다.
- 가볍고 빨라야 한다.
스택 고르기#
처음에는 정적 사이트 생성기(Hugo)가 가장 가벼운 답이었다. 하지만 조회수, 인기글, 댓글이 들어가는 순간 어딘가에는 서버가 필요하고, 결국 페이지도 서버에서 그리는 편이 단순하다고 판단했다.
| 후보 | 장점 | 걸린 점 |
|---|---|---|
| Hugo (정적) | 프로세스 0개, 가장 빠름 | 조회수, 댓글을 위해 별도 서버가 필요 |
| Go + 템플릿 | 메모리 20MB 안팎 | 화면 쪽 생산성 |
| Next.js | 생태계 | 블로그에는 무겁다 |
| Astro SSR + Svelte | 기본 클라이언트 JS 0, 필요한 곳만 컴포넌트 | Node 프로세스 하나 |
Astro는 요청이 오면 서버에서 HTML을 만들어 보내고, 브라우저로 가는 자바스크립트는 댓글 영역처럼 상태가 바뀌는 곳에만 붙는다. 댓글 컴포넌트는 Svelte로 만들고 화면에 보일 때 불러온다.
// astro.config.mjs
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [svelte()],
});
DB는 이미 돌고 있는 PostgreSQL에 blog 데이터베이스와 전용 계정을 새로 만들어 썼다. ORM은 Drizzle이다.
전체 구성#
방문자 ── HTTPS ──> nginx (도메인별 server 블록)
│
▼
blog (Astro SSR, Node) ──> PostgreSQL (blog DB)
│ stdout: 요청마다 JSON 한 줄
▼
모든 컨테이너 stdout ──> Alloy ──> Loki ──> Grafana (Keycloak 로그인)
블로그 관련 컨테이너(blog, loki, alloy, grafana)는 기존 compose 파일에 섞지 않고 별도 compose 프로젝트로 띄웠다. 같은 Docker 네트워크에만 붙여서 nginx와 PostgreSQL에는 그대로 닿는다. 기존 compose 파일은 다른 저장소가 원본이라, 여기서 고치기 시작하면 두 곳이 어긋난다.
글 저장과 렌더링#
글은 Markdown으로 쓰고, 필요하면 중간에 HTML을 섞는다. 레이아웃이 복잡한 글은 아예 HTML로 쓸 수도 있다. 어느 쪽이든 저장할 때 한 번만 HTML로 변환해서 DB에 같이 넣어 둔다. 요청이 올 때는 변환 없이 꺼내기만 하니, 페이지를 만드는 데 드는 시간은 DB 조회 몇 번이 전부다.
변환 파이프라인은 unified 계열이다.
- Markdown:
remark-parse→remark-gfm→remark-rehype→rehype-raw(섞어 쓴 HTML 살리기) - HTML:
rehype-parse - 공통: 제목 id, 목차 수집, 제목 앵커, Shiki 코드 하이라이트
Shiki는 라이트/다크 두 테마를 CSS 변수로 함께 넣어 두고, 화면 테마에 따라 변수만 바꿔 쓴다. 테마를 바꿔도 다시 그릴 필요가 없다.
조회수: 세지만 보여주지 않는다#
조회수는 서버가 페이지를 만들 때가 아니라, 페이지가 열린 뒤 브라우저가 따로 보내는 요청으로 센다. 검색 엔진 수집기 대부분은 이 요청을 보내지 않기 때문에 그것만으로도 숫자가 꽤 정직해진다.
같은 사람이 새로고침할 때마다 숫자가 오르면 안 되니 하루 단위로 방문자를 구분한다. 쿠키는 쓰지 않는다.
// 하루에 한 번 바뀌는 솔트와 섞어서 해시만 남긴다
const hash = sha256(`${dailySalt}|${ip}|${userAgent}`);
// (post_id, day, hash) 가 유니크라서 같은 날 두 번째부터는 무시된다
솔트는 사흘 뒤 지운다. 그 뒤에는 남아 있는 해시로 원래 IP를 되돌릴 방법이 없다. 집계는 일별 테이블에 쌓이고, 인기글은 최근 7일과 30일 합계로 순서를 정한다. 숫자 자체는 화면에 내보내지 않는다. 글의 가치를 숫자로 먼저 보여주고 싶지 않았다.
로그인 없는 댓글#
로그인이 없으니 닉네임과 비밀번호로 쓰고, 같은 비밀번호로 수정하거나 지운다. 비밀번호는 argon2 해시로만 저장한다.
스팸은 여러 겹으로 막는다.
- 사람 눈에는 보이지 않는 입력칸(honeypot). 채워져 있으면 성공한 척하고 버린다.
- 폼을 연 시각을 서명한 토큰. 3초 안에 제출하거나 하루가 지나면 거부한다.
- 같은 IP 기준 5분에 3건, 하루 20건 제한.
- 같은 내용 반복, 링크 4개 이상은 거부.
- 링크가 하나라도 있으면 바로 공개하지 않고 확인 후 공개.
댓글 본문은 Markdown 일부만 받고 허용한 태그 외에는 모두 지운다. <script>나 javascript: 링크는 남지 않는다. 글 본문과 달리 방문자가 쓰는 내용이라 기준을 다르게 뒀다.
서버 웹 로그 전체를 Loki로#
처음에는 nginx 로그를 직접 PostgreSQL에 넣는 수집기를 만들 생각이었다. 그런데 Grafana, Loki, Alloy 조합을 쓰면 수집기를 만들 필요가 없다. Alloy가 Docker 소켓으로 모든 컨테이너의 출력을 읽어 Loki로 보내 주기 때문이다. 블로그뿐 아니라 서버에서 도는 컨테이너 12개의 로그가 한곳에 모였다.
nginx에서 바꾼 것은 로그 형식 하나다. 기존 텍스트 형식을 JSON으로 바꿨고, 출력 위치는 그대로 두었다.
log_format json_main escape=json '{"ts":"$time_iso8601","host":"$host","ip":"$remote_addr",'
'"method":"$request_method","uri":"$request_uri","status":$status,'
'"rt":$request_time,"urt":"$upstream_response_time","ua":"$http_user_agent", ...}';
access_log /var/log/nginx/access.log json_main;
Alloy에서는 도메인(host)과 상태 코드 범위(2xx, 4xx 같은 것)만 라벨로 올리고, IP나 경로처럼 값이 끝없이 늘어나는 것은 라벨이 아닌 로그 본문 쪽에 둔다. Loki는 라벨 조합 수가 늘어날수록 느려진다. 보존 기간은 12개월이다.
Grafana 로그인은 이미 쓰고 있던 Keycloak에 맡겼다. Keycloak에서 Grafana용 클라이언트를 만들고, 특정 역할이 있는 계정만 관리자 권한으로 들어오게 했다. 블로그에 글을 올리는 내부 API도 같은 Keycloak의 서비스 계정 토큰으로만 열린다. 토큰이 없으면 그 경로는 그냥 404다.
인증서를 무중단으로#
기존 도메인들의 인증서는 certbot이 잠깐 nginx를 멈추고 80번 포트를 직접 열어 발급하는 방식이었다. 갱신 때마다 모든 사이트가 몇 초씩 끊긴다.
새 도메인은 nginx를 멈추지 않는 webroot 방식으로 바꿨다. 80번 포트의 서버 블록에 위치 하나만 추가했다.
location /.well-known/acme-challenge/ {
root /usr/share/nginx/html;
}
발급과 갱신이 끝나면 deploy hook이 인증서를 nginx가 읽는 위치로 복사하고 nginx -s reload만 한다. 컨테이너는 재시작하지 않는다.
nginx 설정을 바꿀 때는 매번 같은 순서를 지켰다. 백업, 변경 내용 diff 확인, nginx -t, reload, 그리고 다른 도메인들이 이전과 같은 응답을 주는지 확인. 이 서버의 nginx 설정 파일은 바인드 마운트라서 sed -i로 고치면 inode가 바뀌어 마운트가 끊긴다는 주석이 있었다. 그래서 새 내용을 만든 뒤 cat 새파일 > nginx.conf로 덮어썼다.
중간에 막힌 곳#
공유 이미지의 한글이 사각형으로 나왔다. 글마다 1200x630 미리보기 이미지를 satori로 그리는데, 웹 페이지용 글꼴은 유니코드 범위별로 잘게 나뉜 파일들이다. satori는 같은 이름의 글꼴 여러 개를 하나로 합치지 못해서 한글이 빠졌고, 이름을 서로 다르게 주자 이번에는 글자 폭을 잘못 계산해 제목이 이미지 밖으로 넘쳤다. 결국 이미지 렌더링에만 한글 전체가 든 TTF를 따로 두었다.
그 다음에는 하단 줄이 통째로 사라졌는데, 이건 글꼴 문제가 아니었다. 바깥 상자에 width: 100%와 여백을 같이 주면서 상자가 이미지보다 커졌던 것이다. box-sizing: border-box와 고정 크기로 해결했다.
고정폭 글꼴에 한글이 없다. 날짜나 라벨에 IBM Plex Mono를 썼더니 한글 사이 공백이 유난히 넓었다. 한글은 다른 글꼴로 대체되지만 공백 문자는 고정폭 글꼴의 넓은 공백을 그대로 쓰기 때문이다. 한글이 섞이는 라벨은 본문 글꼴로 바꿨다.
개발 PC의 Docker가 이미지를 받지 못했다. 로컬에서 PostgreSQL 이미지를 받다가 계속 멈췄다. 로컬 DB는 Homebrew의 PostgreSQL 16으로 대신하고, 배포용 이미지는 서버에서 직접 빌드하도록 절차를 바꿨다. 소스를 rsync로 올리고 서버에서 docker build 후 해당 컨테이너만 다시 띄운다.
Loki가 처음에 로그를 거부했다. Alloy를 처음 켜면 Docker에 남아 있던 지난 로그까지 한꺼번에 보내는데, Loki는 7일보다 오래된 로그를 받지 않도록 설정되어 있었다. 첫 기동 때 잠깐 나는 오류라 그대로 두었다.
숫자#
| 항목 | 값 |
|---|---|
| 글 페이지 응답 (캐시 적중) | 약 5ms |
| 글 페이지 HTML | 약 22KB |
| blog 컨테이너 메모리 | 약 36MB |
| Loki / Alloy / Grafana | 약 92MB / 75MB / 300MB |
블로그 자체보다 로그 쪽이 더 많은 자원을 쓴다. 네 개를 합쳐도 약 500MB, 서버 전체 메모리의 3% 남짓이다.
다음 글#
이 글에서는 전체 흐름만 다뤘다. 다음에는 Alloy와 Loki 설정을 조금 더 자세히, 특히 nginx JSON 로그를 어떤 라벨로 나누고 Grafana에서 어떻게 보는지를 정리할 생각이다.
댓글 0
불러오는 중