9 changed files with 444 additions and 25 deletions
@ -0,0 +1,108 @@ |
|||||||
|
// slack-notifier CI/CD |
||||||
|
// golang 컨테이너에서 빌드·테스트 → EC2(systemd)로 단일 정적 바이너리 배포. |
||||||
|
// |
||||||
|
// 필요한 Jenkins 구성 (자세한 건 deploy/README.md): |
||||||
|
// - 플러그인: Go Plugin, SSH Agent |
||||||
|
// - Global Tool Configuration → Go 에 1.26 등록(이름 = GO_TOOL 값과 일치) |
||||||
|
// - 자격증명: "SSH Username with private key" 를 만들고 ID 를 SSH_CRED_ID 와 일치시킴 (EC2 접속 키) |
||||||
|
// - 대상 EC2 1회 셋업: /opt/slack-notifier, .env.prod, systemd 서비스, Caddy (deploy/README.md) |
||||||
|
// |
||||||
|
// 호스트/유저/아키텍처는 파라미터로 조정. main 브랜치 + DEPLOY_HOST 지정 시에만 배포. |
||||||
|
|
||||||
|
pipeline { |
||||||
|
agent none |
||||||
|
|
||||||
|
options { |
||||||
|
timestamps() |
||||||
|
disableConcurrentBuilds() |
||||||
|
timeout(time: 20, unit: 'MINUTES') |
||||||
|
} |
||||||
|
|
||||||
|
parameters { |
||||||
|
// parameters 를 바꿀 때마다 이 기본값과 environment.PARAMS_VERSION 을 함께 +1 한다. |
||||||
|
// (파라미터는 1빌드 늦게 등록되므로, 아래 Guard 가 stale 빌드를 막는다) |
||||||
|
string(name: 'PARAMS_VERSION', defaultValue: '1', description: '파라미터 동기화 가드용 — 직접 바꾸지 말 것') |
||||||
|
string(name: 'DEPLOY_HOST', defaultValue: '', description: 'EC2 호스트(DNS 또는 IP). 비우면 배포 스킵(빌드/테스트만).') |
||||||
|
string(name: 'DEPLOY_USER', defaultValue: 'ubuntu', description: 'SSH 사용자 (Ubuntu=ubuntu, Amazon Linux=ec2-user)') |
||||||
|
string(name: 'APP_DIR', defaultValue: '/opt/slack-notifier', description: '원격 설치 경로') |
||||||
|
choice(name: 'ARCH', choices: ['arm64', 'amd64'], description: 'EC2 아키텍처 (Graviton/t4g=arm64, x86=amd64)') |
||||||
|
} |
||||||
|
|
||||||
|
environment { |
||||||
|
SSH_CRED_ID = 'slack-notifier-ec2' // Jenkins SSH 자격증명 ID (실제 ID로 교체 가능) |
||||||
|
BIN = 'slack-notifier' |
||||||
|
GOFLAGS = '-buildvcs=false' // .git 소유권 경고 회피 |
||||||
|
PARAMS_VERSION = '1' // ← parameters 변경 시 위 기본값과 함께 +1 |
||||||
|
} |
||||||
|
|
||||||
|
stages { |
||||||
|
// 파라미터 정의가 갱신되기 전(=stale)에 빌드/배포되는 것을 막는다. |
||||||
|
// Jenkinsfile 의 step/environment 는 즉시 반영되지만 parameters 는 1빌드 늦게 등록되므로, |
||||||
|
// "현재 코드의 PARAMS_VERSION(즉시 반영)" 과 "등록된 params.PARAMS_VERSION(지난 등록)" 이 다르면 중단. |
||||||
|
stage('Guard: params sync') { |
||||||
|
agent any |
||||||
|
steps { |
||||||
|
script { |
||||||
|
if (params.PARAMS_VERSION != env.PARAMS_VERSION) { |
||||||
|
error("파라미터가 아직 갱신되지 않음(stale): 등록=${params.PARAMS_VERSION}, 현재=${env.PARAMS_VERSION}. " + |
||||||
|
"이 빌드가 파라미터를 재등록했으니, 한 번 더 빌드하면 정상 진행됩니다.") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
stage('Build & Test') { |
||||||
|
agent any |
||||||
|
// 'go-1.26' = Global Tool Configuration 의 Go 도구 이름(다르면 여기와 일치시킬 것). 컨테이너 없음 — 경량. |
||||||
|
tools { go 'go-1.26' } |
||||||
|
steps { |
||||||
|
sh 'go version' |
||||||
|
sh 'go vet ./...' |
||||||
|
sh 'go test ./...' |
||||||
|
sh "CGO_ENABLED=0 GOOS=linux GOARCH=${params.ARCH} go build -ldflags='-s -w' -o ${BIN} ." |
||||||
|
sh "ls -lh ${BIN}" |
||||||
|
stash name: 'binary', includes: "${BIN}" |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
stage('Deploy') { |
||||||
|
when { |
||||||
|
allOf { |
||||||
|
branch 'main' |
||||||
|
expression { return params.DEPLOY_HOST?.trim() } |
||||||
|
} |
||||||
|
} |
||||||
|
agent any |
||||||
|
steps { |
||||||
|
unstash 'binary' |
||||||
|
sshagent(credentials: [env.SSH_CRED_ID]) { |
||||||
|
sh ''' |
||||||
|
set -eu |
||||||
|
H="${DEPLOY_USER}@${DEPLOY_HOST}" |
||||||
|
SSH="ssh -o StrictHostKeyChecking=accept-new" |
||||||
|
|
||||||
|
# 1) 새 바이너리를 임시 위치로 전송 |
||||||
|
scp -o StrictHostKeyChecking=accept-new "${BIN}" "$H:/tmp/${BIN}.new" |
||||||
|
|
||||||
|
# 2) 원자적 교체 후 재시작. |
||||||
|
# 실행 중 바이너리 덮어쓰기는 ETXTBSY 가 나므로, 같은 파일시스템에서 rename(mv) 으로 교체한다. |
||||||
|
# (running 프로세스는 옛 inode 를 유지 → 안전) |
||||||
|
$SSH "$H" "set -e; \ |
||||||
|
sudo mkdir -p '${APP_DIR}' '${APP_DIR}/logs' '${APP_DIR}/data'; \ |
||||||
|
sudo mv '/tmp/${BIN}.new' '${APP_DIR}/${BIN}.staged'; \ |
||||||
|
sudo chmod 0755 '${APP_DIR}/${BIN}.staged'; \ |
||||||
|
sudo mv -f '${APP_DIR}/${BIN}.staged' '${APP_DIR}/${BIN}'; \ |
||||||
|
sudo systemctl restart slack-notifier; \ |
||||||
|
sleep 1; \ |
||||||
|
sudo systemctl is-active slack-notifier" |
||||||
|
''' |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
post { |
||||||
|
success { echo "✅ ${env.BRANCH_NAME} 빌드 성공" } |
||||||
|
failure { echo "❌ 빌드 실패 — 콘솔 로그 확인" } |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,121 @@ |
|||||||
|
# 배포 가이드 (EC2 + systemd + Jenkins) |
||||||
|
|
||||||
|
slack-notifier 를 **EC2(리눅스)** 에 단일 정적 바이너리로 올리고, **systemd** 로 운영하며, |
||||||
|
**Jenkins** 로 빌드·배포를 자동화한다. 도커 레지스트리 불필요(바이너리 직접 배포). |
||||||
|
|
||||||
|
``` |
||||||
|
[git push main] → Jenkins (golang:1.26 컨테이너) → go test/build → scp 바이너리 → EC2 |
||||||
|
→ systemctl restart slack-notifier |
||||||
|
[Gitea/Notion] ──webhook(HTTPS)──→ Caddy(:443) ──→ slack-notifier(:8000) ──→ Slack |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1. EC2 최초 1회 셋업 |
||||||
|
|
||||||
|
### 1-1. 설치 경로 / 환경파일 |
||||||
|
|
||||||
|
```bash |
||||||
|
sudo mkdir -p /opt/slack-notifier/{logs,data} |
||||||
|
sudo chown -R $USER:$USER /opt/slack-notifier # Jenkins 배포 유저가 쓸 수 있게(또는 sudo 권한) |
||||||
|
|
||||||
|
# 비밀값/설정 — 서버에만 두고 절대 커밋하지 않음 |
||||||
|
sudo vi /opt/slack-notifier/.env.prod |
||||||
|
# SLACK_BOT_TOKEN=xoxb-... |
||||||
|
# DEFAULT_SLACK_CHANNEL=C... (브로드캐스트 채널, GITEA_BROADCAST=on 일 때) |
||||||
|
# GITEA_BROADCAST=off |
||||||
|
# MONITOR_SLACK_CHANNEL=C... (에러 알림) |
||||||
|
# GITEA_WEBHOOK_SECRET=... |
||||||
|
# NOTION_VERIFICATION_TOKEN=... |
||||||
|
# NOTION_API_TOKEN=... |
||||||
|
# LOG_RETENTION_DAYS=7 |
||||||
|
``` |
||||||
|
|
||||||
|
> systemd 서비스는 `-env prod` 로 실행되어 `.env.prod` 를 읽고, 로그는 `/opt/slack-notifier/logs/app-YYYY-MM-DD.log` 로 날짜별 회전된다. |
||||||
|
|
||||||
|
### 1-2. systemd 서비스 등록 |
||||||
|
|
||||||
|
```bash |
||||||
|
sudo cp deploy/slack-notifier.service /etc/systemd/system/slack-notifier.service |
||||||
|
sudo systemctl daemon-reload |
||||||
|
sudo systemctl enable --now slack-notifier |
||||||
|
sudo systemctl status slack-notifier |
||||||
|
``` |
||||||
|
|
||||||
|
서비스는 `/opt/slack-notifier/slack-notifier` 바이너리를 실행한다(첫 배포 전이면 아직 없음 → Jenkins 첫 배포 후 정상 기동). |
||||||
|
|
||||||
|
### 1-3. HTTPS 리버스 프록시(Caddy) |
||||||
|
|
||||||
|
웹훅은 공개 HTTPS 가 필수. Caddy 가 Let's Encrypt 인증서를 자동 발급한다. |
||||||
|
|
||||||
|
```bash |
||||||
|
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile |
||||||
|
sudo vi /etc/caddy/Caddyfile # your-domain.com 을 실제 도메인으로 교체 |
||||||
|
sudo systemctl restart caddy |
||||||
|
``` |
||||||
|
|
||||||
|
- 도메인 A 레코드 → EC2 퍼블릭 IP |
||||||
|
- 보안그룹 인바운드 **80, 443** 개방(ACME 검증 + 웹훅 수신) |
||||||
|
- 웹훅 등록 주소: `https://your-domain.com/webhooks/{gitea,notion}` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 2. Jenkins 셋업 |
||||||
|
|
||||||
|
### 2-1. 플러그인 / Go 도구 |
||||||
|
- 플러그인: **Go Plugin**, **SSH Agent** |
||||||
|
- **Manage Jenkins → Tools → Go installations** 에서 Go 추가: |
||||||
|
- Name: **`go-1.26`** (Jenkinsfile 의 `GO_TOOL` 과 일치, 다르면 Jenkinsfile 수정) |
||||||
|
- "Install automatically" 로 1.26.x 선택(또는 에이전트에 설치된 Go 경로 지정) |
||||||
|
- 컨테이너 없이 에이전트의 네이티브 Go 로 빌드 → 이미지 풀/디스크 오버헤드 없음 |
||||||
|
|
||||||
|
### 2-2. 자격증명(SSH 키) |
||||||
|
- Jenkins → Credentials → **SSH Username with private key** 추가 |
||||||
|
- Username: EC2 SSH 유저(`ubuntu` 등) |
||||||
|
- Private key: EC2 접속 키(.pem) |
||||||
|
- **ID: `slack-notifier-ec2`** (Jenkinsfile 의 `SSH_CRED_ID` 와 일치, 다르면 Jenkinsfile 수정) |
||||||
|
- 배포 유저는 `/opt/slack-notifier` 쓰기 + `systemctl restart slack-notifier` 가능해야 함 |
||||||
|
(sudoers 에 무암호 허용 권장): |
||||||
|
``` |
||||||
|
ubuntu ALL=(root) NOPASSWD: /bin/systemctl restart slack-notifier, /bin/systemctl is-active slack-notifier, /bin/mv, /bin/mkdir, /bin/chmod |
||||||
|
``` |
||||||
|
|
||||||
|
### 2-3. 파이프라인 잡 |
||||||
|
- **Pipeline script from SCM** → 이 저장소 → `Jenkinsfile` |
||||||
|
- 파라미터(첫 실행 시 생성됨): |
||||||
|
- `DEPLOY_HOST` = EC2 DNS/IP (비우면 빌드·테스트만, 배포 스킵) |
||||||
|
- `DEPLOY_USER` = `ubuntu`(또는 `ec2-user`) |
||||||
|
- `APP_DIR` = `/opt/slack-notifier` |
||||||
|
- `ARCH` = `arm64`(Graviton/t4g) 또는 `amd64`(x86) ← **인스턴스 아키텍처와 반드시 일치** |
||||||
|
|
||||||
|
배포는 **main 브랜치 + DEPLOY_HOST 지정** 시에만 수행된다. |
||||||
|
|
||||||
|
### 2-4. 파라미터 변경 시 (stale 가드) |
||||||
|
|
||||||
|
Jenkins 의 `parameters` 는 변경해도 **1빌드 늦게** 등록된다(step/environment 는 즉시 반영). |
||||||
|
그 사이 옛 파라미터로 빌드·배포되는 사고를 막기 위해 `Guard: params sync` 스테이지가 있다. |
||||||
|
|
||||||
|
`parameters {}` 를 바꿀 때 규칙: |
||||||
|
1. 파라미터를 수정하고 |
||||||
|
2. **`parameters` 의 `PARAMS_VERSION` 기본값**과 **`environment.PARAMS_VERSION`** 을 **둘 다 +1** |
||||||
|
3. 커밋/푸시 → 첫 빌드는 `Guard` 에서 **의도적으로 실패**(파라미터 재등록만 됨) → **한 번 더 빌드**하면 정상 진행 |
||||||
|
|
||||||
|
즉 "파라미터가 실제로 적용된 빌드"에서만 배포가 일어나도록 강제된다. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 3. 배포 동작 / 롤백 |
||||||
|
|
||||||
|
- 무중단 교체: 새 바이너리를 임시 전송 후 **같은 파일시스템에서 rename** 으로 교체(실행 중 `ETXTBSY` 회피) → `systemctl restart`. |
||||||
|
- 로그 확인: `journalctl -u slack-notifier -f` 또는 `tail -f /opt/slack-notifier/logs/app-$(date +%F).log` |
||||||
|
- 롤백: 이전 빌드의 바이너리로 다시 배포(Jenkins "Rebuild" 또는 이전 커밋으로 빌드). 필요하면 배포 전 `cp slack-notifier slack-notifier.bak` 단계를 추가해도 됨. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 4. 수동 배포(참고, Jenkins 없이) |
||||||
|
|
||||||
|
```bash |
||||||
|
make build-linux ARCH=arm64 # 정적 바이너리 생성 |
||||||
|
scp slack-notifier ubuntu@EC2:/tmp/sn.new |
||||||
|
ssh ubuntu@EC2 'sudo mv /tmp/sn.new /opt/slack-notifier/slack-notifier && sudo systemctl restart slack-notifier' |
||||||
|
``` |
||||||
@ -0,0 +1,100 @@ |
|||||||
|
package logging |
||||||
|
|
||||||
|
import ( |
||||||
|
"os" |
||||||
|
"path/filepath" |
||||||
|
"strings" |
||||||
|
"sync" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
// dailyWriter는 날짜별 로그 파일(<dir>/<prefix>-YYYY-MM-DD<ext>)에 기록하고,
|
||||||
|
// 날짜가 바뀌면 새 파일로 전환하며 retention일보다 오래된 파일을 삭제한다.
|
||||||
|
// 외부 의존성 없이 표준 라이브러리만 사용(저사양 타깃 정책 — 회전을 위해 lumberjack 등을 쓰지 않음).
|
||||||
|
type dailyWriter struct { |
||||||
|
mu sync.Mutex |
||||||
|
dir string |
||||||
|
prefix string // 예: "app"
|
||||||
|
ext string // 예: ".log"
|
||||||
|
retention int // 보존 일수(오늘 포함)
|
||||||
|
|
||||||
|
curDate string |
||||||
|
file *os.File |
||||||
|
} |
||||||
|
|
||||||
|
// newDailyWriter는 logFile(예: logs/app.log)을 기준으로 날짜별 writer를 만든다.
|
||||||
|
// → logs/app-2026-06-11.log 형태로 기록.
|
||||||
|
func newDailyWriter(logFile string, retention int) (*dailyWriter, error) { |
||||||
|
if retention <= 0 { |
||||||
|
retention = 7 |
||||||
|
} |
||||||
|
base := filepath.Base(logFile) |
||||||
|
ext := filepath.Ext(base) // ".log" (없으면 "")
|
||||||
|
w := &dailyWriter{ |
||||||
|
dir: filepath.Dir(logFile), |
||||||
|
prefix: strings.TrimSuffix(base, ext), |
||||||
|
ext: ext, |
||||||
|
retention: retention, |
||||||
|
} |
||||||
|
if err := w.rotate(time.Now()); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return w, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (w *dailyWriter) filename(date string) string { |
||||||
|
return filepath.Join(w.dir, w.prefix+"-"+date+w.ext) |
||||||
|
} |
||||||
|
|
||||||
|
func (w *dailyWriter) Write(p []byte) (int, error) { |
||||||
|
w.mu.Lock() |
||||||
|
defer w.mu.Unlock() |
||||||
|
if err := w.rotate(time.Now()); err != nil { |
||||||
|
return 0, err |
||||||
|
} |
||||||
|
return w.file.Write(p) |
||||||
|
} |
||||||
|
|
||||||
|
// rotate은 now 날짜의 파일을 연다. 이미 같은 날짜 파일이 열려 있으면 아무것도 안 한다.
|
||||||
|
// (newDailyWriter에서 최초 1회, 이후 Write에서 lock 보유 상태로 호출)
|
||||||
|
func (w *dailyWriter) rotate(now time.Time) error { |
||||||
|
date := now.Format("2006-01-02") |
||||||
|
if w.file != nil && date == w.curDate { |
||||||
|
return nil |
||||||
|
} |
||||||
|
if w.dir != "" && w.dir != "." { |
||||||
|
if err := os.MkdirAll(w.dir, 0o755); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
f, err := os.OpenFile(w.filename(date), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if w.file != nil { |
||||||
|
_ = w.file.Close() |
||||||
|
} |
||||||
|
w.file = f |
||||||
|
w.curDate = date |
||||||
|
w.cleanup(now) |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
// cleanup은 retention일치(오늘 포함)만 남기고 오래된 날짜 파일을 삭제한다.
|
||||||
|
func (w *dailyWriter) cleanup(now time.Time) { |
||||||
|
cutoff := now.AddDate(0, 0, -(w.retention - 1)).Format("2006-01-02") |
||||||
|
matches, err := filepath.Glob(filepath.Join(w.dir, w.prefix+"-*"+w.ext)) |
||||||
|
if err != nil { |
||||||
|
return |
||||||
|
} |
||||||
|
for _, path := range matches { |
||||||
|
name := filepath.Base(path) |
||||||
|
ds := strings.TrimSuffix(strings.TrimPrefix(name, w.prefix+"-"), w.ext) |
||||||
|
if _, err := time.Parse("2006-01-02", ds); err != nil { |
||||||
|
continue // 날짜 형식이 아니면 우리 파일이 아님 — 건드리지 않음
|
||||||
|
} |
||||||
|
if ds < cutoff { // YYYY-MM-DD는 사전식 비교 = 날짜순 비교
|
||||||
|
_ = os.Remove(path) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
package logging |
||||||
|
|
||||||
|
import ( |
||||||
|
"os" |
||||||
|
"path/filepath" |
||||||
|
"testing" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
// 오래된 날짜 파일은 지우고 최근 retention일치만 남기는지 확인.
|
||||||
|
func TestDailyWriterCleanup(t *testing.T) { |
||||||
|
dir := t.TempDir() |
||||||
|
now := time.Date(2026, 6, 11, 10, 0, 0, 0, time.UTC) |
||||||
|
|
||||||
|
// 14일치 더미 파일 생성 (app-YYYY-MM-DD.log)
|
||||||
|
for i := 0; i < 14; i++ { |
||||||
|
d := now.AddDate(0, 0, -i).Format("2006-01-02") |
||||||
|
if err := os.WriteFile(filepath.Join(dir, "app-"+d+".log"), []byte("x"), 0o644); err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
} |
||||||
|
// 우리 형식이 아닌 파일은 보존돼야 함
|
||||||
|
keep := filepath.Join(dir, "other.txt") |
||||||
|
_ = os.WriteFile(keep, []byte("x"), 0o644) |
||||||
|
|
||||||
|
w := &dailyWriter{dir: dir, prefix: "app", ext: ".log", retention: 7} |
||||||
|
w.cleanup(now) |
||||||
|
|
||||||
|
// 오늘 포함 7일치(today..today-6)만 남아야 함
|
||||||
|
for i := 0; i < 14; i++ { |
||||||
|
d := now.AddDate(0, 0, -i).Format("2006-01-02") |
||||||
|
_, err := os.Stat(filepath.Join(dir, "app-"+d+".log")) |
||||||
|
if i < 7 && err != nil { |
||||||
|
t.Errorf("day-%d (%s) 가 삭제됨 — 보존돼야 함", i, d) |
||||||
|
} |
||||||
|
if i >= 7 && err == nil { |
||||||
|
t.Errorf("day-%d (%s) 가 남아있음 — 삭제돼야 함", i, d) |
||||||
|
} |
||||||
|
} |
||||||
|
if _, err := os.Stat(keep); err != nil { |
||||||
|
t.Errorf("형식이 다른 파일 other.txt 가 삭제됨 — 보존돼야 함") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Write가 날짜 파일을 만들고 거기에 기록하는지 확인.
|
||||||
|
func TestDailyWriterWrite(t *testing.T) { |
||||||
|
dir := t.TempDir() |
||||||
|
w, err := newDailyWriter(filepath.Join(dir, "app.log"), 7) |
||||||
|
if err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
if _, err := w.Write([]byte("hello\n")); err != nil { |
||||||
|
t.Fatal(err) |
||||||
|
} |
||||||
|
today := time.Now().Format("2006-01-02") |
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "app-"+today+".log")) |
||||||
|
if err != nil { |
||||||
|
t.Fatalf("오늘 날짜 파일이 없음: %v", err) |
||||||
|
} |
||||||
|
if string(data) != "hello\n" { |
||||||
|
t.Errorf("기록 내용 불일치: %q", string(data)) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue