Skip to main content

        Hugo 블로그 셀프호스팅 하기 - Featured image

Hugo 블로그 셀프호스팅 하기

개요


블로그를 새로 구축하며 엔진 변경부터 UI 개선, 그리고 배포 자동화까지 진행했던 작업 내용을 정리한다.

  1. Jekyll에서 Hugo로 블로그 엔진 변경
  2. 테마 적용 및 커스터마이징
  3. Obsidian 연동과 Git Submodule 기반 콘텐츠 분리
  4. Argo Events / Argo Workflows 기반 배포 자동화

Jekyll에서 Hugo로 블로그 엔진을 변경한 이유

기존 블로그는 Jekyll 기반으로 운영하고 있었다.
GitHub Pages와의 호환성이 좋아 널리 사용되는 정적 사이트 생성기이지만, 실제로 운영하면서 몇 가지 불편한 점이 있었다.

테마 관리의 어려움

직접 테마를 제작하는 경우라면 큰 문제가 되지 않겠지만, 오픈소스 테마를 기반으로 블로그를 운영하는 경우에는 상황이 다르다.

테마를 커스터마이징하기 시작하면 블로그 레포지토리 내부에서 직접 수정해야 하는 경우가 많고, 이 상태에서 테마가 업데이트되면 변경 사항을 다시 병합해야 하는 문제가 발생한다.

결과적으로 테마를 조금만 수정해도 상위 테마 업데이트를 적용하기 어려워지는 구조가 된다.
이 때문에 테마를 적극적으로 커스터마이징하는 것이 부담스러웠다.

포스트 관리의 어려움

Jekyll에서는 기본적으로 다음과 같은 형식의 파일명을 요구한다.

YYYY-MM-DD-post-title.md

이 규칙 자체가 큰 문제는 아니지만, 포스트를 작성하고 관리하는 과정에서 항상 이 형식을 유지해야 한다는 점이 번거롭게 느껴졌다.

또한 포스트에 포함되는 이미지나 첨부 파일 관리 방식도 불편했다.

이미지 파일을 보통 별도의 assets 디렉토리에 저장하고 경로를 직접 지정해야 하기 때문에, 포스트와 관련된 리소스를 하나의 단위로 묶어 관리하기 어렵다.

예를 들어 다음과 같은 구조가 된다.

_posts/
assets/images/

이 구조에서는

  • 포스트와 이미지가 서로 다른 위치에 존재하고
  • 포스트 이동 시 이미지 경로 수정이 필요하며
  • 포스트 단위 관리가 어렵다

특히 포스트만 별도의 레포지토리로 분리하여 관리하기도 쉽지 않다.
결과적으로 블로그 전체 레포지토리를 함께 관리하는 것이 가장 현실적인 방법이었다.

Hugo를 선택한 이유

HugoJekyll과 마찬가지로 정적 사이트 생성기이지만, 몇 가지 구조적인 특징이 눈에 띄었다.

테마 오버라이드 구조

Hugo는 기본적으로 테마 오버라이드(override) 구조를 지원한다.

테마 파일을 직접 수정하는 대신, 프로젝트에서 필요한 파일만 덮어쓰는 방식으로 커스터마이징할 수 있다.
덕분에 테마를 submodule 형태로 관리하면서도 자유롭게 수정할 수 있다.

이 방식은 다음과 같은 장점이 있다.

  • 테마 원본 유지
  • 테마 업데이트 적용 가능
  • 커스터마이징 파일만 별도 관리

Page Bundle 기능

Hugo에는 Page Bundle이라는 기능이 있다.
이 기능을 사용하면 포스트와 리소스를 하나의 디렉토리 단위로 관리할 수 있다.

예를 들어 다음과 같은 구조가 가능하다.

content/posts/post-title/
├─ index.md
├─ diagram.png
└─ topology.png

이 구조에서는

  • index.md → 실제 포스트
  • 나머지 파일 → 해당 포스트의 리소스

