개인 블로그를 만들었는데 어느새 CI/CD까지 왔다

처음부터 CI/CD나 컨테이너 기반 배포 환경을 만들 생각은 없었다.

라즈베리파이 한 대에서 개인 블로그를 운영하고, 글과 사진을 올리고, 방 안의 센서 값을 기록하는 정도였다. 필요한 기능이 생길 때마다 하나씩 붙이다 보니 Flask, Hugo, PostgreSQL, Redis, Docker, GitHub Actions까지 연결된 작은 플랫폼이 됐다.

돌이켜보면 처음부터 지금의 구조를 설계한 것은 아니다.

오히려 작업을 하면서 계속 새로운 질문이 생겼다.

  • 센서 데이터를 계속 CSV 파일에 쌓는 게 맞을까?
  • 컨테이너를 지웠다가 다시 만들어도 글과 사진은 살아 있어야 하지 않을까?
  • 왜 운영 서버에서 직접 소스를 수정하고 빌드하고 있지?
  • 개발 PC에서는 잘 되는데 깨끗한 Linux 환경에서도 정말 잘 될까?
  • Hugo 코드와 실제 블로그 콘텐츠는 누가 소유해야 할까?
  • Raspberry Pi가 고장 나거나 OS를 다시 설치하면 이 서버를 다시 만들 수 있을까?

이 질문들을 하나씩 해결하면서 현재 구조까지 왔다.

라즈베리파이 기반 블로그 플랫폼 개념도

시작은 평범한 Raspberry Pi 블로그였다

브라우저에서 보는 블로그는 Hugo가 만든 정적 사이트다.

글을 작성하고 사진을 관리하는 기능은 Flask로 만들었고, Hugo가 생성한 결과물은 Nginx가 제공한다.

현재 관리 화면에서는 다음과 같은 작업을 할 수 있다.

  • Tech, Diary, Travel, Books, Hobby 등 카테고리별 글 관리
  • Markdown 기반 글 작성과 수정
  • 이미지 드래그 앤 드롭 및 붙여넣기 업로드
  • 이미지 리사이즈 및 WebP 변환
  • 워터마크 적용
  • Hugo rebuild 및 publish
  • 사진 gallery용 manifest 생성
  • 관리자 로그인 및 반복 로그인 실패 제한

글 자체는 Markdown 파일로 남는다.

이 구조를 선택한 이유 중 하나는 콘텐츠가 특정 CMS나 데이터베이스에 갇히지 않기 때문이다. 서버 프로그램이 없어지더라도 Markdown 파일만 있으면 글을 읽을 수 있고, 다른 정적 사이트 생성기로 옮기는 것도 어렵지 않다.

센서 로그가 CSV를 벗어나기 시작했다

Raspberry Pi에는 환경 센서도 연결되어 있다.

온도와 습도뿐 아니라 이산화탄소와 미세먼지 등의 값을 주기적으로 읽는다.

초기에는 이런 값을 CSV 파일에 기록했다.

개인용 센서 기록 정도라면 CSV도 충분하다. 하지만 시간이 지나면서 최근 값을 빠르게 읽거나 기간별 데이터를 조회하고, 웹 화면에서 사용하려고 하니 파일 하나에 계속 기록하는 방식이 점점 불편해졌다.

그래서 센서 데이터의 역할을 나눴다.

구성 역할
PostgreSQL 센서 이력의 영구 저장
Redis 최신 값과 빠른 조회용 cache
Flask 저장된 값을 웹 화면에 제공

Redis가 죽어도 PostgreSQL 기록은 계속할 수 있고, Redis에 최신 값이 없다면 PostgreSQL에서 다시 읽어올 수 있도록 했다.

센서 수집과 웹 요청도 분리했다. 웹 요청이 센서 장치의 응답을 직접 기다리지 않도록 해서 하드웨어 상태가 블로그 응답 속도에 영향을 주지 않게 했다.

작은 변경이었지만, 이때부터 Raspberry Pi 안의 프로그램들이 하나의 거대한 프로세스가 아니라 서로 다른 책임을 가진 서비스로 보이기 시작했다.

