> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# Docker Compose가 cron에서만 실패할 때: -T·경로·환경 변수 진단
- URL: https://mlog.me/docker-compose-cron-not-running-tty-path-environment/
- Published: 2026-10-25T07:01:02.000Z
- Updated: 2026-10-25T07:01:02.000Z
- Description: 터미널에서는 되지만 cron에서 실패하는 Docker Compose 작업을 실행 계정, PATH, 작업 경로, .env, TTY와 종료 코드 순서로 진단합니다. 로그를 남기고 읽기 전용 명령으로 검증하는 절차를 설명합니다.
- Author: mLog
- Tags: 서버·인프라, Docker, Linux

터미널에서 성공한 명령을 crontab에 붙여 넣었는데 결과가 없다면, 명령 문자열보다 **그 명령을 실행한 환경**을 먼저 비교해야 한다. 실행 계정, 작업 디렉터리, 환경 변수, Docker 연결 대상, 터미널 유무가 달라질 수 있다.

`docker compose exec`의 TTY 문제는 `-T`로 해결할 수 있다. 하지만 이 옵션은 잘못된 Compose 파일 경로, Docker 소켓 권한, 빠진 환경 변수를 고쳐 주지 않는다. cron 로그에 작업 시작이 남았다고 백업이나 점검이 성공한 것도 아니다.

이 글은 **시작 여부 → 실행 계정과 대상 → 경로와 환경 → TTY → 종료 코드와 결과** 순서로 확인한다.

> 검증 범위: 2026-10-25 공식 문서를 확인했다. 호스트의 cron에서 Docker Compose 플러그인을 호출하는 Linux 구성을 전제로 한다. crontab 문법 설명은 Debian `cron` 패키지 문서를 기준으로 하며, Cronie·BusyBox·systemd-cron은 설치된 구현의 매뉴얼을 확인해야 한다. 로컬 Ubuntu 24.04.3 LTS·Bash 5.2.21에서 셸 동작 8항목을 실험했다. 이번 환경에는 cron 데몬과 Docker가 없어 예약 실행·컨테이너 명령·실제 백업은 실행하지 않았다. 아래 경로와 계정은 설명용이며 mLog 서버 설정을 공개한 것이 아니다.

## 1\. 예약이 시작되지 않은 것과 명령이 실패한 것을 나눈다

먼저 해당 작업을 등록한 계정으로 다음을 확인한다.

```bash
id
crontab -l
date -Is
timedatectl status
command -v docker
docker compose version

```

`crontab -l`은 현재 계정의 목록이다. `sudo crontab -l`은 root의 목록이므로 두 결과를 같은 예약표로 생각하면 안 된다. 사용자 crontab에는 시간 필드 다음에 명령을 적고, `/etc/crontab`과 `/etc/cron.d/`에는 실행 사용자 필드가 추가된다.