하나의 포스트가 하나의 디렉토리로 묶여 관리된다.

이 방식은 다음과 같은 장점이 있다.

  • 포스트와 리소스를 함께 관리 가능
  • 레포지토리 분리가 쉬움
  • 경로 관리가 단순함
  • 이미지 링크가 깨질 가능성이 낮음

posts 디렉토리를 submodule로 분리하여 별도의 레포지토리로 관리하는 구조를 만들기 쉬워지기 때문에 Obsidian과 같은 노트 기반 작성 도구와도 궁합이 좋다.

하나의 포스트 디렉토리를 하나의 노트처럼 사용할 수 있기 때문이다.

비교 항목JekyllHugo
빌드 속도포스트가 많아질수록 느려짐 (Ruby 기반)압도적으로 빠름 (Go 기반)
테마 관리원본 수정이 잦아 업데이트가 어려움Override 구조로 테마 원본 유지 가능
리소스 관리이미지와 포스트가 분리되어 경로 관리가 복잡Page Bundle로 디렉토리 단위 관리
파일 네이밍YYYY-MM-DD-title.md 형식 강제자유로운 네이밍 가능

1. Hugo 블로그 설정


이 섹션에서는 Hugo 기반 블로그를 생성하고 테마를 적용하는 방법, 그리고 기본적인 커스터마이징 구조에 대해 간략하게 소개한다. Hugo는 정적 사이트 생성기이기 때문에 프로젝트 디렉토리 구조와 테마 관리 방식이 비교적 명확하게 분리되어 있는 것이 특징이다.

1.1 Hugo 사이트 생성

먼저 Hugo CLI를 이용해 새로운 블로그 프로젝트를 생성한다.

hugo new site hugo-blog
cd hugo-blog
Tip

설정 포맷을 yaml, toml(default), json중에 선택할 수 있다. 옵션으로 --format yaml을 주면 hugo.yaml이 생성된다.

명령을 실행하면 다음과 같은 기본 디렉토리 구조가 생성된다.

blog/
├─ archetypes
├─ assets
├─ content
├─ data
├─ i18n
├─ layouts
├─ static
├─ themes
└─ hugo.toml

각 디렉토리의 역할은 다음과 같다.

디렉토리역할
content실제 블로그 콘텐츠가 저장되는 위치
themesHugo 테마가 위치하는 디렉토리
layouts페이지 템플릿 오버라이드
assetsCSS, JS 등 빌드 대상 리소스
static정적 파일

블로그 포스트는 일반적으로 content/posts 디렉토리에 작성하게 된다.

content/posts/
└─ example-post/
   └─ index.md

Hugo에서는 Page Bundle 구조를 사용할 수 있기 때문에, 하나의 포스트를 디렉토리 단위로 관리하는 방식이 가능하다.
이 방식은 포스트에 포함되는 이미지나 파일을 함께 관리하기에 편리하다.

1.2 테마 적용 (Git Submodule)

Hugo에서는 다양한 오픈소스 테마를 사용할 수 있다.
여기에서는 Livour/hugo-mana-theme 테마를 사용했다.

테마는 일반적으로 themes 디렉토리에 위치하며, Git Submodule 방식으로 관리하면 테마 업데이트를 유지하면서 프로젝트와 분리할 수 있다.

다음 명령으로 테마를 추가할 수 있다.

git submodule add https://github.com/Livour/hugo-mana-theme.git themes/mana

테마를 추가한 후 hugo.yaml 설정 파일에서 사용할 테마를 지정한다.

theme = mana
Info

세부 설정 방법은 각 테마의 레포를 확인한다.

이렇게 설정하면 Hugo 빌드 시 해당 테마가 기본 레이아웃으로 적용된다.

1.3 Hugo 커스터마이징 구조