SSD로 옮기면서 Docker를 시작했다

Raspberry Pi의 저장장치를 SSD로 옮긴 뒤 서버 구성도 같이 정리하기 시작했다.

기존에는 Python 환경, Flask, Hugo, systemd 서비스 등이 Raspberry Pi OS에 직접 설치되어 있었다.

이 구조에서는 현재 OS에 설치된 상태 자체가 곧 서버였다.

어떤 Python package가 설치되어 있는지, Hugo는 어느 버전인지, 어느 systemd unit이 어떤 프로세스를 실행하는지를 모두 기억해야 했다.

Docker로 옮기면서 기준을 바꿨다.

Ririlog 빌드와 배포 아키텍처

현재 애플리케이션 구성은 대략 다음과 같다.

  • Flask / Gunicorn
  • Hugo
  • Nginx
  • PostgreSQL
  • Redis

애플리케이션과 데이터베이스를 하나의 컨테이너에 넣지 않고 역할별로 분리했다.

그리고 Flask가 글을 저장한 뒤 Hugo를 호출하는 기존 동작을 유지하기 위해 Flask와 Hugo runtime은 같은 application image 안에서 동작하도록 했다.

Docker화를 하면서 처음 생각했던 것보다 훨씬 중요한 문제가 하나 나타났다.

코드와 콘텐츠는 누가 소유하는가

기존 Hugo 디렉터리에는 여러 종류의 파일이 섞여 있었다.

  • Hugo 설정
  • layout
  • theme
  • CSS
  • 실제 Markdown 글
  • 실제 업로드 사진
  • 생성된 사이트 결과물

처음에는 이 전체 디렉터리를 컨테이너에 mount하는 방식도 가능했다.

하지만 그렇게 하면 Docker image가 있어도 실제 실행 환경은 여전히 특정 Raspberry Pi 디렉터리에 크게 의존한다.

그래서 경계를 다시 정했다.

코드는 image가 소유하고, 데이터는 host가 소유한다.

현재는 다음처럼 구분한다.

종류 소유
Flask application Docker image
Hugo runtime Docker image
Hugo layout / assets / theme Docker image
실제 Markdown 글 Persistent storage
실제 업로드 사진 Persistent storage
생성 데이터 Persistent storage
Production 전용 설정 Persistent storage

Docker image에는 실제 블로그 글이나 사진을 넣지 않는다.

반대로 새 image를 받아도 기존 글과 사진은 계속 같은 storage를 사용한다.

덕분에 application container는 언제든 버리고 다시 만들 수 있다.

이 구조를 만들고 나서 가장 크게 달라진 생각은 이것이었다.

서버는 소스 저장소가 아니라 검증된 실행 이미지를 받는 장소다.

정적 사이트도 중간 상태를 보여주지 않는다

Hugo가 사이트를 다시 생성할 때 기존 웹 디렉터리에 파일을 하나씩 덮어쓰게 하면 짧은 순간이라도 서로 다른 버전의 HTML과 CSS가 섞일 수 있다.

그래서 새로운 release 디렉터리에 전체 사이트를 먼저 생성하고, build가 성공한 경우에만 현재 release를 가리키는 링크를 교체하도록 했다.

이 방식에서는:

  1. Hugo build가 실패해도 기존 사이트는 그대로 남는다.
  2. build 중간 상태가 방문자에게 노출되지 않는다.
  3. HTML과 asset 버전이 서로 섞이지 않는다.
  4. 직전의 정상 release를 잠시 유지할 수 있다.

Nginx는 완성된 정적 결과만 서비스한다.

Fingerprint가 붙은 asset은 길게 cache할 수 있고, HTML은 새 글이 빠르게 반영되도록 cache 정책을 다르게 적용할 수 있다.

사진 페이지도 자동으로 만들었다

블로그에 사진이 많아지면서 게시글과 별개로 전체 사진을 모아보는 페이지도 만들었다.

일반 static image와 Hugo page bundle 내부의 image resource를 검색해 manifest를 만들고, Hugo의 Photos 페이지가 이를 이용해 gallery를 구성한다.

