AILego(비동시 AI 화상면접 SaaS)를 운영 서버에 처음 올리던 날, 웹은 멀쩡한데 큐 워커만 뜨지 않았다. 에러는 하나가 아니라 연속극이었다. 하나를 고치면 다음 에러가 나왔다. 그날 겪은 순서대로 기록하고, 마지막에 "워커가 안 돌 때" 체크리스트로 정리한다.
배경: 워커 구성
AILego는 웹(Apache + mod_php), 큐 워커, 스케줄러(cron) 세 프로세스가 같은 코드베이스와 같은 .env를 공유한다. 워커는 Redis 큐를 소비하는 Spark 명령이다. STT·채점 같은 작업은 전부 워커가 처리하므로, 워커가 없으면 응시 데이터가 쌓이기만 하고 평가되지 않는다.
php spark queue:work --sleep 3 --max-jobs 500
워커는 템플릿 유닛 ailego-worker@.service로 만들어 인스턴스 2개(@1, @2)를 띄웠다.
[Unit]
Description=AILego queue worker %i
After=network.target redis-server.service
[Service]
User=deploy
Group=www-data
WorkingDirectory=/var/www/ailego
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/php spark queue:work --sleep 3 --max-jobs 500
Restart=always
RestartSec=3
KillSignal=SIGTERM
TimeoutStopSec=900
[Install]
WantedBy=multi-user.target
문서의 예시값을 그대로 복사해 쓴 것이 이후 에러의 공통 원인이었다.
1화. journalctl이 "No data available"
기동 직후 로그를 보려고 journalctl -u 'ailego-worker@*' -f를 실행했다.
Hint: You are currently not seeing messages from other users and the system.
Users in groups 'adm', 'systemd-journal' can see all messages.
Failed to add filter for units: No data available
이건 워커 에러가 아니었다. 현재 계정이 adm/systemd-journal 그룹이 아니라 로그를 못 읽는 것뿐이다.
- 당장은
sudo journalctl -u 'ailego-worker@*' -f로 본다. - 영구 해결은
sudo usermod -aG systemd-journal $USER후 재로그인.
교훈: 로그를 못 읽는 것과 워커가 죽은 것을 구분하려면 systemctl status부터 본다.
2화. status=217/USER
systemctl status를 보니 워커가 activating (auto-restart) 상태에서 계속 죽고 있었다.
Active: activating (auto-restart) (Result: exit-code)
Main PID: 876987 (code=exited, status=217/USER)
217/USER는 유닛의 User=에 적은 계정이 서버에 없다는 뜻이다. 문서 기준값인 User=deploy를 그대로 복사했는데, 이 서버에는 deploy 계정이 없었다. 실제 소유자는 writable/ 디렉터리에서 확인했다.
getent passwd deploy # 아무것도 안 나오면 계정 없음
ls -ld /var/www/ailego/writable # 실제 소유자 확인
소유자는 aicreo였다. 유닛을 고치고 다시 올렸다.
sudo sed -i 's/^User=deploy/User=aicreo/' /etc/systemd/system/ailego-worker@.service
sudo systemctl daemon-reload
sudo systemctl restart ailego-worker@1 ailego-worker@2
3화. status=200/CHDIR
User를 통과하자 에러 코드가 바뀌었다. 에러 코드가 바뀌었다는 건 한 단계 앞으로 나갔다는 신호다.
Main PID: 878627 (code=exited, status=200/CHDIR)
200/CHDIR은 WorkingDirectory= 경로가 없거나 접근할 수 없다는 뜻이다. 이것도 문서 예시 경로(/var/www/ailego)를 그대로 쓴 것이 원인이었다. 이 값은 단순한 시작 위치가 아니다. 워커는 .env를 CWD 기준으로 읽고 spark를 상대 경로로 실행하므로, 저장소 루트(spark가 있는 디렉터리)여야 한다.
ls -d /var/www/ailego # 경로 존재 여부
sudo -u aicreo ls /var/www/ailego/spark # 해당 계정이 접근 가능한지
namei -l /var/www/ailego # 상위 디렉터리에서 x 권한이 빠진 곳 찾기
경로가 맞는데도 CHDIR이 나면 상위 디렉터리를 지나갈 실행 권한(x)이 없는 경우다. namei -l이 그 지점을 알려준다. 올바른 경로로 고친 뒤에는 드디어 이렇게 나왔다.
Active: active (running)
└─879986 /usr/bin/php spark queue:work --sleep 3 --max-jobs 500
4화. 설치 검증에서 터진 환경변수 오류
워커가 떴으니 설치 검증을 돌렸다. 이번에는 .env 문제였다.
[LogicException] LLM_MODEL 과 ANTHROPIC_API_KEY 가 필요하다(.env).
서버 .env에 LLM_MODEL과 ANTHROPIC_API_KEY가 비어 있었다. 코드는 모델 ID를 하드코딩하지 않고 환경에서만 읽으며, 값이 없으면 시작 단계에서 예외를 던진다. 이런 방어는 "조용히 잘못 도는 것"보다 훨씬 낫다. 아래는 이 패턴을 단순화한 예시다.
<?php
declare(strict_types=1);
// 예시: 필수 환경변수가 없으면 부팅 시점에 바로 실패시킨다
$model = env('LLM_MODEL');
$apiKey = env('ANTHROPIC_API_KEY');
if ($model === null || $model === '' || $apiKey === null || $apiKey === '') {
throw new \LogicException('LLM_MODEL 과 ANTHROPIC_API_KEY 가 필요하다(.env).');
}
같은 출력의 마지막 줄에는 zsh: no such file or directory: /api/docs도 있었다. 이것은 서버 문제가 아니었다. 문서의 https://<서비스 도메인>/api/docs에서 <서비스 도메인> 자리표시자를 그대로 붙여 넣으면 zsh가 <를 입력 리다이렉션으로 해석한다. 또 curl -sI(HEAD)로 확인하면 GET 전용 라우트가 404를 돌려주므로, 검증은 GET으로 해야 한다.
curl -s -o /dev/null -w "%{http_code}\n" https://실제도메인/api/docs
5화. 로그 파일 권한 0664
웹(www-data)과 워커(aicreo/deploy)는 다른 사용자인데 같은 writable/logs/에 로그를 쓴다. CI4 앱 로그는 log-YYYY-MM-DD.log로 날짜별 파일이 만들어지는데, 기본 파일 권한이 0644면 먼저 파일을 만든 쪽만 쓸 수 있다. 그러면 같은 날 앱 로그가 한쪽에서만 남는다.
해결은 두 가지를 같이 하는 것이다. 먼저 코드에서 새 로그 파일 권한을 0664로 지정한다. 8진수 표기이므로 따옴표 없는 정수여야 한다.
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
use CodeIgniter\Log\Handlers\FileHandler;
class Logger extends BaseConfig
{
public array $handlers = [
FileHandler::class => [
'handles' => ['critical', 'alert', 'emergency', 'debug', 'error', 'info', 'notice', 'warning'],
'fileExtension' => '',
// 새 로그 파일에 적용할 권한. 따옴표 없는 8진수 정수여야 한다.
// 0664 = 소유자·그룹 쓰기 → 웹(www-data)과 워커가 같은 그룹으로 한 파일을 함께 쓴다.
'filePermissions' => 0664,
'path' => '',
],
];
}
다음으로 디렉터리에 setgid를 걸어 새 파일이 www-data 그룹을 물려받게 한다.
sudo chown -R deploy:www-data /var/www/ailego/writable
sudo chmod -R g+rwX /var/www/ailego/writable
sudo find /var/www/ailego/writable -type d -exec chmod 2775 {} +
sudo -u www-data test -w /var/www/ailego/writable/logs && echo "web OK"
test -w /var/www/ailego/writable/logs && echo "worker OK"
함정이 하나 있다. 코드 수정 이전에 0644로 만들어진 옛 로그 파일은 권한이 그대로다. 배포 후 한 번 직접 풀어 줘야 한다.
sudo chmod g+w /var/www/ailego/writable/logs/log-*.log
6화. 배포 자동화에서 막힌 sudo 비밀번호
마지막 에피소드는 GitHub Actions 배포였다. 워크플로가 SSH로 들어와 sudo apachectl graceful을 실행하는 단계에서 멈췄다. SSH 배치 모드에는 터미널이 없어 sudo 비밀번호를 물을 수 없기 때문이다. 스크립트가 set -e라 그 앞의 git checkout, composer install, migrate는 끝났고 Apache 재시작과 워커 재시작은 실행되지 않았다. 디스크에는 새 코드가 있는데 프로세스는 옛 코드로 도는 어정쩡한 상태였다.
배포 계정이 필요한 명령만 비밀번호 없이 실행하도록 허용한다. ALL 허용은 금지다.
sudo visudo -f /etc/sudoers.d/ailego-deploy
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload apache2, /usr/bin/systemctl restart ailego-worker@*
sudo visudo -cf /etc/sudoers.d/ailego-deploy # parsed OK
워커는 부팅 시점의 코드를 메모리에 들고 있으므로 배포할 때마다 재시작해야 한다. 이 재시작 명령(sudo systemctl restart 'ailego-worker@*')이 sudoers에 허용된 명령과 정확히 같아야 한다.
정리: systemd 워커가 안 도는 이유 체크리스트
증상(종료 코드)을 보면 어느 단계에서 막혔는지 바로 알 수 있다. 위에서 아래로 한 칸씩 풀리는 순서이기도 하다.
| 증상 | 원인 | 확인·조치 |
|---|---|---|
journalctl이 No data available |
로그 열람 권한 부족 (워커 문제 아님) | sudo journalctl, 또는 systemd-journal 그룹 추가 |
status=217/USER |
User= 계정이 서버에 없음 |
getent passwd <계정>, ls -ld writable로 실제 소유자 확인 |
status=200/CHDIR |
WorkingDirectory= 경로 없음·접근 불가 |
저장소 루트인지, namei -l로 상위 디렉터리 x 권한 확인 |
기동 직후 예외, .env 언급 |
필수 환경변수 누락 (REDIS_URL, QUEUE_DRIVER, LLM_MODEL 등) |
서버 .env 확인, 포그라운드로 php spark queue:work --max-jobs 1 먼저 실행 |
| 웹은 되는데 로그가 한쪽에만 남음 | 로그 파일 권한 0644, 그룹 쓰기 없음 |
filePermissions = 0664, writable/ 2775, 옛 로그 chmod g+w |
배포 Action이 sudo: a password is required |
sudoers 허용 명령이 실제 호출과 불일치 | 경로·인자가 WORKER_RESTART_CMD와 정확히 같은지 확인 |
디버깅 순서
- 포그라운드로 한 번 실행한다.
cd로 저장소 루트에 들어가php spark queue:work --sleep 3 --max-jobs 1을 직접 돌린다. 여기서 실패하면 systemd로 옮겨도 똑같이 실패한다. - 유닛을 올린 뒤
systemctl status의 종료 코드를 본다.217은 계정,200은 디렉터리다. - 그다음
sudo journalctl -u ailego-worker@1 -n 50 --no-pager로 앱 에러를 읽는다. - Redis와 큐 상태를 확인한다.
llen queue:default:ready가 계속 늘면 워커 부족,llen queue:default:failed는 0이어야 정상이다. - 배포 파이프라인에는 워커 재시작과 sudoers를 포함시킨다.
마치며
이번 연속극의 공통 원인은 하나였다. 문서의 기준값(User=deploy, /var/www/ailego)을 서버의 실제 값과 대조하지 않고 복사한 것. 에러는 서로 달랐지만 모두 "예시값이 현실과 다르다"로 수렴했다. 그래서 설치 문서에 User=와 WorkingDirectory=는 서버의 실제 값으로 바꾸라는 주의와, 종료 코드별 문제 해결 표를 추가했다. 다음에 같은 자리에서 막히면 종료 코드 한 줄이면 원인을 짚을 수 있다.
댓글 0개