Hugo의 장점 중 하나는 테마 오버라이드 구조이다.
테마 파일을 직접 수정하지 않고, 프로젝트 레벨에서 동일한 경로의 파일을 생성하면 해당 파일이 우선적으로 사용된다.

예를 들어 테마의 일부 레이아웃을 수정하고 싶다면 다음과 같은 구조를 사용할 수 있다.

layouts/
└─ partials/

또는 스타일을 수정하고 싶다면 다음 위치를 사용할 수 있다.

assets/css/

이 구조를 이용하면

  • 테마 원본을 유지할 수 있고
  • 테마 업데이트 적용이 가능하며
  • 커스터마이징 파일만 프로젝트에서 관리할 수 있다.

현재 블로그도 테마를 직접 수정하지 않고 프로젝트 레벨에서 필요한 부분만 override하는 방식으로 커스터마이징을 진행했다.

2. Obsidian 연동


블로그 포스트를 작성하는 환경으로 Obsidian을 사용하고 있다.
Markdown 기반 노트 도구이기 때문에 정적 사이트 생성기와 궁합이 좋고, 로컬에서 글을 작성하고 관리하기에도 편리하다.

이번 블로그에서는 포스트 관리와 블로그 엔진을 분리하기 위해 posts 디렉토리를 별도의 레포지토리로 관리하고, 이를 Git Submodule로 연결하는 방식으로 구성했다.

Obsidian Vault 구성

먼저 블로그 포스트 작성을 위한 별도의 Obsidian Vault를 생성한다.
이 Vault는 실제로 블로그 포스트 저장소 역할을 하는 Git 레포지토리가 된다.

Vault 내부 구조는 다음과 같이 구성했다.

blog-posts/
└─ post-title/
   ├─ index.md
   └─ image.png

이 구조는 Hugo의 Page Bundle 방식에 맞춰 작성한 것이다.

  • index.md → 실제 포스트 내용
  • 이미지 및 첨부파일 → 같은 디렉토리에 함께 저장

이렇게 하면 포스트와 관련된 리소스를 하나의 디렉토리 단위로 관리할 수 있다.

예를 들어 포스트에서 이미지를 사용할 때도 단순한 상대 경로로 참조할 수 있다.

![image](image.png)

이 방식은 다음과 같은 장점이 있다.

  • 포스트와 리소스를 함께 관리 가능
  • 이미지 경로 관리가 단순함
  • 포스트 단위로 이동 및 삭제가 쉬움
  • Obsidian에서도 자연스럽게 관리 가능

Post repo 분리

블로그를 운영하면서 느낀 점 중 하나는 포스트와 블로그 엔진을 분리하는 것이 관리 측면에서 훨씬 편리하다는 것이다.

일반적으로 블로그 레포지토리는 다음과 같은 요소를 포함한다.

  • Hugo 설정
  • 테마
  • 레이아웃
  • CI/CD 설정
  • 포스트

하지만 실제로 자주 변경되는 것은 대부분 포스트 콘텐츠이다. 따라서 포스트만 별도의 레포지토리로 분리하면 다음과 같은 장점이 있다.

  • 블로그 엔진과 콘텐츠 분리
  • 글 작성 환경 독립
  • Git 히스토리 관리 단순화
  • 글 작성용 도구(Obsidian)와 자연스럽게 연결

이를 위해 blog-posts라는 별도의 레포지토리를 생성하고, 이 레포지토리를 Obsidian Vault로 사용하도록 구성했다.

Submodule 연결

포스트 레포지토리를 블로그 프로젝트에 연결하기 위해 Git Submodule을 사용했다.

블로그 프로젝트 구조는 다음과 같다.

hugo-blog/
├─ content
│  └─ posts (submodule)
├─ themes
│  └─ theme (submodule)
├─ assets
├─ layouts
│  ...
└─ hugo.yaml

즉,

  • hugo-blog → Hugo 블로그 엔진
  • blog-posts → 블로그 콘텐츠 저장소