사진 파일 자체를 새로운 위치로 복사하는 것이 아니라 기존 콘텐츠 구조를 유지하면서 URL 목록만 생성한다.

생성된 manifest 역시 application code와는 분리된 데이터다.

블로그인데 작은 웹앱도 있다

정적 블로그만 있는 것은 아니다.

별도로 만든 Web Tetris와 Web Chess도 같은 사이트에서 접근할 수 있게 연결했다.

환경 센서 dashboard와 게임까지 포함되면서 이제는 단순히 글을 읽는 블로그라기보다 개인용 웹 포털에 조금 더 가까워졌다.

그렇다고 모든 기능을 하나의 거대한 프로그램 안에 넣지는 않았다.

정적 콘텐츠는 Hugo가 담당하고, 관리 기능은 Flask가 담당하며, 센서 기록은 별도로 수집한다.

서비스가 늘어날수록 무엇을 추가하느냐보다 어디까지를 하나의 책임으로 볼 것인가가 더 중요해졌다.

개발 환경을 Raspberry Pi에서 떼어냈다

Docker 전환 다음에는 개발 장소를 바꿨다.

운영 중인 Raspberry Pi에서 직접 소스를 수정하는 대신 WSL을 개발 환경으로 사용하고 GitHub를 source of truth로 두기 시작했다.

개발 환경에서는 실제 production 글과 사진을 사용하지 않는다.

대신 가짜 게시물과 synthetic image fixture를 이용해서:

  • Hugo 전체 build
  • 글 생성 / 수정 / 삭제
  • 이미지 upload
  • Photo gallery
  • PostgreSQL persistence
  • Redis fallback

등을 테스트한다.

Raspberry Pi에서만 존재하는 실제 센서 하드웨어는 테스트 fixture로 대체한다.

그 결과 역할은 다음처럼 나뉘었다.

WSL
= 개발

GitHub
= source of truth + CI

Container Registry
= 검증된 실행 image

Raspberry Pi
= production runtime + 실제 데이터

두 개의 저장소를 만들었다가 다시 하나로 합쳤다

중간에는 Flask application과 Hugo 사이트 코드를 서로 다른 Git repository로 분리하기도 했다.

실제 production Hugo tree에 글과 사진이 같이 들어 있었기 때문에, 안전한 개발용 Hugo repository를 별도로 만드는 것이 당시에는 좋은 선택이었다.

하지만 코드와 production 데이터의 분리가 끝난 뒤에는 다른 문제가 보였다.

실제로 가장 자주 수정하는 것은 Flask 기능보다 Hugo layout, CSS, 정적 페이지였다.

Photo 기능 하나를 변경해도 application 쪽에서는 manifest 생성 로직을 바꾸고 Hugo 쪽에서는 layout을 바꿔야 했다.

두 repository를 유지하면 하나의 기능을 release하기 위해 두 commit의 조합까지 관리해야 했다.

그래서 다시 하나의 monorepo로 합쳤다.

현재 repository에는:

  • Flask application
  • Hugo config
  • layout
  • assets
  • shortcode
  • PaperMod theme
  • Docker / Compose
  • integration test
  • synthetic fixture

가 함께 들어 있다.

하지만 실제 글과 실제 사진은 여전히 repository 밖에 있다.

이제 Git commit 하나가 블로그 프로그램 전체의 한 버전을 의미한다.

CI를 붙이자 로컬에서는 안 보이던 문제가 나타났다

GitHub Actions를 붙이고 가장 먼저 한 일은 기존 WSL 테스트를 깨끗한 GitHub runner에서도 실행하는 것이었다.

그러자 바로 실패했다.

첫 번째 원인은 Python package 중 일부가 native extension을 build하는 데 필요한 compiler와 system library가 GitHub runner에 없었던 것이었다.

WSL에서는 Docker image를 만드는 과정에서 이미 build dependency가 설치되어 있었기 때문에 보이지 않았던 문제였다.

필요한 native build dependency를 명시적으로 설치하도록 바꾸자 다음 문제가 나타났다.

이번에는 Hugo fixture 디렉터리의 permission 문제였다.

WSL의 Docker Desktop에서는 정상적으로 접근할 수 있었지만 GitHub의 native Linux Docker에서는 bind mount의 실제 Linux UID와 directory permission이 그대로 적용됐다.

컨테이너 안의 non-root 사용자에게 directory traverse 권한이 없어 테스트가 실패했다.

두 문제 모두 내 개발 환경에서는 이미 정상적으로 동작하던 코드였다.

이 경험을 통해 CI가 단순히 테스트 명령을 자동 실행하는 도구가 아니라는 것을 다시 확인했다.

CI는 개발 머신에 숨어 있는 전제를 찾아내는 도구이기도 했다.

GitHub Actions에서 ARM64 production image 만들기

Raspberry Pi는 ARM64 환경이다.

GitHub-hosted runner 자체는 다른 architecture에서 실행되지만 Docker Buildx를 이용해 production용 ARM64 image를 생성한다.

현재 pipeline은 다음 순서로 동작한다.

git push
GitHub Actions CI
unit / integration test
Docker image 검사
ARM64 production image build
GHCR publish

CI에서는 다음을 확인한다.

  • Python unit test
  • Hugo / Photo gallery test
  • 게시물 create → update → delete lifecycle
  • Redis fallback
  • PostgreSQL persistence
  • Docker Compose integration
  • runtime ownership
  • production content와 실제 image가 Docker image에 포함되지 않았는지
  • .env, key 등 secret 파일이 image에 포함되지 않았는지

CI가 실패하면 production image는 publish되지 않는다.

성공한 경우에만 ARM64 image를 registry에 올린다.

Production version 역시 움직이는 latest 하나를 신뢰하기보다는 Git commit SHA와 image digest로 식별할 수 있게 했다.

Push와 production 배포는 분리했다

CI/CD를 구성하면서 한 가지 원칙은 분명히 했다.

코드를 push했다고 production까지 자동으로 바꾸지는 않는다.

Hugo layout이나 CSS를 수정하면서 하루에도 여러 번 commit할 수 있다.

그때마다 실제 블로그가 자동으로 교체되는 것은 원하는 동작이 아니다.

그래서 현재 자동화의 경계는 여기까지다.

push
CI
검증
ARM64 image
Registry

--- 자동화 경계 ---

Production 배포

즉 Continuous Deployment보다는 Continuous Delivery에 가깝다.

배포 가능한 결과물까지는 자동으로 만들지만, 그 결과물을 실제 production으로 승격할지는 사람이 결정한다.

Raspberry Pi runner의 역할도 달랐다

처음 self-hosted GitHub Actions runner를 생각했을 때는 Raspberry Pi가 CI도 수행하는 구조를 떠올리기 쉽다.

하지만 현재 구조에서는 그럴 이유가 없다.

CI와 image build는 GitHub-hosted runner가 훨씬 잘한다.

Raspberry Pi runner가 필요한 이유는 단 하나다.

배포 에이전트.

GitHub-hosted runner
= build / test

Raspberry Pi self-hosted runner
= production deploy

Raspberry Pi에 GitHub Actions runner를 설치하고 production 전용 label을 붙인 뒤, 실제 production을 건드리지 않는 smoke test도 수행했다.

Runner는 GitHub에서 job을 정상적으로 받고 Docker와 현재 container 상태를 조회할 수 있다.

Private container registry 인증 역시 GitHub Actions가 실행 중에 발급하는 credential을 이용할 수 있기 때문에 Raspberry Pi에 장기적인 registry password를 별도로 저장하지 않아도 된다.

현재는 실제 production deployment workflow를 연결하는 마지막 단계만 남아 있다.

배포에서 중요한 것은 교체보다 rollback이다

새 image를 실행하는 것 자체는 어렵지 않다.

더 중요한 것은 새 image에 문제가 있을 때 기존 상태로 돌아갈 수 있는가이다.