예를 들어 사용자 crontab에 `시간 ubuntu 명령`을 넣으면, 의도한 사용자 지정이 아니라 `ubuntu`라는 명령을 실행하려는 형태가 된다. 등록 위치에 맞는 문법인지 먼저 확인한다. [crontab 공식 매뉴얼](https://manpages.debian.org/trixie/cron/crontab.5.en.html?ref=mlog.me)

systemd로 Debian 계열 cron을 관리하는 호스트에서는 다음이 출발점이다.

```bash
systemctl status cron --no-pager
journalctl -u cron --since today --no-pager
journalctl -t CRON --since today --no-pager

```

서비스명이 `crond`인 구현도 있고, 작업 기록이 syslog 쪽에 남는 구성도 있다. 로그를 읽을 권한도 필요하다. 한 명령의 출력이 비었다고 작업 미실행으로 확정하지 않는다.

Debian cron은 기본 설정에서 작업 시작을 기록한다. 시작 기록은 명령을 호출했다는 단서이며, 스크립트의 마지막 단계나 백업 복원 성공을 보증하지 않는다. 기록이 있으면 이제 스크립트의 표준 오류와 종료 코드를 확인해야 한다. [cron 로깅 설명](https://manpages.debian.org/trixie/cron/cron.8.en.html?ref=mlog.me)

시간이 어긋난 경우에도 구현을 먼저 확인한다. `TZ`를 추가하면 모든 cron의 예약 시간이 바뀐다고 일반화할 수 없다. 서버 시간대와 설치된 cron 매뉴얼을 대조한다.

## 2\. 같은 사용자라도 터미널 환경이 그대로 전달되지는 않는다

예약 작업에 필요한 프로그램을 터미널에서 alias로 만들었거나, 로그인할 때만 PATH를 추가했다면 cron에서는 동일한 명령을 찾지 못할 수 있다. `.bashrc`에 둔 값도 자동으로 전달된다고 가정하지 않는다.

Bash는 대화형·로그인·비대화형 실행에 따라 읽는 초기화 파일이 다르다. 비대화형 Bash가 `BASH_ENV`를 읽는 경우도 있으므로 “cron 환경은 항상 완전히 비어 있다”는 설명 역시 정확하지 않다. 필요한 값과 인터프리터를 작업 스크립트에서 분명하게 정하는 편이 비교하기 쉽다. [Bash 실행 환경 매뉴얼](https://manpages.debian.org/trixie/bash/bash.1.en.html?ref=mlog.me)

```bash
#!/bin/bash
export PATH=/usr/local/bin:/usr/bin:/bin

# 이 경로는 command -v docker 결과와 대조한다.
task_docker=/usr/bin/docker
"$task_docker" compose version

```

`/usr/bin/docker`는 예시다. 설치 위치가 다르면 실제 절대경로로 바꾼다. Compose 플러그인이 사용자별로 설치돼 있으면 다른 계정에서 `docker`는 찾아도 `docker compose`는 사용할 수 없을 수 있으므로, 두 명령을 같은 계정에서 각각 확인한다. [Compose 플러그인 설치 범위](https://docs.docker.com/compose/install/linux/?ref=mlog.me)

Debian cron의 환경 변수 선언은 일반 셸의 대입문처럼 확장되지 않는다. crontab 상단에 `PATH=$HOME/bin:$PATH`를 넣기보다 필요한 절대경로 목록을 적는다. 배열이나 `pipefail`을 쓰는 스크립트는 `/bin/bash script.sh`로 호출하고, `sh script.sh`로 바꿔 실행하지 않는다.

환경 차이를 확인한다고 `env` 전체, `.env` 본문, `set -x` 출력을 그대로 공유하지 않는다. 계정·PATH·작업 경로와 필요한 변수의 존재 여부부터 기록하면 비밀번호를 로그에 넣지 않고도 범위를 좁힐 수 있다.

## 3\. Compose 파일 경로와 프로젝트를 고정한다

터미널에서는 프로젝트 폴더로 이동한 다음 명령을 실행했을 수 있다. cron에서 스크립트를 절대경로로 호출했다고 해서 현재 디렉터리까지 그 스크립트의 폴더로 바뀌는 것은 아니다.

Compose는 `-f`를 생략하면 현재 디렉터리와 상위에서 구성 파일을 찾는다. `--env-file`에 상대경로를 주면 그 경로도 호출 위치의 영향을 받는다. 예약 작업에서는 무엇을 읽을지 명시하는 편이 확인하기 쉽다.

```bash
/usr/bin/docker compose \
  --project-directory /srv/ghost \
  -f /srv/ghost/compose.yaml \
  --env-file /srv/ghost/.env \
  config --quiet

```

이 명령은 구성 유효성을 점검한다. 컨테이너를 생성하거나 설정을 반영하는 명령이 아니다. `.env`를 사용하지 않는 구성이라면 해당 옵션을 제거하고, 사용한다면 경로와 읽기 권한을 확인한다. `config`나 `config --environment`의 전체 출력에는 보간된 민감한 값이 포함될 수 있으므로 공유용 로그에는 우선 `--quiet`를 사용한다.

수동 실행 때 `-p`로 프로젝트 이름을 지정했거나 여러 `-f` 파일을 순서대로 적용했다면 예약 작업도 동일해야 한다. 다른 프로젝트를 선택한 상태에서 “서비스가 없다”는 결과를 보고 `up`을 실행하면 별도 컨테이너를 만들 수 있다. 먼저 같은 프로젝트의 `ps -a` 결과인지 비교한다. [Compose 파일·프로젝트 선택](https://docs.docker.com/reference/cli/docker/compose/?ref=mlog.me) · [config 옵션](https://docs.docker.com/reference/cli/docker/compose/config/?ref=mlog.me)

`.env` 경로를 고정해도 터미널의 셸 환경 변수가 같은 이름의 값을 덮어쓰는 경우는 남는다. Compose 보간에서 셸 환경 변수는 `--env-file`보다 우선한다. 또한 `--env-file`은 호스트 셸에 모든 값을 export하는 명령도, 실행 중인 컨테이너 설정을 즉시 갱신하는 명령도 아니다. [Compose 변수 보간](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/?ref=mlog.me)

설정 변경의 컨테이너 반영 문제라면 [Compose 환경 변수 변경과 restart·재생성 구분](https://mlog.me/docker-compose-env-change-restart-vs-up/)으로 이어서 확인한다.

## 4\. Docker 연결 대상과 권한을 확인한다

같은 서버에서 실행해도 Docker CLI가 원격 데몬이나 다른 사용자 설정을 보고 있을 수 있다.

```bash
docker context show
docker context ls
docker version

```

context의 연결 대상과 `DOCKER_HOST`, `DOCKER_CONTEXT`, `DOCKER_CONFIG` 사용 여부를 비교한다. rootless Docker나 사용자별 context를 쓰는 경우에는 root crontab으로 옮기는 것이 해결책이 아닐 수 있다. 계정과 클라이언트 설정이 달라지기 때문이다. [Docker contexts](https://docs.docker.com/engine/manage-resources/contexts/?ref=mlog.me)

Docker 소켓 접근 오류라면 이미 승인된 실행 계정과 권한 구성을 확인한다. 오류를 없애려고 소켓에 `chmod 666`을 적용하거나 무조건 root로 옮기지 않는다. Docker 공식 문서는 `docker` 그룹이 root 수준 권한을 부여한다고 설명한다. 그룹 추가도 단순한 실행 편의 설정으로 취급해서는 안 된다. [Docker Linux 권한 안내](https://docs.docker.com/engine/install/linux-postinstall/?ref=mlog.me)

여기까지는 실행 대상을 식별하는 단계다. 소켓 접근이 안 된다는 이유로 컨테이너·볼륨을 삭제하거나 서비스를 재생성할 필요는 없다.

## 5\. exec의 TTY와 표준 입력을 구분한다

`docker compose exec`는 기본으로 TTY를 할당한다. 예약 작업처럼 터미널이 없는 실행에서는 TTY 관련 실패가 발생할 수 있으므로 `-T`를 사용한다. 표준 입력도 필요 없는 명령이라면 `--interactive=false`를 추가할 수 있다.

Ghost 서비스에서 Node 버전만 확인하는 예시는 다음과 같다. 서비스 이름과 경로는 실제 구성에 맞춰야 한다.

```bash
/usr/bin/docker compose \
  --project-directory /srv/ghost \
  -f /srv/ghost/compose.yaml \
  --env-file /srv/ghost/.env \
  exec -T --interactive=false ghost node --version

```

`-T`는 가상 터미널 할당을 끄고, `--interactive=false`는 표준 입력 연결을 끈다. 파일을 표준 입력으로 전달하는 복원 명령에 두 옵션을 무조건 그대로 적용하면 안 된다. 위 예시는 입력을 받지 않는 진단 명령이다.

`docker exec`와 `docker compose exec`도 구분해야 한다. 전자는 기본 TTY 동작과 옵션이 다르므로 Compose의 `-T`를 그대로 옮겨 붙이지 않는다. `exec`는 실행 중인 컨테이너를 대상으로 하므로 서비스가 중지돼 있으면 별도의 상태 확인이 필요하다. [공식 exec 설명](https://docs.docker.com/reference/cli/docker/compose/exec/?ref=mlog.me)

## 6\. 시작·종료를 남기는 작은 진단 스크립트

아래는 **백업 생성 스크립트가 아니라 환경 확인용 예시**다. 실행 중인 Ghost 컨테이너에서 버전만 읽는다. 실제 백업·삭제 작업을 붙이기 전에 이 정도의 명령으로 호출 경로를 먼저 확인한다.

예시를 적용하려면 다음 값을 대조한다.

- `task_docker`: 해당 계정에서 확인한 Docker 실행 파일.
- `task_root`, Compose 파일, `.env`: 수동 실행에 사용한 경로.
- `task_context`: 해당 계정에서 확인한 연결 대상. `default`가 실제 대상일 때만 그대로 사용.
- 프로젝트 이름·추가 Compose 파일·서비스 이름: 기존 실행 방식과 동일하게 지정.

```bash
#!/bin/bash
set -euo pipefail
export PATH=/usr/local/bin:/usr/bin:/bin
umask 077

task_root=/srv/ghost
task_docker=/usr/bin/docker
task_context=default
task_state="${HOME:?}/.local/state/mlog-cron"

mkdir -p "$task_state"
exec >>"$task_state/compose-check.log" 2>&1

finish() {
  task_rc=$?
  printf 'end=%s exit=%s\n' "$(date -Is)" "$task_rc"
  exit "$task_rc"
}
trap finish EXIT

printf '\nstart=%s\n' "$(date -Is)"
id
printf 'initial_cwd=%s\n' "$PWD"
printf 'PATH=%s\n' "$PATH"

cd "$task_root"
task_compose=(
  "$task_docker" --context "$task_context" compose
  --project-directory "$task_root"
  -f "$task_root/compose.yaml"
  --env-file "$task_root/.env"
)

"${task_compose[@]}" config --quiet
"${task_compose[@]}" ps -a
"${task_compose[@]}" exec -T --interactive=false ghost node --version

```

로그는 대상 계정의 홈 아래에 만든다. 디렉터리 생성이나 로그 파일 열기에 실패하면 `start`를 쓰기 전에 종료한다. 이때는 cron 쪽 표준 오류 처리와 경로 권한을 확인한다. `umask`는 새 파일의 권한에 영향을 주지만 기존 로그의 권한을 바꾸지는 않는다.

사용자가 `ubuntu`이고 스크립트를 해당 홈에 배치한 경우의 등록 예시다. 실제 계정과 경로에 맞게 바꾼다.

```cron
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
15 3 * * * /bin/bash /home/ubuntu/jobs/compose-cron-check.sh

```

이 예시는 cron이 사용하는 시간대의 매일 03:15에 실행한다. 먼저 같은 계정으로 수동 실행한 다음, 실제 예약 시각에 시작과 종료가 모두 남는지 확인한다. 진단이 끝나면 불필요한 확인 작업을 제거하고 로그 보존 기간도 정한다.

날짜 파일명처럼 `%`를 쓰는 처리는 가능하면 스크립트 안에 둔다. crontab의 명령 부분에서 이스케이프하지 않은 `%`에는 특별한 의미가 있다. 셸의 따옴표만으로 피할 수 있다고 생각하면 안 된다.

호스트의 `compose-check.log`는 Docker가 저장하는 컨테이너 표준 출력 로그와 별개다. [Docker 로그 용량 제한](https://mlog.me/docker-log-rotation-max-size-compose-recreate/)을 설정해도 이 호스트 파일에 같은 상한이 적용되지는 않는다.

## 7\. 백업에서는 파일 생성만으로 성공을 판단하지 않는다

예약 작업의 앞 단계가 실패해도 뒤의 압축 처리만 성공할 수 있다. 다음은 실제 데이터를 사용하지 않고 종료 코드만 비교하는 실험이다.

```bash
/bin/bash -c '(exit 7) | gzip > /dev/null'
printf 'exit=%s\n' "$?"

/bin/bash -o pipefail -c '(exit 7) | gzip > /dev/null'
printf 'exit=%s\n' "$?"

```

이번 로컬 실행에서는 위 명령이 `exit=0`, 아래 명령이 `exit=7`을 반환했다. 같은 형태의 별도 실험에서 압축 출력을 메모리로 받았을 때는, 앞 단계가 실패해도 20바이트 gzip 출력이 생겼고 압축 해제한 데이터는 0바이트였다. 20바이트는 이 실험의 관측값이며 백업 파일의 판정 기준이 아니다.

Bash의 일반 파이프라인은 마지막 명령의 종료 상태를 반환한다. `pipefail`을 사용하면 중간 실패도 실패로 처리할 수 있다. 다만 불완전한 출력까지 자동으로 없애 주지는 않는다. 실패한 결과를 정식 백업 파일로 채택하지 않는 처리와 결과 검증이 따로 필요하다.

또한 `set -e`만으로 모든 분기에서 안전하게 종료한다고 보장할 수 없다. 조건식 등에서는 동작이 달라지므로 백업 처리에는 성공·실패 분기를 명시하고, 최종 종료 코드를 유지한다. [Bash 파이프라인과 종료 상태](https://manpages.debian.org/trixie/bash/bash.1.en.html?ref=mlog.me)

압축 파일이 열리더라도 필요한 데이터가 빠졌을 수 있다. 파일 무결성, 데이터 내용, 실제 복원 가능성을 나누어 평가한다. [Ghost·MySQL 백업 무결성 점검](https://mlog.me/ghost-mysql-backup-integrity-check/)과 [격리 환경의 백업 복원 검증](https://mlog.me/server-security-check-backup-restore-docker-forensics/)을 이어서 참고할 수 있다.

## 8\. 수정한 뒤에는 세 가지 기록을 확인한다

1. **호출 기록**: 예상한 시각과 계정으로 시작했다.
2. **처리 기록**: 의도한 Docker 데몬과 Compose 프로젝트에 연결해 마지막 단계까지 종료했다.
3. **결과 기록**: 점검 결과나 백업 내용이 목적을 충족한다. 백업이라면 별도 환경에서의 복원 가능성도 평가한다.

`-T`를 적용한 뒤 권한 오류가 보인다면 다른 문제까지 도달했을 수 있다. 오류를 구분하고 해당 설정만 수정한다.

이번 로컬 검증에서는 PATH, 상대경로, 필수 변수, Bash와 sh의 차이, 파이프라인 종료 상태, 로그 리다이렉트 실패, EXIT 핸들러를 확인했다. cron에서의 실행과 Docker 연결은 각 환경에서 확인해야 한다. 먼저 작은 읽기 전용 명령의 시작·종료를 확인하면, 데이터를 다루는 작업에 들어가기 전에 실패 구간을 좁힐 수 있다.