라는 역할로 분리된다.

이 구조에서는 Obsidian으로 포스트를 작성하고 Git으로 관리하면서도, Hugo 빌드 시에는 content/posts 디렉토리를 통해 자연스럽게 콘텐츠를 불러올 수 있다.

Tip

Submodule 권한 문제 해결: SSH vs HTTP 보통 로컬 환경에서는 매번 비밀번호나 토큰을 입력할 필요가 없는 SSH 방식을 선호한다.

그대로 사용하면 CI/CD 환경에서 Submodule을 fetch할 때 SSH 키 권한 문제가 발생할 수 있다. CI/CD용 Private Key를 생성하여 Git저장소 및 Kubernetes Secret으로 등록하고, Argo Workflow 실행 시 SSH 에이전트나 볼륨 마운트를 통해 이를 주입해주는 과정이 필요하다.

결과적으로 글 작성 환경과 블로그 엔진을 분리하면서도, Hugo의 Page Bundle 구조를 그대로 활용할 수 있는 구성이 된다.

3. 배포 자동화(Argo Workflows + Argo Events)


블로그 포스트 작성 환경과 Hugo 기반 사이트 구성을 분리한 이후에는, 변경 사항이 생겼을 때 자동으로 빌드하고 배포하는 workflow가 필요하다.

이번 구성에서는 별도의 CI 러너를 두는 대신, Kubernetes 내부에서 Argo Events가 Git 이벤트를 수신하고 Argo Workflows가 실제 빌드 파이프라인을 실행하는 구조로 변경했다.

즉, Git 저장소에 push가 발생하면 웹훅을 통해 이벤트가 전달되고, 이를 기반으로 Workflow가 생성되어 체크아웃 → Hugo 빌드 → 이미지 빌드 및 Push → 배포 반영 순서로 처리된다.

기본적인 흐름은 다음과 같다.

graph TD User[git push] -->|Webhook| Gitea[Gitea Server] subgraph Argo_Events ["Argo Events"] ES[EventSource] S[Sensor] end subgraph Argo_Workflows ["Argo Workflows"] WF[Workflow] Step1["Git Clone + Submodule Update"] Step2["Hugo Build"] Step3["Container Image Build"] Step4["Registry Push"] Step5["Deploy or Image Update"] end subgraph K8s_Cluster ["Kubernetes Cluster"] Blog["Blog Pod / Deployment"] end Gitea --> ES ES --> S S -->|Trigger| WF WF --> Step1 Step1 --> Step2 Step2 --> Step3 Step3 --> Step4 Step4 --> Step5 Step5 --> Blog

이 구조를 사용하면 포스트를 수정하거나 블로그 설정을 변경한 뒤 Git에 반영하는 것 만으로도, 클러스터 내부에서 동일한 방식으로 빌드와 배포가 수행된다.

Argo Events

Argo Events는 외부 시스템에서 발생한 이벤트를 받아서 Kubernetes 리소스를 트리거하는 역할을 한다.
이번 구성에서는 Gitea Webhook을 받아서 블로그 배포용 Workflow를 실행하는 용도로 사용했다.

구성 요소는 크게 다음과 같다.

  • EventSource 외부에서 들어오는 웹훅을 수신하는 엔드포인트를 생성한다.
  • Sensor 어떤 이벤트가 들어왔을 때 무엇을 실행할지 정의한다.

EventSource

Gitea Webhook을 받아서 처리하려면 먼저 Webhook을 받을 수 있는 endpoint가 필요하다.

apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
  name: blog-webhook
  namespace: argo-events
spec:
  service:
    ports:
      - port: 12000
        targetPort: 12000
  webhook:
    hugo-blog:
      port: "12000"
      endpoint: /hugo-blog
      method: POST
      authSecret:                            # Webhook 요청 토큰
        name: blog-webhook-token
        key: token
    blog-posts:
      port: "12000"
      endpoint: /blog-posts
      method: POST
	  authSecret:                            # Webhook 요청 토큰
        name: blog-webhook-token
        key: token
Note

EventSource의 webhook 인증은 HTTP Header에 포함된 토큰 값을 기반으로 동작한다. 임의 요청 차단 및 Webhook 위조를 방지하여 이벤트 신뢰성을 확보할 수 있다.

각 저장소별로 endpoint를 분리하여 Sensor에서 dependency를 명확하게 구분할 수 있도록 했다.

  • hugo-blog: 블로그 사이트 저장소
  • blog-posts: 포스트 관리 저장소

Argo EventsEventSource는 내부적으로 Kubernetes Service를 생성하지만, 이 상태에서는 클러스터 내부에서만 접근이 가능하다.

따라서 외부에서 Webhook을 전달하려면, Ingress 리소스 또는 **Gateway API(HTTPRoute + Gateway)**를 통해 외부로 노출해야 한다.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: argo-events-webhook
  namespace: argo-events
spec:
  parentRefs:
    - name: homelab-external
      namespace: gateway
  hostnames:
    - "webhook.example.com"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /hugo-blog
      backendRefs:
        - name: blog-webhook-eventsource-svc
          port: 12000

    - matches:
        - path:
            type: PathPrefix
            value: /blog-posts
      backendRefs:
        - name: blog-webhook-eventsource-svc
          port: 12000

이런식으로 구성하면 외부에서는 다음과 같은 endpoint로 접근 가능하다.

https://webhook.example.com/hugo-blog
https://webhook.example.com/blog-posts

EventSourceHTTPRoute까지 구성되면, 이제 Gitea에서 Webhook을 연결할 수 있는 상태가 된다.

테스트 Push를 해보면

Status: 200 OK

200으로 정상적으로 통신하는 것을 볼 수 있다.

Sensor

EventSource를 통해 Webhook을 정상적으로 수신할 수 있다면, 다음 단계는 이 이벤트를 실제 CI/CD 파이프라인(Workflow)으로 연결하는 것이다.

이 역할을 담당하는 것이 Sensor다.

Sensor는 단순히 이벤트를 전달하는 것이 아니라,

  • 어떤 이벤트를 사용할지 (dependency)
  • 어떤 조건에서 실행할지 (filter)
  • 무엇을 실행할지 (trigger)

를 정의하는 역할을 한다.

결과적으로 Sensor는 단순 이벤트 전달이 아니라, 이벤트를 정제하고 Workflow 실행에 필요한 형태로 변환하는 역할을 수행한다.


다음 두 가지 이벤트를 처리하도록 설정했다.

  • hugo-blog (메인 블로그 레포지토리)
  • blog-posts (포스트 저장소)

두 저장소 중 어느 하나에서 변경이 발생하면 각각의 trigger를 통해 동일한 Workflow를 실행하도록 구성했다.

apiVersion: argoproj.io/v1alpha1
kind: Sensor
metadata:
  name: blog-update-sensor
  namespace: argo-events
spec:
  template:
    serviceAccountName: sensor                 # SA 및 RBAC 설정

  dependencies:
    - name: hugo-blog
      eventSourceName: blog-webhook
      eventName: hugo-blog
      filters:
        dataLogicalOperator: "and"
        data:
          - path: header.X-Gitea-Event.0
            type: string
            value:
              - push
          - path: body.ref
            type: string
            value:
              - refs/heads/main

    - name: blog-posts
      eventSourceName: blog-webhook
      eventName: blog-posts
      filters:
        dataLogicalOperator: "and"
        data:
          - path: header.X-Gitea-Event.0
            type: string
            value:
              - push
          - path: body.ref
            type: string
            value:
              - refs/heads/main

이벤트 필터링

Sensor에서는 다음과 같은 조건을 기준으로 이벤트를 필터링할 수 있다.

  • 이벤트 타입이 push인지
  • 대상 브랜치가 main인지