현재 production 배포 구조에서는 배포 전에 기존 image를 기록하고, 새 image로 container를 교체한 뒤 다음을 검증한다.

  • container health
  • localhost HTTP
  • 실제 공개 홈페이지
  • 글 목록
  • 사진 페이지

실패하면 직전에 실행하던 image로 돌아가 다시 health와 HTTP 상태를 확인한다.

이 과정에서도 실제 Markdown 글, 업로드 사진, database 등 persistent data는 교체하지 않는다.

프로그램과 데이터를 나눈 가장 큰 이유가 여기에서 드러난다.

이제 Raspberry Pi OS도 서버의 정체성이 아니다

이번 작업을 하면서 예상하지 못했던 변화도 하나 있었다.

이제 Raspberry Pi OS를 다시 설치하는 일이 예전만큼 무섭지 않다.

예전에는 OS에 설치된 Python package, Hugo, 서비스 설정과 각종 파일이 곧 서버였다.

현재는 중요한 요소가 대부분 분리되어 있다.

  • 애플리케이션 실행환경은 Docker image
  • 소스는 GitHub
  • production image는 container registry
  • 글과 사진은 persistent storage
  • database는 backup 가능
  • production 설정은 별도 관리

따라서 새 OS를 설치하더라도 Docker와 운영 설정을 준비하고 데이터를 복원한 뒤 검증된 image를 다시 받으면 상당 부분을 재구성할 수 있다.

애플리케이션 코드는 더 이상 특정 Raspberry Pi에 속하지 않는다.

이 변화가 이번 작업에서 가장 마음에 드는 부분이다.

다음은 Kubernetes?

처음 Docker를 시작했을 때 Kubernetes까지 갈 생각은 없었다.

그런데 지금 구조를 보면 다음 단계로 k3s 같은 경량 Kubernetes를 시험해보기 좋은 상태가 되어 있다.

이미:

  • container별 역할 분리
  • immutable image
  • persistent data
  • health check
  • registry
  • CI
  • ARM64 build
  • deployment와 data의 분리

가 되어 있기 때문이다.

Docker Compose를 k3s로 바꾸더라도 application image를 만드는 앞부분은 거의 그대로 사용할 수 있다.

현재

GitHub
 → CI
 → Docker image
 → Registry
 → Raspberry Pi Docker Compose


나중

GitHub
 → CI
 → Docker image
 → Registry
 → Raspberry Pi k3s

마지막 실행 관리자만 달라지는 셈이다.

하지만 Kubernetes 자체가 목표는 아니다.

지금까지도 새로운 기술을 먼저 골라놓고 시스템을 거기에 맞춘 것이 아니라, 현재 구조에서 불편한 점이 생길 때 그 문제를 해결하면서 다음 단계로 넘어왔다.

k3s도 같은 방식으로 접근해볼 생각이다.

마무리

시작은 Raspberry Pi 한 대에서 돌아가는 개인 블로그였다.

CSV에 센서 값을 기록하고, Flask에서 Markdown 글을 저장하고, Hugo를 실행해 정적 페이지를 만드는 정도였다.

그런데 하나씩 문제를 해결하다 보니:

CSV
→ PostgreSQL / Redis

Host process
→ Docker Compose

Production에서 직접 개발
→ WSL + GitHub

코드와 콘텐츠가 섞인 Hugo tree
→ Immutable image + persistent data

로컬 테스트
→ GitHub Actions CI

Pi에서 직접 build
→ GHCR ARM64 image

수동 명령
→ Production deploy agent

까지 왔다.

재미있는 점은 이 모든 작업을 했어도 블로그를 사용하는 방법은 거의 변하지 않았다는 것이다.

여전히 관리자 화면에서 글을 쓰고 사진을 올리면 된다.

달라진 것은 문제가 생겼을 때의 생각이다.

예전에는 현재 Raspberry Pi 안에 들어 있는 상태 자체가 서버였다.

지금은 Raspberry Pi가 없어져도 다시 가져올 수 있는 코드와 image, 별도로 보존되는 데이터와 설정으로 서버를 설명할 수 있다.

개인 블로그 하나가 생각보다 꽤 멀리 왔다.