fix: 자동배포 임시 deployer 컨테이너 분리 + 푸시 알림 denied 안내 강화
Deploy momo-erp / deploy (push) Failing after 12m55s

자동배포 (모든 워크플로 빨강 문제 근본 해결):
- 옛 흐름: webhook → momo-erp 안의 sh → docker compose up
  → momo-erp 자기 자신 down 시점에 sh 도 같이 죽어 swap 중단
  → 새 컨테이너 'Created' 상태로 멈춤 (실제 운영에서 관측)
- 새 흐름: webhook → host docker daemon 에 임시 deployer 컨테이너 spawn
  - docker:cli 이미지에 git + compose-plugin apk add
  - root 로 git fetch + reset + build-sha + compose up
  - 임시 컨테이너는 momo-erp 와 PID 무관 → swap 완전 분리
  - --rm 으로 종료 후 자동 정리, 로그는 /tmp/momo-deploy.log 누적

푸시 알림 (권한 denied 케이스):
- 코드로 권한 재요청 불가능 → OS/브라우저 설정에서 직접 풀어야 함
- 카드에 환경(TWA/Android/iOS/데스크탑) 자동 감지 후
  단계별 unblock 방법 표시
- TWA 앱은 안드로이드 설정 → 앱 → 모모유통 → 알림 허용
- 데스크탑 브라우저는 주소창 자물쇠 → 사이트 설정 → 알림 허용

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-30 16:29:41 +09:00
parent 4933655c26
commit 46eba2996f
2 changed files with 76 additions and 12 deletions
+35 -5
View File
@@ -24,11 +24,41 @@ export async function POST(req: NextRequest) {
await writeFile(logPath, `[${new Date().toISOString()}] deploy 시작\n`);
} catch { /* ignore */ }
// 백그라운드 실행 — 응답은 즉시 반환.
// sh 인터프리터로 명시 호출(스크립트 자체에 exec 권한 없어도 동작).
const child = spawn("/bin/sh", ["-c",
`(sh ${DEPLOY_SCRIPT} 2>&1 || echo "[deploy.sh 실행 실패 — 호스트에 docker.sock + 스크립트 마운트 필요]") >> ${logPath}`
], { detached: true, stdio: "ignore" });
// 임시 deployer 컨테이너로 분리 실행.
// momo-erp 자기 자신을 down → recreate 하는 순간 자기 안의 sh 프로세스도 같이 죽어버려
// 배포가 도중에 끊겼던 문제를 해결. docker daemon 이 띄운 별도 컨테이너는
// momo-erp 의 PID/network 와 무관하게 살아남는다.
//
// 임시 컨테이너:
// - root(--user 0:0) 로 실행 → .git permission 문제 없음
// - 호스트 source 디렉토리와 docker.sock 마운트
// - 종료 후 자동 삭제(--rm), 로그는 /tmp/momo-deploy.log 에 누적
const deployCmd = [
"docker", "run", "-d", "--rm",
"--name", `momo-deployer-${Date.now()}`,
"-v", "/home/chpark/momo-erp/source:/src",
"-w", "/src",
"-v", "/var/run/docker.sock:/var/run/docker.sock",
"--user", "0:0",
"docker:cli",
"sh", "-c",
[
`echo "[$(date)] deployer 시작" >> ${logPath}`,
`apk add --no-cache git docker-cli-compose >/dev/null 2>&1`,
`git config --global --add safe.directory '*'`,
`git fetch origin >> ${logPath} 2>&1`,
`git reset --hard origin/main >> ${logPath} 2>&1`,
`git rev-parse HEAD > public/build-sha.txt`,
`echo "[$(date)] 배포 SHA: $(cat public/build-sha.txt)" >> ${logPath}`,
`docker compose -f docker-compose.prod.yml build momo-erp >> ${logPath} 2>&1`,
`docker compose -f docker-compose.prod.yml up -d --force-recreate momo-erp >> ${logPath} 2>&1`,
`docker restart traefik >> ${logPath} 2>&1`,
`docker image prune -f >> ${logPath} 2>&1`,
`echo "[$(date)] ✔ 배포 완료" >> ${logPath}`,
].join(" && "),
];
const child = spawn(deployCmd[0], deployCmd.slice(1), { detached: true, stdio: "ignore" });
child.unref();
return NextResponse.json({
+41 -7
View File
@@ -243,6 +243,12 @@ export function PushOptIn({ variant = "compact" }: PushOptInProps) {
}
// === card 변형 (회원정보 페이지) ===
// denied 환경 감지 — TWA 앱은 안드로이드 OS 설정, 브라우저는 사이트 설정에서 풀어야
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "";
const isAndroid = /Android/i.test(ua);
const isiOS = /iPhone|iPad|iPod/i.test(ua);
const isTWA = isAndroid && (/wv|; ?Trusted Web Activity/i.test(ua) || (typeof document !== "undefined" && (document.referrer ?? "").startsWith("android-app://")));
return (
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-3">
<h3 className="font-bold text-slate-700 mb-3 border-b pb-2 inline-flex items-center gap-2">
@@ -264,20 +270,48 @@ export function PushOptIn({ variant = "compact" }: PushOptInProps) {
>
<span className={`inline-block h-6 w-6 transform rounded-full bg-white shadow transition-transform ${on ? "translate-x-[22px]" : "translate-x-0.5"}`} />
</button>
{denied && (
<span className="text-[11px] text-rose-500 inline-flex items-center gap-1">
<AlertCircle size={12} /> /
</span>
)}
{busy && <span className="text-[11px] text-slate-400"> </span>}
</div>
{statusMsg && (
{/* denied 상태: 코드로 권한 재요청 불가 → OS/브라우저 설정에서 직접 풀어야 */}
{denied && (
<div className="bg-rose-50 border border-rose-200 rounded-lg p-3 text-xs text-slate-700 space-y-1.5">
<div className="font-bold text-rose-700 inline-flex items-center gap-1">
<AlertCircle size={13} />
</div>
{isTWA || isAndroid ? (
<ol className="list-decimal list-inside leading-relaxed space-y-0.5 text-slate-600 pl-1">
<li> <b></b> </li>
<li><b> </b> ( ) <b></b></li>
<li><b> </b> </li>
<li> <b> </b> </li>
</ol>
) : isiOS ? (
<ol className="list-decimal list-inside leading-relaxed space-y-0.5 text-slate-600 pl-1">
<li>iOS <b> </b></li>
<li> ( Safari) <b> </b> </li>
<li> </li>
</ol>
) : (
<ol className="list-decimal list-inside leading-relaxed space-y-0.5 text-slate-600 pl-1">
<li> <b> </b> </li>
<li><b> </b> <b></b> </li>
<li> </li>
</ol>
)}
</div>
)}
{statusMsg && !denied && (
<div className="text-[11px] text-rose-600 bg-rose-50 border border-rose-100 rounded p-2 leading-snug">
: {statusMsg}
</div>
)}
<div className="text-[10px] text-slate-400 leading-snug pt-1">
· : {typeof Notification !== "undefined" ? Notification.permission : "?"} · : {httpsOK ? "OK" : "NO"} · SW: {"serviceWorker" in (typeof navigator !== "undefined" ? navigator : ({} as Navigator)) ? "지원" : "미지원"}
· : <b className={denied ? "text-rose-500" : "text-slate-600"}>{typeof Notification !== "undefined" ? Notification.permission : "?"}</b>
{" · "}: <b className={httpsOK ? "text-emerald-600" : "text-rose-500"}>{httpsOK ? "OK" : "NO"}</b>
{" · "}SW: <b>{"serviceWorker" in (typeof navigator !== "undefined" ? navigator : ({} as Navigator)) ? "지원" : "미지원"}</b>
{" · "}: <b>{isTWA ? "TWA앱" : isAndroid ? "안드로이드" : isiOS ? "iOS" : "데스크탑/기타"}</b>
</div>
</div>
);