즉, 불필요한 이벤트를 모두 처리하는 것이 아니라 실제로 배포가 필요한 변경만 Workflow로 연결하게 된다.

트리거 설정

이벤트를 수신하고 필터링한 뒤에는, Sensor가 실제로 Workflow를 실행해야 한다.
triggers를 사용해서 저장소별 이벤트를 각각 독립적으로 처리하도록 했다.

  • hugo-blog 레포지토리에서 이벤트가 발생하면 event-from-hugo-blog 트리거 실행
  • blog-posts 레포지토리에서 이벤트가 발생하면 event-from-blog-posts 트리거 실행

두 경우 모두 최종적으로는 동일한 WorkflowTemplate(blog-build-push)를 실행하지만,
어떤 저장소에서 어떤 ref와 commit 변경이 발생했는지를 Workflow 인자로 함께 전달하도록 구성했다.

예시는 다음과 같다.

  triggers:
    - template:
        name: event-from-hugo-blog
        conditions: "hugo-blog"
        argoWorkflow:
          operation: submit
          source:
            resource:
              apiVersion: argoproj.io/v1alpha1
              kind: Workflow
              metadata:
                generateName: blog-build-push-
                namespace: argo-workflows
              spec:
                workflowTemplateRef:
                  name: blog-build-push
                arguments:
                  parameters:
                    - name: event-repo
                      value: ""
                    - name: event-ref
                      value: ""
                    - name: event-before-sha
                      value: ""
                    - name: event-after-sha
                      value: ""
          parameters:
            - src:
                dependencyName: hugo-blog
                dataKey: body.repository.name
              dest: spec.arguments.parameters.0.value
            - src:
                dependencyName: hugo-blog
                dataKey: body.ref
              dest: spec.arguments.parameters.1.value
            - src:
                dependencyName: hugo-blog
                dataKey: body.before
              dest: spec.arguments.parameters.2.value
            - src:
                dependencyName: hugo-blog
                dataKey: body.after
              dest: spec.arguments.parameters.3.value

다음 정보를 Workflow에 전달하도록 구성했다:

  • event-repo: 어떤 저장소에서 이벤트가 발생했는지
  • event-ref: 어떤 브랜치/ref인지
  • event-before-sha: 변경 전 commit SHA
  • event-after-sha: 변경 후 commit SHA

이를 통해 포스트 저장소의 업데이트로 인한 작업인지, 블로그 저장소 업데이트로 인한 작업인지 구분이 가능해지며, 이후 Workflow 내부에서 조건 분기나 로그 추적에도 활용할 수 있다.

Argo Workflows

Sensor를 통해 이벤트가 트리거되면, 실제 CI/CD 파이프라인은 Argo Workflows에서 실행된다.

이번 구성에서는 Workflow를 하나로 모두 처리하지 않고, 역할에 따라 다음 두 개의 WorkflowTemplate으로 분리했다.

  • blog-build-push: 소스 체크아웃, Submodule 동기화, 이미지 빌드 및 Push, 배포 Workflow 트리거
  • blog-deploy: Helm chart를 이용한 실제 배포 반영

이렇게 분리하면 나중에 배포 정책이 바뀌더라도 빌드 파이프라인을 크게 수정하지 않아도 되고, 문제가 발생했을 때도 “이미지 생성 실패”와 “배포 실패”를 명확히 구분할 수 있다.

WorkflowTemplate

먼저 메인 파이프라인 역할을 하는 blog-build-push 템플릿을 정의했다.
이 Workflow는 DAG 형태로 구성되어 있으며, 크게 다음 세 단계로 나뉜다.

  1. clone-source
  2. build-push-image
  3. trigger-deploy
templates:
  - name: main
    dag:
      tasks:
        - name: clone-source
          template: clone-source

        - name: build-push-image
          dependencies: [clone-source]
          template: build-push-image

        - name: trigger-deploy
          dependencies: [build-push-image]
          template: trigger-deploy

이 구조를 통해 소스 준비, 이미지 생성, 배포 실행을 단계적으로 분리했다.

blog-build-push: 빌드 및 배포 트리거

blog-build-push는 실제 CI/CD의 중심이 되는 WorkflowTemplate이다.

이 Workflow는 Webhook에서 전달받은 이벤트 메타데이터를 입력값으로 받아 다음 작업을 수행한다.

  • 블로그 저장소 clone
  • Git Submodule 동기화
  • theme / posts / blog 각각의 실제 commit SHA 확인
  • 이미지 태그 생성
  • BuildKit으로 이미지 빌드 및 Harbor Push
  • 별도의 배포 Workflow 실행
1. clone-source

첫 단계에서는 블로그 저장소를 clone하고 submodule을 동기화한다. themeposts가 별도 저장소로 관리되기 때문에, 이 과정이 반드시 필요하다.

git clone --branch "$GIT_DEFAULT_BRANCH" "$GIT_REPO_URL" /workspace/src

git submodule sync --recursive
git submodule update --init --recursive

cd themes/mana
git checkout "$THEME_TAG"

cd content/posts
git checkout "origin/$POSTS_BRANCH"

또한 이 단계에서는 다음 값을 계산하여 이후 단계로 전달한다.

  • source-blog-sha
  • source-posts-sha
  • source-theme-sha
  • build-image-tag
BUILD_IMAGE_TAG="${SOURCE_POSTS_SHA}-${SOURCE_BLOG_SHA}-${SOURCE_THEME_SHA}"

즉, 단순히 소스를 받아오는 역할만 하는 것이 아니라, 실제 어떤 소스 조합으로 빌드가 수행되는지 식별할 수 있는 메타데이터를 만드는 단계라고 볼 수 있다.

2. build-push-image

소스 준비가 끝나면 BuildKit을 사용해 컨테이너 이미지를 빌드하고 레지스트리에 Push한다.

이번 환경은 Kubernetes 노드가 containerd 기반이기 때문에 Docker daemon에 의존하지 않는 방식이 필요했고, 그에 맞춰 daemonless 방식으로 동작하는 moby/buildkit:rootless 이미지를 사용했다.

이 단계의 핵심은 다음과 같다.

  • public/ 결과물만 만드는 것이 아니라, 최종 서비스 이미지까지 생성
  • BLOG_SHA, POSTS_SHA, THEME_SHA, BUILD_DATE를 build-arg로 전달
  • ${IMAGE_REPOSITORY}:<generated-tag>latest를 동시에 push
buildctl-daemonless.sh build \
  --frontend dockerfile.v0 \
  --local context=. \
  --local dockerfile=. \
  --opt build-arg:BLOG_SHA={{inputs.parameters.source-blog-sha}} \
  --opt build-arg:POSTS_SHA={{inputs.parameters.source-posts-sha}} \
  --opt build-arg:THEME_SHA={{inputs.parameters.source-theme-sha}} \
  --output type=image,"name=${IMAGE_REPOSITORY}:{{inputs.parameters.build-image-tag}},${IMAGE_REPOSITORY}:latest",push=true
3. trigger-deploy

이미지 Push가 끝나면 마지막 단계에서 blog-deploy Workflow를 별도로 생성한다.

여기서는 직접 Deployment를 수정하는 대신, 별도의 배포 전용 Workflow를 생성해서 Helm 기반 배포 로직을 분리했다.

- name: trigger-deploy
  resource:
    action: create
    manifest: |
      apiVersion: argoproj.io/v1alpha1
      kind: Workflow
      ...
      workflowTemplateRef:
        name: blog-deploy

이 방식의 장점은 다음과 같다.

  • 빌드와 배포의 역할 분리
  • 배포만 별도로 재실행 가능
  • 실행 이력 구분이 쉬움
  • 추후 GitOps(ArgoCD)로 전환할 때도 큰 변경 없이 가능

blog-deploy: Helm 기반 배포

두 번째 WorkflowTemplate인 blog-deploy는 실제 배포 반영을 담당한다. 이 Workflow는 앞 단계에서 전달받은 이벤트 정보와 이미지 태그를 입력값으로 받아, Helm chart를 이용해 블로그를 배포한다.

배포 단계에서는 다음과 같은 정보를 함께 출력하도록 구성했다.

  • 어떤 이벤트로 인해 배포가 발생했는지
  • 어떤 blog/posts/theme SHA 조합인지
  • 어떤 이미지 태그를 배포하는지

이후 helm upgrade -i를 사용해 배포를 수행한다.

echo "=== Deploy Metadata ==="
echo "event-repo      : {{workflow.parameters.event-repo}}"
echo "event-ref       : {{workflow.parameters.event-ref}}"
echo "event-before-sha: {{workflow.parameters.event-before-sha}}"
echo "event-after-sha : {{workflow.parameters.event-after-sha}}"
echo "source-blog-sha : {{workflow.parameters.source-blog-sha}}"
echo "source-posts-sha: {{workflow.parameters.source-posts-sha}}"
echo "source-theme-sha: {{workflow.parameters.source-theme-sha}}"
echo "build-image-tag : {{workflow.parameters.build-image-tag}}"
echo "image-repository: ${IMAGE_REPOSITORY}"
echo

helm upgrade -i hugo-blog oci://${IMAGE_REPOSITORY%/*} \
  --version 0.1.1 \
  --namespace blog \
  --create-namespace \
  --set image.repository="${IMAGE_REPOSITORY}" \
  --set image.tag="{{workflow.parameters.build-image-tag}}" \
  --wait \
  --timeout 5m \
  --rollback-on-failure

이 방식의 장점은 다음과 같다.

  • 설치/업데이트를 동일 명령으로 처리 가능
  • 실패 시 --rollback-on-failure으로 자동 롤백 가능
  • --wait, --timeout으로 배포 완료 여부 확인 가능

즉, 단순히 이미지만 갱신하는 방식이 아니라, Helm chart를 기준으로 선언적으로 배포 상태를 맞추는 구조로 구성했다.

4. 마무리

지금까지 Hugo와 Obsidian, 그리고 Kubernetes 기반의 이벤트 중심 CI/CD(Argo Events + Workflows)를 이용해 블로그를 셀프 호스팅하는 과정을 정리했다.

이 방대한(?) 시스템을 구축한 최종 목적은 결국 ‘글쓰기에만 집중할 수 있는 환경’ 을 만드는 것이었다. 모든 설정이 완료된 지금, 블로그 운영 프로세스는 아래 다이어그램처럼 지극히 단순해졌다.

sequenceDiagram participant Obsidian participant Git as Git 저장소 (Gitea) participant Argo as CI/CD 파이프라인 (Argo) participant K8s as 클러스터 (Kubernetes) Note over Obsidian: 포스트 작성 & 이미지 첨부 Obsidian->>Git: git push (콘텐츠 또는 설정 변경) activate Argo Git->>Argo: Webhook 트리거 (이벤트 수신) Note over Argo: 소스 체크아웃 & Hugo 빌드
컨테이너 이미지 생성 & Push Argo->>K8s: Helm 배포 업데이트 deactivate Argo Note over K8s: 새 파드 가동 & 사이트 반영 Note over Obsidian: 브라우저에서 배포 결과 확인

블로그 엔진 설정(hugo-blog)이나 포스트 콘텐츠(blog-posts) 중 어느 쪽 레포지토리에 push가 발생하더라도, 클러스터 내부의 Argo 파이프라인이 즉시 가동되어 빌드부터 배포까지의 전 과정을 롤백 걱정 없이 안전하게 처리한다.